ImBenji 537fc4f750 Add v2 quote API with video support and Flutter tweet template widget
- Implement Express routes for creating, updating, retrieving, and deleting quote sessions
- Add video detection and rendering pipeline using Playwright and FFmpeg
- Add caching utilities for images and videos
- Provide helpers for formatting counts, timestamps, and normalizing usernames
- Add snapshot creation and retrieval endpoints
- Implement SSE endpoint for render progress updates
- Add Flutter widget for rendering tweet templates with image/video support
2026-02-13 21:56:26 +00:00
2025-12-31 07:07:52 +00:00

Quote Generator API

Generate Twitter-style quote images programmatically. Perfect for creating social media content, screenshots, or fake tweets.

Base URL: https://quotes.imbenji.net

Features

  • Twitter-authentic styling
  • Support for images, videos, GIFs, and text-only tweets
  • Verified badge (blue checkmark)
  • Engagement metrics (likes, retweets, replies, views)
  • High-resolution output (3240x3240)
  • Built-in caching (24-hour TTL)
  • Base64 image support
  • Video/GIF rendering with Playwright (v2 API only)
  • Background video processing with status tracking
  • Docker-ready
  • v2 API with stateful sessions for incremental updates
  • Persistent snapshot links (48-hour TTL, immutable, shareable)

API Endpoints

POST /generate

Generate a quote image using JSON request body.

Request:

curl -X POST https://quotes.imbenji.net/generate \
-H "Content-Type: application/json" \
-d '{
  "displayName": "Geoff Marshall",
  "username": "@geofftech",
  "avatarUrl": "https://example.com/avatar.jpg",
  "text": "Does anyone else find it immensely satisfying when you turn a pocket inside out and get rid of the crumbs and fluff stuck in the bottom.",
  "timestamp": 1499766270
}' --output quote.png

GET /generate

Generate a quote image using query parameters.

Request:

curl "https://quotes.imbenji.net/generate?displayName=John%20Doe&username=@johndoe&text=Hello%20World&timestamp=1735574400" --output quote.png

Parameters

Parameter Type Required Description
displayName string No Display name (defaults to "Anonymous")
username string No Twitter handle with @ (defaults to "@anonymous")
avatarUrl string No Avatar image URL or base64 data URI
text string No Tweet text content
imageUrl string No Tweet image URL, base64 data URI, or null
timestamp integer No Unix epoch timestamp in seconds
verified boolean No Show verified badge (blue checkmark) next to name
engagement object No Engagement metrics (likes, retweets, replies, views)

Response

  • Content-Type: image/png
  • Headers:
  • X-Cache: HIT (served from cache) or MISS (newly generated)

Examples

Text-only tweet

fetch('https://quotes.imbenji.net/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
  displayName: 'John Doe',
  username: '@johndoe',
  text: 'Just deployed my new API!',
  timestamp: Math.floor(Date.now() / 1000)
})
})
.then(res => res.blob())
.then(blob => {
const url = URL.createObjectURL(blob);
document.getElementById('img').src = url;
});

Tweet with image

const response = await fetch('https://quotes.imbenji.net/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
  displayName: 'Jane Smith',
  username: '@janesmith',
  avatarUrl: 'https://example.com/avatar.jpg',
  text: 'Check out this amazing view! 🌄',
  imageUrl: 'https://example.com/photo.jpg',
  timestamp: 1735574400
})
});

const blob = await response.blob();
// Save or display the image

Using base64 images

fetch('https://quotes.imbenji.net/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
  displayName: 'Alice',
  username: '@alice',
  avatarUrl: 'data:image/png;base64,iVBORw0KGgo...',
  text: 'Using base64 images works too!',
  imageUrl: 'data:image/jpeg;base64,/9j/4AAQSkZJ...',
  timestamp: 1735574400
})
});

Python example

import requests
import time

response = requests.post('https://quotes.imbenji.net/generate', json={
  'displayName': 'Python User',
  'username': '@pythonista',
  'text': 'Making API calls with Python!',
  'timestamp': int(time.time())
})

with open('quote.png', 'wb') as f:
  f.write(response.content)

cURL with GET

curl -G "https://quotes.imbenji.net/generate" \
--data-urlencode "displayName=Test User" \
--data-urlencode "username=@testuser" \
--data-urlencode "text=This is a test tweet" \
--data-urlencode "timestamp=1735574400" \
--output quote.png

Verified badge

fetch('https://quotes.imbenji.net/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
  displayName: 'Elon Musk',
  username: '@elonmusk',
  text: 'Just bought Twitter!',
  verified: true,
  timestamp: 1735574400
})
});

