Add v2 quote API with video support and Flutter tweet template widget
- Implement Express routes for creating, updating, retrieving, and deleting quote sessions - Add video detection and rendering pipeline using Playwright and FFmpeg - Add caching utilities for images and videos - Provide helpers for formatting counts, timestamps, and normalizing usernames - Add snapshot creation and retrieval endpoints - Implement SSE endpoint for render progress updates - Add Flutter widget for rendering tweet templates with image/video support
This commit is contained in:
709
docs/DISTRIBUTED_RENDERING_PLAN.md
Normal file
709
docs/DISTRIBUTED_RENDERING_PLAN.md
Normal file
@@ -0,0 +1,709 @@
|
||||
an # Distributed Render Node Architecture
|
||||
|
||||
## Goal
|
||||
Allow lightweight render nodes (like personal PC with GPU) to handle video rendering for the main server, with automatic fallback and minimal resource usage on nodes.
|
||||
|
||||
## Design Philosophy
|
||||
- **Single Codebase**: Same code runs as main server or render node (determined by env vars)
|
||||
- **Proxy-Like**: Nodes act almost like a rendering proxy with minimal state
|
||||
- **Stateful but Lightweight**: Nodes use local SQLite for temporary config caching (1hr TTL)
|
||||
- **Bandwidth Aware**: Minimize data transfer, handle slow upload speeds
|
||||
- **No Replication**: Nodes only cache render configs, never replicate main server's session DB
|
||||
- **Low Memory Usage**: Configs stored in SQLite instead of memory (important for large base64 data)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Main Server Mode (default)
|
||||
```env
|
||||
# No special vars needed - runs as normal API server
|
||||
```
|
||||
|
||||
### Render Node Mode
|
||||
```env
|
||||
MAIN_SERVER_URL=http://192.168.1.100:3000 # or ws://... for WebSocket URL
|
||||
NODE_KEY=shared-secret-key
|
||||
NODE_NAME=my-gaming-pc # optional, defaults to hostname
|
||||
HAS_GPU=true # optional, for prioritization
|
||||
```
|
||||
|
||||
**Note:** No port configuration needed - node connects outbound to main server via WebSocket
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ Main Server │
|
||||
│ - Full SQLite DB (sessions, snapshots) │
|
||||
│ - Long-term cache (24hr TTL) │
|
||||
│ - Node registry (in-memory) │
|
||||
│ - Job dispatcher │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌────────────┴────────────┐
|
||||
│ │
|
||||
┌───────▼─────────┐ ┌────────▼────────┐
|
||||
│ Render Node 1 │ │ Render Node 2 │
|
||||
│ (Your PC) │ │ (Server Local) │
|
||||
│ │ │ │
|
||||
│ - Local SQLite │ │ - Local SQLite │
|
||||
│ (cache only) │ │ (cache only) │
|
||||
│ - 1hr TTL │ │ - 1hr TTL │
|
||||
│ - Auto-cleanup │ │ - Auto-cleanup │
|
||||
│ - GPU: NVENC │ │ - CPU: libx264 │
|
||||
│ - Priority: 1 │ │ - Priority: 99 │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Initial Render (No Cache)
|
||||
```
|
||||
1. Client → Main Server: POST /v2/quote (with base64 image)
|
||||
2. Main Server: Store session in DB
|
||||
3. Main Server: Pick best available node
|
||||
4. Main Server → Node: POST /internal/render
|
||||
Payload: {
|
||||
sessionId: "abc123",
|
||||
config: { ...full config with base64... }
|
||||
}
|
||||
5. Node: Cache config temporarily (key: sessionId)
|
||||
6. Node: Render video
|
||||
7. Node → Main Server: Return MP4 buffer
|
||||
8. Main Server: Cache MP4 (24hr)
|
||||
9. Main Server: Update session renderStatus = 'completed'
|
||||
```
|
||||
|
||||
### Edit Same Session (Cache Hit)
|
||||
```
|
||||
1. Client → Main Server: PATCH /v2/quote/abc123 (change text)
|
||||
2. Main Server: Update session in DB
|
||||
3. Main Server: Check which node has sessionId cached
|
||||
4. Main Server → Same Node: POST /internal/render
|
||||
Payload: {
|
||||
sessionId: "abc123",
|
||||
config: { ...full config with base64... } // sent again but node may have it cached
|
||||
}
|
||||
5. Node: Check cache for sessionId → HIT! Reuse base64
|
||||
6. Node: Render with updated config
|
||||
7. Node → Main Server: Return MP4 buffer
|
||||
8. Main Server: Cache new MP4
|
||||
```
|
||||
|
||||
### Node Unavailable (Fallback)
|
||||
```
|
||||
1. Main Server → Node: POST /internal/render (5s timeout)
|
||||
2. Node: No response / timeout
|
||||
3. Main Server: Mark node as offline
|
||||
4. Main Server: Render locally (fallback)
|
||||
```
|
||||
|
||||
## WebSocket Communication
|
||||
|
||||
### Why WebSocket?
|
||||
- **No Port Forwarding**: Node initiates outbound connection to main server
|
||||
- **Persistent Connection**: Single connection for all communication
|
||||
- **Bidirectional**: Server can push jobs, node can push results
|
||||
- **Built-in Heartbeat**: WebSocket connection state = node status (no explicit heartbeat needed)
|
||||
- **Auto Ping/Pong**: WebSocket library handles keep-alive automatically
|
||||
|
||||
### Node Connection (on startup)
|
||||
```javascript
|
||||
let ws = null;
|
||||
|
||||
function connectToMainServer() {
|
||||
ws = new WebSocket('ws://main-server:3000/nodes');
|
||||
|
||||
ws.on('open', () => {
|
||||
console.log('[Node] Connected to main server');
|
||||
|
||||
// Send registration
|
||||
ws.send(JSON.stringify({
|
||||
type: 'register',
|
||||
key: NODE_KEY,
|
||||
name: NODE_NAME,
|
||||
hasGpu: HAS_GPU
|
||||
}));
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('[Node] Disconnected from main server, reconnecting in 5s...');
|
||||
setTimeout(connectToMainServer, 5000);
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('[Node] WebSocket error:', error.message);
|
||||
});
|
||||
}
|
||||
|
||||
connectToMainServer();
|
||||
```
|
||||
|
||||
**No explicit heartbeat needed** - WebSocket connection state indicates node availability
|
||||
|
||||
### Server Side
|
||||
```javascript
|
||||
// Main server accepts WebSocket connections
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
server.on('upgrade', (request, socket, head) => {
|
||||
if (request.url === '/nodes') {
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
wss.emit('connection', ws, request);
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Node Registry (Main Server, in-memory)
|
||||
```javascript
|
||||
const nodeRegistry = new Map(); // nodeId -> { ws, name, hasGpu, ... }
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
let nodeId = null;
|
||||
|
||||
ws.on('message', (data) => {
|
||||
const msg = JSON.parse(data);
|
||||
|
||||
if (msg.type === 'register') {
|
||||
// Verify key
|
||||
if (msg.key !== NODE_KEY) {
|
||||
ws.close(1008, 'Invalid key');
|
||||
return;
|
||||
}
|
||||
|
||||
nodeId = generateId();
|
||||
nodeRegistry.set(nodeId, {
|
||||
id: nodeId,
|
||||
ws: ws,
|
||||
name: msg.name,
|
||||
hasGpu: msg.hasGpu,
|
||||
currentJobs: 0,
|
||||
cachedSessions: new Set(),
|
||||
connectedAt: Date.now()
|
||||
});
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'registered',
|
||||
nodeId: nodeId
|
||||
}));
|
||||
|
||||
console.log(`[NodeRegistry] ${msg.name} registered (GPU: ${msg.hasGpu})`);
|
||||
}
|
||||
|
||||
// Handle other message types (progress, result_start, etc.)
|
||||
// ...
|
||||
});
|
||||
|
||||
// Automatic cleanup on disconnect
|
||||
ws.on('close', () => {
|
||||
if (nodeId && nodeRegistry.has(nodeId)) {
|
||||
const node = nodeRegistry.get(nodeId);
|
||||
console.log(`[NodeRegistry] ${node.name} disconnected`);
|
||||
|
||||
// Cancel any pending jobs on this node
|
||||
if (node.currentJobs > 0) {
|
||||
console.log(`[NodeRegistry] ${node.name} had ${node.currentJobs} active jobs, will fallback`);
|
||||
}
|
||||
|
||||
// Remove from registry
|
||||
nodeRegistry.delete(nodeId);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error(`[NodeRegistry] WebSocket error for node ${nodeId}:`, error.message);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Connection-based availability:**
|
||||
- Node in registry = online and available
|
||||
- WebSocket closes = automatically removed from registry
|
||||
- No need for timeout checks or heartbeat monitoring
|
||||
|
||||
## Render Job Dispatch
|
||||
|
||||
### Node Selection Algorithm
|
||||
```javascript
|
||||
function selectNode(sessionId) {
|
||||
const onlineNodes = Object.values(nodeRegistry)
|
||||
.filter(n => n.status === 'online')
|
||||
.sort((a, b) => {
|
||||
// 1. Prefer node with session cached
|
||||
const aHasCache = a.cachedSessions.has(sessionId);
|
||||
const bHasCache = b.cachedSessions.has(sessionId);
|
||||
if (aHasCache && !bHasCache) return -1;
|
||||
if (!aHasCache && bHasCache) return 1;
|
||||
|
||||
// 2. Prefer GPU nodes
|
||||
if (a.hasGpu && !b.hasGpu) return -1;
|
||||
if (!a.hasGpu && b.hasGpu) return 1;
|
||||
|
||||
// 3. Prefer nodes with fewer jobs
|
||||
return a.currentJobs - b.currentJobs;
|
||||
});
|
||||
|
||||
return onlineNodes[0] || null; // null = render locally
|
||||
}
|
||||
```
|
||||
|
||||
## WebSocket Message Protocol
|
||||
|
||||
### Server → Node: Render Job
|
||||
```json
|
||||
{
|
||||
"type": "render",
|
||||
"jobId": "uuid-job-123",
|
||||
"sessionId": "abc123",
|
||||
"config": {
|
||||
"displayName": "User",
|
||||
"username": "@user",
|
||||
"text": "Hello",
|
||||
"avatarUrl": "data:image/png;base64,...",
|
||||
"imageUrl": "data:video/mp4;base64,...",
|
||||
"timestamp": 1234567890,
|
||||
"verified": true,
|
||||
"engagement": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Node → Server: Progress Update
|
||||
```json
|
||||
{
|
||||
"type": "progress",
|
||||
"jobId": "uuid-job-123",
|
||||
"sessionId": "abc123",
|
||||
"stage": "rendering", // or "encoding"
|
||||
"progress": 45
|
||||
}
|
||||
```
|
||||
|
||||
### Node → Server: Render Complete (Chunked Transfer)
|
||||
|
||||
**Step 1: Send metadata**
|
||||
```json
|
||||
{
|
||||
"type": "result_start",
|
||||
"jobId": "uuid-job-123",
|
||||
"sessionId": "abc123",
|
||||
"success": true,
|
||||
"format": "mp4",
|
||||
"totalSize": 15728640,
|
||||
"chunkSize": 1048576,
|
||||
"totalChunks": 15
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Send chunks (1MB each)**
|
||||
```json
|
||||
{
|
||||
"type": "result_chunk",
|
||||
"jobId": "uuid-job-123",
|
||||
"chunkIndex": 0,
|
||||
"data": "<base64-encoded chunk>"
|
||||
}
|
||||
```
|
||||
```json
|
||||
{
|
||||
"type": "result_chunk",
|
||||
"jobId": "uuid-job-123",
|
||||
"chunkIndex": 1,
|
||||
"data": "<base64-encoded chunk>"
|
||||
}
|
||||
```
|
||||
... (continue for all chunks)
|
||||
|
||||
**Step 3: Send completion**
|
||||
```json
|
||||
{
|
||||
"type": "result_end",
|
||||
"jobId": "uuid-job-123",
|
||||
"sessionId": "abc123"
|
||||
}
|
||||
```
|
||||
|
||||
### Node → Server: Render Failed
|
||||
```json
|
||||
{
|
||||
"type": "result",
|
||||
"jobId": "uuid-job-123",
|
||||
"sessionId": "abc123",
|
||||
"success": false,
|
||||
"error": "FFmpeg encoding failed"
|
||||
}
|
||||
```
|
||||
|
||||
## Node Implementation
|
||||
|
||||
### WebSocket Client (on node)
|
||||
```javascript
|
||||
const ws = new WebSocket(`ws://${MAIN_SERVER_URL}/nodes`);
|
||||
|
||||
ws.on('message', async (data) => {
|
||||
const msg = JSON.parse(data);
|
||||
|
||||
if (msg.type === 'render') {
|
||||
// Check cache first (SQLite lookup)
|
||||
let config = getCachedConfig(msg.sessionId);
|
||||
if (!config) {
|
||||
// Cache miss - use provided config and cache it
|
||||
config = msg.config;
|
||||
cacheConfig(msg.sessionId, config);
|
||||
console.log(`[Node] Cache miss for session ${msg.sessionId}, cached new config`);
|
||||
} else {
|
||||
console.log(`[Node] Cache hit for session ${msg.sessionId}, reusing cached config`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Render video/image
|
||||
const buffer = await renderVideo(config, (progress, stage) => {
|
||||
// Send progress updates during rendering
|
||||
ws.send(JSON.stringify({
|
||||
type: 'progress',
|
||||
jobId: msg.jobId,
|
||||
sessionId: msg.sessionId,
|
||||
stage: stage,
|
||||
progress: progress
|
||||
}));
|
||||
});
|
||||
|
||||
// Send result in chunks
|
||||
const CHUNK_SIZE = 1048576; // 1MB chunks
|
||||
const totalChunks = Math.ceil(buffer.length / CHUNK_SIZE);
|
||||
|
||||
// Step 1: Send metadata
|
||||
ws.send(JSON.stringify({
|
||||
type: 'result_start',
|
||||
jobId: msg.jobId,
|
||||
sessionId: msg.sessionId,
|
||||
success: true,
|
||||
format: isVideo ? 'mp4' : 'png',
|
||||
totalSize: buffer.length,
|
||||
chunkSize: CHUNK_SIZE,
|
||||
totalChunks: totalChunks
|
||||
}));
|
||||
|
||||
// Step 2: Send chunks
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const start = i * CHUNK_SIZE;
|
||||
const end = Math.min(start + CHUNK_SIZE, buffer.length);
|
||||
const chunk = buffer.slice(start, end);
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'result_chunk',
|
||||
jobId: msg.jobId,
|
||||
chunkIndex: i,
|
||||
data: chunk.toString('base64')
|
||||
}));
|
||||
|
||||
// Small delay to avoid overwhelming the connection
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
// Step 3: Send completion
|
||||
ws.send(JSON.stringify({
|
||||
type: 'result_end',
|
||||
jobId: msg.jobId,
|
||||
sessionId: msg.sessionId
|
||||
}));
|
||||
|
||||
} catch (error) {
|
||||
// Send error
|
||||
ws.send(JSON.stringify({
|
||||
type: 'result',
|
||||
jobId: msg.jobId,
|
||||
sessionId: msg.sessionId,
|
||||
success: false,
|
||||
error: error.message
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Server-Side: Reassembling Chunks
|
||||
```javascript
|
||||
const pendingJobs = new Map(); // jobId -> { chunks, metadata }
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
// ... registration code ...
|
||||
|
||||
ws.on('message', (data) => {
|
||||
const msg = JSON.parse(data);
|
||||
|
||||
if (msg.type === 'result_start') {
|
||||
// Initialize job
|
||||
pendingJobs.set(msg.jobId, {
|
||||
sessionId: msg.sessionId,
|
||||
format: msg.format,
|
||||
totalSize: msg.totalSize,
|
||||
totalChunks: msg.totalChunks,
|
||||
chunks: new Array(msg.totalChunks),
|
||||
receivedChunks: 0
|
||||
});
|
||||
}
|
||||
|
||||
else if (msg.type === 'result_chunk') {
|
||||
const job = pendingJobs.get(msg.jobId);
|
||||
if (!job) return;
|
||||
|
||||
// Store chunk
|
||||
job.chunks[msg.chunkIndex] = Buffer.from(msg.data, 'base64');
|
||||
job.receivedChunks++;
|
||||
|
||||
// Log progress
|
||||
const uploadProgress = Math.floor((job.receivedChunks / job.totalChunks) * 100);
|
||||
console.log(`[Job ${msg.jobId}] Upload progress: ${uploadProgress}%`);
|
||||
}
|
||||
|
||||
else if (msg.type === 'result_end') {
|
||||
const job = pendingJobs.get(msg.jobId);
|
||||
if (!job) return;
|
||||
|
||||
// Reassemble buffer
|
||||
const completeBuffer = Buffer.concat(job.chunks);
|
||||
|
||||
// Cache the result
|
||||
cacheVideo(job.sessionId, completeBuffer);
|
||||
|
||||
// Update session status
|
||||
updateSessionRenderStatus(job.sessionId, 'completed', null, null, 100, 'completed');
|
||||
|
||||
// Cleanup
|
||||
pendingJobs.delete(msg.jobId);
|
||||
|
||||
console.log(`[Job ${msg.jobId}] Render complete, cached ${completeBuffer.length} bytes`);
|
||||
}
|
||||
|
||||
else if (msg.type === 'progress') {
|
||||
// Forward progress to SSE clients
|
||||
updateSessionRenderStatus(msg.sessionId, 'rendering', null, null, msg.progress, msg.stage);
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Node Cache (SQLite)
|
||||
```javascript
|
||||
// Lightweight SQLite DB on each node (data/node_cache.db)
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database('data/node_cache.db');
|
||||
|
||||
// Create cache table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS config_cache (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
config_json TEXT NOT NULL,
|
||||
cached_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Cache config
|
||||
function cacheConfig(sessionId, config) {
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO config_cache (session_id, config_json, cached_at)
|
||||
VALUES (?, ?, ?)
|
||||
`);
|
||||
stmt.run(sessionId, JSON.stringify(config), Date.now());
|
||||
}
|
||||
|
||||
// Get cached config
|
||||
function getCachedConfig(sessionId) {
|
||||
const stmt = db.prepare(`
|
||||
SELECT config_json FROM config_cache
|
||||
WHERE session_id = ? AND cached_at > ?
|
||||
`);
|
||||
const oneHourAgo = Date.now() - 3600000;
|
||||
const row = stmt.get(sessionId, oneHourAgo);
|
||||
return row ? JSON.parse(row.config_json) : null;
|
||||
}
|
||||
|
||||
// Aggressive cleanup every 10 minutes
|
||||
setInterval(() => {
|
||||
const oneHourAgo = Date.now() - 3600000;
|
||||
const stmt = db.prepare('DELETE FROM config_cache WHERE cached_at < ?');
|
||||
const result = stmt.run(oneHourAgo);
|
||||
if (result.changes > 0) {
|
||||
console.log(`[Node Cache] Cleaned up ${result.changes} expired config(s)`);
|
||||
}
|
||||
}, 600000); // 10 minutes
|
||||
|
||||
// Cleanup on exit
|
||||
process.on('exit', () => {
|
||||
db.close();
|
||||
});
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Low memory usage (configs stored on disk)
|
||||
- Fast lookups (indexed by session_id)
|
||||
- Automatic persistence (survives restarts if < 1hr old)
|
||||
- Small disk footprint (configs expire after 1 hour)
|
||||
|
||||
## Bandwidth Optimizations
|
||||
|
||||
### 1. Chunked Transfer
|
||||
- Videos sent in 1MB chunks over WebSocket
|
||||
- Prevents memory issues with large files
|
||||
- Allows progress tracking during upload
|
||||
- Small delay between chunks prevents connection overwhelming
|
||||
- Server can timeout slow uploads and fallback to local
|
||||
|
||||
### 2. Cache Reuse
|
||||
- Node keeps config cached for 1 hour
|
||||
- If main server sends same sessionId, node reuses cached base64
|
||||
- Only updated fields need to be applied
|
||||
- Massive bandwidth savings on session edits
|
||||
|
||||
### 3. Timeout & Fallback
|
||||
- Monitor upload progress per chunk
|
||||
- If upload stalls for >30s, cancel and fallback
|
||||
- Track node performance metrics (avg upload speed)
|
||||
- Prefer faster nodes for future jobs
|
||||
|
||||
### 4. Compression (Optional)
|
||||
- Could gzip chunks if bandwidth is critical
|
||||
- Probably not needed - MP4 is already compressed
|
||||
- Base64 encoding adds ~33% overhead (unavoidable for JSON)
|
||||
|
||||
## Security
|
||||
|
||||
### Authentication
|
||||
- Shared `NODE_KEY` in environment
|
||||
- All internal endpoints check `X-Node-Key` header
|
||||
- Reject requests without valid key
|
||||
|
||||
### Rate Limiting (Optional)
|
||||
- Limit render jobs per node to prevent abuse
|
||||
- Max 5 concurrent jobs per node
|
||||
|
||||
## Monitoring & Debugging
|
||||
|
||||
### Main Server Logs
|
||||
```
|
||||
[NodeRegistry] my-gaming-pc registered (GPU: true)
|
||||
[NodeRegistry] my-gaming-pc heartbeat received
|
||||
[Dispatcher] Assigned session abc123 to my-gaming-pc (cache hit)
|
||||
[Dispatcher] Node my-gaming-pc offline, rendering locally
|
||||
```
|
||||
|
||||
### Node Logs
|
||||
```
|
||||
[Node] Registered with main server: http://192.168.1.100:3000
|
||||
[Node] Heartbeat sent
|
||||
[Node] Render request received: session abc123 (cache miss)
|
||||
[Node] Render completed in 3.2s
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Node Failures
|
||||
1. **Node offline**: Fallback to local render immediately
|
||||
2. **Node timeout**: Wait 5s max, then fallback
|
||||
3. **Render failed on node**: Node returns 500, main server fallbacks
|
||||
4. **Upload timeout**: If MP4 upload takes >30s, cancel and fallback
|
||||
|
||||
### Node Recovery
|
||||
- Node auto-reconnects 5s after WebSocket disconnection
|
||||
- Node clears cache on restart
|
||||
- Main server automatically removes node from registry on WebSocket close
|
||||
- No timeout monitoring needed - connection state is the source of truth
|
||||
|
||||
## File Changes
|
||||
|
||||
### New Files
|
||||
- `renderNode.js` - Node WebSocket client, cache management, render worker
|
||||
- `nodeRegistry.js` - Main server's WebSocket server, node management, job dispatcher
|
||||
- `DISTRIBUTED_RENDERING_PLAN.md` - This file
|
||||
|
||||
### Modified Files
|
||||
- `api.js` - Initialize WebSocket server and node mode detection
|
||||
- `videoRenderer.js` - Check for available nodes before local render, dispatch jobs via WebSocket
|
||||
- `v2Routes.js` - Forward progress updates from nodes to SSE clients
|
||||
|
||||
### Dependencies to Add
|
||||
```json
|
||||
{
|
||||
"ws": "^8.14.0"
|
||||
}
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Main Server
|
||||
```bash
|
||||
# .env
|
||||
# No special config needed
|
||||
npm start
|
||||
```
|
||||
|
||||
### Render Node (Your PC)
|
||||
```bash
|
||||
# .env
|
||||
MAIN_SERVER_URL=http://your-server-ip:3000
|
||||
NODE_KEY=your-shared-secret
|
||||
HAS_GPU=true
|
||||
|
||||
npm start
|
||||
```
|
||||
|
||||
**Node Resource Usage:**
|
||||
- **Memory**: Low (~50-100MB idle, spikes during render)
|
||||
- **Disk**: `data/node_cache.db` (typically < 10MB, auto-cleaned every 10 min)
|
||||
- **CPU/GPU**: Only used during active renders
|
||||
- **Network**: Minimal (WebSocket connection + render jobs)
|
||||
|
||||
### Docker Compose Example
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
main-server:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
|
||||
render-node-local:
|
||||
build: .
|
||||
environment:
|
||||
- MAIN_SERVER_URL=http://main-server:3000
|
||||
- NODE_KEY=shared-secret
|
||||
- NODE_NAME=local-fallback
|
||||
- NODE_PORT=3001
|
||||
```
|
||||
|
||||
## Testing Plan
|
||||
|
||||
### Unit Tests
|
||||
- [ ] Node registration with valid/invalid key
|
||||
- [ ] Heartbeat updates lastHeartbeat timestamp
|
||||
- [ ] selectNode() prioritizes GPU nodes
|
||||
- [ ] selectNode() prioritizes cached sessions
|
||||
- [ ] Cache cleanup removes old entries
|
||||
|
||||
### Integration Tests
|
||||
- [ ] Node registers and receives heartbeat acknowledgment
|
||||
- [ ] Main server dispatches job to online node
|
||||
- [ ] Main server falls back when node is offline
|
||||
- [ ] Session edit reuses cached config on same node
|
||||
- [ ] Node cache expires after 1 hour
|
||||
- [ ] Multiple nodes balance load
|
||||
|
||||
### Manual Tests
|
||||
1. Start main server
|
||||
2. Start node on PC with GPU
|
||||
3. Create session with video → verify node renders
|
||||
4. Stop node → verify fallback to local
|
||||
5. Edit session → verify same node reused (if online)
|
||||
6. Wait 1 hour → verify node cache cleared
|
||||
|
||||
## Future Enhancements (Out of Scope)
|
||||
|
||||
- Load balancing across multiple nodes
|
||||
- Node performance metrics (render time tracking)
|
||||
- Node priority override (manual priority setting)
|
||||
- Webhook notifications when node comes online/offline
|
||||
- Web UI for node management
|
||||
- Automatic node discovery (mDNS/Bonjour)
|
||||
402
lib/tweet_template_widget.dart
Normal file
402
lib/tweet_template_widget.dart
Normal file
@@ -0,0 +1,402 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
class TweetTemplateWidget extends StatefulWidget {
|
||||
final Uint8List? avatarBytes;
|
||||
final String displayName;
|
||||
final bool isVerified;
|
||||
final String username;
|
||||
final String text;
|
||||
final Uint8List? imageBytes;
|
||||
final Uint8List? videoBytes;
|
||||
final DateTime timestamp;
|
||||
final Map<String, int>? engagement; // {replies, retweets, likes, views}
|
||||
|
||||
const TweetTemplateWidget({
|
||||
Key? key,
|
||||
this.avatarBytes,
|
||||
required this.displayName,
|
||||
this.isVerified = false,
|
||||
required this.username,
|
||||
required this.text,
|
||||
this.imageBytes,
|
||||
this.videoBytes,
|
||||
required this.timestamp,
|
||||
this.engagement,
|
||||
}) : assert(imageBytes == null || videoBytes == null,
|
||||
'Cannot have both imageBytes and videoBytes set at the same time'),
|
||||
super(key: key);
|
||||
|
||||
@override
|
||||
State<TweetTemplateWidget> createState() => _TweetTemplateWidgetState();
|
||||
}
|
||||
|
||||
class _TweetTemplateWidgetState extends State<TweetTemplateWidget> {
|
||||
VideoPlayerController? _videoController;
|
||||
late String _normalizedUsername;
|
||||
File? _tempVideoFile;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_normalizedUsername = _normalizeUsername(widget.username);
|
||||
_initializeMedia();
|
||||
}
|
||||
|
||||
String _normalizeUsername(String username) {
|
||||
final trimmed = username.trim();
|
||||
|
||||
// if empty or just "@", return default
|
||||
if (trimmed.isEmpty || trimmed == '@') return '@anonymous';
|
||||
|
||||
// add @ if it doesnt start with it
|
||||
return trimmed.startsWith('@') ? trimmed : '@$trimmed';
|
||||
}
|
||||
|
||||
Future<void> _initializeMedia() async {
|
||||
if (widget.videoBytes == null) return;
|
||||
|
||||
// write video bytes to a temp file
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempPath = '${tempDir.path}/temp_video_${DateTime.now().millisecondsSinceEpoch}.mp4';
|
||||
_tempVideoFile = File(tempPath);
|
||||
await _tempVideoFile!.writeAsBytes(widget.videoBytes!);
|
||||
|
||||
_videoController = VideoPlayerController.file(_tempVideoFile!)
|
||||
..initialize().then((_) {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
_videoController?.play();
|
||||
_videoController?.setLooping(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_videoController?.dispose();
|
||||
_tempVideoFile?.delete().catchError((_) {});
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: Colors.black,
|
||||
child: Center(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final size = constraints.biggest.shortestSide - 20;
|
||||
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
color: Colors.black,
|
||||
child: Center(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.contain,
|
||||
child: SizedBox(
|
||||
width: 450,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
color: Colors.black,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// header with avatar and user info
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Avatar
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
margin: const EdgeInsets.only(right: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color.fromRGBO(51, 54, 57, 1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: widget.avatarBytes != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Image.memory(
|
||||
widget.avatarBytes!,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
// user info section
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
widget.displayName,
|
||||
style: const TextStyle(
|
||||
fontFamily: '-apple-system',
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color.fromRGBO(231, 233, 234, 1),
|
||||
height: 1.33,
|
||||
),
|
||||
),
|
||||
if (widget.isVerified) ...[
|
||||
const SizedBox(width: 4),
|
||||
SvgPicture.string(
|
||||
_verifiedBadgeSvg,
|
||||
width: 18,
|
||||
height: 18,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
Text(
|
||||
_normalizedUsername,
|
||||
style: const TextStyle(
|
||||
fontFamily: '-apple-system',
|
||||
fontSize: 15,
|
||||
color: Color.fromRGBO(113, 118, 123, 1),
|
||||
height: 1.33,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
|
||||
const SizedBox(height: 12),
|
||||
// tweet text
|
||||
Text(
|
||||
widget.text,
|
||||
style: const TextStyle(
|
||||
fontFamily: '-apple-system',
|
||||
fontSize: 15,
|
||||
color: Color.fromRGBO(231, 233, 234, 1),
|
||||
height: 1.33,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// media content (image or vdeo)
|
||||
if (widget.imageBytes != null) ...[
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: const Color.fromRGBO(47, 51, 54, 1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Image.memory(
|
||||
widget.imageBytes!,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
if (widget.videoBytes != null) ...[
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: const Color.fromRGBO(47, 51, 54, 1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: (_videoController?.value.isInitialized ?? false)
|
||||
? AspectRatio(
|
||||
aspectRatio: _videoController!.value.aspectRatio,
|
||||
child: VideoPlayer(_videoController!),
|
||||
)
|
||||
: Container(
|
||||
height: 200,
|
||||
color: const Color.fromRGBO(51, 54, 57, 1),
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Color.fromRGBO(29, 155, 240, 1),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
// timestmp and source
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
style: const TextStyle(
|
||||
fontFamily: '-apple-system',
|
||||
fontSize: 15,
|
||||
color: Color.fromRGBO(113, 118, 123, 1),
|
||||
),
|
||||
children: [
|
||||
TextSpan(text: _formatTimestamp(widget.timestamp)),
|
||||
const TextSpan(text: ' · '),
|
||||
const TextSpan(
|
||||
text: 'via tweetforge.imbenji.net',
|
||||
style: TextStyle(
|
||||
color: Color.fromRGBO(29, 155, 240, 1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// engagement metrics if provided
|
||||
if (widget.engagement != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Color.fromRGBO(47, 51, 54, 1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (widget.engagement!['replies'] != null)
|
||||
_buildEngagementItem(
|
||||
_replyIcon,
|
||||
widget.engagement!['replies']!,
|
||||
),
|
||||
if (widget.engagement!['retweets'] != null)
|
||||
_buildEngagementItem(
|
||||
_retweetIcon,
|
||||
widget.engagement!['retweets']!,
|
||||
),
|
||||
if (widget.engagement!['likes'] != null)
|
||||
_buildEngagementItem(
|
||||
_likeIcon,
|
||||
widget.engagement!['likes']!,
|
||||
),
|
||||
if (widget.engagement!['views'] != null)
|
||||
_buildEngagementItem(
|
||||
_viewsIcon,
|
||||
widget.engagement!['views']!,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEngagementItem(String iconSvg, int count) {
|
||||
return Row(
|
||||
children: [
|
||||
SvgPicture.string(
|
||||
iconSvg,
|
||||
width: 18.75,
|
||||
height: 18.75,
|
||||
colorFilter: const ColorFilter.mode(
|
||||
Color.fromRGBO(113, 118, 123, 1),
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_formatCount(count),
|
||||
style: const TextStyle(
|
||||
fontFamily: '-apple-system',
|
||||
fontSize: 13,
|
||||
color: Color.fromRGBO(113, 118, 123, 1),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _formatCount(int count) {
|
||||
if (count >= 1000000000) {
|
||||
final formatted = (count / 1000000000).toStringAsFixed(1);
|
||||
return formatted.replaceAll(RegExp(r'\.0$'), '') + 'B';
|
||||
}
|
||||
if (count >= 1000000) {
|
||||
final formatted = (count / 1000000).toStringAsFixed(1);
|
||||
return formatted.replaceAll(RegExp(r'\.0$'), '') + 'M';
|
||||
}
|
||||
if (count >= 1000) {
|
||||
final formatted = (count / 1000).toStringAsFixed(1);
|
||||
return formatted.replaceAll(RegExp(r'\.0$'), '') + 'K';
|
||||
}
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
String _formatTimestamp(DateTime date) {
|
||||
final hours = date.hour;
|
||||
final minutes = date.minute.toString().padLeft(2, '0');
|
||||
final ampm = hours >= 12 ? 'PM' : 'AM';
|
||||
final hour12 = hours % 12 == 0 ? 12 : hours % 12;
|
||||
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
final month = months[date.month - 1];
|
||||
final day = date.day;
|
||||
final year = date.year;
|
||||
|
||||
return '$hour12:$minutes $ampm · $month $day, $year';
|
||||
}
|
||||
|
||||
// verified badge svg path
|
||||
static const String _verifiedBadgeSvg = '''
|
||||
<svg viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#1D9BF0" 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"/>
|
||||
</svg>
|
||||
''';
|
||||
|
||||
// engagment icons (simplified versions)
|
||||
static const String _replyIcon = '''
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<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"/>
|
||||
</svg>
|
||||
''';
|
||||
|
||||
static const String _retweetIcon = '''
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<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"/>
|
||||
</svg>
|
||||
''';
|
||||
|
||||
static const String _likeIcon = '''
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<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"/>
|
||||
</svg>
|
||||
''';
|
||||
|
||||
static const String _viewsIcon = '''
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.75 21V3h2v18h-2zM18 21V8.5h2V21h-2zM4 21l.004-10h2L6 21H4zm9.248 0v-7h2v7h-2z"/>
|
||||
</svg>
|
||||
''';
|
||||
}
|
||||
75
src/services/videoRenderer.js
Normal file
75
src/services/videoRenderer.js
Normal file
@@ -0,0 +1,75 @@
|
||||
const { fork } = require('child_process');
|
||||
const path = require('path');
|
||||
const { updateSessionRenderStatus } = require('../database/db');
|
||||
|
||||
function startVideoRender(sessionId, config) {
|
||||
console.log(`[VideoRenderer] Starting video render for session ${sessionId}`);
|
||||
|
||||
// spawn child process to do actual rendering
|
||||
const child = fork(path.join(__dirname, '../workers/videoWorker.js'), {
|
||||
detached: false,
|
||||
stdio: 'pipe'
|
||||
});
|
||||
|
||||
console.log(`[VideoRenderer] Spawned worker process PID: ${child.pid}`);
|
||||
|
||||
// capture stdout/stderr from worker
|
||||
if (child.stdout) {
|
||||
child.stdout.on('data', (data) => {
|
||||
console.log(`[VideoWorker ${child.pid}] ${data.toString().trim()}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (child.stderr) {
|
||||
child.stderr.on('data', (data) => {
|
||||
console.error(`[VideoWorker ${child.pid} ERROR] ${data.toString().trim()}`);
|
||||
});
|
||||
}
|
||||
|
||||
// store PID in database with initial progress and stage
|
||||
updateSessionRenderStatus(sessionId, 'rendering', child.pid, null, 0, 'rendering');
|
||||
|
||||
// send config to worker
|
||||
console.log(`[VideoRenderer] Sending config to worker...`);
|
||||
child.send({ sessionId, config });
|
||||
|
||||
// handle worker mesages
|
||||
child.on('message', (msg) => {
|
||||
console.log(`[VideoRenderer] Received message from worker:`, msg);
|
||||
if (msg.type === 'completed') {
|
||||
console.log(`[VideoRenderer] Render completed for session ${sessionId}`);
|
||||
updateSessionRenderStatus(sessionId, 'completed', null, null, 100, 'completed');
|
||||
} else if (msg.type === 'failed') {
|
||||
console.error(`[VideoRenderer] Render failed for session ${sessionId}:`, msg.error);
|
||||
updateSessionRenderStatus(sessionId, 'failed', null, msg.error, 0, 'failed');
|
||||
} else if (msg.type === 'progress') {
|
||||
const stage = msg.stage || 'rendering';
|
||||
console.log(`[VideoRenderer] Progress update: ${msg.progress}% (${stage})`);
|
||||
updateSessionRenderStatus(sessionId, 'rendering', child.pid, null, msg.progress, stage);
|
||||
} else if (msg.type === 'stage') {
|
||||
console.log(`[VideoRenderer] Stage changed to: ${msg.stage}`);
|
||||
updateSessionRenderStatus(sessionId, 'rendering', child.pid, null, msg.progress || 0, msg.stage);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('exit', (code) => {
|
||||
console.log(`[VideoRenderer] Worker process exited with code ${code}`);
|
||||
if (code !== 0) {
|
||||
console.error(`[VideoRenderer] Non-zero exit code, marking as failed`);
|
||||
updateSessionRenderStatus(sessionId, 'failed', null, `Worker exited with code ${code}`);
|
||||
}
|
||||
});
|
||||
|
||||
return child.pid;
|
||||
}
|
||||
|
||||
function cancelVideoRender(pid) {
|
||||
if (!pid) return;
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
} catch (err) {
|
||||
console.error('Failed to kill render proccess:', err);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { startVideoRender, cancelVideoRender };
|
||||
26
src/versiontwo/index.js
Normal file
26
src/versiontwo/index.js
Normal file
@@ -0,0 +1,26 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
// import route handlers
|
||||
const createQuoteRoute = require('./routes/createQuote');
|
||||
const getQuoteRoute = require('./routes/getQuote');
|
||||
const updateQuoteRoute = require('./routes/updateQuote');
|
||||
const deleteQuoteRoute = require('./routes/deleteQuote');
|
||||
const renderInfoRoute = require('./routes/renderInfo');
|
||||
const getImageRoute = require('./routes/getImage');
|
||||
const createSnapshotRoute = require('./routes/createSnapshot');
|
||||
const getSnapshotRoute = require('./routes/getSnapshot');
|
||||
|
||||
// quote routes
|
||||
router.use('/quote', createQuoteRoute);
|
||||
router.use('/quote', getQuoteRoute);
|
||||
router.use('/quote', updateQuoteRoute);
|
||||
router.use('/quote', deleteQuoteRoute);
|
||||
router.use('/quote', renderInfoRoute);
|
||||
router.use('/quote', getImageRoute);
|
||||
router.use('/quote', createSnapshotRoute);
|
||||
|
||||
// snapshot routes
|
||||
router.use('/snapshot', getSnapshotRoute);
|
||||
|
||||
module.exports = router;
|
||||
51
src/versiontwo/routes/createQuote.js
Normal file
51
src/versiontwo/routes/createQuote.js
Normal file
@@ -0,0 +1,51 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { createSession, updateSessionRenderStatus } = require('../../database/db');
|
||||
const { normalizeUsername, fixDataUri } = require('../utils/helpers');
|
||||
const { isVideoUrl } = require('../utils/video');
|
||||
const { buildConfigFromSession, cacheImage } = require('../utils/cache');
|
||||
const { generateQuoteBuffer } = require('../utils/rendering');
|
||||
const { startVideoRender } = require('../../services/videoRenderer');
|
||||
|
||||
// POST /v2/quote - create new session
|
||||
router.post('/', async (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);
|
||||
|
||||
// trigger background render if video detected
|
||||
const isVideo = isVideoUrl(data.imageUrl);
|
||||
console.log(`[POST] Checking if imageUrl is video: ${isVideo}`);
|
||||
console.log(`[POST] imageUrl starts with: ${data.imageUrl ? data.imageUrl.substring(0, 50) : 'null'}...`);
|
||||
|
||||
if (isVideo) {
|
||||
console.log(`[POST] Starting video render for session ${session.id}`);
|
||||
const config = buildConfigFromSession(session);
|
||||
startVideoRender(session.id, config);
|
||||
} else {
|
||||
console.log(`[POST] Rendering static image for session ${session.id}`);
|
||||
// for images, render immediately (sync)
|
||||
const config = buildConfigFromSession(session);
|
||||
const buffer = await generateQuoteBuffer(config);
|
||||
cacheImage(config, buffer);
|
||||
updateSessionRenderStatus(session.id, 'completed', null, null, 100, 'completed');
|
||||
}
|
||||
|
||||
res.status(201).json(session);
|
||||
} catch (error) {
|
||||
console.error('Failed to create session:', error);
|
||||
res.status(500).json({ error: 'Failed to create session' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
37
src/versiontwo/routes/createSnapshot.js
Normal file
37
src/versiontwo/routes/createSnapshot.js
Normal file
@@ -0,0 +1,37 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getSession, createSnapshot } = require('../../database/db');
|
||||
const { getClientIp } = require('../utils/helpers');
|
||||
const { buildConfigFromSession } = require('../utils/cache');
|
||||
const { notifySnapshotCreated } = require('../../services/discordWebhook');
|
||||
|
||||
// POST /v2/quote/:id/snapshot-link - create snapshot link
|
||||
router.post('/: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' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
14
src/versiontwo/routes/deleteQuote.js
Normal file
14
src/versiontwo/routes/deleteQuote.js
Normal file
@@ -0,0 +1,14 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { deleteSession } = require('../../database/db');
|
||||
|
||||
// DELETE /v2/quote/:id - delete session
|
||||
router.delete('/: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();
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
61
src/versiontwo/routes/getImage.js
Normal file
61
src/versiontwo/routes/getImage.js
Normal file
@@ -0,0 +1,61 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getSession } = require('../../database/db');
|
||||
const { getClientIp } = require('../utils/helpers');
|
||||
const { isVideoUrl } = require('../utils/video');
|
||||
const { buildConfigFromSession, getCachedImage } = require('../utils/cache');
|
||||
const { notifyImageGenerated } = require('../../services/discordWebhook');
|
||||
|
||||
// GET /v2/quote/:id/image - render the image
|
||||
router.get('/:id/image', async (req, res) => {
|
||||
try {
|
||||
let session = getSession(req.params.id);
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Session not found or expired' });
|
||||
}
|
||||
|
||||
const config = buildConfigFromSession(session);
|
||||
const isVideo = isVideoUrl(config.imageUrl);
|
||||
|
||||
// poll until render completes (max 15 minutes)
|
||||
const maxWaitTime = 900000; // 15 minutes
|
||||
const pollInterval = 500; // 500ms
|
||||
const startTime = Date.now();
|
||||
|
||||
while (session.renderStatus === 'rendering' || session.renderStatus === 'pending') {
|
||||
if (Date.now() - startTime > maxWaitTime) {
|
||||
return res.status(504).json({ error: 'Render timeout' });
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
||||
session = getSession(req.params.id); // refresh status
|
||||
}
|
||||
|
||||
// check final status
|
||||
if (session.renderStatus === 'failed') {
|
||||
return res.status(500).json({
|
||||
error: 'Render failed',
|
||||
details: session.renderError
|
||||
});
|
||||
}
|
||||
|
||||
// render completed, return cached result
|
||||
const cached = getCachedImage(config);
|
||||
if (!cached) {
|
||||
return res.status(500).json({ error: 'Cached file missing after successful render' });
|
||||
}
|
||||
|
||||
const clientIp = getClientIp(req);
|
||||
notifyImageGenerated(config, clientIp).catch(err => console.error('Discord notification failed:', err));
|
||||
|
||||
res.setHeader('Content-Type', isVideo ? 'video/mp4' : 'image/png');
|
||||
res.setHeader('X-Cache', 'HIT');
|
||||
res.setHeader('X-Session-Id', session.id);
|
||||
res.send(cached);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate image:', error);
|
||||
res.status(500).json({ error: 'Failed to generate image' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
14
src/versiontwo/routes/getQuote.js
Normal file
14
src/versiontwo/routes/getQuote.js
Normal file
@@ -0,0 +1,14 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getSession } = require('../../database/db');
|
||||
|
||||
// GET /v2/quote/:id - get session state
|
||||
router.get('/: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);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
73
src/versiontwo/routes/getSnapshot.js
Normal file
73
src/versiontwo/routes/getSnapshot.js
Normal file
@@ -0,0 +1,73 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getSnapshot, touchSnapshot, getSession } = require('../../database/db');
|
||||
const { getClientIp } = require('../utils/helpers');
|
||||
const { isVideoUrl } = require('../utils/video');
|
||||
const { getCachedImage, cacheImage } = require('../utils/cache');
|
||||
const { generateQuoteBuffer } = require('../utils/rendering');
|
||||
const { notifyImageGenerated } = require('../../services/discordWebhook');
|
||||
|
||||
// GET /v2/snapshot/:token - retrieve snapshot image
|
||||
router.get('/: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);
|
||||
const isVideo = isVideoUrl(config.imageUrl);
|
||||
|
||||
// get session to check render status
|
||||
let session = getSession(snapshot.sessionId);
|
||||
if (session) {
|
||||
// poll until render completes (max 15 minutes)
|
||||
const maxWaitTime = 900000; // 15 minutes
|
||||
const pollInterval = 500;
|
||||
const startTime = Date.now();
|
||||
|
||||
while (session.renderStatus === 'rendering' || session.renderStatus === 'pending') {
|
||||
if (Date.now() - startTime > maxWaitTime) {
|
||||
return res.status(504).json({ error: 'Render timeout' });
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
||||
session = getSession(snapshot.sessionId);
|
||||
}
|
||||
|
||||
if (session.renderStatus === 'failed') {
|
||||
return res.status(500).json({
|
||||
error: 'Render failed',
|
||||
details: session.renderError
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// try to get cached result
|
||||
let image = getCachedImage(config);
|
||||
let fromCache = true;
|
||||
|
||||
if (!image) {
|
||||
// fallback: render now if not cached
|
||||
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', isVideo ? 'video/mp4' : '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;
|
||||
76
src/versiontwo/routes/renderInfo.js
Normal file
76
src/versiontwo/routes/renderInfo.js
Normal file
@@ -0,0 +1,76 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getSession } = require('../../database/db');
|
||||
|
||||
// GET /v2/quote/:id/render-info - SSE stream for render progress
|
||||
router.get('/:id/render-info', (req, res) => {
|
||||
const session = getSession(req.params.id);
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Session not found or expired' });
|
||||
}
|
||||
|
||||
// set SSE headers
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no'); // disable nginx buffering
|
||||
|
||||
// disable TCP buffering (Nagle's algorithm) for immediate delivery
|
||||
if (req.socket) {
|
||||
req.socket.setNoDelay(true);
|
||||
}
|
||||
|
||||
res.flushHeaders();
|
||||
|
||||
// send SSE event - ensure immediate delivery without buffering
|
||||
const sendEvent = (data) => {
|
||||
const message = `data: ${JSON.stringify(data)}\n\n`;
|
||||
res.write(message, 'utf8');
|
||||
|
||||
// flush immediately if available (provided by compression middleware if present)
|
||||
if (typeof res.flush === 'function') {
|
||||
res.flush();
|
||||
}
|
||||
};
|
||||
|
||||
sendEvent({
|
||||
status: session.renderStatus,
|
||||
sessionId: session.id,
|
||||
progress: session.renderProgress || 0,
|
||||
stage: session.renderStage || 'idle',
|
||||
error: session.renderError
|
||||
});
|
||||
|
||||
// poll for updates
|
||||
const pollInterval = setInterval(() => {
|
||||
const updatedSession = getSession(req.params.id);
|
||||
|
||||
if (!updatedSession) {
|
||||
sendEvent({ status: 'deleted', message: 'Session was deleted' });
|
||||
clearInterval(pollInterval);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
sendEvent({
|
||||
status: updatedSession.renderStatus,
|
||||
sessionId: updatedSession.id,
|
||||
progress: updatedSession.renderProgress || 0,
|
||||
stage: updatedSession.renderStage || 'idle',
|
||||
error: updatedSession.renderError
|
||||
});
|
||||
|
||||
// close stream when render is done
|
||||
if (updatedSession.renderStatus === 'completed' || updatedSession.renderStatus === 'failed') {
|
||||
clearInterval(pollInterval);
|
||||
res.end();
|
||||
}
|
||||
}, 200);
|
||||
|
||||
// cleanup on client disconnect
|
||||
req.on('close', () => {
|
||||
clearInterval(pollInterval);
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
69
src/versiontwo/routes/updateQuote.js
Normal file
69
src/versiontwo/routes/updateQuote.js
Normal file
@@ -0,0 +1,69 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getSession, updateSession, updateSessionRenderStatus } = require('../../database/db');
|
||||
const { normalizeUsername, fixDataUri } = require('../utils/helpers');
|
||||
const { isVideoUrl } = require('../utils/video');
|
||||
const { buildConfigFromSession, deleteCachedImage, cacheImage } = require('../utils/cache');
|
||||
const { generateQuoteBuffer } = require('../utils/rendering');
|
||||
const { startVideoRender, cancelVideoRender } = require('../../services/videoRenderer');
|
||||
|
||||
// PATCH /v2/quote/:id - update session
|
||||
router.patch('/:id', async (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' });
|
||||
}
|
||||
|
||||
// cancel existing render if in progress
|
||||
if (currentSession.renderPid) {
|
||||
cancelVideoRender(currentSession.renderPid);
|
||||
}
|
||||
|
||||
// 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' });
|
||||
}
|
||||
|
||||
// start new render
|
||||
const newConfig = buildConfigFromSession(session);
|
||||
const isVideo = isVideoUrl(newConfig.imageUrl);
|
||||
console.log(`[PATCH] Checking if imageUrl is video: ${isVideo}`);
|
||||
console.log(`[PATCH] imageUrl starts with: ${newConfig.imageUrl ? newConfig.imageUrl.substring(0, 50) : 'null'}...`);
|
||||
|
||||
if (isVideo) {
|
||||
console.log(`[PATCH] Starting video render for session ${session.id}`);
|
||||
startVideoRender(session.id, newConfig);
|
||||
} else {
|
||||
console.log(`[PATCH] Rendering static image for session ${session.id}`);
|
||||
const buffer = await generateQuoteBuffer(newConfig);
|
||||
cacheImage(newConfig, buffer);
|
||||
updateSessionRenderStatus(session.id, 'completed', null, null, 100, 'completed');
|
||||
}
|
||||
|
||||
res.json(session);
|
||||
} catch (error) {
|
||||
console.error('Failed to update session:', error);
|
||||
res.status(500).json({ error: 'Failed to update session' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
96
src/versiontwo/utils/cache.js
Normal file
96
src/versiontwo/utils/cache.js
Normal file
@@ -0,0 +1,96 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { formatTimestamp, formatCount } = require('./helpers');
|
||||
const { isVideoUrl } = require('./video');
|
||||
|
||||
const CACHE_DIR = path.join(__dirname, '../../../cache');
|
||||
|
||||
// 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 isVideo = isVideoUrl(config.imageUrl);
|
||||
const ext = isVideo ? 'mp4' : 'png';
|
||||
const cachePath = path.join(CACHE_DIR, `${hash}.${ext}`);
|
||||
|
||||
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 isVideo = isVideoUrl(config.imageUrl);
|
||||
const ext = isVideo ? 'mp4' : 'png';
|
||||
const cachePath = path.join(CACHE_DIR, `${hash}.${ext}`);
|
||||
fs.writeFileSync(cachePath, imageBuffer);
|
||||
}
|
||||
|
||||
function deleteCachedImage(config) {
|
||||
const hash = hashConfig(config);
|
||||
// try both extensions when deleting
|
||||
const pngPath = path.join(CACHE_DIR, `${hash}.png`);
|
||||
const mp4Path = path.join(CACHE_DIR, `${hash}.mp4`);
|
||||
if (fs.existsSync(pngPath)) {
|
||||
fs.unlinkSync(pngPath);
|
||||
}
|
||||
if (fs.existsSync(mp4Path)) {
|
||||
fs.unlinkSync(mp4Path);
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeConfig,
|
||||
hashConfig,
|
||||
getCachedImage,
|
||||
cacheImage,
|
||||
deleteCachedImage,
|
||||
buildConfigFromSession
|
||||
};
|
||||
100
src/versiontwo/utils/helpers.js
Normal file
100
src/versiontwo/utils/helpers.js
Normal file
@@ -0,0 +1,100 @@
|
||||
// helper functions
|
||||
|
||||
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;
|
||||
|
||||
// Don't "fix" video URIs - they're already correct
|
||||
if (dataUri.startsWith('data:video/')) 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}`;
|
||||
}
|
||||
|
||||
// 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';
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
formatCount,
|
||||
formatTimestamp,
|
||||
detectImageType,
|
||||
fixDataUri,
|
||||
normalizeUsername,
|
||||
getClientIp
|
||||
};
|
||||
61
src/versiontwo/utils/rendering.js
Normal file
61
src/versiontwo/utils/rendering.js
Normal file
@@ -0,0 +1,61 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { renderHtml } = require('../../services/browserPool');
|
||||
|
||||
const TEMPLATE_PATH = path.join(__dirname, '../../../templates/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);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildEngagementHtml,
|
||||
generateQuoteBuffer
|
||||
};
|
||||
57
src/versiontwo/utils/video.js
Normal file
57
src/versiontwo/utils/video.js
Normal file
@@ -0,0 +1,57 @@
|
||||
const fs = require('fs');
|
||||
const { exec } = require('child_process');
|
||||
const util = require('util');
|
||||
const execPromise = util.promisify(exec);
|
||||
|
||||
// set ffprobe path
|
||||
const ffprobeInstaller = require('@ffprobe-installer/ffprobe');
|
||||
process.env.FFPROBE_PATH = ffprobeInstaller.path;
|
||||
|
||||
// video detection helper
|
||||
function isVideoUrl(url) {
|
||||
if (!url) return false;
|
||||
|
||||
// check data URI MIME type
|
||||
if (url.startsWith('data:video/')) return true;
|
||||
if (url.startsWith('data:image/gif')) return true;
|
||||
|
||||
// check file extension
|
||||
const videoExtensions = ['.mp4', '.webm', '.mov', '.gif'];
|
||||
return videoExtensions.some(ext => url.toLowerCase().includes(ext));
|
||||
}
|
||||
|
||||
// get video duration helper
|
||||
async function getVideoDuration(videoUrl) {
|
||||
let tempPath = null;
|
||||
let targetPath = videoUrl;
|
||||
|
||||
if (videoUrl.startsWith('data:')) {
|
||||
const matches = videoUrl.match(/^data:(.+);base64,(.+)$/);
|
||||
if (matches) {
|
||||
const buffer = Buffer.from(matches[2], 'base64');
|
||||
tempPath = `/tmp/video-${Date.now()}.mp4`;
|
||||
fs.writeFileSync(tempPath, buffer);
|
||||
targetPath = tempPath;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execPromise(
|
||||
`"${ffprobeInstaller.path}" -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${targetPath}"`
|
||||
);
|
||||
const duration = parseFloat(stdout.trim()) * 1000;
|
||||
return duration;
|
||||
} catch (error) {
|
||||
console.error('Failed to get video duraton:', error);
|
||||
return 5000; // defualt 5 seconds
|
||||
} finally {
|
||||
if (tempPath) {
|
||||
fs.unlinkSync(tempPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isVideoUrl,
|
||||
getVideoDuration
|
||||
};
|
||||
307
src/workers/videoWorker.js
Normal file
307
src/workers/videoWorker.js
Normal file
@@ -0,0 +1,307 @@
|
||||
const { chromium } = require('playwright');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { exec } = require('child_process');
|
||||
const util = require('util');
|
||||
const execPromise = util.promisify(exec);
|
||||
|
||||
// set ffprobe path
|
||||
const ffprobeInstaller = require('@ffprobe-installer/ffprobe');
|
||||
process.env.FFPROBE_PATH = ffprobeInstaller.path;
|
||||
|
||||
const TEMPLATE_PATH = path.join(__dirname, '../../templates/template.html');
|
||||
const templateHtml = fs.readFileSync(TEMPLATE_PATH, 'utf8');
|
||||
const CACHE_DIR = path.join(__dirname, '../../cache');
|
||||
|
||||
// helper function to get video duraton
|
||||
async function getVideoDuration(videoUrl) {
|
||||
let tempPath = null;
|
||||
let targetPath = videoUrl;
|
||||
|
||||
if (videoUrl.startsWith('data:')) {
|
||||
const matches = videoUrl.match(/^data:(.+);base64,(.+)$/);
|
||||
if (matches) {
|
||||
const buffer = Buffer.from(matches[2], 'base64');
|
||||
tempPath = `/tmp/video-${Date.now()}.mp4`;
|
||||
fs.writeFileSync(tempPath, buffer);
|
||||
targetPath = tempPath;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execPromise(
|
||||
`"${ffprobeInstaller.path}" -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${targetPath}"`
|
||||
);
|
||||
const duration = parseFloat(stdout.trim()) * 1000;
|
||||
return duration;
|
||||
} catch (error) {
|
||||
console.error('Faild to get video duration:', error);
|
||||
return 5000; // defalt 5 seconds
|
||||
} finally {
|
||||
if (tempPath) {
|
||||
fs.unlinkSync(tempPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// build html from config
|
||||
function buildHtmlFromConfig(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>`;
|
||||
|
||||
// for video, use <video> tag instead of <img>
|
||||
let imageHtml = '';
|
||||
if (config.imageUrl) {
|
||||
const isVideo = config.imageUrl.startsWith('data:video/') ||
|
||||
config.imageUrl.includes('.mp4') ||
|
||||
config.imageUrl.includes('.webm') ||
|
||||
config.imageUrl.includes('.mov');
|
||||
|
||||
if (isVideo || config.imageUrl.startsWith('data:image/gif')) {
|
||||
imageHtml = `<div class="tweet-image-container" style="margin-bottom:12px;border-radius:16px;overflow:hidden;border:1px solid rgb(47,51,54);"><video src="${config.imageUrl}" style="width:100%;display:block;" autoplay loop muted playsinline></video></div>`;
|
||||
} else {
|
||||
imageHtml = `<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 html;
|
||||
}
|
||||
|
||||
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>
|
||||
`;
|
||||
}
|
||||
|
||||
function hashConfig(config) {
|
||||
const crypto = require('crypto');
|
||||
const normalized = {};
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (value !== null && value !== undefined && value !== '') {
|
||||
normalized[key] = value;
|
||||
}
|
||||
}
|
||||
const configString = JSON.stringify(normalized);
|
||||
return crypto.createHash('sha256').update(configString).digest('hex');
|
||||
}
|
||||
|
||||
function cacheVideo(config, videoBuffer) {
|
||||
const hash = hashConfig(config);
|
||||
const cachePath = path.join(CACHE_DIR, `${hash}.mp4`);
|
||||
fs.writeFileSync(cachePath, videoBuffer);
|
||||
}
|
||||
|
||||
// main worker logic
|
||||
process.on('message', async ({ sessionId, config }) => {
|
||||
console.log(`[Worker] Received message for session ${sessionId}`);
|
||||
|
||||
try {
|
||||
// ensure temp video directory exists
|
||||
const videoTempDir = '/tmp/videos';
|
||||
console.log(`[Worker] Ensuring temp directory exists: ${videoTempDir}`);
|
||||
if (!fs.existsSync(videoTempDir)) {
|
||||
fs.mkdirSync(videoTempDir, { recursive: true });
|
||||
console.log(`[Worker] Created temp directory`);
|
||||
}
|
||||
|
||||
// probe video duration
|
||||
console.log(`[Worker] Probing video duration...`);
|
||||
const duration = await getVideoDuration(config.imageUrl);
|
||||
console.log(`[Worker] Video duration: ${duration}ms`);
|
||||
|
||||
// genrate HTML
|
||||
console.log(`[Worker] Building HTML template...`);
|
||||
const html = buildHtmlFromConfig(config);
|
||||
|
||||
// launch playwright
|
||||
console.log(`[Worker] Launching Playwright browser...`);
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
|
||||
console.log(`[Worker] Creating recording context...`);
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1500, height: 1500 },
|
||||
recordVideo: {
|
||||
dir: videoTempDir,
|
||||
size: { width: 1500, height: 1500 }
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[Worker] Opening page and starting recording...`);
|
||||
const recordingStartTime = Date.now();
|
||||
const page = await context.newPage();
|
||||
await page.setContent(html, { waitUntil: 'networkidle' });
|
||||
|
||||
// wait for page to fully render (fonts, layout, etc)
|
||||
console.log(`[Worker] Waiting for page to fully render...`);
|
||||
await page.evaluate(() => {
|
||||
return Promise.all([
|
||||
document.fonts.ready,
|
||||
new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
||||
]);
|
||||
});
|
||||
|
||||
// small buffer to ensure everything is settled
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// calculate how long it took to load
|
||||
const loadTime = (Date.now() - recordingStartTime) / 1000; // convert to seconds
|
||||
console.log(`[Worker] Page took ${loadTime}s to fully load`);
|
||||
|
||||
// seek video back to the beginning
|
||||
console.log(`[Worker] Seeking video to beginning...`);
|
||||
await page.evaluate(() => {
|
||||
const video = document.querySelector('video');
|
||||
if (video) {
|
||||
video.currentTime = 0;
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[Worker] Page settled, recording clean video playback...`);
|
||||
|
||||
// wait for video to play with progress updates
|
||||
// we need to record for duration + loadTime, so after trimming we get the exact duration
|
||||
const totalDuration = duration + (loadTime * 1000);
|
||||
const startTime = Date.now();
|
||||
const progressInterval = 200; // update every 200ms
|
||||
|
||||
while (Date.now() - startTime < totalDuration) {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const progress = Math.min(99, (elapsed / totalDuration) * 100);
|
||||
|
||||
// send progress update to parent
|
||||
process.send({ type: 'progress', progress: progress });
|
||||
|
||||
await page.waitForTimeout(Math.min(progressInterval, totalDuration - elapsed));
|
||||
}
|
||||
|
||||
// close and get video path
|
||||
console.log(`[Worker] Closing browser context...`);
|
||||
await context.close();
|
||||
console.log(`[Worker] Getting video path...`);
|
||||
const videoPath = await page.video().path();
|
||||
console.log(`[Worker] Video saved to: ${videoPath}`);
|
||||
await browser.close();
|
||||
|
||||
// notify encoding stage started
|
||||
process.send({ type: 'stage', stage: 'encoding', progress: 0 });
|
||||
|
||||
// convert to H.264 MP4 with required specs
|
||||
console.log(`[Worker] Converting to H.264 MP4...`);
|
||||
const outputPath = videoPath.replace(/\.[^.]+$/, '.mp4');
|
||||
|
||||
// run FFmpeg with progress reporting and trim loading time
|
||||
await new Promise((resolve, reject) => {
|
||||
const { spawn } = require('child_process');
|
||||
const ffmpeg = spawn('ffmpeg', [
|
||||
'-ss', loadTime.toString(), // skip the loading portion dynamically
|
||||
'-i', videoPath,
|
||||
'-c:v', 'libx264',
|
||||
'-profile:v', 'baseline',
|
||||
'-level', '3.0',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-movflags', '+faststart',
|
||||
'-an',
|
||||
'-progress', 'pipe:1',
|
||||
outputPath
|
||||
]);
|
||||
|
||||
let totalFrames = 0;
|
||||
let processedFrames = 0;
|
||||
|
||||
ffmpeg.stdout.on('data', (data) => {
|
||||
const output = data.toString();
|
||||
|
||||
// parse FFmpeg progress output
|
||||
const frameMatch = output.match(/frame=\s*(\d+)/);
|
||||
if (frameMatch) {
|
||||
processedFrames = parseInt(frameMatch[1]);
|
||||
|
||||
// estimate total frames from video duration (assuming 30fps)
|
||||
if (totalFrames === 0) {
|
||||
totalFrames = Math.ceil(duration / 1000 * 30);
|
||||
}
|
||||
|
||||
if (totalFrames > 0) {
|
||||
const encodingProgress = Math.min(99, (processedFrames / totalFrames) * 100);
|
||||
process.send({ type: 'progress', progress: encodingProgress, stage: 'encoding' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ffmpeg.stderr.on('data', (data) => {
|
||||
// FFmpeg outputs to stderr, log it
|
||||
const msg = data.toString();
|
||||
if (!msg.includes('frame=') && !msg.includes('speed=')) {
|
||||
console.log(`[FFmpeg] ${msg.trim()}`);
|
||||
}
|
||||
});
|
||||
|
||||
ffmpeg.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`FFmpeg exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
ffmpeg.on('error', reject);
|
||||
});
|
||||
|
||||
console.log(`[Worker] Conversion complete: ${outputPath}`);
|
||||
|
||||
// read and cache converted video
|
||||
console.log(`[Worker] Reading converted video file...`);
|
||||
const videoBuffer = fs.readFileSync(outputPath);
|
||||
console.log(`[Worker] Video file size: ${videoBuffer.length} bytes`);
|
||||
console.log(`[Worker] Caching video...`);
|
||||
cacheVideo(config, videoBuffer);
|
||||
|
||||
// cleanup temp files
|
||||
console.log(`[Worker] Deleting temp files...`);
|
||||
fs.unlinkSync(videoPath); // original webm
|
||||
fs.unlinkSync(outputPath); // converted mp4
|
||||
|
||||
console.log(`[Worker] Video render completed for session ${sessionId}`);
|
||||
|
||||
// notify parent proccess
|
||||
process.send({ type: 'completed' });
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error(`[Worker] Video render failed:`, error);
|
||||
console.error(`[Worker] Stack trace:`, error.stack);
|
||||
process.send({ type: 'failed', error: error.message });
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user