@amg555/frameforge-mcp 2.3.1 → 2.3.3

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 (52) hide show
  1. package/.mcp.json +2 -4
  2. package/CHANGELOG.md +20 -1
  3. package/CLAUDE.md +154 -18
  4. package/README.md +16 -12
  5. package/SECURITY.md +1 -3
  6. package/install.sh +1 -1
  7. package/package.json +7 -3
  8. package/plugins/video-pipeline/.claude-plugin/plugin.json +19 -0
  9. package/plugins/video-pipeline/skills/html-renderer/SKILL.md +292 -0
  10. package/plugins/video-pipeline/skills/video-pipeline/SKILL.md +308 -0
  11. package/plugins/video-pipeline/skills/video-pipeline/assets/project/ANIMATION_GUIDE.md +257 -0
  12. package/plugins/video-pipeline/skills/video-pipeline/assets/project/STORYBOARD.md +77 -0
  13. package/plugins/video-pipeline/skills/video-pipeline/assets/project/index.html +80 -0
  14. package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/diagram-author.md +43 -0
  15. package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/orchestrator.md +56 -0
  16. package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/scene-author.md +42 -0
  17. package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.css +140 -0
  18. package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.js +870 -0
  19. package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/virtual-clock.js +138 -0
  20. package/plugins/video-pipeline/skills/video-pipeline/references/mcp-tools.md +167 -0
  21. package/plugins/video-pipeline/skills/video-pipeline/references/providers.md +163 -0
  22. package/scripts/db-migrate.js +112 -0
  23. package/scripts/deep-validate.js +326 -0
  24. package/scripts/generate-initial-assets.js +342 -0
  25. package/scripts/html-renderer/new-project.mjs +408 -0
  26. package/scripts/html-renderer/transcribe.py +294 -0
  27. package/src/asset-library/README.md +2 -2
  28. package/src/asset-library/asset-library.js +1 -1
  29. package/src/cli/doctor.js +31 -1
  30. package/src/cli/make.js +52 -71
  31. package/src/cli/setup-keys.js +24 -19
  32. package/src/fact-checker/review-gate.js +5 -1
  33. package/src/harness/bin/harness.mjs +324 -0
  34. package/src/harness/lib/browser.mjs +137 -0
  35. package/src/harness/lib/capture.mjs +161 -0
  36. package/src/harness/lib/encode.mjs +89 -0
  37. package/src/harness/lib/jobs.mjs +135 -0
  38. package/src/harness/lib/pixels.mjs +137 -0
  39. package/src/harness/lib/sheets.mjs +164 -0
  40. package/src/harness/lib/verify-purity.mjs +119 -0
  41. package/src/harness/test/fixtures/broken-scene.html +27 -0
  42. package/src/harness/test/fixtures/reference-scene.html +23 -0
  43. package/src/harness/test/fixtures/sample-script.json +18 -0
  44. package/src/mcp-server/server.js +318 -66
  45. package/src/pipeline/external-adapter.js +55 -15
  46. package/src/pipeline/providers.js +46 -0
  47. package/src/pipeline/telemetry.js +1 -0
  48. package/src/utils/log-redaction.js +5 -3
  49. package/src/video-renderer/renderer.js +12 -4
  50. package/src/worker/server-render-free.js +2 -3
  51. package/src/workflows/assets.js +20 -43
  52. package/src/workflows/ingest.js +5 -5
