From 537fc4f750a38175a1a6bfc1d875cd9727c055ff Mon Sep 17 00:00:00 2001 From: ImBenji Date: Fri, 13 Feb 2026 21:56:26 +0000 Subject: [PATCH] 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 --- docs/DISTRIBUTED_RENDERING_PLAN.md | 709 ++++++++++++++++++++++++ lib/tweet_template_widget.dart | 402 ++++++++++++++ src/services/videoRenderer.js | 75 +++ src/versiontwo/index.js | 26 + src/versiontwo/routes/createQuote.js | 51 ++ src/versiontwo/routes/createSnapshot.js | 37 ++ src/versiontwo/routes/deleteQuote.js | 14 + src/versiontwo/routes/getImage.js | 61 ++ src/versiontwo/routes/getQuote.js | 14 + src/versiontwo/routes/getSnapshot.js | 73 +++ src/versiontwo/routes/renderInfo.js | 76 +++ src/versiontwo/routes/updateQuote.js | 69 +++ src/versiontwo/utils/cache.js | 96 ++++ src/versiontwo/utils/helpers.js | 100 ++++ src/versiontwo/utils/rendering.js | 61 ++ src/versiontwo/utils/video.js | 57 ++ src/workers/videoWorker.js | 307 ++++++++++ 17 files changed, 2228 insertions(+) create mode 100644 docs/DISTRIBUTED_RENDERING_PLAN.md create mode 100644 lib/tweet_template_widget.dart create mode 100644 src/services/videoRenderer.js create mode 100644 src/versiontwo/index.js create mode 100644 src/versiontwo/routes/createQuote.js create mode 100644 src/versiontwo/routes/createSnapshot.js create mode 100644 src/versiontwo/routes/deleteQuote.js create mode 100644 src/versiontwo/routes/getImage.js create mode 100644 src/versiontwo/routes/getQuote.js create mode 100644 src/versiontwo/routes/getSnapshot.js create mode 100644 src/versiontwo/routes/renderInfo.js create mode 100644 src/versiontwo/routes/updateQuote.js create mode 100644 src/versiontwo/utils/cache.js create mode 100644 src/versiontwo/utils/helpers.js create mode 100644 src/versiontwo/utils/rendering.js create mode 100644 src/versiontwo/utils/video.js create mode 100644 src/workers/videoWorker.js diff --git a/docs/DISTRIBUTED_RENDERING_PLAN.md b/docs/DISTRIBUTED_RENDERING_PLAN.md new file mode 100644 index 0000000..3b7ed33 --- /dev/null +++ b/docs/DISTRIBUTED_RENDERING_PLAN.md @@ -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": "" +} +``` +```json +{ + "type": "result_chunk", + "jobId": "uuid-job-123", + "chunkIndex": 1, + "data": "" +} +``` +... (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) \ No newline at end of file diff --git a/lib/tweet_template_widget.dart b/lib/tweet_template_widget.dart new file mode 100644 index 0000000..79167d4 --- /dev/null +++ b/lib/tweet_template_widget.dart @@ -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? 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 createState() => _TweetTemplateWidgetState(); +} + +class _TweetTemplateWidgetState extends State { + 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 _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 = ''' + + + +'''; + + // engagment icons (simplified versions) + static const String _replyIcon = ''' + + + +'''; + + static const String _retweetIcon = ''' + + + +'''; + + static const String _likeIcon = ''' + + + +'''; + + static const String _viewsIcon = ''' + + + +'''; +} diff --git a/src/services/videoRenderer.js b/src/services/videoRenderer.js new file mode 100644 index 0000000..ca39dbb --- /dev/null +++ b/src/services/videoRenderer.js @@ -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 }; diff --git a/src/versiontwo/index.js b/src/versiontwo/index.js new file mode 100644 index 0000000..c1d238b --- /dev/null +++ b/src/versiontwo/index.js @@ -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; diff --git a/src/versiontwo/routes/createQuote.js b/src/versiontwo/routes/createQuote.js new file mode 100644 index 0000000..bf6ca4c --- /dev/null +++ b/src/versiontwo/routes/createQuote.js @@ -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; diff --git a/src/versiontwo/routes/createSnapshot.js b/src/versiontwo/routes/createSnapshot.js new file mode 100644 index 0000000..3026a31 --- /dev/null +++ b/src/versiontwo/routes/createSnapshot.js @@ -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; diff --git a/src/versiontwo/routes/deleteQuote.js b/src/versiontwo/routes/deleteQuote.js new file mode 100644 index 0000000..31864ed --- /dev/null +++ b/src/versiontwo/routes/deleteQuote.js @@ -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; diff --git a/src/versiontwo/routes/getImage.js b/src/versiontwo/routes/getImage.js new file mode 100644 index 0000000..977dff4 --- /dev/null +++ b/src/versiontwo/routes/getImage.js @@ -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; diff --git a/src/versiontwo/routes/getQuote.js b/src/versiontwo/routes/getQuote.js new file mode 100644 index 0000000..721400b --- /dev/null +++ b/src/versiontwo/routes/getQuote.js @@ -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; diff --git a/src/versiontwo/routes/getSnapshot.js b/src/versiontwo/routes/getSnapshot.js new file mode 100644 index 0000000..01a34cc --- /dev/null +++ b/src/versiontwo/routes/getSnapshot.js @@ -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; diff --git a/src/versiontwo/routes/renderInfo.js b/src/versiontwo/routes/renderInfo.js new file mode 100644 index 0000000..69a672b --- /dev/null +++ b/src/versiontwo/routes/renderInfo.js @@ -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; diff --git a/src/versiontwo/routes/updateQuote.js b/src/versiontwo/routes/updateQuote.js new file mode 100644 index 0000000..02a0e62 --- /dev/null +++ b/src/versiontwo/routes/updateQuote.js @@ -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; diff --git a/src/versiontwo/utils/cache.js b/src/versiontwo/utils/cache.js new file mode 100644 index 0000000..d8371d6 --- /dev/null +++ b/src/versiontwo/utils/cache.js @@ -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 +}; diff --git a/src/versiontwo/utils/helpers.js b/src/versiontwo/utils/helpers.js new file mode 100644 index 0000000..e5c1444 --- /dev/null +++ b/src/versiontwo/utils/helpers.js @@ -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 +}; diff --git a/src/versiontwo/utils/rendering.js b/src/versiontwo/utils/rendering.js new file mode 100644 index 0000000..22825d4 --- /dev/null +++ b/src/versiontwo/utils/rendering.js @@ -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 ` + + `; +} + +async function generateQuoteBuffer(config) { + const avatarHtml = config.avatarUrl + ? `` + : `
`; + + const imageHtml = config.imageUrl + ? `
` + : ''; + + const engagementHtml = buildEngagementHtml(config.engagement); + + const verifiedBadge = config.verified ? '' : ''; + + const html = templateHtml + .replace('{{avatarHtml}}', avatarHtml) + .replace('{{displayName}}', config.displayName) + .replace('{{verifiedBadge}}', verifiedBadge) + .replace('{{username}}', config.username) + .replace('{{text}}', config.text) + .replace('{{imageHtml}}', imageHtml) + .replace('{{timestamp}}', config.timestamp) + .replace('{{engagementHtml}}', engagementHtml); + + return await renderHtml(html, 1000); +} + +module.exports = { + buildEngagementHtml, + generateQuoteBuffer +}; diff --git a/src/versiontwo/utils/video.js b/src/versiontwo/utils/video.js new file mode 100644 index 0000000..0dc036c --- /dev/null +++ b/src/versiontwo/utils/video.js @@ -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 +}; diff --git a/src/workers/videoWorker.js b/src/workers/videoWorker.js new file mode 100644 index 0000000..e4176a1 --- /dev/null +++ b/src/workers/videoWorker.js @@ -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 + ? `` + : `
`; + + // for video, use