@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,328 @@
1
+ /**
2
+ * Worker HTTP API
3
+ *
4
+ * Single-worker job executor. Receives pipeline jobs from the MCP server or any HTTP caller,
5
+ * runs them sequentially through the workflow chain, tracks state via StateMachine,
6
+ * enforces stage timeouts, and returns structured results.
7
+ *
8
+ * Endpoints:
9
+ * POST /jobs — submit a job { type, payload }
10
+ * GET /jobs/:id — poll job status
11
+ * GET /jobs — list recent jobs
12
+ * GET /health — liveness
13
+ * POST /jobs/:id/retry — retry a FAILED job
14
+ *
15
+ * Job types:
16
+ * full_pipeline — research → script → assets → render → shorts → package → publish
17
+ * research — research only
18
+ * script — script from existing research
19
+ * assets — assets for existing script
20
+ * render — render from existing assets
21
+ * shorts — extract shorts from existing master
22
+ * package — package an existing render
23
+ * publish — publish an existing package
24
+ *
25
+ * Start: node src/worker/server.js
26
+ * Port: WORKER_PORT env (default 3211)
27
+ */
28
+ 'use strict';
29
+
30
+ const http = require('http');
31
+ const crypto = require('crypto');
32
+ const path = require('path');
33
+ const os = require('os');
34
+ const fs = require('fs').promises;
35
+
36
+ // ── Internal auth ─────────────────────────────────────────────────────────────
37
+ // When INTERNAL_API_TOKEN is set, all routes except GET /health require a
38
+ // matching Authorization: Bearer <token> header. The compare is timing-safe
39
+ // to prevent oracle attacks on the token value.
40
+
41
+ const INTERNAL_TOKEN = process.env.INTERNAL_API_TOKEN || '';
42
+
43
+ function _timingSafeEqual(a, b) {
44
+ const bufA = Buffer.from(String(a));
45
+ const bufB = Buffer.from(String(b));
46
+ if (bufA.length !== bufB.length) {
47
+ crypto.timingSafeEqual(bufA, bufA); // constant-time dummy compare
48
+ return false;
49
+ }
50
+ return crypto.timingSafeEqual(bufA, bufB);
51
+ }
52
+
53
+ function _checkAuth(req) {
54
+ if (!INTERNAL_TOKEN) return true; // auth disabled when token not configured
55
+ const header = req.headers['authorization'] || req.headers['x-internal-token'] || '';
56
+ const token = header.replace(/^Bearer\s+/i, '').trim();
57
+ return _timingSafeEqual(token, INTERNAL_TOKEN);
58
+ }
59
+
60
+ const { runResearch } = require('../workflows/research');
61
+ const { runScript } = require('../workflows/script');
62
+ const { runAssets } = require('../workflows/assets');
63
+ const { runRender } = require('../workflows/render');
64
+ const { runShorts } = require('../workflows/shorts');
65
+ const { runPackaging } = require('../workflows/packaging');
66
+ const { runPublishing } = require('../workflows/publishing');
67
+ const { StateMachine } = require('../pipeline/state-machine');
68
+ const { RetryPolicy } = require('../pipeline/retry-policy');
69
+
70
+ const PORT = parseInt(process.env.WORKER_PORT || '3211', 10);
71
+ const FILES_DIR = process.env.N8N_FILES_DIR || path.join(os.homedir(), '.frameforge-files');
72
+ const STATE_DIR = path.join(FILES_DIR, 'state');
73
+
74
+ // In-memory job registry (persisted to state dir for crash recovery)
75
+ const jobs = new Map();
76
+
77
+ // Semaphore — only 1 job running at a time (single worker)
78
+ let _runningJob = null;
79
+ const _queue = [];
80
+
81
+ // ── Job runner ────────────────────────────────────────────────────────────
82
+
83
+ async function executeJob(job) {
84
+ job.status = 'RUNNING';
85
+ job.startedAt = new Date().toISOString();
86
+ await _persistJob(job);
87
+
88
+ try {
89
+ let result;
90
+ switch (job.type) {
91
+ case 'full_pipeline': result = await runFullPipeline(job.payload, job.id); break;
92
+ case 'research': result = await runResearch(job.payload.topic, job.payload); break;
93
+ case 'script': result = await runScript(job.payload); break;
94
+ case 'assets': result = await runAssets(job.payload); break;
95
+ case 'render': result = await runRender(job.payload); break;
96
+ case 'shorts': result = await runShorts(job.payload); break;
97
+ case 'package': result = await runPackaging(job.payload); break;
98
+ case 'publish': result = await runPublishing(job.payload); break;
99
+ default: throw new Error(`Unknown job type: ${job.type}`);
100
+ }
101
+ job.status = 'SUCCEEDED';
102
+ job.completedAt = new Date().toISOString();
103
+ job.result = result;
104
+ } catch (err) {
105
+ job.status = 'FAILED';
106
+ job.completedAt = new Date().toISOString();
107
+ job.error = { message: err.message, errorClass: err.errorClass, stageName: err.stageName };
108
+ console.error(`[Worker] Job ${job.id} (${job.type}) FAILED: ${err.message}`);
109
+ }
110
+
111
+ await _persistJob(job);
112
+ _runningJob = null;
113
+ _drainQueue();
114
+ }
115
+
116
+ function _drainQueue() {
117
+ if (_runningJob || _queue.length === 0) return;
118
+ const next = _queue.shift();
119
+ _runningJob = next.id;
120
+ executeJob(next).catch(console.error);
121
+ }
122
+
123
+ function _submitJob(type, payload) {
124
+ const id = `job_${Date.now()}_${crypto.randomBytes(3).toString('hex')}`;
125
+ const job = { id, type, payload, status: 'QUEUED', createdAt: new Date().toISOString(), startedAt: null, completedAt: null, result: null, error: null };
126
+ jobs.set(id, job);
127
+ _queue.push(job);
128
+ _persistJob(job).catch(() => {});
129
+ _drainQueue();
130
+ return job;
131
+ }
132
+
133
+ async function _persistJob(job) {
134
+ const dir = path.join(FILES_DIR, 'jobs');
135
+ await fs.mkdir(dir, { recursive: true });
136
+ await fs.writeFile(path.join(dir, `${job.id}.json`), JSON.stringify(job, null, 2), 'utf8');
137
+ }
138
+
139
+ // ── Full pipeline ─────────────────────────────────────────────────────────
140
+
141
+ async function runFullPipeline(payload, jobId) {
142
+ const { topic, channels = ['file'], force = false } = payload;
143
+ if (!topic) throw new Error('full_pipeline requires { topic }');
144
+
145
+ console.log(`[Worker/${jobId}] Starting full pipeline: "${topic}"`);
146
+
147
+ // A → Research
148
+ const res = await runResearch(topic, { claimCount: 10 });
149
+ console.log(`[Worker/${jobId}] Research done: ${res.claims.length} claims`);
150
+
151
+ // B → Script
152
+ const scr = await runScript({ productionId: res.productionId, claims: res.claims, topic });
153
+ console.log(`[Worker/${jobId}] Script done: ${scr.sceneCount} scenes, ${scr.totalWords} words`);
154
+
155
+ // C → Assets (images + TTS)
156
+ const ast = await runAssets({ productionId: scr.productionId, scenes: scr.scenes });
157
+ console.log(`[Worker/${jobId}] Assets done: ${ast.sceneCount} scenes`);
158
+
159
+ // D → Render master
160
+ const ren = await runRender({ productionId: scr.productionId, sceneAssets: ast.sceneAssets });
161
+ console.log(`[Worker/${jobId}] Render done: ${ren.sizeBytes ? Math.round(ren.sizeBytes/1024/1024) + 'MB' : 'FAILED'}`);
162
+ if (!ren.passed) throw new Error(`Render QC failed: ${JSON.stringify(ren.qcReport?.checks?.filter(c => !c.passed).map(c => c.name))}`);
163
+
164
+ // D2 → Shorts
165
+ const sht = await runShorts({ productionId: scr.productionId, masterPath: ren.masterPath, scenes: scr.scenes });
166
+ console.log(`[Worker/${jobId}] Shorts done: ${sht.shorts?.length || 0} passed`);
167
+
168
+ // E → Package
169
+ const pkg = await runPackaging({
170
+ productionId: scr.productionId,
171
+ masterPath: ren.masterPath,
172
+ shorts: sht.shorts || [],
173
+ scenes: scr.scenes,
174
+ title: scr.title,
175
+ description: scr.description,
176
+ tags: scr.tags,
177
+ thumbnailPrompt: scr.thumbnailPrompt,
178
+ qcReport: ren.qcReport,
179
+ totalWords: scr.totalWords,
180
+ });
181
+ console.log(`[Worker/${jobId}] Package done: ${pkg.packageDir}`);
182
+
183
+ // F → Publish
184
+ const pub = await runPublishing({ productionId: scr.productionId, manifest: pkg.manifest, channels, force });
185
+ console.log(`[Worker/${jobId}] Publish done: ${JSON.stringify(pub.results)}`);
186
+
187
+ return {
188
+ productionId: scr.productionId,
189
+ topic,
190
+ title: scr.title,
191
+ masterPath: ren.masterPath,
192
+ packageDir: pkg.packageDir,
193
+ publishResults: pub.results,
194
+ publishDecision: pub.publishDecision,
195
+ sceneCount: scr.sceneCount,
196
+ totalWords: scr.totalWords,
197
+ shortsCount: sht.shorts?.length || 0,
198
+ qcPassed: ren.passed,
199
+ };
200
+ }
201
+
202
+ // ── HTTP server ───────────────────────────────────────────────────────────
203
+
204
+ const CORS = {
205
+ 'Access-Control-Allow-Origin': '*',
206
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
207
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
208
+ };
209
+
210
+ function jsonResponse(res, status, body) {
211
+ res.writeHead(status, { ...CORS, 'Content-Type': 'application/json' });
212
+ res.end(JSON.stringify(body, null, 2));
213
+ }
214
+
215
+ async function handleRequest(req, res) {
216
+ if (req.method === 'OPTIONS') { res.writeHead(204, CORS); return res.end(); }
217
+
218
+ const url = new URL(req.url, `http://localhost:${PORT}`);
219
+ const parts = url.pathname.replace(/^\/+|\/+$/g, '').split('/');
220
+
221
+ // GET /health — unauthenticated (health-check probes must work without a token)
222
+ if (req.method === 'GET' && parts[0] === 'health') {
223
+ return jsonResponse(res, 200, {
224
+ status: 'ok',
225
+ running: _runningJob,
226
+ queued: _queue.length,
227
+ jobs: jobs.size,
228
+ });
229
+ }
230
+
231
+ // All other routes require internal auth when INTERNAL_API_TOKEN is set
232
+ if (!_checkAuth(req)) {
233
+ return jsonResponse(res, 401, { error: 'Unauthorized — set Authorization: Bearer <INTERNAL_API_TOKEN>' });
234
+ }
235
+
236
+ // GET /jobs
237
+ if (req.method === 'GET' && parts[0] === 'jobs' && !parts[1]) {
238
+ const recent = [...jobs.values()]
239
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
240
+ .slice(0, 50)
241
+ .map(j => ({ id: j.id, type: j.type, status: j.status, createdAt: j.createdAt, completedAt: j.completedAt, error: j.error?.message }));
242
+ return jsonResponse(res, 200, { jobs: recent, queued: _queue.length, running: _runningJob });
243
+ }
244
+
245
+ // GET /jobs/:id
246
+ if (req.method === 'GET' && parts[0] === 'jobs' && parts[1]) {
247
+ const job = jobs.get(parts[1]);
248
+ if (!job) return jsonResponse(res, 404, { error: `Job not found: ${parts[1]}` });
249
+ return jsonResponse(res, 200, job);
250
+ }
251
+
252
+ // POST /jobs
253
+ if (req.method === 'POST' && parts[0] === 'jobs' && !parts[1]) {
254
+ let rawBody;
255
+ try { rawBody = await readBody(req); }
256
+ catch (err) { return jsonResponse(res, err.statusCode || 500, { error: err.message }); }
257
+ let parsed;
258
+ try { parsed = JSON.parse(rawBody); } catch { return jsonResponse(res, 400, { error: 'Invalid JSON' }); }
259
+ const { type, payload } = parsed;
260
+ if (!type) return jsonResponse(res, 400, { error: 'Missing field: type' });
261
+ const job = _submitJob(type, payload || {});
262
+ return jsonResponse(res, 202, { jobId: job.id, status: job.status, type: job.type });
263
+ }
264
+
265
+ // POST /jobs/:id/retry
266
+ if (req.method === 'POST' && parts[0] === 'jobs' && parts[2] === 'retry') {
267
+ const job = jobs.get(parts[1]);
268
+ if (!job) return jsonResponse(res, 404, { error: `Job not found: ${parts[1]}` });
269
+ if (job.status !== 'FAILED') return jsonResponse(res, 409, { error: `Job ${parts[1]} is not in FAILED state` });
270
+ const retried = _submitJob(job.type, job.payload);
271
+ return jsonResponse(res, 202, { jobId: retried.id, status: retried.status, retriedFrom: job.id });
272
+ }
273
+
274
+ jsonResponse(res, 404, { error: 'Not found' });
275
+ }
276
+
277
+ // 4 MB body limit — prevents OOM from oversized payloads
278
+ const MAX_BODY_BYTES = 4 * 1024 * 1024;
279
+
280
+ function readBody(req) {
281
+ return new Promise((resolve, reject) => {
282
+ let size = 0;
283
+ const chunks = [];
284
+ req.on('data', chunk => {
285
+ size += chunk.length;
286
+ if (size > MAX_BODY_BYTES) {
287
+ req.destroy();
288
+ return reject(Object.assign(new Error('Request body too large'), { statusCode: 413 }));
289
+ }
290
+ chunks.push(chunk);
291
+ });
292
+ req.on('end', () => resolve(Buffer.concat(chunks).toString()));
293
+ req.on('error', reject);
294
+ });
295
+ }
296
+
297
+ async function start() {
298
+ // Load persisted jobs for crash recovery
299
+ const jobDir = path.join(FILES_DIR, 'jobs');
300
+ await fs.mkdir(jobDir, { recursive: true }).catch(() => {});
301
+ const jobFiles = await fs.readdir(jobDir).catch(() => []);
302
+ for (const f of jobFiles.filter(f => f.endsWith('.json'))) {
303
+ try {
304
+ const j = JSON.parse(await fs.readFile(path.join(jobDir, f), 'utf8'));
305
+ jobs.set(j.id, j);
306
+ // Re-queue QUEUED jobs that were interrupted
307
+ if (j.status === 'QUEUED') _queue.push(j);
308
+ } catch { /* skip corrupt files */ }
309
+ }
310
+ console.log(`[Worker] Loaded ${jobs.size} persisted jobs, ${_queue.length} re-queued`);
311
+
312
+ const server = http.createServer(async (req, res) => {
313
+ try { await handleRequest(req, res); }
314
+ catch (err) { jsonResponse(res, 500, { error: err.message }); }
315
+ });
316
+
317
+ server.listen(PORT, '127.0.0.1', () => {
318
+ console.log(`[Worker] HTTP API listening on http://127.0.0.1:${PORT}`);
319
+ console.log(`[Worker] Submit jobs: POST /jobs { type, payload }`);
320
+ _drainQueue(); // start any re-queued jobs
321
+ });
322
+ }
323
+
324
+ if (require.main === module) {
325
+ start().catch(err => { console.error('[Worker] startup failed:', err); process.exit(1); });
326
+ }
327
+
328
+ module.exports = { _submitJob, jobs, runFullPipeline, start, _checkAuth, _timingSafeEqual };
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Workflow C — Assets
3
+ * Task #30: scenes → character identity lookup → image generation per scene
4
+ *
5
+ * Returns: { productionId, sceneAssets[{ sceneId, imagePath, voicePath }] }
6
+ */
7
+ 'use strict';
8
+
9
+ const path = require('path');
10
+ const crypto = require('crypto');
11
+ const { ImageProvider, TTSProvider } = require('../pipeline/providers');
12
+ const { StateMachine } = require('../pipeline/state-machine');
13
+ const { RetryPolicy } = require('../pipeline/retry-policy');
14
+ const { Telemetry } = require('../pipeline/telemetry');
15
+ const { AssetLibrary } = require('../asset-library/asset-library');
16
+
17
+ const os = require('os');
18
+ const FILES_DIR = process.env.N8N_FILES_DIR || require('path').join(os.homedir(), '.frameforge-files');
19
+ const STATE_DIR = `${FILES_DIR}/state`;
20
+ const VDIR = `${FILES_DIR}/video_tmp`;
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
+ /**
27
+ * Generate all scene images and TTS audio in parallel batches.
28
+ * @param {{ productionId: string, scenes: object[], parallelism?: number }} opts
29
+ * @returns {Promise<AssetsResult>}
30
+ */
31
+ async function runAssets({ productionId, scenes, parallelism = 1, skipImages = false, skipTts = false, imageWidth = 1920, imageHeight = 1080, voice = null }) {
32
+ const imageGen = new ImageProvider();
33
+ const ttsGen = new TTSProvider();
34
+ const sm = new StateMachine({ mode: 'file', stateDir: STATE_DIR });
35
+ const retryPol = new RetryPolicy({ maxAttempts: 3, baseDelayMs: 8000 });
36
+ const telemetry = new Telemetry({ mode: 'file', logPath: `${FILES_DIR}/usage.ndjson` });
37
+ const assetLib = new AssetLibrary({ assetsDir: `${FILES_DIR}/assets` });
38
+
39
+ await sm.init();
40
+ await telemetry.init();
41
+ await assetLib.initialize().catch(() => {}); // best-effort asset lib init
42
+
43
+ const fs = require('fs').promises;
44
+ await fs.mkdir(VDIR, { recursive: true });
45
+
46
+ await sm.startStage(productionId, 'Asset_Generation');
47
+
48
+ const results = [];
49
+
50
+ // Process scenes in batches of `parallelism`
51
+ for (let i = 0; i < scenes.length; i += parallelism) {
52
+ const batch = scenes.slice(i, i + parallelism);
53
+ const batchResults = await Promise.all(batch.map(async scene => {
54
+ const idx = scene.scene_id - 1;
55
+ const seed = 42 + idx * 37; // deterministic seed per scene
56
+
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;
61
+ if (scene.video_path) {
62
+ imagePath = null; // renderer handles video_path directly
63
+ } else if (scene.image_path) {
64
+ 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
+ }
82
+ }
83
+
84
+ // ── TTS / User Audio ───────────────────────────────────────────────
85
+ // Priority: audio_path (pre-recorded) > TTS synthesis.
86
+ // --no-tts / skipTts: skip synthesis (scene must have audio_path or silence is used).
87
+ let voicePath;
88
+ if (scene.audio_path) {
89
+ voicePath = scene.audio_path;
90
+ } else if (skipTts || scene._skipTts) {
91
+ // No TTS — renderer will use silence for this scene
92
+ voicePath = null;
93
+ } else {
94
+ voicePath = path.join(VDIR, `voice_${idx}.m4a`);
95
+ // Per-scene voice overrides global voice, which overrides TTS_VOICE env
96
+ const ttsVoice = scene.voice || voice || undefined;
97
+ await retryPol.executeStage('TTS', async () =>
98
+ ttsGen.synthesize({ text: scene.narration, voice: ttsVoice, outputPath: voicePath })
99
+ );
100
+ await telemetry.track({ productionId, stage: 'TTS', provider: process.env.PROVIDER_TTS || 'edge-tts', operation: 'tts_synthesis', units: scene.narration.length });
101
+ }
102
+
103
+ return {
104
+ sceneId: scene.scene_id,
105
+ imagePath,
106
+ videoPath: scene.video_path || null, // user clip — non-null means renderer uses this
107
+ voicePath,
108
+ caption: scene.caption,
109
+ camera: scene.camera_hint || scene.camera || 'static',
110
+ focalPoint: scene.focal_point || null,
111
+ };
112
+ }));
113
+ results.push(...batchResults);
114
+ if (i + parallelism < scenes.length) {
115
+ // Small delay between batches to avoid rate limits
116
+ await new Promise(r => setTimeout(r, 500));
117
+ }
118
+ }
119
+
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
+ const outputHash = crypto.createHash('sha256')
123
+ .update(results.map(r => `${r.sceneId}:${r.voicePath}`).join(','))
124
+ .digest('hex').slice(0, 16);
125
+
126
+ await sm.completeStage(productionId, 'Asset_Generation', { outputHash, metrics: { sceneCount: results.length } });
127
+
128
+ return { productionId, sceneAssets: results, sceneCount: results.length, outputHash };
129
+ }
130
+
131
+ module.exports = { runAssets };