@amg555/frameforge-mcp 0.0.0-stage → 2.3.1

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 +45 -0
  3. package/.gitattributes +19 -0
  4. package/.mcp.json +45 -0
  5. package/CHANGELOG.md +447 -0
  6. package/CLAUDE.md +320 -0
  7. package/CONTRIBUTING.md +112 -0
  8. package/LICENSE +21 -0
  9. package/Procfile +1 -0
  10. package/README.md +377 -2
  11. package/SECURITY.md +71 -0
  12. package/START_HERE.md +200 -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 +392 -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,655 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * FrameForge Make — Standalone Video Generator
4
+ *
5
+ * ┌─────────────────────────────────────────────────────────────────────────────┐
6
+ * │ MODES │
7
+ * │ │
8
+ * │ full-auto (default) │
9
+ * │ Research → Script → AI Images → TTS → Render → Publish │
10
+ * │ node src/cli/make.js --topic "Your Topic" │
11
+ * │ │
12
+ * │ edit-only │
13
+ * │ Bring your own clips, images, audio — skip all AI generation │
14
+ * │ node src/cli/make.js --mode edit-only --scenes-file ./scenes.json │
15
+ * │ node src/cli/make.js --mode edit-only --media-dir ./my-clips │
16
+ * │ │
17
+ * │ script-only │
18
+ * │ Research → Script → stop. Review/edit the JSON before continuing. │
19
+ * │ node src/cli/make.js --mode script-only --topic "Your Topic" │
20
+ * │ │
21
+ * │ custom (mix-and-match — skip individual stages you don't need) │
22
+ * │ node src/cli/make.js --topic "X" --no-research --script-file s.json │
23
+ * │ node src/cli/make.js --topic "X" --no-images --no-tts (use your own) │
24
+ * │ node src/cli/make.js --script-file script.json --no-publish │
25
+ * └─────────────────────────────────────────────────────────────────────────────┘
26
+ *
27
+ * All options:
28
+ * --mode <mode> full-auto|edit-only|script-only|custom (default: full-auto)
29
+ * --topic <text> Video topic (required for full-auto/script-only)
30
+ * --topics-file <path> Batch mode: text file, one topic per line
31
+ * --scenes-file <path> Skip research+script, load scenes JSON directly
32
+ * --script-file <path> Skip research, load approved script JSON directly
33
+ * --media-dir <path> Auto-discover clips from a directory (edit-only)
34
+ * --language <code> BCP-47 language code for TTS voice (default: en)
35
+ * --style <text> Narrative style (default: "educational comedy")
36
+ * --channels <list> file,webhook (default: file)
37
+ * --no-research Skip research stage (use --topic as raw facts)
38
+ * --no-script Skip script stage (requires --scenes-file)
39
+ * --no-images Skip AI image generation (use video/image_path in scenes)
40
+ * --no-tts Skip TTS synthesis (use audio_path in scenes)
41
+ * --no-shorts Skip Shorts extraction
42
+ * --no-publish Skip publishing (render only)
43
+ * --dry-run Research + script only, stop before render
44
+ * --out-dir <path> Output directory (default: ./output)
45
+ * --env <path> Path to .env file (default: ./.env)
46
+ * --help Print this help
47
+ *
48
+ * No n8n, no IDE, no Docker required. Run 'node src/cli/setup-keys.js' to configure.
49
+ */
50
+
51
+ 'use strict';
52
+
53
+ const path = require('path');
54
+ const fs = require('fs');
55
+ const crypto = require('crypto');
56
+ const https = require('https');
57
+ const http = require('http');
58
+
59
+ // ── Language → edge-tts voice map ─────────────────────────────────────────────
60
+ // Maps BCP-47 language codes to sensible edge-tts defaults.
61
+ // User can still override with TTS_VOICE env var.
62
+ const LANGUAGE_VOICES = {
63
+ 'en': 'en-US-BrianNeural',
64
+ 'en-us': 'en-US-BrianNeural',
65
+ 'en-gb': 'en-GB-RyanNeural',
66
+ 'ml': 'ml-IN-MidhunNeural', // Malayalam
67
+ 'hi': 'hi-IN-MadhurNeural', // Hindi
68
+ 'ta': 'ta-IN-ValluvarNeural', // Tamil
69
+ 'te': 'te-IN-MohanNeural', // Telugu
70
+ 'kn': 'kn-IN-GaganNeural', // Kannada
71
+ 'es': 'es-ES-AlvaroNeural', // Spanish
72
+ 'fr': 'fr-FR-HenriNeural', // French
73
+ 'de': 'de-DE-ConradNeural', // German
74
+ 'pt': 'pt-BR-AntonioNeural', // Portuguese
75
+ 'it': 'it-IT-DiegoNeural', // Italian
76
+ 'ja': 'ja-JP-KeitaNeural', // Japanese
77
+ 'ko': 'ko-KR-InJoonNeural', // Korean
78
+ 'zh': 'zh-CN-YunxiNeural', // Mandarin
79
+ 'ar': 'ar-SA-HamedNeural', // Arabic
80
+ 'ru': 'ru-RU-DmitryNeural', // Russian
81
+ 'tr': 'tr-TR-AhmetNeural', // Turkish
82
+ 'id': 'id-ID-ArdiNeural', // Indonesian
83
+ };
84
+
85
+ // ── Argument parsing ──────────────────────────────────────────────────────────
86
+
87
+ function parseArgs(argv) {
88
+ const args = {
89
+ mode: 'full-auto',
90
+ topic: '', topicsFile: '', language: 'en',
91
+ style: 'educational comedy', channels: ['file'],
92
+ scenesFile: '', scriptFile: '', mediaDir: '',
93
+ shorts: true, dryRun: false,
94
+ noResearch: false, noScript: false, noImages: false, noTts: false, noPublish: false,
95
+ outDir: './output', env: '.env',
96
+ };
97
+ for (let i = 2; i < argv.length; i++) {
98
+ switch (argv[i]) {
99
+ case '--mode': args.mode = (argv[++i] || 'full-auto').toLowerCase(); break;
100
+ case '--topic': args.topic = argv[++i] || ''; break;
101
+ case '--topics-file': args.topicsFile = argv[++i] || ''; break;
102
+ case '--scenes-file': args.scenesFile = argv[++i] || ''; break;
103
+ case '--script-file': args.scriptFile = argv[++i] || ''; break;
104
+ case '--media-dir': args.mediaDir = argv[++i] || ''; break;
105
+ case '--language': args.language = (argv[++i] || 'en').toLowerCase(); break;
106
+ case '--style': args.style = argv[++i] || args.style; break;
107
+ case '--channels': args.channels = (argv[++i] || 'file').split(',').map(s => s.trim()); break;
108
+ case '--no-research': args.noResearch = true; break;
109
+ case '--no-script': args.noScript = true; break;
110
+ case '--no-images': args.noImages = true; break;
111
+ case '--no-tts': args.noTts = true; break;
112
+ case '--no-shorts': args.shorts = false; break;
113
+ case '--no-publish': args.noPublish = true; break;
114
+ case '--dry-run': args.dryRun = true; break;
115
+ case '--out-dir': args.outDir = argv[++i] || args.outDir; break;
116
+ case '--env': args.env = argv[++i] || args.env; break;
117
+ case '--help': case '-h': printHelp(); process.exit(0); break;
118
+ }
119
+ }
120
+
121
+ // Mode shorthands → set stage flags
122
+ if (args.mode === 'edit-only') {
123
+ args.noResearch = true;
124
+ args.noScript = true;
125
+ } else if (args.mode === 'script-only') {
126
+ args.dryRun = true;
127
+ }
128
+
129
+ return args;
130
+ }
131
+
132
+ function printHelp() {
133
+ console.log(`
134
+ FrameForge Make — Flexible Video Generator
135
+
136
+ MODES:
137
+ full-auto (default) Research → Script → AI Images → TTS → Render → Publish
138
+ edit-only Bring your own clips/images/audio — skip all AI generation
139
+ script-only Research + Script only — review JSON before continuing
140
+ custom Use --no-* flags to skip individual stages
141
+
142
+ USAGE (full-auto):
143
+ npm run make -- --topic "Your Topic"
144
+ npm run make -- --topic "Your Topic" --language ml --channels file,webhook
145
+ npm run make -- --topics-file topics.txt
146
+
147
+ USAGE (edit-only — your own media):
148
+ npm run make -- --mode edit-only --scenes-file ./my-scenes.json
149
+ npm run make -- --mode edit-only --media-dir ./my-clips
150
+
151
+ USAGE (custom — mix AI + your media):
152
+ npm run make -- --topic "X" --script-file approved-script.json --no-research
153
+ npm run make -- --topic "X" --no-images (generate TTS but skip AI images)
154
+ npm run make -- --topic "X" --no-tts (use your own voice files)
155
+ npm run make -- --topic "X" --no-publish (render only, don't upload)
156
+
157
+ OPTIONS:
158
+ --mode full-auto|edit-only|script-only|custom (default: full-auto)
159
+ --topic Video topic text (required for full-auto / script-only)
160
+ --topics-file Batch mode: text file, one topic per line
161
+ --scenes-file Load a scenes.json directly (skips research + script)
162
+ --script-file Load an approved script.json (skips research stage only)
163
+ --media-dir Auto-discover clips from a directory (edit-only)
164
+ --language BCP-47 code: en (default), ml, hi, ta, te, kn, es, fr, de, pt, it, ja, ko, zh, ar, ru, tr, id
165
+ --style Narrative style (default: "educational comedy")
166
+ --channels Delivery: file,webhook (default: file)
167
+ --no-research Skip research (use --topic text directly as context)
168
+ --no-script Skip script generation (requires --scenes-file)
169
+ --no-images Skip AI image generation (supply image_path / video_path in scenes)
170
+ --no-tts Skip TTS synthesis (supply audio_path in scenes)
171
+ --no-shorts Skip 9:16 Shorts extraction
172
+ --no-publish Skip publishing (produce master video only)
173
+ --dry-run Research + script only, stop before render
174
+ --out-dir Output directory (default: ./output)
175
+ --env Path to .env file (default: ./.env)
176
+ --help Show this message
177
+
178
+ FIRST TIME SETUP:
179
+ node src/cli/setup-keys.js Interactive API key wizard
180
+ node src/cli/setup-keys.js --check Validate existing .env
181
+ npm run doctor System dependency check
182
+
183
+ SCENES FILE FORMAT (for --scenes-file / --mode edit-only):
184
+ [
185
+ {
186
+ "narration": "Voice-over text (used for TTS if no audio_path)",
187
+ "caption": "ON-SCREEN TEXT",
188
+ "video_path": "./clips/scene1.mp4", (optional)
189
+ "image_path": "./images/scene1.png", (optional, used if no video_path)
190
+ "audio_path": "./audio/scene1.mp3", (optional, used instead of TTS)
191
+ "camera_hint": "zoom_in",
192
+ "music_mood": "energetic"
193
+ }
194
+ ]
195
+ `);
196
+ }
197
+
198
+ // ── .env loader ───────────────────────────────────────────────────────────────
199
+
200
+ function loadEnv(envPath) {
201
+ const full = path.resolve(process.cwd(), envPath);
202
+ if (!fs.existsSync(full)) return;
203
+ fs.readFileSync(full, 'utf8').split('\n').forEach(line => {
204
+ const stripped = line.trim();
205
+ if (!stripped || stripped.startsWith('#')) return;
206
+ const eq = stripped.indexOf('=');
207
+ if (eq < 1) return;
208
+ const key = stripped.slice(0, eq).trim();
209
+ const val = stripped.slice(eq + 1).trim().replace(/^["']|["']$/g, '');
210
+ if (key && val && !process.env[key]) process.env[key] = val;
211
+ });
212
+ }
213
+
214
+ // ── Progress logger ───────────────────────────────────────────────────────────
215
+
216
+ function log(stage, msg) {
217
+ const ts = new Date().toISOString().slice(11, 19);
218
+ console.log(`[${ts}] [${stage}] ${msg}`);
219
+ }
220
+
221
+ function logSection(title) {
222
+ console.log(`\n${'─'.repeat(60)}`);
223
+ console.log(` ${title}`);
224
+ console.log('─'.repeat(60));
225
+ }
226
+
227
+ // ── Thumbnail generator ───────────────────────────────────────────────────────
228
+ // Downloads a Pollinations image for the thumbnail prompt and optionally
229
+ // overlays the video title with FFmpeg drawtext. Saves to <outDir>/thumbnail.jpg.
230
+ // Non-fatal: failures are logged as warnings, not errors.
231
+
232
+ async function generateThumbnail({ thumbnailPrompt, title, outDir }) {
233
+ if (!thumbnailPrompt) return null;
234
+ const outputPath = path.join(outDir, 'thumbnail.jpg');
235
+ try {
236
+ const model = process.env.POLLINATIONS_MODEL || 'flux';
237
+ const prompt = encodeURIComponent(thumbnailPrompt.slice(0, 500));
238
+ const imgUrl = `https://image.pollinations.ai/prompt/${prompt}?width=1280&height=720&model=${model}&nologo=true`;
239
+ // Download image
240
+ const imgPath = path.join(outDir, 'thumbnail_raw.jpg');
241
+ await _downloadFile(imgUrl, imgPath);
242
+ if (!fs.existsSync(imgPath)) return null;
243
+
244
+ // Optionally burn title text with FFmpeg
245
+ const { spawnSync } = require('child_process');
246
+ if (title) {
247
+ // Resolve a usable font (same logic as renderer.js)
248
+ const fontCandidates = process.platform === 'win32'
249
+ ? ['C:/Windows/Fonts/arialbd.ttf', 'C:/Windows/Fonts/calibrib.ttf', 'C:/Windows/Fonts/segoeui.ttf']
250
+ : process.platform === 'darwin'
251
+ ? ['/System/Library/Fonts/Helvetica.ttc', '/Library/Fonts/Arial Bold.ttf']
252
+ : ['/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', '/usr/share/fonts/truetype/freefont/FreeSansBold.ttf'];
253
+ const font = process.env.FFMPEG_FONT
254
+ || fontCandidates.find(f => fs.existsSync(f))
255
+ || '';
256
+
257
+ if (font) {
258
+ const safeTitle = title.replace(/['":\\]/g, ' ').slice(0, 80);
259
+ const result = spawnSync('ffmpeg', [
260
+ '-y', '-i', imgPath,
261
+ '-vf', `drawtext=fontfile='${font}':text='${safeTitle}':fontcolor=white:fontsize=52:x=(w-text_w)/2:y=h-120:shadowx=3:shadowy=3:shadowcolor=black`,
262
+ '-q:v', '3',
263
+ outputPath,
264
+ ], { encoding: 'utf8' });
265
+ if (result.status === 0) {
266
+ fs.unlinkSync(imgPath); // remove raw
267
+ return outputPath;
268
+ }
269
+ }
270
+ // FFmpeg unavailable or failed — just rename the raw image
271
+ fs.renameSync(imgPath, outputPath);
272
+ } else {
273
+ fs.renameSync(imgPath, outputPath);
274
+ }
275
+ return outputPath;
276
+ } catch (err) {
277
+ log('Thumbnail', `⚠️ Thumbnail generation failed (non-fatal): ${err.message}`);
278
+ return null;
279
+ }
280
+ }
281
+
282
+ function _downloadFile(url, destPath) {
283
+ return new Promise((resolve, reject) => {
284
+ const client = url.startsWith('https') ? https : http;
285
+ const file = fs.createWriteStream(destPath);
286
+ function follow(u, hops) {
287
+ if (hops > 5) return reject(new Error('Too many redirects'));
288
+ client.get(u, (res) => {
289
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
290
+ res.resume();
291
+ return follow(res.headers.location, hops + 1);
292
+ }
293
+ if (res.statusCode !== 200) {
294
+ res.resume();
295
+ return reject(new Error(`HTTP ${res.statusCode} downloading ${u}`));
296
+ }
297
+ res.pipe(file);
298
+ file.on('finish', () => file.close(resolve));
299
+ file.on('error', reject);
300
+ }).on('error', reject);
301
+ }
302
+ follow(url, 0);
303
+ });
304
+ }
305
+
306
+ // ── Single video runner (used by both single-topic and batch modes) ───────────
307
+
308
+ async function runSingleVideo(args, topic, outDir, workflows) {
309
+ const { runResearch, runScript, runAssets, runRender, runShorts, runPackaging, runPublishing, runIngest } = workflows;
310
+
311
+ console.log(`\n🎬 FrameForge — Starting Production`);
312
+ console.log(` Mode: ${args.mode}`);
313
+ if (topic) console.log(` Topic: "${topic}"`);
314
+ if (args.scenesFile) console.log(` Scenes: ${args.scenesFile}`);
315
+ if (args.scriptFile) console.log(` Script: ${args.scriptFile}`);
316
+ if (args.mediaDir) console.log(` Media: ${args.mediaDir}`);
317
+ console.log(` Style: ${args.style}`);
318
+ console.log(` Language: ${args.language}`);
319
+ console.log(` Channels: ${args.channels.join(', ')}`);
320
+ console.log(` Output: ${outDir}`);
321
+ const skipFlags = ['noResearch','noScript','noImages','noTts','noPublish'].filter(f => args[f]).map(f => '--' + f.replace(/([A-Z])/g, '-$1').toLowerCase());
322
+ if (args.dryRun) skipFlags.push('--dry-run');
323
+ if (skipFlags.length) console.log(` Skipping: ${skipFlags.join(', ')}`);
324
+
325
+ const startTime = Date.now();
326
+
327
+ let scenes = null;
328
+ let productionId = null;
329
+ let title = topic || 'Video';
330
+ let description = '';
331
+ let tags = [];
332
+ let thumbnailPrompt = null;
333
+ let totalWords = 0;
334
+
335
+ // ── Stage A: Research (skip if --no-research / --scenes-file / --script-file) ─
336
+ if (!args.noResearch && !args.scenesFile && !args.scriptFile) {
337
+ logSection('Stage A: Research');
338
+ const researchResult = await runResearch(topic, { claimCount: 10 });
339
+ productionId = researchResult.productionId;
340
+ log('Research', `✅ ${researchResult.claims.length} claims extracted (production: ${productionId})`);
341
+
342
+ // ── Stage B: Script (uses research output) ──────────────────────────────
343
+ if (!args.noScript) {
344
+ logSection('Stage B: Script');
345
+ const scriptResult = await runScript({
346
+ productionId,
347
+ claims: researchResult.claims,
348
+ topic,
349
+ style: args.style,
350
+ });
351
+ scenes = scriptResult.scenes;
352
+ title = scriptResult.title || topic;
353
+ description = scriptResult.description || '';
354
+ tags = scriptResult.tags || [];
355
+ thumbnailPrompt = scriptResult.thumbnailPrompt;
356
+ totalWords = scriptResult.totalWords || 0;
357
+ log('Script', `✅ ${scenes.length} scenes, ~${totalWords} words`);
358
+ log('Script', ` Title: "${title}"`);
359
+
360
+ if (args.dryRun) {
361
+ logSection('Script-Only / Dry Run Complete');
362
+ console.log(`\n📄 Script saved. Skipping assets/render/publish.\n`);
363
+ const scriptPath = path.join(outDir, `${productionId}-script.json`);
364
+ fs.writeFileSync(scriptPath, JSON.stringify({ research: researchResult, script: scriptResult }, null, 2));
365
+ log('Output', `Script written to: ${scriptPath}`);
366
+ log('TIP', `Resume with: npm run make -- --script-file ${scriptPath}`);
367
+ summarise(startTime);
368
+ return { productionId, topic, dryRun: true, title, scriptPath };
369
+ }
370
+ }
371
+ }
372
+
373
+ // ── Script file bypass (--script-file): skip research, load approved JSON ──
374
+ if (args.scriptFile && !scenes) {
375
+ logSection('Loading Approved Script');
376
+ const absScript = path.resolve(process.cwd(), args.scriptFile);
377
+ if (!fs.existsSync(absScript)) throw new Error(`Script file not found: ${absScript}`);
378
+ const raw = JSON.parse(fs.readFileSync(absScript, 'utf8'));
379
+ const scriptData = raw.script || raw;
380
+ scenes = scriptData.scenes;
381
+ productionId = scriptData.productionId || raw.productionId || `script_${Date.now()}`;
382
+ title = scriptData.title || topic || 'Video';
383
+ description = scriptData.description || '';
384
+ tags = scriptData.tags || [];
385
+ thumbnailPrompt = scriptData.thumbnailPrompt || null;
386
+ totalWords = scriptData.totalWords || scenes?.reduce((n, s) => n + (s.narration || '').split(/\s+/).filter(Boolean).length, 0) || 0;
387
+ log('Script', `✅ Loaded ${scenes?.length} scenes from: ${absScript}`);
388
+
389
+ if (args.dryRun) {
390
+ logSection('Dry Run Complete');
391
+ summarise(startTime);
392
+ return { productionId, topic, dryRun: true, title };
393
+ }
394
+ }
395
+
396
+ // ── Scenes file bypass (--scenes-file / --media-dir / edit-only) ──────────
397
+ if ((args.scenesFile || args.mediaDir) && !scenes) {
398
+ logSection('Stage A: Media Ingest (edit-only)');
399
+ const ingestResult = await runIngest({
400
+ productionId,
401
+ scenesFile: args.scenesFile || undefined,
402
+ mediaDir: args.mediaDir || undefined,
403
+ outDir,
404
+ });
405
+ scenes = ingestResult.scenes;
406
+ productionId = ingestResult.productionId;
407
+ log('Ingest', `✅ ${scenes.length} scene(s) ingested`);
408
+ }
409
+
410
+ if (!scenes || scenes.length === 0) {
411
+ throw new Error('No scenes available. Use --topic, --scenes-file, --script-file, or --media-dir.');
412
+ }
413
+
414
+ // ── Stage C: Assets (images + TTS) ────────────────────────────────────────
415
+ logSection('Stage C: Assets (images + TTS)');
416
+
417
+ // Pass stage-skip flags into scenes so runAssets can honour them
418
+ const preparedScenes = scenes.map(s => ({
419
+ ...s,
420
+ // --no-images: mark scene as if it has a placeholder image path → renderer skips generation
421
+ _skipImageGen: args.noImages,
422
+ // --no-tts: mark scene as if it has pre-recorded audio → renderer skips TTS
423
+ _skipTts: args.noTts,
424
+ }));
425
+
426
+ const assetsResult = await runAssets({
427
+ productionId,
428
+ scenes: preparedScenes,
429
+ parallelism: 4,
430
+ skipImages: args.noImages,
431
+ skipTts: args.noTts,
432
+ });
433
+ log('Assets', `✅ ${assetsResult.sceneCount} scenes prepared`);
434
+
435
+ // ── Stage D: Render ────────────────────────────────────────────────────────
436
+ logSection('Stage D: Video Render (FFmpeg)');
437
+ const renderResult = await runRender({
438
+ productionId,
439
+ sceneAssets: assetsResult.sceneAssets,
440
+ });
441
+ if (!renderResult.passed) {
442
+ throw new Error(`Quality gate FAILED: ${JSON.stringify(renderResult.qcReport?.checks?.filter(c => !c.passed).map(c => c.name))}`);
443
+ }
444
+ log('Render', `✅ Master: ${renderResult.masterPath}`);
445
+ log('Render', ` Size: ${(renderResult.sizeBytes / 1024 / 1024).toFixed(1)} MB`);
446
+
447
+ // ── Stage D2: Shorts ───────────────────────────────────────────────────────
448
+ let shortsResult = { shorts: [] };
449
+ if (args.shorts) {
450
+ logSection('Stage D2: Shorts Extraction (9:16)');
451
+ shortsResult = await runShorts({
452
+ productionId,
453
+ masterPath: renderResult.masterPath,
454
+ scenes,
455
+ }).catch(err => {
456
+ log('Shorts', `⚠️ Shorts extraction failed (non-blocking): ${err.message}`);
457
+ return { shorts: [] };
458
+ });
459
+ log('Shorts', `✅ ${shortsResult.shorts?.length || 0} shorts extracted`);
460
+ }
461
+
462
+ // ── Stage E: Packaging ────────────────────────────────────────────────────
463
+ logSection('Stage E: Packaging');
464
+ const packageResult = await runPackaging({
465
+ productionId,
466
+ masterPath: renderResult.masterPath,
467
+ shorts: shortsResult.shorts || [],
468
+ scenes,
469
+ title,
470
+ description,
471
+ tags,
472
+ thumbnailPrompt,
473
+ qcReport: renderResult.qcReport,
474
+ totalWords,
475
+ });
476
+ log('Package', `✅ Manifest: ${packageResult.manifestPath || 'saved'}`);
477
+
478
+ // ── Thumbnail ─────────────────────────────────────────────────────────────
479
+ logSection('Thumbnail');
480
+ const thumbPath = await generateThumbnail({
481
+ thumbnailPrompt,
482
+ title,
483
+ outDir,
484
+ });
485
+ if (thumbPath) log('Thumbnail', `✅ Saved: ${thumbPath}`);
486
+
487
+ // ── Stage F: Publish ──────────────────────────────────────────────────────
488
+ if (args.noPublish) {
489
+ logSection('Publish Skipped (--no-publish)');
490
+ log('Output', `Master video: ${renderResult.masterPath}`);
491
+ summarise(startTime);
492
+ return { productionId, topic, masterPath: renderResult.masterPath, thumbnailPath: thumbPath, title, shorts: shortsResult.shorts || [] };
493
+ }
494
+
495
+ logSection('Stage F: Publish');
496
+ const publishResult = await runPublishing({
497
+ productionId,
498
+ manifest: packageResult.manifest,
499
+ channels: args.channels,
500
+ });
501
+ for (const [channel, result] of Object.entries(publishResult.results || {})) {
502
+ log('Publish', `${result.ok ? '✅' : '⚠️ '} ${channel}: ${result.ok ? 'delivered' : result.reason || result.error || 'failed'}`);
503
+ }
504
+
505
+ // ── Done ───────────────────────────────────────────────────────────────────
506
+ logSection('Production Complete');
507
+ console.log(`\n🎉 Video ready!`);
508
+ console.log(` Production ID: ${productionId}`);
509
+ console.log(` Master: ${renderResult.masterPath}`);
510
+ if (thumbPath) console.log(` Thumbnail: ${thumbPath}`);
511
+ if (shortsResult.shorts?.length) {
512
+ shortsResult.shorts.forEach((s, i) => console.log(` Short #${i + 1}: ${s.path || s}`));
513
+ }
514
+ summarise(startTime);
515
+ return {
516
+ productionId,
517
+ topic,
518
+ masterPath: renderResult.masterPath,
519
+ thumbnailPath: thumbPath,
520
+ title,
521
+ shorts: shortsResult.shorts || [],
522
+ publishResult,
523
+ };
524
+ }
525
+
526
+ // ── Main pipeline ─────────────────────────────────────────────────────────────
527
+
528
+ async function main() {
529
+ const args = parseArgs(process.argv);
530
+
531
+ // Validate: must have a topic source OR a media source
532
+ const hasTopic = args.topic || args.topicsFile;
533
+ const hasMedia = args.scenesFile || args.scriptFile || args.mediaDir;
534
+ if (!hasTopic && !hasMedia) {
535
+ console.error(
536
+ '\n❌ A content source is required. Examples:\n' +
537
+ ' npm run make -- --topic "Your Topic"\n' +
538
+ ' npm run make -- --mode edit-only --scenes-file ./scenes.json\n' +
539
+ ' npm run make -- --mode edit-only --media-dir ./my-clips\n' +
540
+ ' npm run make -- --script-file approved-script.json\n' +
541
+ '\n Run: npm run make -- --help for all options\n'
542
+ );
543
+ process.exit(1);
544
+ }
545
+
546
+ // Load env file before requiring modules (they read env at import time)
547
+ loadEnv(args.env);
548
+
549
+ // ── Language → TTS voice auto-select ──────────────────────────────────────
550
+ // Only set if user hasn't already set TTS_VOICE in the env file
551
+ if (args.language && args.language !== 'en' && !process.env.TTS_VOICE) {
552
+ const voice = LANGUAGE_VOICES[args.language];
553
+ if (voice) {
554
+ process.env.TTS_VOICE = voice;
555
+ log('Language', `Auto-selected voice: ${voice} (${args.language})`);
556
+ } else {
557
+ log('Language', `⚠️ Unknown language code "${args.language}" — using default voice`);
558
+ }
559
+ }
560
+
561
+ // Set N8N_FILES_DIR to outDir so all artifacts go there
562
+ const outDir = path.resolve(process.cwd(), args.outDir);
563
+ fs.mkdirSync(outDir, { recursive: true });
564
+ process.env.N8N_FILES_DIR = outDir;
565
+
566
+ // Lazy-require pipeline modules (after env + language are set)
567
+ const workflows = {
568
+ runResearch: require('../workflows/research').runResearch,
569
+ runScript: require('../workflows/script').runScript,
570
+ runIngest: require('../workflows/ingest').runIngest,
571
+ runAssets: require('../workflows/assets').runAssets,
572
+ runRender: require('../workflows/render').runRender,
573
+ runShorts: require('../workflows/shorts').runShorts,
574
+ runPackaging: require('../workflows/packaging').runPackaging,
575
+ runPublishing: require('../workflows/publishing').runPublishing,
576
+ };
577
+
578
+ // ── Batch mode (--topics-file) ────────────────────────────────────────────
579
+ if (args.topicsFile) {
580
+ const topicsPath = path.resolve(process.cwd(), args.topicsFile);
581
+ if (!fs.existsSync(topicsPath)) {
582
+ console.error(`\n❌ Topics file not found: ${topicsPath}\n`);
583
+ process.exit(1);
584
+ }
585
+ const topics = fs.readFileSync(topicsPath, 'utf8')
586
+ .split('\n')
587
+ .map(l => l.trim())
588
+ .filter(l => l && !l.startsWith('#'));
589
+
590
+ if (topics.length === 0) {
591
+ console.error(`\n❌ Topics file is empty: ${topicsPath}\n`);
592
+ process.exit(1);
593
+ }
594
+
595
+ console.log(`\n🎬 FrameForge Batch — ${topics.length} topic(s) queued`);
596
+ console.log(` Language: ${args.language} Channels: ${args.channels.join(', ')}`);
597
+
598
+ const batchStart = Date.now();
599
+ const batchResults = [];
600
+ let succeeded = 0, failed = 0;
601
+
602
+ for (let i = 0; i < topics.length; i++) {
603
+ const topic = topics[i];
604
+ const topicDir = path.join(outDir, `batch_${String(i + 1).padStart(3, '0')}`);
605
+ fs.mkdirSync(topicDir, { recursive: true });
606
+ process.env.N8N_FILES_DIR = topicDir;
607
+
608
+ console.log(`\n${'═'.repeat(60)}`);
609
+ console.log(` Batch ${i + 1}/${topics.length}: "${topic}"`);
610
+ console.log('═'.repeat(60));
611
+
612
+ try {
613
+ const result = await runSingleVideo(args, topic, topicDir, workflows);
614
+ batchResults.push({ index: i + 1, topic, status: 'ok', ...result });
615
+ succeeded++;
616
+ } catch (err) {
617
+ log('Batch', `❌ Topic ${i + 1} FAILED: ${err.message}`);
618
+ batchResults.push({ index: i + 1, topic, status: 'failed', error: err.message });
619
+ failed++;
620
+ }
621
+ }
622
+
623
+ // ── Batch summary ────────────────────────────────────────────────────────
624
+ const elapsed = Math.round((Date.now() - batchStart) / 1000);
625
+ logSection(`Batch Complete — ${succeeded} succeeded, ${failed} failed`);
626
+ console.log(`⏱️ Total time: ${Math.floor(elapsed / 60)}m ${elapsed % 60}s\n`);
627
+
628
+ // Print per-topic summary
629
+ batchResults.forEach(r => {
630
+ const icon = r.status === 'ok' ? '✅' : '❌';
631
+ const detail = r.status === 'ok' ? r.title || r.masterPath : r.error;
632
+ console.log(` ${icon} [${r.index}] ${r.topic} — ${detail}`);
633
+ });
634
+
635
+ // Exit with error code if any topic failed
636
+ if (failed > 0) process.exit(1);
637
+ return;
638
+ }
639
+
640
+ // ── Single topic mode ─────────────────────────────────────────────────────
641
+ await runSingleVideo(args, args.topic, outDir, workflows);
642
+ }
643
+
644
+ function summarise(startTime) {
645
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(0);
646
+ const mins = Math.floor(elapsed / 60);
647
+ const secs = elapsed % 60;
648
+ console.log(`\n⏱️ Total time: ${mins > 0 ? `${mins}m ` : ''}${secs}s\n`);
649
+ }
650
+
651
+ main().catch(err => {
652
+ console.error(`\n❌ Fatal error: ${err.message}`);
653
+ if (process.env.DEBUG_PROVIDERS) console.error(err.stack);
654
+ process.exit(1);
655
+ });