Engagement metrics

fetch('https://quotes.imbenji.net/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
  displayName: 'Popular User',
  username: '@viral',
  text: 'This tweet went viral!',
  verified: true,
  engagement: {
    replies: 1234,
    retweets: 5678,
    likes: 98765,
    views: 1500000
  },
  timestamp: 1735574400
})
});

Note: All four engagement fields (replies, retweets, likes, views) must be provided for the engagement bar to appear. Numbers are automatically formatted (e.g., 1234 → 1.2K, 1500000 → 1.5M).


v2 API (Stateful Sessions)

The v2 API lets you build quote images incrementally. Instead of sending everything in one request, you create a session and update fields as needed. This is more efficient when making multiple edits since you dont need to resend large images every time.

How it works

  1. Create a session with POST /v2/quote
  2. Update fields with PATCH /v2/quote/:id (only send whats changed)
  3. Render the image with GET /v2/quote/:id/image

Sessions expire after 24 hours of inactivity.

Video/GIF Support (v2 Only)

The v2 API supports video and animated GIF rendering. When you provide a video or GIF in the imageUrl parameter, the API automatically:

  • Detects the video format (MP4, WebM, MOV, GIF)
  • Renders the video in the background using Playwright
  • Returns an animated MP4 file showing the video playing within the tweet frame
  • Tracks render status (pending, rendering, completed, failed)

Supported formats:

  • MP4 (H.264, H.265)
  • WebM (VP8, VP9)
  • MOV (QuickTime)
  • GIF (animated, converted to MP4)

Note: Video rendering happens asynchronously in the background. The GET /v2/quote/:id/image endpoint will wait (up to 15 minutes) for the render to complete before returning the video file.

POST /v2/quote

Create a new session.

Request:

curl -X POST https://quotes.imbenji.net/v2/quote \
-H "Content-Type: application/json" \
-d '{
  "displayName": "John Doe",
  "username": "@johndoe",
  "text": "Hello world"
}'

Response (201):

{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"displayName": "John Doe",
"username": "@johndoe",
"text": "Hello world",
"avatarUrl": null,
"imageUrl": null,
"timestamp": null,
"verified": false,
"engagement": null,
"renderStatus": "completed",
"renderProgress": 100,
"renderPid": null,
"renderError": null,
"createdAt": 1735600000,
"updatedAt": 1735600000
}

Render Status Fields:

  • renderStatus: Current render state (pending, rendering, completed, failed)
  • renderProgress: Render progress percentage (0-100)
  • renderPid: Process ID of background render (only present during rendering)
  • renderError: Error message if renderStatus is failed

GET /v2/quote/:id

Get current session state.

curl https://quotes.imbenji.net/v2/quote/a1b2c3d4-e5f6-7890-abcd-ef1234567890

PATCH /v2/quote/:id

Update specific fields. Only send the fields you want to change.

curl -X PATCH https://quotes.imbenji.net/v2/quote/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
-H "Content-Type: application/json" \
-d '{
  "text": "Updated text!",
  "avatarUrl": "data:image/png;base64,..."
}'

Engagement updates:

# Set all engagement metrics
curl -X PATCH https://quotes.imbenji.net/v2/quote/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
-H "Content-Type: application/json" \
-d '{
  "engagement": {
    "replies": 100,
    "retweets": 250,
    "likes": 5000,
    "views": 50000
  }
}'

# Partial update (update only likes, keep other fields)
curl -X PATCH https://quotes.imbenji.net/v2/quote/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
-H "Content-Type: application/json" \
-d '{
  "engagement": {
    "likes": 10000
  }
}'

# Clear engagement entirely (removes engagement bar)
curl -X PATCH https://quotes.imbenji.net/v2/quote/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
-H "Content-Type: application/json" \
-d '{
  "engagement": null
}'

Verified badge:

# Add verified badge
curl -X PATCH https://quotes.imbenji.net/v2/quote/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
-H "Content-Type: application/json" \
-d '{
  "verified": true
}'

GET /v2/quote/:id/render-info

Get real-time render progress via Server-Sent Events (SSE).

curl -N https://quotes.imbenji.net/v2/quote/a1b2c3d4-e5f6-7890-abcd-ef1234567890/render-info

Response (text/event-stream):

