Add Discord notifications for image generation and snapshot creation
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
*.png
|
||||
*.mp4
|
||||
cache/
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
@@ -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"]
|
||||
|
||||
615
README.md
615
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: <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/<token>
|
||||
```
|
||||
|
||||
### 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
|
||||
<!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 |
|
||||
@@ -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
|
||||
|
||||
10
api.js
10
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) {
|
||||
|
||||
51
generate.js
51
generate.js
@@ -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 = `
|
||||
<script>
|
||||
window.tweetConfig = ${JSON.stringify(config)};
|
||||
</script>
|
||||
`;
|
||||
|
||||
const modifiedHtml = htmlTemplate.replace('</head>', `${configScript}</head>`);
|
||||
|
||||
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 };
|
||||
172
package-lock.json
generated
172
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
336
quote.html
336
quote.html
@@ -1,336 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Quote Generator</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
.square-container {
|
||||
width: calc(100vmin - 20px);
|
||||
height: calc(100vmin - 20px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: #000;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tweet-container {
|
||||
width: 450px;
|
||||
background-color: rgb(0, 0, 0);
|
||||
/* border: 1px solid rgb(47, 51, 54); */
|
||||
padding: 12px 16px;
|
||||
box-sizing: border-box;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.tweet-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background-color: rgb(51, 54, 57);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tweet-header-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0px;
|
||||
}
|
||||
|
||||
.display-name {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: rgb(231, 233, 234);
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 15px;
|
||||
color: rgb(113, 118, 123);
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.tweet-text {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 15px;
|
||||
color: rgb(231, 233, 234);
|
||||
line-height: 20px;
|
||||
margin-bottom: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.tweet-image-container {
|
||||
margin-bottom: 12px;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(47, 51, 54);
|
||||
}
|
||||
|
||||
.tweet-image {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tweet-metadata {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tweet-time {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 15px;
|
||||
color: rgb(113, 118, 123);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.metadata-dot {
|
||||
color: rgb(113, 118, 123);
|
||||
}
|
||||
|
||||
.tweet-views {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.views-count {
|
||||
color: rgb(231, 233, 234);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.views-label {
|
||||
color: rgb(113, 118, 123);
|
||||
}
|
||||
|
||||
.tweet-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
max-width: 425px;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgb(113, 118, 123);
|
||||
cursor: pointer;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 13px;
|
||||
padding: 0;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
color: rgb(29, 155, 240);
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 18.75px;
|
||||
height: 18.75px;
|
||||
fill: currentColor;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="square-container">
|
||||
<div class="tweet-container">
|
||||
<div class="tweet-header">
|
||||
<div class="avatar-container">
|
||||
<div class="avatar" id="avatar">
|
||||
<img id="avatarImg" style="display: none;" />
|
||||
<div class="avatar-placeholder" id="avatarPlaceholder" style="display: none;">
|
||||
<div class="css-175oi2r r-sdzlij r-1udh08x r-45ll9u r-u8s1d r-1v2oles r-176fswd" style="width: calc(100% - 4px); height: calc(100% - 4px);">
|
||||
<div class="css-175oi2r r-172uzmj r-1pi2tsx r-13qz1uu r-1ny4l3l"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tweet-header-info">
|
||||
<div class="user-info">
|
||||
<span class="display-name" id="displayName">miss katie</span>
|
||||
<span class="username" id="username">@katiopolis</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tweet-text" id="tweetText">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</div>
|
||||
|
||||
<div class="tweet-image-container" id="imageContainer" style="display: none;">
|
||||
<img src="" alt="Image" class="tweet-image" id="tweetImage">
|
||||
</div>
|
||||
|
||||
<div class="tweet-metadata">
|
||||
<a href="#" class="tweet-time" id="tweetTime">5:58 PM · Dec 29, 2025</a>
|
||||
<!-- <span class="metadata-dot">·</span>
|
||||
<span class="tweet-views">
|
||||
<span class="views-count" id="viewsCount">1.1M</span> <span class="views-label">Views</span>
|
||||
</span> -->
|
||||
</div>
|
||||
|
||||
<div class="tweet-actions">
|
||||
<button class="action-button">
|
||||
<svg viewBox="0 0 24 24" class="action-icon">
|
||||
<g><path d="M1.751 10c0-4.42 3.584-8 8.005-8h4.366c4.49 0 8.129 3.64 8.129 8.13 0 2.96-1.607 5.68-4.196 7.11l-8.054 4.46v-3.69h-.067c-4.49.1-8.183-3.51-8.183-8.01zm8.005-6c-3.317 0-6.005 2.69-6.005 6 0 3.37 2.77 6.08 6.138 6.01l.351-.01h1.761v2.3l5.087-2.81c1.951-1.08 3.163-3.13 3.163-5.36 0-3.39-2.744-6.13-6.129-6.13H9.756z"></path></g>
|
||||
</svg>
|
||||
<span>134</span>
|
||||
</button>
|
||||
|
||||
<button class="action-button">
|
||||
<svg viewBox="0 0 24 24" class="action-icon">
|
||||
<g><path d="M4.5 3.88l4.432 4.14-1.364 1.46L5.5 7.55V16c0 1.1.896 2 2 2H13v2H7.5c-2.209 0-4-1.79-4-4V7.55L1.432 9.48.068 8.02 4.5 3.88zM16.5 6H11V4h5.5c2.209 0 4 1.79 4 4v8.45l2.068-1.93 1.364 1.46-4.432 4.14-4.432-4.14 1.364-1.46 2.068 1.93V8c0-1.1-.896-2-2-2z"></path></g>
|
||||
</svg>
|
||||
<span>3.3K</span>
|
||||
</button>
|
||||
|
||||
<button class="action-button">
|
||||
<svg viewBox="0 0 24 24" class="action-icon">
|
||||
<g><path d="M16.697 5.5c-1.222-.06-2.679.51-3.89 2.16l-.805 1.09-.806-1.09C9.984 6.01 8.526 5.44 7.304 5.5c-1.243.07-2.349.78-2.91 1.91-.552 1.12-.633 2.78.479 4.82 1.074 1.97 3.257 4.27 7.129 6.61 3.87-2.34 6.052-4.64 7.126-6.61 1.111-2.04 1.03-3.7.477-4.82-.561-1.13-1.666-1.84-2.908-1.91zm4.187 7.69c-1.351 2.48-4.001 5.12-8.379 7.67l-.503.3-.504-.3c-4.379-2.55-7.029-5.19-8.382-7.67-1.36-2.5-1.41-4.86-.514-6.67.887-1.79 2.647-2.91 4.601-3.01 1.651-.09 3.368.56 4.798 2.01 1.429-1.45 3.146-2.1 4.796-2.01 1.954.1 3.714 1.22 4.601 3.01.896 1.81.846 4.17-.514 6.67z"></path></g>
|
||||
</svg>
|
||||
<span>111K</span>
|
||||
</button>
|
||||
|
||||
<button class="action-button">
|
||||
<svg viewBox="0 0 24 24" class="action-icon">
|
||||
<g><path d="M4 4.5C4 3.12 5.119 2 6.5 2h11C18.881 2 20 3.12 20 4.5v18.44l-8-5.71-8 5.71V4.5zM6.5 4c-.276 0-.5.22-.5.5v14.56l6-4.29 6 4.29V4.5c0-.28-.224-.5-.5-.5h-11z"></path></g>
|
||||
</svg>
|
||||
<span>2.2K</span>
|
||||
</button>
|
||||
|
||||
<button class="action-button">
|
||||
<svg viewBox="0 0 24 24" class="action-icon">
|
||||
<g><path d="M12 2.59l5.7 5.7-1.41 1.42L13 6.41V16h-2V6.41l-3.3 3.3-1.41-1.42L12 2.59zM21 15l-.02 3.51c0 1.38-1.12 2.49-2.5 2.49H5.5C4.11 21 3 19.88 3 18.5V15h2v3.5c0 .28.22.5.5.5h12.98c.28 0 .5-.22.5-.5L19 15h2z"></path></g>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Configuration object - set this via Puppeteer
|
||||
window.tweetConfig = window.tweetConfig || {
|
||||
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: null,
|
||||
timestamp: "5:58 PM · Dec 29, 2025",
|
||||
viewsCount: "1.1M"
|
||||
};
|
||||
|
||||
function loadTweetData() {
|
||||
try {
|
||||
const config = window.tweetConfig;
|
||||
console.log('Loading tweet data with config:', config);
|
||||
|
||||
document.getElementById('displayName').textContent = config.displayName;
|
||||
document.getElementById('username').textContent = config.username;
|
||||
document.getElementById('tweetText').textContent = config.text;
|
||||
document.getElementById('tweetTime').textContent = config.timestamp;
|
||||
document.getElementById('viewsCount').textContent = config.viewsCount;
|
||||
|
||||
const avatarImg = document.getElementById('avatarImg');
|
||||
const avatarPlaceholder = document.getElementById('avatarPlaceholder');
|
||||
|
||||
if (config.avatarUrl) {
|
||||
console.log('Setting avatar URL, length:', config.avatarUrl.length);
|
||||
avatarImg.src = config.avatarUrl;
|
||||
avatarImg.style.display = 'block';
|
||||
avatarPlaceholder.style.display = 'none';
|
||||
console.log('Avatar image set successfully');
|
||||
} else {
|
||||
console.log('No avatarUrl provided, showing placeholder');
|
||||
avatarImg.style.display = 'none';
|
||||
avatarPlaceholder.style.display = 'flex';
|
||||
}
|
||||
|
||||
const imageContainer = document.getElementById('imageContainer');
|
||||
const tweetImage = document.getElementById('tweetImage');
|
||||
|
||||
if (config.imageUrl) {
|
||||
console.log('Setting image URL, length:', config.imageUrl.length);
|
||||
tweetImage.src = config.imageUrl;
|
||||
imageContainer.style.display = 'block';
|
||||
} else {
|
||||
console.log('No imageUrl provided, hiding image container');
|
||||
imageContainer.style.display = 'none';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading tweet data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function fitTweetToSquare() {
|
||||
const square = document.querySelector('.square-container');
|
||||
const tweet = document.querySelector('.tweet-container');
|
||||
|
||||
const squareWidth = square.offsetWidth;
|
||||
const squareHeight = square.offsetHeight;
|
||||
|
||||
tweet.style.transform = 'none';
|
||||
|
||||
const tweetWidth = tweet.offsetWidth;
|
||||
const tweetHeight = tweet.offsetHeight;
|
||||
|
||||
const scaleX = squareWidth / tweetWidth;
|
||||
const scaleY = squareHeight / tweetHeight;
|
||||
const scale = Math.min(scaleX, scaleY) * 0.95;
|
||||
|
||||
tweet.style.transform = `scale(${scale})`;
|
||||
}
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
loadTweetData();
|
||||
setTimeout(fitTweetToSquare, 100);
|
||||
});
|
||||
window.addEventListener('resize', fitTweetToSquare);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -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
|
||||
};
|
||||
429
v2Routes.js
429
v2Routes.js
@@ -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 `
|
||||
<div class="engagement-bar" role="group">
|
||||
<div class="engagement-item">
|
||||
<svg viewBox="0 0 24 24" class="engagement-icon"><g><path d="M1.751 10c0-4.42 3.584-8 8.005-8h4.366c4.49 0 8.129 3.64 8.129 8.13 0 2.96-1.607 5.68-4.196 7.11l-8.054 4.46v-3.69h-.067c-4.49.1-8.183-3.51-8.183-8.01zm8.005-6c-3.317 0-6.005 2.69-6.005 6 0 3.37 2.77 6.08 6.138 6.01l.351-.01h1.761v2.3l5.087-2.81c1.951-1.08 3.163-3.13 3.163-5.36 0-3.39-2.744-6.13-6.129-6.13H9.756z"></path></g></svg>
|
||||
<span class="engagement-count">${engagement.replies}</span>
|
||||
</div>
|
||||
<div class="engagement-item">
|
||||
<svg viewBox="0 0 24 24" class="engagement-icon"><g><path d="M4.5 3.88l4.432 4.14-1.364 1.46L5.5 7.55V16c0 1.1.896 2 2 2H13v2H7.5c-2.209 0-4-1.79-4-4V7.55L1.432 9.48.068 8.02 4.5 3.88zM16.5 6H11V4h5.5c2.209 0 4 1.79 4 4v8.45l2.068-1.93 1.364 1.46-4.432 4.14-4.432-4.14 1.364-1.46 2.068 1.93V8c0-1.1-.896-2-2-2z"></path></g></svg>
|
||||
<span class="engagement-count">${engagement.retweets}</span>
|
||||
</div>
|
||||
<div class="engagement-item">
|
||||
<svg viewBox="0 0 24 24" class="engagement-icon"><g><path d="M16.697 5.5c-1.222-.06-2.679.51-3.89 2.16l-.805 1.09-.806-1.09C9.984 6.01 8.526 5.44 7.304 5.5c-1.243.07-2.349.78-2.91 1.91-.552 1.12-.633 2.78.479 4.82 1.074 1.97 3.257 4.27 7.129 6.61 3.87-2.34 6.052-4.64 7.126-6.61 1.111-2.04 1.03-3.7.477-4.82-.561-1.13-1.666-1.84-2.908-1.91zm4.187 7.69c-1.351 2.48-4.001 5.12-8.379 7.67l-.503.3-.504-.3c-4.379-2.55-7.029-5.19-8.382-7.67-1.36-2.5-1.41-4.86-.514-6.67.887-1.79 2.647-2.91 4.601-3.01 1.651-.09 3.368.56 4.798 2.01 1.429-1.45 3.146-2.1 4.796-2.01 1.954.1 3.714 1.22 4.601 3.01.896 1.81.846 4.17-.514 6.67z"></path></g></svg>
|
||||
<span class="engagement-count">${engagement.likes}</span>
|
||||
</div>
|
||||
<div class="engagement-item">
|
||||
<svg viewBox="0 0 24 24" class="engagement-icon"><g><path d="M8.75 21V3h2v18h-2zM18 21V8.5h2V21h-2zM4 21l.004-10h2L6 21H4zm9.248 0v-7h2v7h-2z"></path></g></svg>
|
||||
<span class="engagement-count">${engagement.views}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function generateQuoteBuffer(config) {
|
||||
const avatarHtml = config.avatarUrl
|
||||
? `<img src="${config.avatarUrl}" style="width:100%;height:100%;object-fit:cover;" />`
|
||||
: `<div style="width:100%;height:100%;background:rgb(51,54,57);"></div>`;
|
||||
|
||||
const imageHtml = config.imageUrl
|
||||
? `<div class="tweet-image-container" style="margin-bottom:12px;border-radius:16px;overflow:hidden;border:1px solid rgb(47,51,54);"><img src="${config.imageUrl}" style="width:100%;display:block;" /></div>`
|
||||
: '';
|
||||
|
||||
const engagementHtml = buildEngagementHtml(config.engagement);
|
||||
|
||||
const verifiedBadge = config.verified ? '<svg viewBox="0 0 22 22" class="verified-badge"><g><path d="M20.396 11c-.018-.646-.215-1.275-.57-1.816-.354-.54-.852-.972-1.438-1.246.223-.607.27-1.264.14-1.897-.131-.634-.437-1.218-.882-1.687-.47-.445-1.053-.75-1.687-.882-.633-.13-1.29-.083-1.897.14-.273-.587-.704-1.086-1.245-1.44S11.647 1.62 11 1.604c-.646.017-1.273.213-1.813.568s-.969.854-1.24 1.44c-.608-.223-1.267-.272-1.902-.14-.635.13-1.22.436-1.69.882-.445.47-.749 1.055-.878 1.688-.13.633-.08 1.29.144 1.896-.587.274-1.087.705-1.443 1.245-.356.54-.555 1.17-.574 1.817.02.647.218 1.276.574 1.817.356.54.856.972 1.443 1.245-.224.606-.274 1.263-.144 1.896.13.634.433 1.218.877 1.688.47.443 1.054.747 1.687.878.633.132 1.29.084 1.897-.136.274.586.705 1.084 1.246 1.439.54.354 1.17.551 1.816.569.647-.016 1.276-.213 1.817-.567s.972-.854 1.245-1.44c.604.239 1.266.296 1.903.164.636-.132 1.22-.447 1.68-.907.46-.46.776-1.044.908-1.681s.075-1.299-.165-1.903c.586-.274 1.084-.705 1.439-1.246.354-.54.551-1.17.569-1.816zM9.662 14.85l-3.429-3.428 1.293-1.302 2.072 2.072 4.4-4.794 1.347 1.246z"></path></g></svg>' : '';
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user