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:
ImBenji
2026-02-13 21:56:26 +00:00
parent 813ed39102
commit 537fc4f750
17 changed files with 2228 additions and 0 deletions

View 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)

View 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>
''';
}

View 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
View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
};

View 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
};

View 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
};

View 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
View 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);
}
});