data: {"status":"rendering","sessionId":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","progress":0,"stage":"rendering","error":null}

data: {"status":"rendering","sessionId":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","progress":25,"stage":"rendering","error":null}

data: {"status":"rendering","sessionId":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","progress":50,"stage":"rendering","error":null}

data: {"status":"rendering","sessionId":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","progress":99,"stage":"rendering","error":null}

data: {"status":"rendering","sessionId":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","progress":0,"stage":"encoding","error":null}

data: {"status":"rendering","sessionId":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","progress":50,"stage":"encoding","error":null}

data: {"status":"rendering","sessionId":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","progress":99,"stage":"encoding","error":null}

data: {"status":"completed","sessionId":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","progress":100,"stage":"completed","error":null}

JavaScript Example:

const eventSource = new EventSource(
`https://quotes.imbenji.net/v2/quote/${sessionId}/render-info`
);

eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log(`Render: ${data.stage} (${data.progress}%)`);

// Update progress bar and stage label in UI
document.getElementById('progress-bar').style.width = `${data.progress}%`;
document.getElementById('progress-text').textContent = `${data.progress}%`;

// Show current stage
let stageText = '';
if (data.stage === 'rendering') {
  stageText = 'Rendering video...';
} else if (data.stage === 'encoding') {
  stageText = 'Encoding to H.264...';
} else if (data.stage === 'completed') {
  stageText = 'Complete!';
}
document.getElementById('stage-label').textContent = stageText;

if (data.status === 'completed') {
  console.log('Render complete! Fetching video...');
  eventSource.close();
  // Now fetch the video
  fetch(`https://quotes.imbenji.net/v2/quote/${sessionId}/image`)
    .then(res => res.blob())
    .then(blob => {
      // Handle the video blob
    });
} else if (data.status === 'failed') {
  console.error('Render failed:', data.error);
  eventSource.close();
}
};

eventSource.onerror = (error) => {
console.error('SSE error:', error);
eventSource.close();
};

Status values:

  • pending - Render queued but not started (progress: 0%)
  • rendering - In progress (progress: 0-99%)
  • completed - Successfully rendered (progress: 100%)
  • failed - Render failed (check error field)
  • deleted - Session was deleted

Response fields:

  • status - Current render status (pending, rendering, completed, failed)
  • sessionId - Session identifier
  • progress - Current stage completion percentage (0-100)
  • stage - Current render stage (idle, rendering, encoding, completed, failed)
  • error - Error message (only present if status is failed)

Render Stages:

  1. rendering - Recording video with Playwright (progress 0-99%)
  2. encoding - Converting to H.264 MP4 with FFmpeg (progress 0-99%)
  3. completed - Render finished successfully (progress 100%)

Notes:

  • The stream automatically closes when render is completed or failed
  • Progress updates are sent every 200ms while render is in progress
  • Progress resets to 0% when transitioning between stages (rendering → encoding)
  • Each stage reports its own progress percentage independently
  • Use the stage field to show different messages in your UI

GET /v2/quote/:id/image

Render the current session state as an image or video.

# For images
curl https://quotes.imbenji.net/v2/quote/a1b2c3d4-e5f6-7890-abcd-ef1234567890/image --output quote.png

# For videos
curl https://quotes.imbenji.net/v2/quote/a1b2c3d4-e5f6-7890-abcd-ef1234567890/image --output quote.mp4

Response headers:

  • Content-Type: image/png (for images) or video/mp4 (for videos)
  • X-Session-Id: <session-id>
  • X-Cache: HIT or MISS

Notes:

  • For video renders, this endpoint will wait up to 15 minutes for the background render to complete
  • If the render is still in progress, the response will be delayed (polling internally)
  • If the render fails, a 500 error will be returned with details
  • If the render times out, a 504 error will be returned
  • For better UX, use /render-info to track progress and only call this when complete

DELETE /v2/quote/:id

Delete a session.

curl -X DELETE https://quotes.imbenji.net/v2/quote/a1b2c3d4-e5f6-7890-abcd-ef1234567890

Returns 204 No Content on success.

POST /v2/quote/:id/snapshot-link

Create a persistent snapshot link. This captures the current state of your session and generates a shareable URL that persists for 48 hours (refreshing on each access).

Unlike the regular /image endpoint, snapshots are immutable - they always show the image as it was when the snapshot was created, even if you update the session afterwards.

Request:

curl -X POST https://quotes.imbenji.net/v2/quote/a1b2c3d4-e5f6-7890-abcd-ef1234567890/snapshot-link

Response (201):

{
"token": "xY9pQmN3kL8vFw2jRtZ7",
"url": "https://quotes.imbenji.net/v2/snapshot/xY9pQmN3kL8vFw2jRtZ7",
"sessionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"createdAt": 1735600000,
"expiresAt": 1735772800
}

GET /v2/snapshot/:token

Retrieve a snapshot image using its token.

curl https://quotes.imbenji.net/v2/snapshot/xY9pQmN3kL8vFw2jRtZ7 --output snapshot.png

Response headers:

  • Content-Type: image/png
  • X-Snapshot-Token: <token>
  • X-Cache: HIT or MISS

Snapshot behavior:

  • Immutable: Shows the session state when the snapshot was created
  • TTL: 48 hours from last access (resets on each view)
  • Cascade delete: Deleted automatically when parent session is deleted
  • Shareable: Token can be shared with anyone

v2 Example Workflow

// 1. Create session with initial data
const res = await fetch('https://quotes.imbenji.net/v2/quote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
  displayName: 'Jane Doe',
  username: '@janedoe',
  text: 'Working on something cool'
})
});
const session = await res.json();
const sessionId = session.id;

// 2. Later, add an avatar (only sends the avatar, not everything again)
await fetch(`https://quotes.imbenji.net/v2/quote/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
  avatarUrl: 'data:image/png;base64,iVBORw0KGgo...'
})
});

// 3. Update the text
await fetch(`https://quotes.imbenji.net/v2/quote/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
  text: 'Finished building something cool!'
})
});

// 4. Generate the final image
const imgRes = await fetch(`https://quotes.imbenji.net/v2/quote/${sessionId}/image`);
const blob = await imgRes.blob();

// 5. (Optional) Create a shareable snapshot link
const snapshotRes = await fetch(`https://quotes.imbenji.net/v2/quote/${sessionId}/snapshot-link`, {
method: 'POST'
});
const snapshot = await snapshotRes.json();
console.log('Shareable URL:', snapshot.url);
// Anyone can access: https://quotes.imbenji.net/v2/snapshot/<token>

v2 Video Example

// 1. Create session with video
const res = await fetch('https://quotes.imbenji.net/v2/quote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
  displayName: 'Video Creator',
  username: '@videocreator',
  text: 'Check out this awesome clip!',
  imageUrl: 'data:video/mp4;base64,AAAAIGZ0eXBpc29tAAACAGlzb21...' // base64 video
})
});
const session = await res.json();
console.log('Render status:', session.renderStatus); // "rendering"

