From 813ed39102d4e74f620a2f0f9a4cc1d9b0665769 Mon Sep 17 00:00:00 2001 From: ImBenji Date: Fri, 13 Feb 2026 21:47:12 +0000 Subject: [PATCH] Add Discord notifications for image generation and snapshot creation --- .dockerignore | 1 + Dockerfile | 9 +- README.md | 615 +++++++++++++----- api.js | 10 +- generate.js | 51 -- package-lock.json | 172 +++++ package.json | 4 +- quote.html | 336 ---------- db.js => src/database/db.js | 86 ++- browserPool.js => src/services/browserPool.js | 0 .../services/discordWebhook.js | 0 template.html => templates/template.html | 0 v2Routes.js | 429 ------------ 13 files changed, 733 insertions(+), 980 deletions(-) delete mode 100644 generate.js delete mode 100644 quote.html rename db.js => src/database/db.js (82%) rename browserPool.js => src/services/browserPool.js (100%) rename discordWebhook.js => src/services/discordWebhook.js (100%) rename template.html => templates/template.html (100%) delete mode 100644 v2Routes.js diff --git a/.dockerignore b/.dockerignore index 083be89..0092a4c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,7 @@ node_modules npm-debug.log *.png +*.mp4 cache/ .git .gitignore diff --git a/Dockerfile b/Dockerfile index 8761662..be1e22c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM node:18-slim -# Install dependencies for Puppeteer/Chrome +# Install dependencies for Puppeteer/Chrome and Playwright + ffmpeg RUN apt-get update && apt-get install -y \ wget \ gnupg \ @@ -25,6 +25,7 @@ RUN apt-get update && apt-get install -y \ libxkbcommon0 \ libxrandr2 \ xdg-utils \ + ffmpeg \ && rm -rf /var/lib/apt/lists/* WORKDIR /app @@ -33,8 +34,14 @@ COPY package*.json ./ RUN npm install +# install playwright browsers +RUN npx playwright install chromium --with-deps + COPY . . +# create temp videos directory +RUN mkdir -p /tmp/videos + EXPOSE 3000 CMD ["node", "api.js"] diff --git a/README.md b/README.md index b261664..f8aae31 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,14 @@ Generate Twitter-style quote images programmatically. Perfect for creating socia ## Features - Twitter-authentic styling -- Support for images and text-only tweets +- 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) @@ -26,14 +28,14 @@ Generate a quote image using JSON request body. **Request:** ```bash 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 +-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 @@ -62,7 +64,7 @@ curl "https://quotes.imbenji.net/generate?displayName=John%20Doe&username=@johnd - **Content-Type:** `image/png` - **Headers:** - - `X-Cache`: `HIT` (served from cache) or `MISS` (newly generated) +- `X-Cache`: `HIT` (served from cache) or `MISS` (newly generated) ## Examples @@ -70,19 +72,19 @@ curl "https://quotes.imbenji.net/generate?displayName=John%20Doe&username=@johnd ```javascript 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) - }) +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; +const url = URL.createObjectURL(blob); +document.getElementById('img').src = url; }); ``` @@ -90,16 +92,16 @@ fetch('https://quotes.imbenji.net/generate', { ```javascript 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 - }) +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(); @@ -110,16 +112,16 @@ const blob = await response.blob(); ```javascript 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 - }) +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 +}) }); ``` @@ -130,40 +132,40 @@ 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()) + '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) + f.write(response.content) ``` ### cURL with GET ```bash 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 +--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 ```javascript 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 - }) +method: 'POST', +headers: { 'Content-Type': 'application/json' }, +body: JSON.stringify({ + displayName: 'Elon Musk', + username: '@elonmusk', + text: 'Just bought Twitter!', + verified: true, + timestamp: 1735574400 +}) }); ``` @@ -171,21 +173,21 @@ fetch('https://quotes.imbenji.net/generate', { ```javascript 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 - }) +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 +}) }); ``` @@ -205,6 +207,22 @@ The v2 API lets you build quote images incrementally. Instead of sending everyth 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. @@ -212,31 +230,41 @@ Create a new session. **Request:** ```bash curl -X POST https://quotes.imbenji.net/v2/quote \ - -H "Content-Type: application/json" \ - -d '{ - "displayName": "John Doe", - "username": "@johndoe", - "text": "Hello world" - }' +-H "Content-Type: application/json" \ +-d '{ + "displayName": "John Doe", + "username": "@johndoe", + "text": "Hello world" +}' ``` **Response (201):** ```json { - "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "displayName": "John Doe", - "username": "@johndoe", - "text": "Hello world", - "avatarUrl": null, - "imageUrl": null, - "timestamp": null, - "verified": false, - "engagement": null, - "createdAt": 1735600000, - "updatedAt": 1735600000 +"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. @@ -251,11 +279,11 @@ Update specific fields. Only send the fields you want to change. ```bash 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,..." - }' +-H "Content-Type: application/json" \ +-d '{ + "text": "Updated text!", + "avatarUrl": "data:image/png;base64,..." +}' ``` **Engagement updates:** @@ -263,31 +291,31 @@ curl -X PATCH https://quotes.imbenji.net/v2/quote/a1b2c3d4-e5f6-7890-abcd-ef1234 ```bash # 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 - } - }' +-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 - } - }' +-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 - }' +-H "Content-Type: application/json" \ +-d '{ + "engagement": null +}' ``` **Verified badge:** @@ -295,25 +323,135 @@ curl -X PATCH https://quotes.imbenji.net/v2/quote/a1b2c3d4-e5f6-7890-abcd-ef1234 ```bash # 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 - }' +-H "Content-Type: application/json" \ +-d '{ + "verified": true +}' ``` +### GET /v2/quote/:id/render-info + +Get real-time render progress via Server-Sent Events (SSE). + +```bash +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:** +```javascript +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 a PNG image. +Render the current session state as an image or video. ```bash +# 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` +- `Content-Type: image/png` (for images) or `video/mp4` (for videos) - `X-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. @@ -338,11 +476,11 @@ curl -X POST https://quotes.imbenji.net/v2/quote/a1b2c3d4-e5f6-7890-abcd-ef12345 **Response (201):** ```json { - "token": "xY9pQmN3kL8vFw2jRtZ7", - "url": "https://quotes.imbenji.net/v2/snapshot/xY9pQmN3kL8vFw2jRtZ7", - "sessionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "createdAt": 1735600000, - "expiresAt": 1735772800 +"token": "xY9pQmN3kL8vFw2jRtZ7", +"url": "https://quotes.imbenji.net/v2/snapshot/xY9pQmN3kL8vFw2jRtZ7", +"sessionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", +"createdAt": 1735600000, +"expiresAt": 1735772800 } ``` @@ -370,33 +508,33 @@ curl https://quotes.imbenji.net/v2/snapshot/xY9pQmN3kL8vFw2jRtZ7 --output snapsh ```javascript // 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' - }) +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...' - }) +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!' - }) +method: 'PATCH', +headers: { 'Content-Type': 'application/json' }, +body: JSON.stringify({ + text: 'Finished building something cool!' +}) }); // 4. Generate the final image @@ -405,13 +543,172 @@ 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' +method: 'POST' }); const snapshot = await snapshotRes.json(); console.log('Shareable URL:', snapshot.url); // Anyone can access: https://quotes.imbenji.net/v2/snapshot/ ``` +### v2 Video Example + +```javascript +// 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:** +```javascript +// 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:** +```html + + + + + + +
+
0%
+
+ + + + + +``` + ### v2 Parameters | Parameter | Type | Description | @@ -428,12 +725,12 @@ console.log('Shareable URL:', snapshot.url); **Engagement object:** ```json { - "engagement": { - "replies": 89, - "retweets": 567, - "likes": 1234, - "views": 50000 - } +"engagement": { + "replies": 89, + "retweets": 567, + "likes": 1234, + "views": 50000 +} } ``` @@ -495,9 +792,12 @@ The API will be available at `http://localhost:3000` ```bash npm install +npx playwright install chromium npm start ``` +**Note:** Video rendering requires Playwright and FFmpeg. The Docker setup handles this automatically. + ## Health Check ```bash @@ -507,20 +807,23 @@ curl https://quotes.imbenji.net/health Response: ```json { - "status": "ok" +"status": "ok" } ``` ## Technical Details -- **Resolution:** 3240x3240 (1:1 aspect ratio) -- **Format:** PNG +- **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 @@ -528,6 +831,10 @@ Response: - 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 @@ -535,4 +842,4 @@ For issues or questions, please contact the maintainer. --- -**Built with:** Node.js, Express, Puppeteer, SQLite +**Built with:** Node.js, Express, Puppeteer, Playwright, SQLite, FFmpeg diff --git a/api.js b/api.js index 40ffece..47f3147 100644 --- a/api.js +++ b/api.js @@ -3,10 +3,10 @@ const cors = require('cors'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); -const { initPool, renderHtml, POOL_SIZE } = require('./browserPool'); -const v2Routes = require('./v2Routes'); -const { cleanupExpiredSessions, cleanupExpiredSnapshots } = require('./db'); -const { notifyImageGenerated } = require('./discordWebhook'); +const { initPool, renderHtml, POOL_SIZE } = require('./src/services/browserPool'); +const v2Routes = require('./src/versiontwo'); +const { cleanupExpiredSessions, cleanupExpiredSnapshots } = require('./src/database/db'); +const { notifyImageGenerated } = require('./src/services/discordWebhook'); const app = express(); const PORT = 3000; @@ -220,7 +220,7 @@ function formatTimestamp(epoch) { return `${hour12}:${minutes} ${ampm} · ${month} ${day}, ${year}`; } -const TEMPLATE_PATH = path.join(__dirname, 'template.html'); +const TEMPLATE_PATH = path.join(__dirname, 'templates/template.html'); const templateHtml = fs.readFileSync(TEMPLATE_PATH, 'utf8'); function buildEngagementHtml(engagement) { diff --git a/generate.js b/generate.js deleted file mode 100644 index 4297251..0000000 --- a/generate.js +++ /dev/null @@ -1,51 +0,0 @@ -const fs = require('fs'); -const path = require('path'); -const { renderHtml, shutdownPool } = require('./browserPool'); - -async function generateQuote(config, outputPath = 'quote.png') { - const htmlTemplate = fs.readFileSync(path.join(__dirname, 'quote.html'), 'utf8'); - - const configScript = ` - - `; - - const modifiedHtml = htmlTemplate.replace('', `${configScript}`); - - const imageBuffer = await renderHtml(modifiedHtml, 500); - fs.writeFileSync(outputPath, imageBuffer); - - console.log(`Quote generated: ${outputPath}`); -} - - -// Example usage -const exampleTweet = { - displayName: "Geoff Marshall", - username: "@geofftech", - avatarUrl: "https://pbs.twimg.com/profile_images/1295490618140110848/Fu4chISB_x96.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.", - imageUrl: null, - timestamp: "10:04 AM · Jul 11, 2017", - viewsCount: "128K" -}; - -const exampleTweetWithImage = { - displayName: "miss katie", - username: "@katiopolis", - avatarUrl: "https://pbs.twimg.com/profile_images/2004569144893554688/KaYjqylC_x96.jpg", - text: "omg i brought these watches home because none of them worked but i thought id hang onto them and my dad fixed and polished them all for me 😭 i love him so much", - imageUrl: "https://pbs.twimg.com/media/G9Wtk1xWcAA-WMZ?format=jpg&name=large", - timestamp: "5:58 PM · Dec 29, 2025", - viewsCount: "1.1M" -}; - -// Generate both examples -(async () => { - await generateQuote(exampleTweet, 'quote-no-image.png'); - await generateQuote(exampleTweetWithImage, 'quote-with-image.png'); - await shutdownPool(); -})(); - -module.exports = { generateQuote }; diff --git a/package-lock.json b/package-lock.json index d7e0486..102b230 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,11 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@ffprobe-installer/ffprobe": "^1.4.1", "better-sqlite3": "^11.0.0", "cors": "^2.8.5", "express": "^4.18.2", + "playwright": "^1.48.0", "puppeteer": "^23.0.0" } }, @@ -38,6 +40,132 @@ "node": ">=6.9.0" } }, + "node_modules/@ffprobe-installer/darwin-arm64": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@ffprobe-installer/darwin-arm64/-/darwin-arm64-5.0.1.tgz", + "integrity": "sha512-vwNCNjokH8hfkbl6m95zICHwkSzhEvDC3GVBcUp5HX8+4wsX10SP3B+bGur7XUzTIZ4cQpgJmEIAx6TUwRepMg==", + "cpu": [ + "arm64" + ], + "hasInstallScript": true, + "license": "LGPL-2.1", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@ffprobe-installer/darwin-x64": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@ffprobe-installer/darwin-x64/-/darwin-x64-5.0.0.tgz", + "integrity": "sha512-Zl0UkZ+wW/eyMKBPLTUCcNQch2VDnZz/cBn1DXv3YtCBVbYd9aYzGj4MImdxgWcoE0+GpbfbO6mKGwMq5HCm6A==", + "cpu": [ + "x64" + ], + "hasInstallScript": true, + "license": "GPL-3.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@ffprobe-installer/ffprobe": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@ffprobe-installer/ffprobe/-/ffprobe-1.4.1.tgz", + "integrity": "sha512-3WJvxU0f4d7IOZdzoVCAj9fYtiQNC6E0521FJFe9iP5Ej8auTXU7TsrUzIAG1CydeQI+BnM3vGog92SCcF9KtA==", + "license": "LGPL-2.1", + "optionalDependencies": { + "@ffprobe-installer/darwin-arm64": "5.0.1", + "@ffprobe-installer/darwin-x64": "5.0.0", + "@ffprobe-installer/linux-arm": "5.0.0", + "@ffprobe-installer/linux-arm64": "5.0.0", + "@ffprobe-installer/linux-ia32": "5.0.0", + "@ffprobe-installer/linux-x64": "5.0.0", + "@ffprobe-installer/win32-ia32": "5.0.0", + "@ffprobe-installer/win32-x64": "5.0.0" + } + }, + "node_modules/@ffprobe-installer/linux-arm": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@ffprobe-installer/linux-arm/-/linux-arm-5.0.0.tgz", + "integrity": "sha512-mM1PPxP2UX5SUvhy0urcj5U8UolwbYgmnXA/eBWbW78k6N2Wk1COvcHYzOPs6c5yXXL6oshS2rZHU1kowigw7g==", + "cpu": [ + "arm" + ], + "hasInstallScript": true, + "license": "GPL-3.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ffprobe-installer/linux-arm64": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@ffprobe-installer/linux-arm64/-/linux-arm64-5.0.0.tgz", + "integrity": "sha512-IwFbzhe1UydR849YXLPP0RMpHgHXSuPO1kznaCHcU5FscFBV5gOZLkdD8e/xrcC8g/nhKqy0xMjn5kv6KkFQlQ==", + "cpu": [ + "arm64" + ], + "hasInstallScript": true, + "license": "GPL-3.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ffprobe-installer/linux-ia32": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@ffprobe-installer/linux-ia32/-/linux-ia32-5.0.0.tgz", + "integrity": "sha512-c3bWlWEDMST59SAZycVh0oyc2eNS/CxxeRjoNryGRgqcZX3EJWJJQL1rAXbpQOMLMi8to1RqnmMuwPJgLLjjUA==", + "cpu": [ + "ia32" + ], + "hasInstallScript": true, + "license": "GPL-3.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ffprobe-installer/linux-x64": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@ffprobe-installer/linux-x64/-/linux-x64-5.0.0.tgz", + "integrity": "sha512-zgLnWJFvMGCaw1txGtz84sMEQt6mQUzdw86ih9S/kZOWnp06Gj/ams/EXxEkAxgAACCVM6/O0mkDe/6biY5tgA==", + "cpu": [ + "x64" + ], + "hasInstallScript": true, + "license": "GPL-3.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ffprobe-installer/win32-ia32": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@ffprobe-installer/win32-ia32/-/win32-ia32-5.0.0.tgz", + "integrity": "sha512-NnDdAZD6ShFXzJeCkAFl2ZjAv7GcJWYudLA+0T/vjZwvskBop+sq1PGfdmVltfFDcdQiomoThRhn9Xiy9ZC71g==", + "cpu": [ + "ia32" + ], + "license": "GPL-3.0", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@ffprobe-installer/win32-x64": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@ffprobe-installer/win32-x64/-/win32-x64-5.0.0.tgz", + "integrity": "sha512-P4ZMRFxVMnfMsOyTfBM/+nkTodLeOUfXNPo+X1bKEWBiZxRErqX/IHS5sLA0yAH8XmtKZcL7Cu6M26ztGcQYxw==", + "cpu": [ + "x64" + ], + "license": "GPL-3.0", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@puppeteer/browsers": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.3.1.tgz", @@ -1011,6 +1139,20 @@ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "license": "MIT" }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -1590,6 +1732,36 @@ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, + "node_modules/playwright": { + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz", + "integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz", + "integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", diff --git a/package.json b/package.json index f160e64..6ef0d97 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "generate": "node generate.js", + "generate": "node scripts/generate.js", "start": "node api.js" }, "keywords": [], @@ -13,9 +13,11 @@ "license": "ISC", "type": "commonjs", "dependencies": { + "@ffprobe-installer/ffprobe": "^1.4.1", "better-sqlite3": "^11.0.0", "cors": "^2.8.5", "express": "^4.18.2", + "playwright": "^1.48.0", "puppeteer": "^23.0.0" } } diff --git a/quote.html b/quote.html deleted file mode 100644 index 753882d..0000000 --- a/quote.html +++ /dev/null @@ -1,336 +0,0 @@ - - - - - Quote Generator - - - - -
-
-
-
-
- - -
-
-
- -
-
- -
omg i brought these watches home because none of them worked but i thought id hang onto them and my dad fixed and polished them all for me 😭 i love him so much
- - - - - -
- - - - - - - - - -
-
-
- - - - - \ No newline at end of file diff --git a/db.js b/src/database/db.js similarity index 82% rename from db.js rename to src/database/db.js index 4e0d00c..e41e745 100644 --- a/db.js +++ b/src/database/db.js @@ -57,6 +57,52 @@ try { // column already exists, ignore } +// migration: add render tracking columns +try { + db.exec(` + ALTER TABLE sessions ADD COLUMN render_status TEXT DEFAULT 'completed'; + `); + console.log('Added render_status column'); +} catch (err) { + // column already exists, ignore +} + +try { + db.exec(` + ALTER TABLE sessions ADD COLUMN render_pid INTEGER; + `); + console.log('Added render_pid column'); +} catch (err) { + // column already exists, ignore +} + +try { + db.exec(` + ALTER TABLE sessions ADD COLUMN render_error TEXT; + `); + console.log('Added render_error column'); +} catch (err) { + // column already exists, ignore +} + +try { + db.exec(` + ALTER TABLE sessions ADD COLUMN render_progress INTEGER DEFAULT 0; + `); + console.log('Added render_progress column'); +} catch (err) { + // column already exists, ignore +} + +try { + db.exec(` + ALTER TABLE sessions ADD COLUMN render_stage TEXT DEFAULT 'idle'; + `); + console.log('Added render_stage column'); +} catch (err) { + // column already exists, ignore +} + function generateId() { return crypto.randomUUID(); @@ -101,6 +147,11 @@ function rowToSession(row) { timestamp: row.timestamp, verified: Boolean(row.verified), engagement: engagement, + renderStatus: row.render_status, + renderPid: row.render_pid, + renderError: row.render_error, + renderProgress: row.render_progress || 0, + renderStage: row.render_stage || 'idle', createdAt: row.created_at, updatedAt: row.updated_at }; @@ -114,8 +165,8 @@ function createSession(data = {}) { INSERT INTO sessions ( id, display_name, username, text, avatar_url, image_url, timestamp, verified, engagement_likes, engagement_retweets, engagement_replies, engagement_views, - created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + render_status, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); stmt.run( @@ -131,6 +182,7 @@ function createSession(data = {}) { data.engagement?.retweets ?? null, data.engagement?.replies ?? null, data.engagement?.views ?? null, + 'pending', now, now ); @@ -331,6 +383,33 @@ function cleanupExpiredSessions() { } } +function updateSessionRenderStatus(id, status, pid, error, progress = null, stage = null) { + const updates = ['render_status = ?', 'render_pid = ?', 'render_error = ?']; + const values = [status, pid, error]; + + if (progress !== null) { + updates.push('render_progress = ?'); + values.push(Math.min(100, Math.max(0, Math.floor(progress)))); + } + + if (stage !== null) { + updates.push('render_stage = ?'); + values.push(stage); + } + + updates.push('updated_at = ?'); + values.push(nowEpoch()); + values.push(id); + + const stmt = db.prepare(` + UPDATE sessions + SET ${updates.join(', ')} + WHERE id = ? + `); + + stmt.run(...values); +} + module.exports = { createSession, getSession, @@ -343,5 +422,6 @@ module.exports = { deleteSnapshot, getSnapshotsForSession, cleanupExpiredSnapshots, - countSnapshotsForSession + countSnapshotsForSession, + updateSessionRenderStatus }; diff --git a/browserPool.js b/src/services/browserPool.js similarity index 100% rename from browserPool.js rename to src/services/browserPool.js diff --git a/discordWebhook.js b/src/services/discordWebhook.js similarity index 100% rename from discordWebhook.js rename to src/services/discordWebhook.js diff --git a/template.html b/templates/template.html similarity index 100% rename from template.html rename to templates/template.html diff --git a/v2Routes.js b/v2Routes.js deleted file mode 100644 index 8cd7aee..0000000 --- a/v2Routes.js +++ /dev/null @@ -1,429 +0,0 @@ -const express = require('express'); -const fs = require('fs'); -const path = require('path'); -const crypto = require('crypto'); -const router = express.Router(); -const { createSession, getSession, updateSession, deleteSession, createSnapshot, getSnapshot, touchSnapshot } = require('./db'); -const { renderHtml } = require('./browserPool'); -const { notifyImageGenerated, notifySnapshotCreated } = require('./discordWebhook'); - -const CACHE_DIR = path.join(__dirname, 'cache'); - - -// get client ip address -function getClientIp(req) { - const forwarded = req.headers['x-forwarded-for']; - if (forwarded) { - return forwarded.split(',')[0].trim(); - } - return req.ip || req.socket.remoteAddress || 'unknown'; -} - -// caching functions -function normalizeConfig(config) { - const normalized = {}; - for (const [key, value] of Object.entries(config)) { - if (value !== null && value !== undefined && value !== '') { - normalized[key] = value; - } - } - return normalized; -} - -function hashConfig(config) { - const normalized = normalizeConfig(config); - const configString = JSON.stringify(normalized); - return crypto.createHash('sha256').update(configString).digest('hex'); -} - -function getCachedImage(config) { - const hash = hashConfig(config); - const cachePath = path.join(CACHE_DIR, `${hash}.png`); - - if (fs.existsSync(cachePath)) { - const now = new Date(); - fs.utimesSync(cachePath, now, now); - return fs.readFileSync(cachePath); - } - return null; -} - -function cacheImage(config, imageBuffer) { - const hash = hashConfig(config); - const cachePath = path.join(CACHE_DIR, `${hash}.png`); - fs.writeFileSync(cachePath, imageBuffer); -} - -function deleteCachedImage(config) { - const hash = hashConfig(config); - const cachePath = path.join(CACHE_DIR, `${hash}.png`); - if (fs.existsSync(cachePath)) { - fs.unlinkSync(cachePath); - } -} - -// build config from session for cache operations -function buildConfigFromSession(session) { - const timestamp = session.timestamp - ? formatTimestamp(session.timestamp) - : formatTimestamp(Math.floor(Date.now() / 1000)); - - let engagement = null; - if (session.engagement) { - engagement = { - likes: formatCount(session.engagement.likes), - retweets: formatCount(session.engagement.retweets), - replies: formatCount(session.engagement.replies), - views: formatCount(session.engagement.views) - }; - } - - return { - displayName: session.displayName, - username: session.username, - avatarUrl: session.avatarUrl, - text: session.text, - imageUrl: session.imageUrl, - timestamp: timestamp, - engagement: engagement, - verified: session.verified - }; -} - - -// helper functions (copied from api.js) - -function formatCount(num) { - if (num === null || num === undefined) return '0'; - - num = parseInt(num); - - if (num >= 1000000000) { - return (num / 1000000000).toFixed(1).replace(/\.0$/, '') + 'B'; - } - if (num >= 1000000) { - return (num / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'; - } - if (num >= 1000) { - return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'K'; - } - return num.toString(); -} - -function formatTimestamp(epoch) { - const date = new Date(epoch * 1000); - - const hours = date.getHours(); - const minutes = date.getMinutes().toString().padStart(2, '0'); - const ampm = hours >= 12 ? 'PM' : 'AM'; - const hour12 = hours % 12 || 12; - - const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - const month = months[date.getMonth()]; - const day = date.getDate(); - const year = date.getFullYear(); - - return `${hour12}:${minutes} ${ampm} · ${month} ${day}, ${year}`; -} - -function detectImageType(base64String) { - const base64Data = base64String.includes(',') ? base64String.split(',')[1] : base64String; - const buffer = Buffer.from(base64Data, 'base64'); - - if (buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) { - return 'image/jpeg'; - } else if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) { - return 'image/png'; - } else if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) { - return 'image/gif'; - } else if (buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46) { - return 'image/webp'; - } - return 'image/png'; -} - -function fixDataUri(dataUri) { - if (!dataUri || !dataUri.startsWith('data:')) return dataUri; - - const parts = dataUri.split(','); - if (parts.length !== 2) return dataUri; - - const base64Data = parts[1]; - - try { - const correctType = detectImageType(base64Data); - return `data:${correctType};base64,${base64Data}`; - } catch (error) { - console.error('Invalid base64 data:', error.message); - return null; - } -} - -function normalizeUsername(username) { - if (!username) return undefined; - - const trimmed = username.trim(); - - // If empty or just "@", return undefined - if (!trimmed || trimmed === '@') return undefined; - - // Add @ if it doesn't start with it - return trimmed.startsWith('@') ? trimmed : `@${trimmed}`; -} - - -const TEMPLATE_PATH = path.join(__dirname, 'template.html'); -const templateHtml = fs.readFileSync(TEMPLATE_PATH, 'utf8'); - -function buildEngagementHtml(engagement) { - if (!engagement) return ''; - return ` - - `; -} - -async function generateQuoteBuffer(config) { - const avatarHtml = config.avatarUrl - ? `` - : `
`; - - const imageHtml = config.imageUrl - ? `
` - : ''; - - const engagementHtml = buildEngagementHtml(config.engagement); - - const verifiedBadge = config.verified ? '' : ''; - - const html = templateHtml - .replace('{{avatarHtml}}', avatarHtml) - .replace('{{displayName}}', config.displayName) - .replace('{{verifiedBadge}}', verifiedBadge) - .replace('{{username}}', config.username) - .replace('{{text}}', config.text) - .replace('{{imageHtml}}', imageHtml) - .replace('{{timestamp}}', config.timestamp) - .replace('{{engagementHtml}}', engagementHtml); - - return await renderHtml(html, 1000); -} - - -// POST /v2/quote - create new session -router.post('/quote', (req, res) => { - try { - const data = { - displayName: req.body.displayName?.trim(), - username: normalizeUsername(req.body.username), - text: req.body.text, - avatarUrl: fixDataUri(req.body.avatarUrl), - imageUrl: fixDataUri(req.body.imageUrl), - timestamp: req.body.timestamp, - engagement: req.body.engagement, - verified: req.body.verified - }; - - const session = createSession(data); - res.status(201).json(session); - } catch (error) { - console.error('Failed to create session:', error); - res.status(500).json({ error: 'Failed to create session' }); - } -}); - -// GET /v2/quote/:id - get session state -router.get('/quote/:id', (req, res) => { - const session = getSession(req.params.id); - if (!session) { - return res.status(404).json({ error: 'Session not found or expired' }); - } - res.json(session); -}); - -// PATCH /v2/quote/:id - update session -router.patch('/quote/:id', (req, res) => { - try { - // get current session to invalidate its cached image - const currentSession = getSession(req.params.id); - if (!currentSession) { - return res.status(404).json({ error: 'Session not found or expired' }); - } - - // clear cached image for old state - const oldConfig = buildConfigFromSession(currentSession); - deleteCachedImage(oldConfig); - - const data = {}; - - if (req.body.displayName !== undefined) data.displayName = req.body.displayName?.trim(); - if (req.body.username !== undefined) { - data.username = normalizeUsername(req.body.username); - } - if (req.body.text !== undefined) data.text = req.body.text; - if (req.body.avatarUrl !== undefined) data.avatarUrl = fixDataUri(req.body.avatarUrl); - if (req.body.imageUrl !== undefined) data.imageUrl = fixDataUri(req.body.imageUrl); - if (req.body.timestamp !== undefined) data.timestamp = req.body.timestamp; - if (req.body.engagement !== undefined) data.engagement = req.body.engagement; - if (req.body.verified !== undefined) data.verified = req.body.verified; - - const session = updateSession(req.params.id, data); - if (!session) { - return res.status(404).json({ error: 'Session not found or expired' }); - } - - res.json(session); - } catch (error) { - console.error('Failed to update session:', error); - res.status(500).json({ error: 'Failed to update session' }); - } -}); - -// GET /v2/quote/:id/image - render the image -router.get('/quote/:id/image', async (req, res) => { - try { - const session = getSession(req.params.id); - if (!session) { - return res.status(404).json({ error: 'Session not found or expired' }); - } - - const timestamp = session.timestamp - ? formatTimestamp(session.timestamp) - : formatTimestamp(Math.floor(Date.now() / 1000)); - - let engagement = null; - if (session.engagement) { - engagement = { - likes: formatCount(session.engagement.likes), - retweets: formatCount(session.engagement.retweets), - replies: formatCount(session.engagement.replies), - views: formatCount(session.engagement.views) - }; - } - - const config = { - displayName: session.displayName, - username: session.username, - avatarUrl: session.avatarUrl, - text: session.text, - imageUrl: session.imageUrl, - timestamp: timestamp, - engagement: engagement, - verified: session.verified - }; - - // check cache first - let image = getCachedImage(config); - let fromCache = true; - - if (!image) { - image = await generateQuoteBuffer(config); - cacheImage(config, image); - fromCache = false; - - // notify discord about new image - const clientIp = getClientIp(req); - notifyImageGenerated(config, clientIp).catch(err => console.error('Discord notification failed:', err)); - } - - res.setHeader('Content-Type', 'image/png'); - res.setHeader('X-Cache', fromCache ? 'HIT' : 'MISS'); - res.setHeader('X-Session-Id', session.id); - res.send(image); - } catch (error) { - console.error('Failed to generate image:', error); - res.status(500).json({ error: 'Failed to generate image' }); - } -}); - - -// DELETE /v2/quote/:id - delete session -router.delete('/quote/:id', (req, res) => { - const deleted = deleteSession(req.params.id); - if (!deleted) { - return res.status(404).json({ error: 'Session not found or expired' }); - } - res.status(204).send(); -}); - -// POST /v2/quote/:id/snapshot-link - create snapshot link -router.post('/quote/:id/snapshot-link', (req, res) => { - try { - const session = getSession(req.params.id); - if (!session) { - return res.status(404).json({ error: 'Session not found or expired' }); - } - - const config = buildConfigFromSession(session); - const snapshot = createSnapshot(session.id, config); - - // notify discord about new snapshot - const clientIp = getClientIp(req); - notifySnapshotCreated(session.id, snapshot.token, clientIp).catch(err => console.error('Discord notification failed:', err)); - - const baseUrl = `${req.protocol}://${req.get('host')}`; - res.status(201).json({ - token: snapshot.token, - url: `${baseUrl}/v2/snapshot/${snapshot.token}`, - sessionId: session.id, - createdAt: snapshot.createdAt, - expiresAt: snapshot.accessedAt + (48 * 60 * 60) - }); - } catch (error) { - console.error('Failed to create snapshot:', error); - res.status(500).json({ error: 'Failed to create snapshot' }); - } -}); - -// GET /v2/snapshot/:token - retrieve snapshot image -router.get('/snapshot/:token', async (req, res) => { - try { - const snapshot = getSnapshot(req.params.token); - if (!snapshot) { - return res.status(404).json({ error: 'Snapshot not found or expired' }); - } - - touchSnapshot(req.params.token); - - const config = JSON.parse(snapshot.configJson); - - let image = getCachedImage(config); - let fromCache = true; - - if (!image) { - image = await generateQuoteBuffer(config); - cacheImage(config, image); - fromCache = false; - - // notify discord about new image - const clientIp = getClientIp(req); - notifyImageGenerated(config, clientIp).catch(err => console.error('Discord notification failed:', err)); - } - - res.setHeader('Content-Type', 'image/png'); - res.setHeader('X-Cache', fromCache ? 'HIT' : 'MISS'); - res.setHeader('X-Snapshot-Token', snapshot.token); - res.send(image); - } catch (error) { - console.error('Failed to retrieve snapshot:', error); - res.status(500).json({ error: 'Failed to retrieve snapshot' }); - } -}); - -module.exports = router;