@amg555/frameforge-mcp 0.0.0-stage → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bob/artifacts/enterprise-ai-video-agent-platform-agnostic-implementation-guide.html +464 -0
- package/.claude-plugin/marketplace.json +17 -0
- package/.gitattributes +19 -0
- package/.mcp.json +45 -0
- package/CHANGELOG.md +431 -0
- package/CLAUDE.md +278 -0
- package/CONTRIBUTING.md +112 -0
- package/LICENSE +21 -0
- package/Procfile +1 -0
- package/README.md +359 -2
- package/SECURITY.md +71 -0
- package/START_HERE.md +144 -0
- package/assets/metadata/assets.json +5 -0
- package/assets/metadata/embeddings.json +5 -0
- package/install.sh +64 -0
- package/package.json +58 -4
- package/src/animation/animation-system.js +229 -0
- package/src/asset-library/README.md +188 -0
- package/src/asset-library/asset-library.js +639 -0
- package/src/audio-mixer/mixer.js +187 -0
- package/src/cli/doctor.js +173 -0
- package/src/cli/make.js +655 -0
- package/src/cli/setup-keys.js +378 -0
- package/src/config/load-config.js +72 -0
- package/src/fact-checker/checker.js +384 -0
- package/src/fact-checker/review-gate.js +306 -0
- package/src/fact-checker/validation-set.js +1078 -0
- package/src/mcp-server/server.js +1299 -0
- package/src/mcp-server/stdio.js +87 -0
- package/src/pipeline/artifact-manifest.js +323 -0
- package/src/pipeline/artifact-retention.js +189 -0
- package/src/pipeline/external-adapter.js +366 -0
- package/src/pipeline/providers.js +853 -0
- package/src/pipeline/retry-policy.js +134 -0
- package/src/pipeline/state-machine.js +297 -0
- package/src/pipeline/telemetry.js +162 -0
- package/src/quality-gate/gate.js +229 -0
- package/src/schema/scene.schema.json +101 -0
- package/src/shorts-extractor/extractor.js +343 -0
- package/src/utils/log-redaction.js +274 -0
- package/src/video-renderer/renderer.js +364 -0
- package/src/worker/server-render-free.js +420 -0
- package/src/worker/server.js +328 -0
- package/src/workflows/assets.js +131 -0
- package/src/workflows/ingest.js +299 -0
- package/src/workflows/packaging.js +156 -0
- package/src/workflows/publishing.js +206 -0
- package/src/workflows/render.js +102 -0
- package/src/workflows/research.js +151 -0
- package/src/workflows/script.js +210 -0
- package/src/workflows/shorts.js +113 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render-Optimised Worker API
|
|
3
|
+
*
|
|
4
|
+
* Designed for Render Free (512 MB RAM) + local FFmpeg rendering.
|
|
5
|
+
*
|
|
6
|
+
* KEY INSIGHT: The pipeline splits into two categories:
|
|
7
|
+
*
|
|
8
|
+
* NETWORK-BOUND (runs on Render free — no RAM/CPU pressure):
|
|
9
|
+
* - LLM calls (Groq API) — ~50ms response time
|
|
10
|
+
* - Image generation (Pollinations) — network fetch
|
|
11
|
+
* - TTS synthesis (edge-tts API or ElevenLabs) — network
|
|
12
|
+
* - Webhook/file delivery — network / disk
|
|
13
|
+
*
|
|
14
|
+
* CPU/MEMORY-BOUND (runs locally or on Pi/Hetzner — needs FFmpeg):
|
|
15
|
+
* - FFmpeg zoompan rendering — ~800 MB RAM peak
|
|
16
|
+
* - Audio mixing (LUFS loudnorm) — CPU-intensive
|
|
17
|
+
* - Shorts extraction — FFmpeg
|
|
18
|
+
* - Quality gate (ffprobe) — requires binary
|
|
19
|
+
*
|
|
20
|
+
* SPLIT WORKER PATTERN:
|
|
21
|
+
* Job type → Runs on
|
|
22
|
+
* ──────────────────────────────────────────────
|
|
23
|
+
* research → Render free (LLM only)
|
|
24
|
+
* script → Render free (LLM only)
|
|
25
|
+
* assets → Render free (LLM + network image/TTS)
|
|
26
|
+
* render → LOCAL/PI (FFmpeg — see LOCAL_RENDER_URL)
|
|
27
|
+
* shorts → LOCAL/PI (FFmpeg)
|
|
28
|
+
* package → Render free (file ops, cheap)
|
|
29
|
+
* publish → Render free (file copy / webhook delivery)
|
|
30
|
+
* full_pipeline → Render free up to render step; delegates render to LOCAL_RENDER_URL
|
|
31
|
+
*
|
|
32
|
+
* RENDER FREE WORKAROUNDS:
|
|
33
|
+
* 1. Sleep: Render free sleeps after 15 min. The Cloudflare Worker MCP proxy
|
|
34
|
+
* keeps the connection alive by polling /health every 10 min (see keepAlive).
|
|
35
|
+
* 2. No persistent disk: All intermediate files are stored in memory (small)
|
|
36
|
+
* or written to /tmp (ephemeral, fine for single-job state).
|
|
37
|
+
* Final videos are written to N8N_FILES_DIR/published/ or sent via PUBLISH_WEBHOOK_URL.
|
|
38
|
+
* 3. 512 MB RAM: edge-tts runs as a subprocess (~50 MB). Groq calls are pure HTTP.
|
|
39
|
+
* Image generation is a HTTP fetch + stream to disk (~20 MB peak).
|
|
40
|
+
*
|
|
41
|
+
* Environment variables:
|
|
42
|
+
* WORKER_PORT — default 3211
|
|
43
|
+
* LOCAL_RENDER_URL — URL of local/Pi Worker API for FFmpeg jobs
|
|
44
|
+
* (e.g. http://192.168.1.100:3211 on local network,
|
|
45
|
+
* or https://yourpi.tailnet.ts.net:3211 via Tailscale)
|
|
46
|
+
* RENDER_MODE — 'api_only' (skip FFmpeg, delegate to LOCAL_RENDER_URL)
|
|
47
|
+
* 'full' (run everything locally — needs Pi/VPS)
|
|
48
|
+
* default: 'api_only'
|
|
49
|
+
* N8N_FILES_DIR — /tmp (Render) or /home/node/.n8n-files (Pi/VPS)
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
'use strict';
|
|
53
|
+
|
|
54
|
+
const http = require('http');
|
|
55
|
+
const https = require('https');
|
|
56
|
+
const crypto = require('crypto');
|
|
57
|
+
const path = require('path');
|
|
58
|
+
const os = require('os');
|
|
59
|
+
const fs = require('fs').promises;
|
|
60
|
+
|
|
61
|
+
// ── Security helpers ──────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
// Timing-safe string comparison — prevents timing attacks on secret comparison.
|
|
64
|
+
// Falls back to a constant-time loop if crypto.timingSafeEqual is unavailable.
|
|
65
|
+
function timingSafeEqual(a, b) {
|
|
66
|
+
const bufA = Buffer.from(a);
|
|
67
|
+
const bufB = Buffer.from(b);
|
|
68
|
+
if (bufA.length !== bufB.length) {
|
|
69
|
+
// Still compare to avoid length-based timing leak
|
|
70
|
+
crypto.timingSafeEqual(bufA, bufA);
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
return crypto.timingSafeEqual(bufA, bufB);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const PORT = parseInt(process.env.WORKER_PORT || '3211', 10);
|
|
77
|
+
const FILES_DIR = process.env.N8N_FILES_DIR || path.join(os.tmpdir(), 'frameforge-pipeline');
|
|
78
|
+
const RENDER_MODE = process.env.RENDER_MODE || 'api_only';
|
|
79
|
+
const LOCAL_RENDER = process.env.LOCAL_RENDER_URL || '';
|
|
80
|
+
const INTERNAL_TOKEN = process.env.INTERNAL_API_TOKEN || '';
|
|
81
|
+
|
|
82
|
+
// Only require heavy modules when in full mode (avoids startup crash on Render free)
|
|
83
|
+
let runResearch, runScript, runAssets, runRender, runShorts, runPackaging, runPublishing;
|
|
84
|
+
|
|
85
|
+
function loadWorkflows() {
|
|
86
|
+
if (runResearch) return; // already loaded
|
|
87
|
+
// NOTE: this file lives at src/worker/server-render-free.js
|
|
88
|
+
// workflows live at src/workflows/ — so path is one level up
|
|
89
|
+
({ runResearch } = require('../workflows/research'));
|
|
90
|
+
({ runScript } = require('../workflows/script'));
|
|
91
|
+
({ runAssets } = require('../workflows/assets'));
|
|
92
|
+
({ runRender } = require('../workflows/render'));
|
|
93
|
+
({ runShorts } = require('../workflows/shorts'));
|
|
94
|
+
({ runPackaging } = require('../workflows/packaging'));
|
|
95
|
+
({ runPublishing } = require('../workflows/publishing'));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Job registry ──────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
const jobs = new Map();
|
|
101
|
+
let _runningJob = null;
|
|
102
|
+
const _queue = [];
|
|
103
|
+
|
|
104
|
+
function _submitJob(type, payload) {
|
|
105
|
+
const id = `job_${Date.now()}_${crypto.randomBytes(3).toString('hex')}`;
|
|
106
|
+
const job = {
|
|
107
|
+
id, type, payload,
|
|
108
|
+
status: 'QUEUED',
|
|
109
|
+
createdAt: new Date().toISOString(),
|
|
110
|
+
startedAt: null,
|
|
111
|
+
completedAt: null,
|
|
112
|
+
result: null,
|
|
113
|
+
error: null,
|
|
114
|
+
renderMode: RENDER_MODE,
|
|
115
|
+
};
|
|
116
|
+
jobs.set(id, job);
|
|
117
|
+
_queue.push(job);
|
|
118
|
+
_persistJob(job).catch(() => {});
|
|
119
|
+
_drainQueue();
|
|
120
|
+
return job;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function _persistJob(job) {
|
|
124
|
+
try {
|
|
125
|
+
const dir = path.join(FILES_DIR, 'jobs');
|
|
126
|
+
await fs.mkdir(dir, { recursive: true });
|
|
127
|
+
await fs.writeFile(path.join(dir, `${job.id}.json`), JSON.stringify(job, null, 2), 'utf8');
|
|
128
|
+
} catch { /* /tmp may have space limits on Render free — best-effort */ }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function _drainQueue() {
|
|
132
|
+
if (_runningJob || _queue.length === 0) return;
|
|
133
|
+
const next = _queue.shift();
|
|
134
|
+
_runningJob = next.id;
|
|
135
|
+
executeJob(next).catch(console.error).finally(() => {
|
|
136
|
+
_runningJob = null;
|
|
137
|
+
_drainQueue();
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Job executor ──────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
async function executeJob(job) {
|
|
144
|
+
job.status = 'RUNNING';
|
|
145
|
+
job.startedAt = new Date().toISOString();
|
|
146
|
+
await _persistJob(job);
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
loadWorkflows(); // lazy-load (avoids missing binaries crash on Render)
|
|
150
|
+
job.result = await dispatchJob(job);
|
|
151
|
+
job.status = 'SUCCEEDED';
|
|
152
|
+
} catch (err) {
|
|
153
|
+
job.status = 'FAILED';
|
|
154
|
+
job.error = { message: err.message };
|
|
155
|
+
console.error(`[Worker/${job.id}] ${job.type} FAILED: ${err.message}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
job.completedAt = new Date().toISOString();
|
|
159
|
+
await _persistJob(job);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function dispatchJob(job) {
|
|
163
|
+
const { type, payload } = job;
|
|
164
|
+
const isRenderJob = ['render', 'shorts', 'full_pipeline'].includes(type);
|
|
165
|
+
|
|
166
|
+
// In api_only mode, delegate render/FFmpeg jobs to local render endpoint
|
|
167
|
+
if (RENDER_MODE === 'api_only' && isRenderJob && LOCAL_RENDER) {
|
|
168
|
+
console.log(`[Worker] Delegating ${type} to local render: ${LOCAL_RENDER}`);
|
|
169
|
+
return delegateToLocalRender(type, payload);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Run locally (full mode or no local render configured)
|
|
173
|
+
switch (type) {
|
|
174
|
+
case 'research': return runResearch(payload.topic, payload);
|
|
175
|
+
case 'script': return runScript(payload);
|
|
176
|
+
case 'assets': return runAssets(payload);
|
|
177
|
+
case 'render': return runRender(payload);
|
|
178
|
+
case 'shorts': return runShorts(payload);
|
|
179
|
+
case 'package': return runPackaging(payload);
|
|
180
|
+
case 'publish': return runPublishing(payload);
|
|
181
|
+
case 'full_pipeline': return runFullPipeline(payload, job.id);
|
|
182
|
+
default: throw new Error(`Unknown job type: ${type}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function runFullPipeline(payload, jobId) {
|
|
187
|
+
const { topic, channels = ['file'] } = payload;
|
|
188
|
+
console.log(`[Worker/${jobId}] full_pipeline: "${topic}"`);
|
|
189
|
+
|
|
190
|
+
// Steps A-C run here (network-only — safe on Render free)
|
|
191
|
+
const res = await runResearch(topic, { claimCount: 10 });
|
|
192
|
+
const scr = await runScript({ productionId: res.productionId, claims: res.claims, topic });
|
|
193
|
+
const ast = await runAssets({ productionId: scr.productionId, scenes: scr.scenes });
|
|
194
|
+
|
|
195
|
+
// Step D — FFmpeg: delegate if in api_only mode
|
|
196
|
+
let ren;
|
|
197
|
+
if (RENDER_MODE === 'api_only' && LOCAL_RENDER) {
|
|
198
|
+
ren = await delegateToLocalRender('render', { productionId: scr.productionId, sceneAssets: ast.sceneAssets });
|
|
199
|
+
} else {
|
|
200
|
+
ren = await runRender({ productionId: scr.productionId, sceneAssets: ast.sceneAssets });
|
|
201
|
+
}
|
|
202
|
+
if (!ren.passed) throw new Error(`Render QC failed`);
|
|
203
|
+
|
|
204
|
+
// Step D2 — Shorts (FFmpeg)
|
|
205
|
+
let sht = { shorts: [] };
|
|
206
|
+
if (RENDER_MODE === 'api_only' && LOCAL_RENDER) {
|
|
207
|
+
sht = await delegateToLocalRender('shorts', { productionId: scr.productionId, masterPath: ren.masterPath, scenes: scr.scenes }).catch(() => ({ shorts: [] }));
|
|
208
|
+
} else {
|
|
209
|
+
sht = await runShorts({ productionId: scr.productionId, masterPath: ren.masterPath, scenes: scr.scenes }).catch(() => ({ shorts: [] }));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Steps E-F run here (network-only)
|
|
213
|
+
const pkg = await runPackaging({ productionId: scr.productionId, masterPath: ren.masterPath, shorts: sht.shorts || [], scenes: scr.scenes, title: scr.title, description: scr.description, tags: scr.tags, thumbnailPrompt: scr.thumbnailPrompt, qcReport: ren.qcReport, totalWords: scr.totalWords });
|
|
214
|
+
const pub = await runPublishing({ productionId: scr.productionId, manifest: pkg.manifest, channels });
|
|
215
|
+
|
|
216
|
+
return { productionId: scr.productionId, topic, title: scr.title, masterPath: ren.masterPath, publishResults: pub.results, sceneCount: scr.sceneCount, totalWords: scr.totalWords, shortsCount: sht.shorts?.length || 0, qcPassed: ren.passed };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Delegation to local render endpoint ───────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
async function delegateToLocalRender(type, payload) {
|
|
222
|
+
if (!LOCAL_RENDER) throw new Error('LOCAL_RENDER_URL not configured — cannot delegate render job');
|
|
223
|
+
const baseUrl = LOCAL_RENDER.replace(/\/$/, '');
|
|
224
|
+
const extraHeaders = INTERNAL_TOKEN ? { Authorization: `Bearer ${INTERNAL_TOKEN}` } : {};
|
|
225
|
+
|
|
226
|
+
// Submit job to local worker
|
|
227
|
+
const submitResp = await httpJSON(`${baseUrl}/jobs`, 'POST', { type, payload }, extraHeaders);
|
|
228
|
+
const jobId = submitResp.jobId;
|
|
229
|
+
if (!jobId) throw new Error('Local render API did not return jobId');
|
|
230
|
+
|
|
231
|
+
// Poll for completion (max 30 min for FFmpeg render)
|
|
232
|
+
const maxMs = 30 * 60 * 1000;
|
|
233
|
+
const pollMs = 8000;
|
|
234
|
+
const start = Date.now();
|
|
235
|
+
|
|
236
|
+
while (Date.now() - start < maxMs) {
|
|
237
|
+
await sleep(pollMs);
|
|
238
|
+
const status = await httpJSON(`${baseUrl}/jobs/${encodeURIComponent(jobId)}`, 'GET', null, extraHeaders);
|
|
239
|
+
if (status.status === 'SUCCEEDED') return status.result;
|
|
240
|
+
if (status.status === 'FAILED') throw new Error(`Local render job failed: ${status.error?.message}`);
|
|
241
|
+
}
|
|
242
|
+
throw new Error(`Local render job ${jobId} timed out after 30 minutes`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ── Keep-alive: prevents Render free from sleeping during active pipeline ─────
|
|
246
|
+
// Render free sleeps after 15 min. During a pipeline run this matters.
|
|
247
|
+
// We self-ping every 10 min to stay warm.
|
|
248
|
+
|
|
249
|
+
let keepAliveTimer = null;
|
|
250
|
+
|
|
251
|
+
function startKeepAlive() {
|
|
252
|
+
if (keepAliveTimer) return;
|
|
253
|
+
keepAliveTimer = setInterval(async () => {
|
|
254
|
+
try {
|
|
255
|
+
await httpJSON(`http://127.0.0.1:${PORT}/health`, 'GET', null);
|
|
256
|
+
} catch { /* ignore */ }
|
|
257
|
+
}, 10 * 60 * 1000);
|
|
258
|
+
keepAliveTimer.unref(); // don't block process exit
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── HTTP helpers ──────────────────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
function httpJSON(url, method, body, extraHeaders = {}) {
|
|
264
|
+
return new Promise((resolve, reject) => {
|
|
265
|
+
const parsed = new URL(url);
|
|
266
|
+
const lib = parsed.protocol === 'https:' ? https : http;
|
|
267
|
+
const bodyBuf = body ? Buffer.from(JSON.stringify(body)) : null;
|
|
268
|
+
const opts = {
|
|
269
|
+
hostname: parsed.hostname,
|
|
270
|
+
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
271
|
+
path: parsed.pathname + parsed.search,
|
|
272
|
+
method,
|
|
273
|
+
headers: { 'Content-Type': 'application/json', ...extraHeaders, ...(bodyBuf ? { 'Content-Length': bodyBuf.length } : {}) },
|
|
274
|
+
};
|
|
275
|
+
const req = lib.request(opts, res => {
|
|
276
|
+
const chunks = [];
|
|
277
|
+
res.on('data', c => chunks.push(c));
|
|
278
|
+
res.on('end', () => {
|
|
279
|
+
const raw = Buffer.concat(chunks).toString();
|
|
280
|
+
if (res.statusCode >= 400) { reject(new Error(`HTTP ${res.statusCode}: ${raw.slice(0, 200)}`)); return; }
|
|
281
|
+
try { resolve(JSON.parse(raw)); } catch { resolve(raw); }
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
req.on('error', reject);
|
|
285
|
+
if (bodyBuf) req.write(bodyBuf);
|
|
286
|
+
req.end();
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
291
|
+
|
|
292
|
+
// ── HTTP server ───────────────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
const CORS = {
|
|
295
|
+
'Access-Control-Allow-Origin': '*',
|
|
296
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
297
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
function json(res, status, body) {
|
|
301
|
+
res.writeHead(status, { ...CORS, 'Content-Type': 'application/json' });
|
|
302
|
+
res.end(JSON.stringify(body, null, 2));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function checkInternalAuth(req) {
|
|
306
|
+
if (!INTERNAL_TOKEN) return true; // auth disabled if token not configured
|
|
307
|
+
const header = req.headers['authorization'] || req.headers['x-internal-token'] || '';
|
|
308
|
+
const token = header.replace(/^Bearer\s+/i, '').trim();
|
|
309
|
+
return timingSafeEqual(token, INTERNAL_TOKEN);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function handleRequest(req, res) {
|
|
313
|
+
if (req.method === 'OPTIONS') { res.writeHead(204, CORS); return res.end(); }
|
|
314
|
+
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
315
|
+
const parts = url.pathname.replace(/^\/+|\/+$/g, '').split('/');
|
|
316
|
+
|
|
317
|
+
// Health check is unauthenticated (Cloudflare cron pings this)
|
|
318
|
+
if (req.method === 'GET' && parts[0] === 'health') {
|
|
319
|
+
return json(res, 200, { status: 'ok', mode: RENDER_MODE, running: _runningJob, queued: _queue.length, localRender: LOCAL_RENDER ? 'configured' : 'none' });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// All other routes require internal auth when INTERNAL_API_TOKEN is set
|
|
323
|
+
if (!checkInternalAuth(req)) {
|
|
324
|
+
return json(res, 401, { error: 'Unauthorized — set Authorization: Bearer <INTERNAL_API_TOKEN>' });
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (req.method === 'GET' && parts[0] === 'jobs' && !parts[1]) {
|
|
328
|
+
const recent = [...jobs.values()]
|
|
329
|
+
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
|
330
|
+
.slice(0, 50)
|
|
331
|
+
.map(j => ({ id: j.id, type: j.type, status: j.status, createdAt: j.createdAt, completedAt: j.completedAt, renderMode: j.renderMode, error: j.error?.message }));
|
|
332
|
+
return json(res, 200, { jobs: recent, queued: _queue.length, running: _runningJob });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (req.method === 'GET' && parts[0] === 'jobs' && parts[1]) {
|
|
336
|
+
const job = jobs.get(parts[1]);
|
|
337
|
+
if (!job) return json(res, 404, { error: `Job not found: ${parts[1]}` });
|
|
338
|
+
return json(res, 200, job);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (req.method === 'POST' && parts[0] === 'jobs' && !parts[1]) {
|
|
342
|
+
let rawBody;
|
|
343
|
+
try { rawBody = await readBody(req); }
|
|
344
|
+
catch (err) {
|
|
345
|
+
return json(res, err.statusCode || 500, { error: err.message });
|
|
346
|
+
}
|
|
347
|
+
let parsed;
|
|
348
|
+
try { parsed = JSON.parse(rawBody); } catch { return json(res, 400, { error: 'Invalid JSON' }); }
|
|
349
|
+
const { type, payload } = parsed;
|
|
350
|
+
if (!type) return json(res, 400, { error: 'Missing field: type' });
|
|
351
|
+
const job = _submitJob(type, payload || {});
|
|
352
|
+
return json(res, 202, { jobId: job.id, status: job.status, type: job.type, renderMode: RENDER_MODE });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (req.method === 'POST' && parts[0] === 'jobs' && parts[2] === 'retry') {
|
|
356
|
+
const job = jobs.get(parts[1]);
|
|
357
|
+
if (!job) return json(res, 404, { error: `Job not found: ${parts[1]}` });
|
|
358
|
+
if (job.status !== 'FAILED') return json(res, 409, { error: `Job is not FAILED` });
|
|
359
|
+
const retried = _submitJob(job.type, job.payload);
|
|
360
|
+
return json(res, 202, { jobId: retried.id, status: retried.status, retriedFrom: job.id });
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
json(res, 404, { error: 'Not found' });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// 4 MB body limit — prevents OOM on Render free tier from oversized payloads
|
|
367
|
+
const MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
368
|
+
|
|
369
|
+
function readBody(req) {
|
|
370
|
+
return new Promise((resolve, reject) => {
|
|
371
|
+
let size = 0;
|
|
372
|
+
const chunks = [];
|
|
373
|
+
req.on('data', chunk => {
|
|
374
|
+
size += chunk.length;
|
|
375
|
+
if (size > MAX_BODY_BYTES) {
|
|
376
|
+
req.destroy();
|
|
377
|
+
return reject(Object.assign(new Error('Request body too large'), { statusCode: 413 }));
|
|
378
|
+
}
|
|
379
|
+
chunks.push(chunk);
|
|
380
|
+
});
|
|
381
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString()));
|
|
382
|
+
req.on('error', reject);
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function start() {
|
|
387
|
+
await fs.mkdir(path.join(FILES_DIR, 'jobs'), { recursive: true }).catch(() => {});
|
|
388
|
+
|
|
389
|
+
// Crash recovery: re-queue QUEUED jobs
|
|
390
|
+
const jobDir = path.join(FILES_DIR, 'jobs');
|
|
391
|
+
const jobFiles = await fs.readdir(jobDir).catch(() => []);
|
|
392
|
+
for (const f of jobFiles.filter(f => f.endsWith('.json'))) {
|
|
393
|
+
try {
|
|
394
|
+
const j = JSON.parse(await fs.readFile(path.join(jobDir, f), 'utf8'));
|
|
395
|
+
jobs.set(j.id, j);
|
|
396
|
+
if (j.status === 'QUEUED') _queue.push(j);
|
|
397
|
+
} catch { /* skip corrupt */ }
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const server = http.createServer(async (req, res) => {
|
|
401
|
+
try { await handleRequest(req, res); }
|
|
402
|
+
catch (err) { json(res, 500, { error: err.message }); }
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
server.listen(PORT, '0.0.0.0', () => {
|
|
406
|
+
console.log(`[Worker] ${RENDER_MODE} mode — listening on :${PORT}`);
|
|
407
|
+
if (!INTERNAL_TOKEN) {
|
|
408
|
+
console.warn('[Worker] WARNING: INTERNAL_API_TOKEN not set — API is unauthenticated. Set INTERNAL_API_TOKEN in .env for production use.');
|
|
409
|
+
}
|
|
410
|
+
if (RENDER_MODE === 'api_only' && LOCAL_RENDER) {
|
|
411
|
+
console.log(`[Worker] FFmpeg render delegated to: ${LOCAL_RENDER}`);
|
|
412
|
+
} else if (RENDER_MODE === 'api_only' && !LOCAL_RENDER) {
|
|
413
|
+
console.log(`[Worker] WARNING: api_only mode but LOCAL_RENDER_URL not set — render jobs will fail`);
|
|
414
|
+
}
|
|
415
|
+
startKeepAlive();
|
|
416
|
+
_drainQueue();
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
start().catch(err => { console.error('[Worker] startup failed:', err); process.exit(1); });
|