// 2. Track render progress with SSE (recommended)
const eventSource = new EventSource(
`https://quotes.imbenji.net/v2/quote/${session.id}/render-info`
);

eventSource.onmessage = async (event) => {
const data = JSON.parse(event.data);
console.log(`Render: ${data.status} (${data.progress}%)`);

// Update UI with progress
if (data.status === 'rendering') {
  // Show progress bar: 0% -> 99%
  updateProgressBar(data.progress);
}

if (data.status === 'completed') {
  eventSource.close();
  updateProgressBar(100);

  // 3. Get the rendered video
  const videoRes = await fetch(`https://quotes.imbenji.net/v2/quote/${session.id}/image`);
  const videoBlob = await videoRes.blob();
  console.log('Content-Type:', videoRes.headers.get('Content-Type')); // "video/mp4"

  // Save the video
  const url = URL.createObjectURL(videoBlob);
  const a = document.createElement('a');
  a.href = url;
  a.download = 'quote-video.mp4';
  a.click();
} else if (data.status === 'failed') {
  console.error('Render failed:', data.error);
  eventSource.close();
}
};

// Alternative: Simple approach (blocks until complete)
// const videoRes = await fetch(`https://quotes.imbenji.net/v2/quote/${session.id}/image`);
// const videoBlob = await videoRes.blob();

GIF Example:

// Animated GIFs are automatically detected and rendered as MP4 videos
const res = await fetch('https://quotes.imbenji.net/v2/quote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
  displayName: 'GIF Lover',
  username: '@giflover',
  text: 'This GIF is fire 🔥',
  imageUrl: 'data:image/gif;base64,R0lGODlhAQABAIAAAP...' // animated GIF
})
});
// The output will be an MP4 video file

Full HTML Progress Bar Example:

<!DOCTYPE html>
<html>
<head>
<style>
  .progress-container {
    width: 100%;
    background-color: #f0f0f0;
    border-radius: 8px;
    overflow: hidden;
  }
  .progress-bar {
    height: 30px;
    background-color: #4CAF50;
    width: 0%;
    transition: width 0.2s;
    display: flex;
    align-items: center;
    justify-content: center;
    color: white;
    font-weight: bold;
  }
</style>
</head>
<body>
<div class="progress-container">
  <div class="progress-bar" id="progressBar">0%</div>
</div>
<video id="videoPlayer" controls style="display:none; margin-top:20px; width:100%;"></video>

