@amg555/frameforge-mcp 0.0.0-stage → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bob/artifacts/enterprise-ai-video-agent-platform-agnostic-implementation-guide.html +464 -0
- package/.claude-plugin/marketplace.json +17 -0
- package/.gitattributes +19 -0
- package/.mcp.json +45 -0
- package/CHANGELOG.md +431 -0
- package/CLAUDE.md +278 -0
- package/CONTRIBUTING.md +112 -0
- package/LICENSE +21 -0
- package/Procfile +1 -0
- package/README.md +359 -2
- package/SECURITY.md +71 -0
- package/START_HERE.md +144 -0
- package/assets/metadata/assets.json +5 -0
- package/assets/metadata/embeddings.json +5 -0
- package/install.sh +64 -0
- package/package.json +58 -4
- package/src/animation/animation-system.js +229 -0
- package/src/asset-library/README.md +188 -0
- package/src/asset-library/asset-library.js +639 -0
- package/src/audio-mixer/mixer.js +187 -0
- package/src/cli/doctor.js +173 -0
- package/src/cli/make.js +655 -0
- package/src/cli/setup-keys.js +378 -0
- package/src/config/load-config.js +72 -0
- package/src/fact-checker/checker.js +384 -0
- package/src/fact-checker/review-gate.js +306 -0
- package/src/fact-checker/validation-set.js +1078 -0
- package/src/mcp-server/server.js +1299 -0
- package/src/mcp-server/stdio.js +87 -0
- package/src/pipeline/artifact-manifest.js +323 -0
- package/src/pipeline/artifact-retention.js +189 -0
- package/src/pipeline/external-adapter.js +366 -0
- package/src/pipeline/providers.js +853 -0
- package/src/pipeline/retry-policy.js +134 -0
- package/src/pipeline/state-machine.js +297 -0
- package/src/pipeline/telemetry.js +162 -0
- package/src/quality-gate/gate.js +229 -0
- package/src/schema/scene.schema.json +101 -0
- package/src/shorts-extractor/extractor.js +343 -0
- package/src/utils/log-redaction.js +274 -0
- package/src/video-renderer/renderer.js +364 -0
- package/src/worker/server-render-free.js +420 -0
- package/src/worker/server.js +328 -0
- package/src/workflows/assets.js +131 -0
- package/src/workflows/ingest.js +299 -0
- package/src/workflows/packaging.js +156 -0
- package/src/workflows/publishing.js +206 -0
- package/src/workflows/render.js +102 -0
- package/src/workflows/research.js +151 -0
- package/src/workflows/script.js +210 -0
- package/src/workflows/shorts.js +113 -0
|
@@ -0,0 +1,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 };
|