@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.
- package/.mcp.json +2 -4
- package/CHANGELOG.md +20 -1
- package/CLAUDE.md +154 -18
- package/README.md +16 -12
- package/SECURITY.md +1 -3
- package/install.sh +1 -1
- package/package.json +7 -3
- package/plugins/video-pipeline/.claude-plugin/plugin.json +19 -0
- package/plugins/video-pipeline/skills/html-renderer/SKILL.md +292 -0
- package/plugins/video-pipeline/skills/video-pipeline/SKILL.md +308 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/ANIMATION_GUIDE.md +257 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/STORYBOARD.md +77 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/index.html +80 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/diagram-author.md +43 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/orchestrator.md +56 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/scene-author.md +42 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.css +140 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.js +870 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/virtual-clock.js +138 -0
- package/plugins/video-pipeline/skills/video-pipeline/references/mcp-tools.md +167 -0
- package/plugins/video-pipeline/skills/video-pipeline/references/providers.md +163 -0
- package/scripts/db-migrate.js +112 -0
- package/scripts/deep-validate.js +326 -0
- package/scripts/generate-initial-assets.js +342 -0
- package/scripts/html-renderer/new-project.mjs +408 -0
- package/scripts/html-renderer/transcribe.py +294 -0
- package/src/asset-library/README.md +2 -2
- package/src/asset-library/asset-library.js +1 -1
- package/src/cli/doctor.js +31 -1
- package/src/cli/make.js +52 -71
- package/src/cli/setup-keys.js +24 -19
- package/src/fact-checker/review-gate.js +5 -1
- package/src/harness/bin/harness.mjs +324 -0
- package/src/harness/lib/browser.mjs +137 -0
- package/src/harness/lib/capture.mjs +161 -0
- package/src/harness/lib/encode.mjs +89 -0
- package/src/harness/lib/jobs.mjs +135 -0
- package/src/harness/lib/pixels.mjs +137 -0
- package/src/harness/lib/sheets.mjs +164 -0
- package/src/harness/lib/verify-purity.mjs +119 -0
- package/src/harness/test/fixtures/broken-scene.html +27 -0
- package/src/harness/test/fixtures/reference-scene.html +23 -0
- package/src/harness/test/fixtures/sample-script.json +18 -0
- package/src/mcp-server/server.js +318 -66
- package/src/pipeline/external-adapter.js +55 -15
- package/src/pipeline/providers.js +46 -0
- package/src/pipeline/telemetry.js +1 -0
- package/src/utils/log-redaction.js +5 -3
- package/src/video-renderer/renderer.js +12 -4
- package/src/worker/server-render-free.js +2 -3
- package/src/workflows/assets.js +20 -43
- 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',
|
|
156
|
-
renderPath: '/v1/text-to-speech',
|
|
157
|
-
statusPath: '/v1/voices',
|
|
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
|
-
|
|
232
|
+
`Using API key: ${dummyGroqKey}`,
|
|
231
233
|
'Bot token: 1234567890:ABCdefGHI-jklMNOpqrSTUvwxYZ123456',
|
|
232
|
-
|
|
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:
|
|
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:
|
|
127
|
-
//
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
*
|
|
38
|
+
* Media processing streams directly to disk (~20 MB peak).
|
|
40
39
|
*
|
|
41
40
|
* Environment variables:
|
|
42
41
|
* WORKER_PORT — default 3211
|
package/src/workflows/assets.js
CHANGED
|
@@ -1,44 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Workflow C — Assets
|
|
3
|
-
*
|
|
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 {
|
|
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
|
-
*
|
|
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,
|
|
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
|
-
// ──
|
|
58
|
-
// Priority:
|
|
59
|
-
//
|
|
60
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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);
|
package/src/workflows/ingest.js
CHANGED
|
@@ -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
|
|
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} |
|
|
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 (
|
|
288
|
-
if (withTTS > 0)
|
|
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
|
|