<script>
  async function renderVideo() {
    // Create session with video
    const res = await fetch('https://quotes.imbenji.net/v2/quote', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        displayName: 'Video Creator',
        username: '@videocreator',
        text: 'Check out this clip!',
        imageUrl: 'data:video/mp4;base64,AAAAIGZ0eXBpc29t...'
      })
    });
    const session = await res.json();

    // Track progress with SSE
    const eventSource = new EventSource(
      `https://quotes.imbenji.net/v2/quote/${session.id}/render-info`
    );

    eventSource.onmessage = async (event) => {
      const data = JSON.parse(event.data);

      // Update progress bar
      const progressBar = document.getElementById('progressBar');
      progressBar.style.width = data.progress + '%';
      progressBar.textContent = data.progress + '%';

      if (data.status === 'completed') {
        eventSource.close();

        // Fetch and display video
        const videoRes = await fetch(
          `https://quotes.imbenji.net/v2/quote/${session.id}/image`
        );
        const blob = await videoRes.blob();
        const url = URL.createObjectURL(blob);

        const video = document.getElementById('videoPlayer');
        video.src = url;
        video.style.display = 'block';
      } else if (data.status === 'failed') {
        eventSource.close();
        alert('Render failed: ' + data.error);
      }
    };
  }

  renderVideo();
</script>
</body>
</html>

v2 Parameters

Parameter Type Description
displayName string Display name
username string Twitter handle with @
avatarUrl string Avatar image URL or base64
text string Tweet text content
imageUrl string Tweet image URL or base64
timestamp integer Unix epoch in seconds
verified boolean Show verified badge (blue checkmark)
engagement object/null Engagement metrics (see below)

Engagement object:

{
"engagement": {
  "replies": 89,
  "retweets": 567,
  "likes": 1234,
  "views": 50000
}
}

Engagement behavior:

  • All four fields must be provided for the engagement bar to appear
  • Partial updates: Only update specific fields, others remain unchanged
  • Set to null to clear all engagement and hide the engagement bar
  • Numbers are auto-formatted (1234 → 1.2K, 1000000 → 1M)

Caching

The API automatically caches generated images for 24 hours from the last request. Identical requests will be served from cache instantly.

  • Cache hits include X-Cache: HIT header
  • Cache misses include X-Cache: MISS header
  • Cache is cleaned up hourly
  • Images are stored based on SHA-256 hash of parameters

Timestamp Format

The timestamp parameter expects a Unix epoch timestamp in seconds (not milliseconds).

JavaScript:

const timestamp = Math.floor(Date.now() / 1000);

Python:

import time
timestamp = int(time.time())

The timestamp will be formatted as: 5:58 PM · Dec 29, 2025

Default Avatar

If no avatarUrl is provided, a default Twitter-style placeholder avatar will be used.

Rate Limiting

Currently, there are no rate limits. Please use responsibly.

Self-Hosting

git clone <repository>
cd quotegen
docker-compose up -d

The API will be available at http://localhost:3000

Manual Setup

npm install
npx playwright install chromium
npm start

Note: Video rendering requires Playwright and FFmpeg. The Docker setup handles this automatically.

Health Check

curl https://quotes.imbenji.net/health

Response:

{
"status": "ok"
}

Technical Details

  • Resolution: 3240x3240 (1:1 aspect ratio) for images, 1500x1500 for videos
  • Format: PNG (images), MP4 (videos)
  • Max tweet width: 450px (auto-scaled to fit)
  • Cache TTL: 24 hours from last access
  • Session TTL: 24 hours from last update (v2 API)
  • Snapshot TTL: 48 hours from last access (v2 API)
  • Cleanup interval: Every hour
  • Database: SQLite (for v2 sessions and snapshots)
  • Video rendering: Playwright Chromium with background worker processes
  • Video timeout: 15 minutes maximum render time
  • Supported video formats: MP4, WebM, MOV, GIF

Limitations

  • Tweet text is not wrapped intelligently (use \n for line breaks if needed)
  • Very long tweets may be cut off
  • External image URLs must be publicly accessible
  • Base64 images should be reasonably sized
  • Video rendering is only available in v2 API (not in /generate endpoint)
  • Video renders can take several seconds depending on video duration
  • Very long videos (>15 minutes) may timeout
  • Video audio is muted in the output

Support

For issues or questions, please contact the maintainer.


Built with: Node.js, Express, Puppeteer, Playwright, SQLite, FFmpeg

Description
No description provided
Readme 333 KiB
Languages
JavaScript 78%
Dart 17.2%
HTML 4%
Dockerfile 0.8%