@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.
Files changed (51) hide show
  1. package/.bob/artifacts/enterprise-ai-video-agent-platform-agnostic-implementation-guide.html +464 -0
  2. package/.claude-plugin/marketplace.json +17 -0
  3. package/.gitattributes +19 -0
  4. package/.mcp.json +45 -0
  5. package/CHANGELOG.md +431 -0
  6. package/CLAUDE.md +278 -0
  7. package/CONTRIBUTING.md +112 -0
  8. package/LICENSE +21 -0
  9. package/Procfile +1 -0
  10. package/README.md +359 -2
  11. package/SECURITY.md +71 -0
  12. package/START_HERE.md +144 -0
  13. package/assets/metadata/assets.json +5 -0
  14. package/assets/metadata/embeddings.json +5 -0
  15. package/install.sh +64 -0
  16. package/package.json +58 -4
  17. package/src/animation/animation-system.js +229 -0
  18. package/src/asset-library/README.md +188 -0
  19. package/src/asset-library/asset-library.js +639 -0
  20. package/src/audio-mixer/mixer.js +187 -0
  21. package/src/cli/doctor.js +173 -0
  22. package/src/cli/make.js +655 -0
  23. package/src/cli/setup-keys.js +378 -0
  24. package/src/config/load-config.js +72 -0
  25. package/src/fact-checker/checker.js +384 -0
  26. package/src/fact-checker/review-gate.js +306 -0
  27. package/src/fact-checker/validation-set.js +1078 -0
  28. package/src/mcp-server/server.js +1299 -0
  29. package/src/mcp-server/stdio.js +87 -0
  30. package/src/pipeline/artifact-manifest.js +323 -0
  31. package/src/pipeline/artifact-retention.js +189 -0
  32. package/src/pipeline/external-adapter.js +366 -0
  33. package/src/pipeline/providers.js +853 -0
  34. package/src/pipeline/retry-policy.js +134 -0
  35. package/src/pipeline/state-machine.js +297 -0
  36. package/src/pipeline/telemetry.js +162 -0
  37. package/src/quality-gate/gate.js +229 -0
  38. package/src/schema/scene.schema.json +101 -0
  39. package/src/shorts-extractor/extractor.js +343 -0
  40. package/src/utils/log-redaction.js +274 -0
  41. package/src/video-renderer/renderer.js +364 -0
  42. package/src/worker/server-render-free.js +420 -0
  43. package/src/worker/server.js +328 -0
  44. package/src/workflows/assets.js +131 -0
  45. package/src/workflows/ingest.js +299 -0
  46. package/src/workflows/packaging.js +156 -0
  47. package/src/workflows/publishing.js +206 -0
  48. package/src/workflows/render.js +102 -0
  49. package/src/workflows/research.js +151 -0
  50. package/src/workflows/script.js +210 -0
  51. 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); });