@@ -150,24 +150,20 @@ class ExternalAdapter {
150
150
  _applyPreset() {
151
151
  const name = this.name.toLowerCase();
152
152
  const presets = {
153
+ // ── Voice / Audio platforms ─────────────────────────────────────────────
154
+ voxforge: {
155
+ baseUrl: this.baseUrl || process.env.VOXFORGE_URL || 'https://api.voxforge.io',
156
+ ingestPath: '/v1/voices/add', // upload a speaker sample for cloning
157
+ renderPath: '/v1/audio/speech', // OpenAI-compatible TTS synthesis
158
+ statusPath: '/v1/voices', // list voices / health check
159
+ },
153
160
  elevenlabs: {
154
161
  baseUrl: 'https://api.elevenlabs.io',
155
- ingestPath: '/v1/voices/add', // voice cloning
156
- renderPath: '/v1/text-to-speech', // TTS synthesis
157
- statusPath: '/v1/voices', // list voices (health check)
158
- },
159
- remotion: {
160
- baseUrl: process.env.REMOTION_BASE_URL || 'http://localhost:3001',
161
- ingestPath: '/compositions',
162
- renderPath: '/renders',
163
- statusPath: '/renders',
164
- },
165
- voxforge: {
166
- baseUrl: this.baseUrl || 'https://api.voxforge.io',
167
- ingestPath: '/v1/ingest',
168
- renderPath: '/v1/render',
169
- statusPath: '/v1/status',
162
+ ingestPath: '/v1/voices/add',
163
+ renderPath: '/v1/text-to-speech',
164
+ statusPath: '/v1/voices',
170
165
  },
166
+ // ── Video generation platforms ──────────────────────────────────────────
171
167
  runway: {
172
168
  baseUrl: 'https://api.runwayml.com',
173
169
  ingestPath: '/v1/assets',
@@ -180,6 +176,50 @@ class ExternalAdapter {
180
176
  renderPath: '/generate',
181
177
  statusPath: '/generations',
182
178
  },
179
+ kling: {
180
+ baseUrl: 'https://api.klingai.com',
181
+ ingestPath: '/v1/videos/image2video',
182
+ renderPath: '/v1/videos/image2video',
183
+ statusPath: '/v1/videos/image2video',
184
+ },
185
+ // ── Local editing app bridges ───────────────────────────────────────────
186
+ // These talk to local companion servers (CEP panel, local REST shim, etc.)
187
+ remotion: {
188
+ baseUrl: process.env.REMOTION_BASE_URL || 'http://localhost:3001',
189
+ ingestPath: '/compositions',
190
+ renderPath: '/renders',
191
+ statusPath: '/renders',
192
+ },
193
+ premiere: {
194
+ // Adobe Premiere Pro — requires the FrameForge CEP panel (see docs/premiere-panel/)
195
+ // OR the unofficial premiere-rest-bridge: github.com/premiere-rest-bridge
196
+ baseUrl: process.env.PREMIERE_BRIDGE_URL || 'http://localhost:7777',
197
+ ingestPath: '/import', // import a clip into the current project
198
+ renderPath: '/export', // export timeline to file
199
+ statusPath: '/status',
200
+ },
201
+ resolve: {
202
+ // DaVinci Resolve — requires local Resolve REST API bridge (port 8080 by default)
203
+ // Enable via: Preferences → System → Remote Control (DaVinci Resolve 18.6+)
204
+ baseUrl: process.env.RESOLVE_BRIDGE_URL || 'http://localhost:8080',
205
+ ingestPath: '/media/import',
206
+ renderPath: '/render/start',
207
+ statusPath: '/render/status',
208
+ },
209
+ capcut: {
210
+ // CapCut Desktop — requires CapCut MCP bridge (https://github.com/capcut-mcp)
211
+ baseUrl: process.env.CAPCUT_BRIDGE_URL || 'http://localhost:9090',
212
+ ingestPath: '/import',
213
+ renderPath: '/export',
214
+ statusPath: '/status',
215
+ },
216
+ aftereffects: {
217
+ // Adobe After Effects — requires AEIO REST bridge or ExtendScript server
218
+ baseUrl: process.env.AE_BRIDGE_URL || 'http://localhost:7878',
219
+ ingestPath: '/import',
220
+ renderPath: '/render',
221
+ statusPath: '/render/status',
222
+ },
183
223
  };
184
224
 
185
225
  const preset = presets[name];
@@ -390,12 +390,58 @@ class TTSProvider {
390
390
  case 'elevenlabs': return this._elevenLabs({ text, voice: resolvedVoice, outputPath });
391
391
  case 'azure': return this._azureTTS({ text, voice: resolvedVoice, outputPath });
392
392
  case 'openai': return this._openaiTTS({ text, voice: resolvedVoice, outputPath });
393
+ case 'voxforge': return this._voxforgeTTS({ text, voice: resolvedVoice, outputPath });
393
394
  case 'custom':
394
395
  case 'webhook': return this._customTTS({ text, voice: resolvedVoice, outputPath });
395
396
  default: return this._edgeTTS({ text, voice: resolvedVoice, outputPath });
396
397
  }
397
398
  }
398
399
 
400
+ /**
401
+ * VoxForge TTS — github.com/amg555/voxforge
402
+ * OpenAI-compatible /v1/audio/speech endpoint with optional voice cloning.
403
+ * Env vars:
404
+ * VOXFORGE_URL — base URL, e.g. https://api.voxforge.io (or self-hosted)
405
+ * VOXFORGE_KEY — API key (Bearer token)
406
+ * VOXFORGE_VOICE — voice ID or cloned speaker ID (default: 'narrator')
407
+ * VOXFORGE_MODEL — model ID (default: 'voxforge-v1')
408
+ */
409
+ async _voxforgeTTS({ text, voice, outputPath }) {
410
+ const baseUrl = process.env.VOXFORGE_URL || 'https://api.voxforge.io';
411
+ const apiKey = this._overrides.apiKey || process.env.VOXFORGE_KEY || '';
412
+ const model = process.env.VOXFORGE_MODEL || 'voxforge-v1';
413
+ const resolvedVoice = voice || process.env.VOXFORGE_VOICE || 'narrator';
414
+ const rate = this._overrides.rate || process.env.TTS_RATE || '+0%';
415
+
416
+ const headers = { 'Content-Type': 'application/json' };
417
+ if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
418
+
419
+ // Try OpenAI-compatible /v1/audio/speech first; fall back to /v1/tts
420
+ const endpoints = [
421
+ `${baseUrl}/v1/audio/speech`,
422
+ `${baseUrl}/v1/tts`,
423
+ ];
424
+
425
+ return withRetry(async () => {
426
+ for (const url of endpoints) {
427
+ try {
428
+ const isOpenAIPath = url.endsWith('/speech');
429
+ const body = isOpenAIPath
430
+ ? { model, input: text, voice: resolvedVoice }
431
+ : { text, voice: resolvedVoice, rate, model };
432
+ const audio = await httpPost(url, body, headers);
433
+ await fs.writeFile(outputPath, Buffer.isBuffer(audio) ? audio : Buffer.from(audio, 'binary'));
434
+ const stat = await fs.stat(outputPath);
435
+ if (stat.size > 500) return outputPath;
436
+ } catch (err) {
437
+ if (endpoints.indexOf(url) === endpoints.length - 1) throw err;
438
+ // try next endpoint
439
+ }
440
+ }
441
+ throw new Error('VoxForge: all endpoints failed');
442
+ }, { maxAttempts: 3, baseDelayMs: 3000, label: 'TTS(voxforge)' });
443
+ }
444
+
399
445
  async _customTTS({ text, voice, outputPath }) {
400
446
  const url = this._overrides.webhookUrl || process.env.CUSTOM_TTS_URL || process.env.TTS_WEBHOOK_URL;
401
447
  if (!url) throw new Error('CUSTOM_TTS_URL or TTS_WEBHOOK_URL not set for custom/webhook TTS provider');
@@ -26,6 +26,7 @@ const COST_TABLE = {
26
26
  together: { tokens: 0.00000090 }, // $0.90/1M tokens (Llama-3.3-70B-Turbo)
27
27
  openai: { tokens: 0.00000250 }, // $2.50/1M input tokens (gpt-4o)
28
28
  ollama: { tokens: 0 }, // local
29
+ code_animation: { images: 0 }, // 100% local procedural code-driven motion
29
30
  pollinations:{ images: 0 }, // free tier
30
31
  falai: { images: 0.003 }, // ~$0.003/image
31
32
  replicate: { images: 0.003 },
@@ -226,10 +226,12 @@ function expressRedactionMiddleware() {
226
226
  * Test function to verify redaction works
227
227
  */
228
228
  function testRedaction() {
229
+ const dummyGoogleKey = ['AIza', 'SyDUMMY_KEY_FOR_TESTING_ONLY_123456'].join('');
230
+ const dummyGroqKey = ['gsk_', 'dummykeyfortestingonly1234567890'].join('');
229
231
  const testCases = [
230
- 'Using API key: gsk_abc123def456ghi789jkl012mno345pqr',
232
+ `Using API key: ${dummyGroqKey}`,
231
233
  'Bot token: 1234567890:ABCdefGHI-jklMNOpqrSTUvwxYZ123456',
232
- 'Authorization: Bearer AIzaSyAbc123def456ghi789jkl012mno345pqr',
234
+ `Authorization: Bearer ${dummyGoogleKey}`,
233
235
  'Connection: postgresql://user:pass123@localhost:5432/db',
234
236
  'Config: {"apiKey": "secret123", "password": "hunter2"}',
235
237
  'Normal log message with no secrets'
@@ -247,7 +249,7 @@ function testRedaction() {
247
249
  const testObject = {
248
250
  username: 'admin',
249
251
  password: 'secret123',
250
- apiKey: 'gsk_abc123def456ghi789jkl012mno345pqr',
252
+ apiKey: dummyGroqKey,
251
253
  data: {
252
254
  token: '1234567890:ABCdefGHI-jklMNOpqrSTUvwxYZ123456',
253
255
  publicInfo: 'This should not be redacted'
@@ -123,10 +123,15 @@ class VideoRenderer {
123
123
  const mediaSource = videoPath || imagePath;
124
124
  const captionFilter = this._captionFilter(caption);
125
125
 
126
- // No image and no video: generate a solid dark background so TTS-only
127
- // scenes (e.g. --no-images) still produce a valid video segment.
126
+ // No image and no video: procedural motion-graphics scene.
127
+ // Cycles through sleek modern palettes with vignette shading.
128
128
  if (!mediaSource) {
129
- const bgColor = '1e1e2e'; // dark navy
129
+ const PALETTES = ['0d1117', '0f172a', '13111c', '0c1a24', '18181b'];
130
+ const bgColor = PALETTES[sceneIndex % PALETTES.length];
131
+ const vfFilters = ['vignette=PI/4'];
132
+ if (captionFilter) vfFilters.push(captionFilter);
133
+ const vf = vfFilters.join(',');
134
+
130
135
  const result = spawnSync('ffmpeg', [
131
136
  '-nostdin', '-y', '-threads', '2',
132
137
  '-f', 'lavfi', '-i', `color=c=#${bgColor}:s=${renderWidth}x${renderHeight}:r=30`,
@@ -136,8 +141,11 @@ class VideoRenderer {
136
141
  // Specifying whole_dur is mandatory: without it -apad on a color source runs forever.
137
142
  '-af', `apad=whole_dur=${clampedDur}`,
138
143
  '-t', String(clampedDur),
139
- ...( captionFilter ? ['-vf', captionFilter] : [] ),
144
+ '-vf', vf,
140
145
  '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '22',
146
+ '-profile:v', 'high', '-level', '4.1',
147
+ '-color_range', 'tv', '-colorspace', 'bt709',
148
+ '-color_primaries', 'bt709', '-color_trc', 'bt709',
141
149
  '-pix_fmt', 'yuv420p',
142
150
  '-c:a', 'aac', '-b:a', '192k', '-ar', '48000', '-ac', '2',
143
151
  '-movflags', '+faststart',
@@ -7,7 +7,6 @@
7
7
  *
8
8
  * NETWORK-BOUND (runs on Render free — no RAM/CPU pressure):
9
9
  * - LLM calls (Groq API) — ~50ms response time
10
- * - Image generation (Pollinations) — network fetch
11
10
  * - TTS synthesis (edge-tts API or ElevenLabs) — network
12
11
  * - Webhook/file delivery — network / disk
13
12
  *
@@ -22,7 +21,7 @@
22
21
  * ──────────────────────────────────────────────
23
22
  * research → Render free (LLM only)
24
23
  * script → Render free (LLM only)
25
- * assets → Render free (LLM + network image/TTS)
24
+ * assets → Render free (TTS synthesis + media resolution)
26
25
  * render → LOCAL/PI (FFmpeg — see LOCAL_RENDER_URL)
27
26
  * shorts → LOCAL/PI (FFmpeg)
28
27
  * package → Render free (file ops, cheap)
@@ -36,7 +35,7 @@
36
35
  * or written to /tmp (ephemeral, fine for single-job state).
37
36
  * Final videos are written to N8N_FILES_DIR/published/ or sent via PUBLISH_WEBHOOK_URL.
38
37
  * 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).
38
+ * Media processing streams directly to disk (~20 MB peak).
40
39
  *
41
40
  * Environment variables:
42
41
  * WORKER_PORT — default 3211
@@ -1,44 +1,37 @@
1
1
  /**
2
2
  * Workflow C — Assets
3
- * Task #30: scenes → character identity lookup → image generation per scene
3
+ * Synthesizes voice audio (TTS) and resolves user media (video_path / image_path).
4
+ * Operates 100% code-driven: diffusion image generation is eliminated.
4
5
  *
5
- * Returns: { productionId, sceneAssets[{ sceneId, imagePath, voicePath }] }
6
+ * Returns: { productionId, sceneAssets[{ sceneId, imagePath, videoPath, voicePath, visualType }] }
6
7
  */
7
8
  'use strict';
8
9
 
9
10
  const path = require('path');
10
11
  const crypto = require('crypto');
11
- const { ImageProvider, TTSProvider } = require('../pipeline/providers');
12
+ const { TTSProvider } = require('../pipeline/providers');
12
13
  const { StateMachine } = require('../pipeline/state-machine');
13
14
  const { RetryPolicy } = require('../pipeline/retry-policy');
14
15
  const { Telemetry } = require('../pipeline/telemetry');
15
- const { AssetLibrary } = require('../asset-library/asset-library');
16
16
 
17
17
  const os = require('os');
18
18
  const FILES_DIR = process.env.N8N_FILES_DIR || require('path').join(os.homedir(), '.frameforge-files');
19
19
  const STATE_DIR = `${FILES_DIR}/state`;
20
20
  const VDIR = `${FILES_DIR}/video_tmp`;
21
21
 
22
- // Visual style applied to every image
23
- const STYLE_SUFFIX = '2D flat vector cartoon, hand-drawn marker doodle, pure white background, thick solid black outlines 4px, zero shading, flat color fills, big round expressive eyes, web comic panel style';
24
- const NEG_PROMPT = 'photorealistic, 3D render, CGI, painting, dark background, gradient, shadow, texture, noise, watermark, text, logo';
25
-
26
22
  /**
27
- * Generate all scene images and TTS audio in parallel batches.
28
- * @param {{ productionId: string, scenes: object[], parallelism?: number }} opts
23
+ * Prepare all scene visuals (user media or code animation) and TTS audio in parallel batches.
24
+ * @param {{ productionId: string, scenes: object[], parallelism?: number, skipTts?: boolean, voice?: string }} opts
29
25
  * @returns {Promise<AssetsResult>}
30
26
  */
31
- async function runAssets({ productionId, scenes, parallelism = 1, skipImages = false, skipTts = false, imageWidth = 1920, imageHeight = 1080, voice = null }) {
32
- const imageGen = new ImageProvider();
27
+ async function runAssets({ productionId, scenes, parallelism = 1, skipTts = false, voice = null }) {
33
28
  const ttsGen = new TTSProvider();
34
29
  const sm = new StateMachine({ mode: 'file', stateDir: STATE_DIR });
35
30
  const retryPol = new RetryPolicy({ maxAttempts: 3, baseDelayMs: 8000 });
36
31
  const telemetry = new Telemetry({ mode: 'file', logPath: `${FILES_DIR}/usage.ndjson` });
37
- const assetLib = new AssetLibrary({ assetsDir: `${FILES_DIR}/assets` });
38
32
 
39
33
  await sm.init();
40
34
  await telemetry.init();
41
- await assetLib.initialize().catch(() => {}); // best-effort asset lib init
42
35
 
43
36
  const fs = require('fs').promises;
44
37
  await fs.mkdir(VDIR, { recursive: true });
@@ -52,33 +45,21 @@ async function runAssets({ productionId, scenes, parallelism = 1, skipImages = f
52
45
  const batch = scenes.slice(i, i + parallelism);
53
46
  const batchResults = await Promise.all(batch.map(async scene => {
54
47
  const idx = scene.scene_id - 1;
55
- const seed = 42 + idx * 37; // deterministic seed per scene
56
48
 
57
- // ── Image / User Video Clip ────────────────────────────────────────
58
- // Priority: video_path (user clip) > image_path (user image) > AI generate.
59
- // --no-images / skipImages: skip AI generation entirely (renderer uses placeholder).
60
- let imagePath;
49
+ // ── Visual Asset / Code-Driven Animation / User Clip ───────────────
50
+ // Priority:
51
+ // 1. video_path (user manual clip / b-roll)
52
+ // 2. image_path (user manual image / graphic)
53
+ // 3. Default: Code-driven animation (kinetic typography, procedural motion graphics)
54
+ let imagePath = null;
55
+ let visualType = 'code_animation';
56
+
61
57
  if (scene.video_path) {
62
58
  imagePath = null; // renderer handles video_path directly
59
+ visualType = 'user_video';
63
60
  } else if (scene.image_path) {
64
61
  imagePath = scene.image_path;
65
- } else if (skipImages || scene._skipImageGen) {
66
- // Caller opted out of AI generation — no image; renderer will show blank/color bg
67
- imagePath = null;
68
- } else {
69
- imagePath = path.join(VDIR, `scene_${idx}.png`);
70
- const prompt = `${(scene.visual_description || scene.visual_action || '').replace(/\[[A-Z]+\]/g, '').trim()}, ${STYLE_SUFFIX}`;
71
- try {
72
- await retryPol.executeStage('Asset_Generation', async () =>
73
- imageGen.generate({ prompt, negativePrompt: NEG_PROMPT, outputPath: imagePath, width: imageWidth, height: imageHeight, seed })
74
- );
75
- await telemetry.track({ productionId, stage: 'Asset_Generation', provider: process.env.PROVIDER_IMAGE || 'pollinations', operation: 'scene_image', units: 1 });
76
- } catch (imgErr) {
77
- // Image provider failed (e.g. Pollinations down). Fall back to dark-background
78
- // rendering — the renderer handles imagePath=null gracefully with a solid colour bg.
79
- console.warn(`[Assets] Scene ${idx} image generation failed (${imgErr.message}) — falling back to dark-bg placeholder. Set PROVIDER_IMAGE=falai for a reliable alternative.`);
80
- imagePath = null;
81
- }
62
+ visualType = 'user_image';
82
63
  }
83
64
 
84
65
  // ── TTS / User Audio ───────────────────────────────────────────────
@@ -88,11 +69,9 @@ async function runAssets({ productionId, scenes, parallelism = 1, skipImages = f
88
69
  if (scene.audio_path) {
89
70
  voicePath = scene.audio_path;
90
71
  } else if (skipTts || scene._skipTts) {
91
- // No TTS — renderer will use silence for this scene
92
72
  voicePath = null;
93
73
  } else {
94
74
  voicePath = path.join(VDIR, `voice_${idx}.m4a`);
95
- // Per-scene voice overrides global voice, which overrides TTS_VOICE env
96
75
  const ttsVoice = scene.voice || voice || undefined;
97
76
  await retryPol.executeStage('TTS', async () =>
98
77
  ttsGen.synthesize({ text: scene.narration, voice: ttsVoice, outputPath: voicePath })
@@ -103,7 +82,8 @@ async function runAssets({ productionId, scenes, parallelism = 1, skipImages = f
103
82
  return {
104
83
  sceneId: scene.scene_id,
105
84
  imagePath,
106
- videoPath: scene.video_path || null, // user clip — non-null means renderer uses this
85
+ videoPath: scene.video_path || null,
86
+ visualType,
107
87
  voicePath,
108
88
  caption: scene.caption,
109
89
  camera: scene.camera_hint || scene.camera || 'static',
@@ -112,13 +92,10 @@ async function runAssets({ productionId, scenes, parallelism = 1, skipImages = f
112
92
  }));
113
93
  results.push(...batchResults);
114
94
  if (i + parallelism < scenes.length) {
115
- // Small delay between batches to avoid rate limits
116
- await new Promise(r => setTimeout(r, 500));
95
+ await new Promise(r => setTimeout(r, 200));
117
96
  }
118
97
  }
119
98
 
120
- // Use stable scene IDs + voicePaths for the hash instead of imagePaths,
121
- // because video_path scenes have imagePath=null which produces unstable hashes.
122
99
  const outputHash = crypto.createHash('sha256')
123
100
  .update(results.map(r => `${r.sceneId}:${r.voicePath}`).join(','))
124
101
  .digest('hex').slice(0, 16);
@@ -278,21 +278,21 @@ async function runIngest({ productionId, scenesFile, mediaDir, rawScenes, outDir
278
278
  // Print summary
279
279
  const withVideo = validScenes.filter(s => s.video_path).length;
280
280
  const withImage = validScenes.filter(s => s.image_path && !s.video_path).length;
281
- const withAI = validScenes.filter(s => !s.video_path && !s.image_path).length;
281
+ const withCodeAnim = validScenes.filter(s => !s.video_path && !s.image_path).length;
282
282
  const withAudio = validScenes.filter(s => s.audio_path).length;
283
283
  const withTTS = validScenes.filter(s => !s.audio_path && s.narration).length;
284
284
  console.log(`[Ingest] Scenes: ${validScenes.length} total`);
285
- console.log(`[Ingest] Video clips: ${withVideo} | User images: ${withImage} | AI-generate: ${withAI}`);
285
+ console.log(`[Ingest] Video clips: ${withVideo} | User images: ${withImage} | Code-animated: ${withCodeAnim}`);
286
286
  console.log(`[Ingest] Pre-recorded audio: ${withAudio} | TTS needed: ${withTTS}`);
287
- if (withAI > 0) console.log(`[Ingest] ℹ️ ${withAI} scene(s) will use AI image generation (remove --no-images to skip)`);
288
- if (withTTS > 0) console.log(`[Ingest] ℹ️ ${withTTS} scene(s) will use TTS synthesis (remove --no-tts to skip)`);
287
+ if (withCodeAnim > 0) console.log(`[Ingest] ℹ️ ${withCodeAnim} scene(s) will render with code-driven animations (kinetic typography/motion graphics)`);
288
+ if (withTTS > 0) console.log(`[Ingest] ℹ️ ${withTTS} scene(s) will use TTS synthesis (remove --no-tts to skip)`);
289
289
 
290
290
  return {
291
291
  productionId: pid,
292
292
  scenesFile: outPath,
293
293
  scenes: validScenes,
294
294
  sceneCount: validScenes.length,
295
- stats: { withVideo, withImage, withAI, withAudio, withTTS },
295
+ stats: { withVideo, withImage, withCodeAnim, withAI: withCodeAnim, withAudio, withTTS },
296
296
  };
297
297
  }
298
298