@amg555/frameforge-mcp 2.3.4 → 2.3.5
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/CHANGELOG.md +21 -0
- package/README.md +2 -2
- package/out/frames/f000000.jpg +0 -0
- package/out/frames/f000001.jpg +0 -0
- package/out/frames/f000002.jpg +0 -0
- package/out/frames/f000003.jpg +0 -0
- package/out/frames/f000004.jpg +0 -0
- package/out/frames/f000005.jpg +0 -0
- package/out/frames/f000006.jpg +0 -0
- package/out/frames/f000007.jpg +0 -0
- package/out/frames/f000008.jpg +0 -0
- package/out/frames/f000009.jpg +0 -0
- package/out/frames/f000010.jpg +0 -0
- package/out/frames/f000011.jpg +0 -0
- package/out/frames/f000012.jpg +0 -0
- package/out/frames/f000013.jpg +0 -0
- package/out/frames/f000014.jpg +0 -0
- package/out/frames/f000015.jpg +0 -0
- package/out/frames/f000016.jpg +0 -0
- package/out/frames/f000017.jpg +0 -0
- package/out/frames/f000018.jpg +0 -0
- package/out/frames/f000019.jpg +0 -0
- package/out/frames/f000020.jpg +0 -0
- package/out/frames/f000021.jpg +0 -0
- package/out/frames/f000022.jpg +0 -0
- package/out/frames/f000023.jpg +0 -0
- package/out/frames/f000024.jpg +0 -0
- package/out/frames/f000025.jpg +0 -0
- package/out/frames/f000026.jpg +0 -0
- package/out/frames/f000027.jpg +0 -0
- package/out/frames/f000028.jpg +0 -0
- package/out/frames/f000029.jpg +0 -0
- package/out/frames/f000030.jpg +0 -0
- package/out/frames/f000031.jpg +0 -0
- package/out/frames/f000032.jpg +0 -0
- package/out/frames/f000033.jpg +0 -0
- package/out/frames/f000034.jpg +0 -0
- package/out/frames/f000035.jpg +0 -0
- package/out/frames/f000036.jpg +0 -0
- package/out/frames/f000037.jpg +0 -0
- package/out/frames/f000038.jpg +0 -0
- package/out/frames/f000039.jpg +0 -0
- package/out/frames/f000040.jpg +0 -0
- package/out/frames/f000041.jpg +0 -0
- package/out/frames/f000042.jpg +0 -0
- package/out/frames/f000043.jpg +0 -0
- package/out/frames/f000044.jpg +0 -0
- package/out/frames/f000045.jpg +0 -0
- package/out/frames/f000046.jpg +0 -0
- package/out/frames/f000047.jpg +0 -0
- package/package.json +2 -2
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.css +2 -2
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.js +1 -1
- package/scripts/deep-validate.js +2 -2
- package/scripts/html-renderer/new-project.mjs +2 -2
- package/src/harness/bin/harness.mjs +20 -7
- package/src/mcp-server/server.js +1 -1
- package/src/video-renderer/renderer.js +222 -60
- package/src/video-renderer/scene-animator.js +459 -0
- package/src/workflows/assets.js +4 -0
- package/src/workflows/render.js +12 -7
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,27 @@ All notable changes to FrameForge are documented here.
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
## [2.3.5] — 2026-11-09
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **Integrated Code-Driven Scene Animator** (`src/video-renderer/scene-animator.js`):
|
|
11
|
+
- Dynamic 24fps HTML5/Canvas animation generator adhering to pure-function-of-time contract `seek(t)`
|
|
12
|
+
- Contextual motion graphic themes: Code IDE/terminal typing, Data charts & counters, Network node graphs, Flow diagrams, and floating Ken-Burns image cards
|
|
13
|
+
- Dynamic gradient backgrounds with ambient particle systems and perspective motion grids
|
|
14
|
+
- High-performance Chromium harness rendering + direct FFmpeg BT.709 H.264 VUI muxing
|
|
15
|
+
- **Full Multilingual & Malayalam Font Support**:
|
|
16
|
+
- Direct font resolver for Malayalam & Indic scripts (`Nirmala.ttc`, `Noto Sans Malayalam`, `Malayalam Sangam MN`, `Kartika`, `Meera`, `Manjari`)
|
|
17
|
+
- FFmpeg `text_shaping=1` HarfBuzz layout integration preserving complex conjuncts, chillu letters, and viramas
|
|
18
|
+
- Unicode-safe slicing and UTF-8 handling in caption and narration filters
|
|
19
|
+
- Updated `engine.css`, `engine.js`, and `new-project.mjs` typography stacks for seamless multilingual rendering
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- Replaced static solid-color slideshow fallback in `renderer.js` with live code-driven animation engine
|
|
23
|
+
- Fixed `--flag value` space-separated argument parsing in `harness.mjs` CLI
|
|
24
|
+
- Fixed `--out` directory path resolution in `cmdFrames` frame exporter
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
7
28
|
## [2.3.4] — 2026-11-09
|
|
8
29
|
|
|
9
30
|
### Added
|
package/README.md
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
[](https://www.npmjs.com/package/@amg555/frameforge-mcp)
|
|
10
10
|
[](LICENSE)
|
|
11
11
|
[](https://nodejs.org/)
|
|
12
|
-
[](CHANGELOG.md)
|
|
13
13
|
[](https://modelcontextprotocol.io/)
|
|
14
|
-
[](#testing)
|
|
15
15
|
|
|
16
16
|
</div>
|
|
17
17
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amg555/frameforge-mcp",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.5",
|
|
4
4
|
"description": "FrameForge — AI video production engine for agentic IDEs. npx @amg555/frameforge-mcp. FFmpeg + TTS run on your machine. 17 MCP tools.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"start:local-render": "cross-env RENDER_MODE=full WORKER_PORT=3211 node src/worker/server-render-free.js",
|
|
31
31
|
"db:migrate": "node scripts/db-migrate.js",
|
|
32
32
|
"validate": "node scripts/deep-validate.js",
|
|
33
|
-
"test": "node --test src/pipeline/retry-policy.test.js src/pipeline/telemetry.test.js src/pipeline/artifact-manifest.test.js src/pipeline/artifact-retention.test.js src/pipeline/external-adapter.test.js src/workflows/publishing.test.js src/workflows/script.test.js src/animation/animation-system.test.js src/fact-checker/review-gate.test.js src/fact-checker/checker.test.js src/utils/log-redaction.test.js src/worker/server-render-free.test.js src/worker/server.test.js src/mcp-server/server.test.js src/quality-gate/gate.test.js src/shorts-extractor/extractor.test.js src/audio-mixer/mixer.test.js src/video-renderer/renderer.test.js plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.test.js src/harness/lib/pixels.test.js src/harness/lib/verify-purity.test.js src/harness/harness.test.js src/integration.test.js",
|
|
33
|
+
"test": "node --test src/pipeline/retry-policy.test.js src/pipeline/telemetry.test.js src/pipeline/artifact-manifest.test.js src/pipeline/artifact-retention.test.js src/pipeline/external-adapter.test.js src/workflows/publishing.test.js src/workflows/script.test.js src/animation/animation-system.test.js src/fact-checker/review-gate.test.js src/fact-checker/checker.test.js src/utils/log-redaction.test.js src/worker/server-render-free.test.js src/worker/server.test.js src/mcp-server/server.test.js src/quality-gate/gate.test.js src/shorts-extractor/extractor.test.js src/audio-mixer/mixer.test.js src/video-renderer/scene-animator.test.js src/video-renderer/renderer.test.js plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.test.js src/harness/lib/pixels.test.js src/harness/lib/verify-purity.test.js src/harness/harness.test.js src/integration.test.js",
|
|
34
34
|
"lint": "echo 'No linter configured — add eslint for production'",
|
|
35
35
|
"check-credentials": "node src/utils/log-redaction.js",
|
|
36
36
|
"generate-assets": "node scripts/generate-initial-assets.js",
|
|
@@ -21,9 +21,9 @@
|
|
|
21
21
|
--danger: #ef4444;
|
|
22
22
|
|
|
23
23
|
/* Typography scale */
|
|
24
|
-
--font-body: -apple-system, "Segoe UI", system-ui, sans-serif;
|
|
24
|
+
--font-body: -apple-system, "Segoe UI", "Nirmala UI", "Noto Sans Malayalam", "Malayalam Sangam MN", "Kartika", "Meera", "Manjari", system-ui, sans-serif;
|
|
25
25
|
--font-mono: "Fira Code", "Cascadia Code", "Consolas", monospace;
|
|
26
|
-
--font-display: "Archivo", "Segoe UI", system-ui, sans-serif;
|
|
26
|
+
--font-display: "Archivo", "Nirmala UI", "Noto Sans Malayalam", "Malayalam Sangam MN", "Kartika", "Meera", "Manjari", -apple-system, "Segoe UI", system-ui, sans-serif;
|
|
27
27
|
|
|
28
28
|
/* Spacing */
|
|
29
29
|
--gap-sm: 8px;
|
|
@@ -331,7 +331,7 @@ export function shots(shotList = []) {
|
|
|
331
331
|
* @param {{ font, color, x, y, size, weight, align, opacity, duration }} opts
|
|
332
332
|
*/
|
|
333
333
|
export function text(str, {
|
|
334
|
-
font = '48px/1.3 system-ui',
|
|
334
|
+
font = '48px/1.3 "Nirmala UI", "Noto Sans Malayalam", "Malayalam Sangam MN", "Kartika", system-ui, sans-serif',
|
|
335
335
|
color = '#ffffff',
|
|
336
336
|
x = 'center',
|
|
337
337
|
y = 100,
|
package/scripts/deep-validate.js
CHANGED
|
@@ -20,7 +20,7 @@ check('batch handling', code.includes('Array.isArray(body)'));
|
|
|
20
20
|
check('ping handler', code.includes("method === 'ping'"));
|
|
21
21
|
check('notifications/init', code.includes("notifications/initialized"));
|
|
22
22
|
check('body size limit', code.includes('MAX_BODY_BYTES'));
|
|
23
|
-
check('SERVER_INFO version 2.3.
|
|
23
|
+
check('SERVER_INFO version 2.3.5', code.includes("version: '2.3.5'"));
|
|
24
24
|
check('18 tools defined (incl get_job_status)', code.includes('get_job_status'));
|
|
25
25
|
check('export_to_editor tool defined', code.includes('export_to_editor'));
|
|
26
26
|
check('voxforge TTS provider', code.includes('voxforge'));
|
|
@@ -293,7 +293,7 @@ check('cf-worker has checkRateLimit', cfWorker.includes('checkRateLimit'));
|
|
|
293
293
|
check('cf-worker hashes token (SHA-256)', cfWorker.includes('SHA-256'));
|
|
294
294
|
check('cf-worker uses RATE_LIMITS KV', cfWorker.includes('env.RATE_LIMITS'));
|
|
295
295
|
check('cf-worker returns 429 on limit', cfWorker.includes('429'));
|
|
296
|
-
check('cf-worker version 2.3.
|
|
296
|
+
check('cf-worker version 2.3.5', cfWorker.includes("'2.3.5'"));
|
|
297
297
|
check('wrangler.toml has kv_namespaces',fs.readFileSync('./cloudflare-mcp/wrangler.toml','utf8').includes('kv_namespaces'));
|
|
298
298
|
|
|
299
299
|
// ── plugins/skills present ────────────────────────────────────────────────
|
|
@@ -97,7 +97,7 @@ const indexHtml = `<!DOCTYPE html>
|
|
|
97
97
|
<!-- Preload primary typeface — must resolve before frame 0 (fonts.ready gate) -->
|
|
98
98
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
99
99
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
100
|
-
<link href="https://fonts.googleapis.com/css2?family=Archivo:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet">
|
|
100
|
+
<link href="https://fonts.googleapis.com/css2?family=Archivo:ital,wght@0,100..900;1,100..900&family=Noto+Sans+Malayalam:wght@400;600;700;800;900&display=swap" rel="stylesheet">
|
|
101
101
|
<link rel="stylesheet" href="reel/engine.css">
|
|
102
102
|
<style>
|
|
103
103
|
/* Project-level overrides — edit here */
|
|
@@ -105,7 +105,7 @@ const indexHtml = `<!DOCTYPE html>
|
|
|
105
105
|
--primary: #ffffff;
|
|
106
106
|
--accent: #3b82f6;
|
|
107
107
|
--bg: #0d1117;
|
|
108
|
-
--font: 'Archivo', system-ui, sans-serif;
|
|
108
|
+
--font: 'Archivo', 'Nirmala UI', 'Noto Sans Malayalam', 'Malayalam Sangam MN', 'Kartika', system-ui, sans-serif;
|
|
109
109
|
}
|
|
110
110
|
</style>
|
|
111
111
|
</head>
|
|
@@ -39,8 +39,19 @@ const extras = [];
|
|
|
39
39
|
for (let i = 1; i < argv.length; i++) {
|
|
40
40
|
const arg = argv[i];
|
|
41
41
|
if (arg.startsWith('--')) {
|
|
42
|
-
const
|
|
43
|
-
|
|
42
|
+
const eqIdx = arg.indexOf('=');
|
|
43
|
+
if (eqIdx !== -1) {
|
|
44
|
+
const k = arg.slice(2, eqIdx);
|
|
45
|
+
const v = arg.slice(eqIdx + 1);
|
|
46
|
+
flags[k] = v;
|
|
47
|
+
} else {
|
|
48
|
+
const k = arg.slice(2);
|
|
49
|
+
if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) {
|
|
50
|
+
flags[k] = argv[++i];
|
|
51
|
+
} else {
|
|
52
|
+
flags[k] = true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
44
55
|
} else {
|
|
45
56
|
extras.push(arg);
|
|
46
57
|
}
|
|
@@ -56,7 +67,7 @@ function resolveProjectFile(filename, fallback) {
|
|
|
56
67
|
const HTML_FILE = flags.html ? path.resolve(CWD, flags.html) : resolveProjectFile('index.html', resolveProjectFile('studio.html'));
|
|
57
68
|
const OUT_DIR = flags.outDir ? path.resolve(CWD, flags.outDir) : path.join(CWD, 'out');
|
|
58
69
|
const CHECK_DIR = path.join(OUT_DIR, 'check');
|
|
59
|
-
const FRAMES_DIR = path.join(OUT_DIR, 'frames');
|
|
70
|
+
const FRAMES_DIR = flags.frames ? path.resolve(CWD, flags.frames) : flags.out ? path.resolve(CWD, flags.out) : path.join(OUT_DIR, 'frames');
|
|
60
71
|
const PURITY_LOG = path.join(CHECK_DIR, 'purity_report.json');
|
|
61
72
|
|
|
62
73
|
fs.mkdirSync(OUT_DIR, { recursive: true });
|
|
@@ -214,17 +225,19 @@ async function cmdFrames() {
|
|
|
214
225
|
const fps = parseInt(flags.fps || '30', 10);
|
|
215
226
|
const workers = parseInt(flags.workers || '4', 10);
|
|
216
227
|
const duration = flags.duration ? parseFloat(flags.duration) : 2.0;
|
|
217
|
-
const
|
|
228
|
+
const first = flags.first !== undefined ? parseInt(flags.first, 10) : 0;
|
|
229
|
+
const last = flags.last !== undefined ? parseInt(flags.last, 10) : (Math.round(duration * fps) - 1);
|
|
230
|
+
const totalFrames = last - first + 1;
|
|
218
231
|
|
|
219
|
-
console.log(`📦 Rendering Frame Range [
|
|
232
|
+
console.log(`📦 Rendering Frame Range [${first}..${last}] (${totalFrames} frames @ ${fps}fps, workers=${workers})`);
|
|
220
233
|
const { browser } = await launchHarnessBrowser();
|
|
221
234
|
|
|
222
235
|
const res = await renderFrameJobs({
|
|
223
236
|
browser,
|
|
224
237
|
htmlPath: HTML_FILE,
|
|
225
238
|
framesDir: FRAMES_DIR,
|
|
226
|
-
first
|
|
227
|
-
last
|
|
239
|
+
first,
|
|
240
|
+
last,
|
|
228
241
|
fps,
|
|
229
242
|
workers,
|
|
230
243
|
width: parseInt(flags.width || '1920', 10),
|
package/src/mcp-server/server.js
CHANGED
|
@@ -29,6 +29,26 @@ const DEFAULT_FILES_DIR = process.env.N8N_FILES_DIR || path.join(os.homedir(), '
|
|
|
29
29
|
|
|
30
30
|
const { buildCameraFilter, selectCameraHint } = require('../animation/animation-system');
|
|
31
31
|
const { AudioMixer } = require('../audio-mixer/mixer');
|
|
32
|
+
const { generateSceneHtml } = require('./scene-animator');
|
|
33
|
+
const { findChromePath } = require('../harness/lib/browser.mjs');
|
|
34
|
+
|
|
35
|
+
// ── Cross-platform Indic / Malayalam font resolver ────────────────────────────
|
|
36
|
+
const INDIC_FONTS = [
|
|
37
|
+
// Windows
|
|
38
|
+
'C:\\Windows\\Fonts\\Nirmala.ttc',
|
|
39
|
+
'C:\\Windows\\Fonts\\NirmalaB.ttf',
|
|
40
|
+
'C:\\Windows\\Fonts\\Nirmala.ttf',
|
|
41
|
+
'C:\\Windows\\Fonts\\kartika.ttf',
|
|
42
|
+
'C:\\Windows\\Fonts\\kartikab.ttf',
|
|
43
|
+
// macOS
|
|
44
|
+
'/System/Library/Fonts/Supplemental/Malayalam Sangam MN.ttc',
|
|
45
|
+
'/System/Library/Fonts/Supplemental/Malayalam MN.ttc',
|
|
46
|
+
// Linux
|
|
47
|
+
'/usr/share/fonts/opentype/noto/NotoSansMalayalam-Bold.ttf',
|
|
48
|
+
'/usr/share/fonts/truetype/noto/NotoSansMalayalam-Bold.ttf',
|
|
49
|
+
'/usr/share/fonts/truetype/malayalam/Meera.ttf',
|
|
50
|
+
'/usr/share/fonts/truetype/lohit-malayalam/Lohit-Malayalam.ttf',
|
|
51
|
+
];
|
|
32
52
|
|
|
33
53
|
// ── Cross-platform font resolver ──────────────────────────────────────────────
|
|
34
54
|
// Checked in priority order. First existing file wins. Covers:
|
|
@@ -61,10 +81,16 @@ const FONTS = [
|
|
|
61
81
|
];
|
|
62
82
|
|
|
63
83
|
/** Return the first font file that exists, or '' if none found. */
|
|
64
|
-
function resolveFont() {
|
|
84
|
+
function resolveFont(text = '') {
|
|
65
85
|
// Allow explicit override via FFMPEG_FONT env var
|
|
66
86
|
const override = process.env.FFMPEG_FONT;
|
|
67
87
|
if (override && fs.existsSync(override)) return override;
|
|
88
|
+
|
|
89
|
+
const hasIndic = /[\u0D00-\u0D7F\u0900-\u0DFF]/.test(text || '');
|
|
90
|
+
if (hasIndic) {
|
|
91
|
+
const indicFont = INDIC_FONTS.find(f => f && fs.existsSync(f));
|
|
92
|
+
if (indicFont) return indicFont;
|
|
93
|
+
}
|
|
68
94
|
return FONTS.find(f => f && fs.existsSync(f)) || '';
|
|
69
95
|
}
|
|
70
96
|
|
|
@@ -80,23 +106,46 @@ class VideoRenderer {
|
|
|
80
106
|
}
|
|
81
107
|
|
|
82
108
|
/**
|
|
83
|
-
* Render a single scene:
|
|
109
|
+
* Render a single scene: code-driven animated video or clip + voice → scene_N_out.mp4
|
|
84
110
|
*
|
|
85
111
|
* @param {{
|
|
86
|
-
* sceneIndex:
|
|
87
|
-
* imagePath?:
|
|
88
|
-
* videoPath?:
|
|
89
|
-
* voicePath:
|
|
90
|
-
* caption?:
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
112
|
+
* sceneIndex: number,
|
|
113
|
+
* imagePath?: string,
|
|
114
|
+
* videoPath?: string, // optional user clip / B-roll
|
|
115
|
+
* voicePath: string,
|
|
116
|
+
* caption?: string,
|
|
117
|
+
* narration?: string,
|
|
118
|
+
* visualDescription?: string,
|
|
119
|
+
* visualType?: string,
|
|
120
|
+
* language?: string,
|
|
121
|
+
* musicMood?: string,
|
|
122
|
+
* camera?: string, // camera_hint from scene schema v1.0.0
|
|
123
|
+
* focalPoint?: { x: number, y: number },
|
|
124
|
+
* manifest?: ArtifactManifest // optional — enables idempotency
|
|
125
|
+
* renderWidth?: number, // default 1920
|
|
126
|
+
* renderHeight?: number, // default 1080
|
|
127
|
+
* fps?: number, // default 24
|
|
96
128
|
* }} opts
|
|
97
129
|
* @returns {string} outputPath
|
|
98
130
|
*/
|
|
99
|
-
renderScene({
|
|
131
|
+
renderScene({
|
|
132
|
+
sceneIndex,
|
|
133
|
+
imagePath,
|
|
134
|
+
videoPath,
|
|
135
|
+
voicePath,
|
|
136
|
+
caption = '',
|
|
137
|
+
narration = '',
|
|
138
|
+
visualDescription = '',
|
|
139
|
+
visualType = 'code_animation',
|
|
140
|
+
language = '',
|
|
141
|
+
musicMood = 'neutral',
|
|
142
|
+
camera,
|
|
143
|
+
focalPoint,
|
|
144
|
+
manifest,
|
|
145
|
+
renderWidth = 1920,
|
|
146
|
+
renderHeight = 1080,
|
|
147
|
+
fps = 24,
|
|
148
|
+
} = {}) {
|
|
100
149
|
const outputPath = path.join(this.vdir, `scene_${sceneIndex}_out.mp4`);
|
|
101
150
|
|
|
102
151
|
// Idempotency: skip if already rendered and manifest says so
|
|
@@ -108,83 +157,107 @@ class VideoRenderer {
|
|
|
108
157
|
fs.mkdirSync(this.vdir, { recursive: true });
|
|
109
158
|
|
|
110
159
|
// Duration from voice file.
|
|
111
|
-
// No hard 60s cap — long scenes (documentary, lecture, podcast) can run as long as
|
|
112
|
-
// the narration dictates. The per-scene ffmpeg timeout (5 min) is the real ceiling.
|
|
113
|
-
//
|
|
114
|
-
// Fix: guard voicePath=null/undefined before calling ffprobe.
|
|
115
|
-
// _getAudioDuration(null) → ffprobe stdout empty → parseFloat('') = NaN → clampedDur=NaN
|
|
116
|
-
// → ffmpeg receives "-t NaN" → scene silently produces 0-byte output → concat breaks A/V sync.
|
|
117
160
|
if (!voicePath || !fs.existsSync(voicePath)) {
|
|
118
161
|
throw new Error(`Scene ${sceneIndex} render failed: voicePath is missing or does not exist (${voicePath})`);
|
|
119
162
|
}
|
|
120
163
|
const dur = this._getAudioDuration(voicePath);
|
|
121
164
|
const clampedDur = Math.max(2, Number.isFinite(dur) ? dur + 0.15 : 3.15);
|
|
122
165
|
|
|
123
|
-
const
|
|
124
|
-
const captionFilter = this._captionFilter(caption);
|
|
166
|
+
const isVideoInput = Boolean(videoPath) || (videoPath && /\.(mp4|mov|webm|mkv|m4v)$/i.test(videoPath));
|
|
125
167
|
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
const
|
|
131
|
-
const vfFilters = ['vignette=PI/4'];
|
|
132
|
-
if (captionFilter) vfFilters.push(captionFilter);
|
|
133
|
-
const vf = vfFilters.join(',');
|
|
168
|
+
// Priority 1: User-supplied video clip
|
|
169
|
+
if (isVideoInput) {
|
|
170
|
+
const captionFilter = this._captionFilter(caption);
|
|
171
|
+
const scaleFilter = `scale=${renderWidth}:${renderHeight}:force_original_aspect_ratio=increase:flags=lanczos,crop=${renderWidth}:${renderHeight}`;
|
|
172
|
+
const vf = [scaleFilter, captionFilter].filter(Boolean).join(',');
|
|
134
173
|
|
|
135
174
|
const result = spawnSync('ffmpeg', [
|
|
136
175
|
'-nostdin', '-y', '-threads', '2',
|
|
137
|
-
'-
|
|
138
|
-
'-
|
|
139
|
-
'-map', '0:v:0', '-map', '1:a:0',
|
|
140
|
-
// apad=whole_dur pads audio to exactly clampedDur so -t never truncates mid-word.
|
|
141
|
-
// Specifying whole_dur is mandatory: without it -apad on a color source runs forever.
|
|
176
|
+
'-stream_loop', '-1', '-i', videoPath, '-i', voicePath,
|
|
177
|
+
'-vf', vf || `scale=${renderWidth}:${renderHeight},format=yuv420p`,
|
|
142
178
|
'-af', `apad=whole_dur=${clampedDur}`,
|
|
143
179
|
'-t', String(clampedDur),
|
|
144
|
-
'-
|
|
180
|
+
'-map', '0:v:0', '-map', '1:a:0',
|
|
145
181
|
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '22',
|
|
146
182
|
'-profile:v', 'high', '-level', '4.1',
|
|
147
183
|
'-color_range', 'tv', '-colorspace', 'bt709',
|
|
148
184
|
'-color_primaries', 'bt709', '-color_trc', 'bt709',
|
|
149
|
-
'-
|
|
185
|
+
'-x264-params', 'colorprim=bt709:transfer=bt709:colormatrix=bt709',
|
|
150
186
|
'-c:a', 'aac', '-b:a', '192k', '-ar', '48000', '-ac', '2',
|
|
151
187
|
'-movflags', '+faststart',
|
|
188
|
+
'-pix_fmt', 'yuv420p',
|
|
152
189
|
outputPath,
|
|
153
190
|
], { timeout: 5 * 60 * 1000, encoding: 'utf8' });
|
|
154
191
|
|
|
155
192
|
if (result.status !== 0) {
|
|
156
|
-
|
|
193
|
+
const err = `Scene ${sceneIndex} render failed: ${(result.stderr || '').slice(-300)}`;
|
|
194
|
+
manifest?.setSceneFailed(sceneIndex, err);
|
|
195
|
+
throw new Error(err);
|
|
157
196
|
}
|
|
197
|
+
|
|
158
198
|
manifest?.setSceneRendered(sceneIndex, outputPath);
|
|
159
199
|
return outputPath;
|
|
160
200
|
}
|
|
161
201
|
|
|
162
|
-
|
|
202
|
+
// Priority 2: Code-driven animated video scene via Chromium + Harness
|
|
203
|
+
// Produces pure-function-of-t 24fps motion graphics (kinetic typography,
|
|
204
|
+
// spring motion, animated particles, graphs, code typing, full multilingual Indic/Malayalam support).
|
|
205
|
+
const chrome = findChromePath();
|
|
206
|
+
if (chrome && process.env.FRAMEFORGE_NO_BROWSER !== '1') {
|
|
207
|
+
try {
|
|
208
|
+
return this._renderCodeAnimationScene({
|
|
209
|
+
sceneIndex,
|
|
210
|
+
imagePath,
|
|
211
|
+
voicePath,
|
|
212
|
+
caption,
|
|
213
|
+
narration,
|
|
214
|
+
visualDescription,
|
|
215
|
+
clampedDur,
|
|
216
|
+
renderWidth,
|
|
217
|
+
renderHeight,
|
|
218
|
+
fps,
|
|
219
|
+
outputPath,
|
|
220
|
+
manifest,
|
|
221
|
+
musicMood,
|
|
222
|
+
});
|
|
223
|
+
} catch (err) {
|
|
224
|
+
console.warn(`[Renderer] Code-driven browser scene render failed (${err.message}), falling back to procedural animation`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
163
227
|
|
|
228
|
+
// Priority 3: Procedural dynamic animated fallback (dynamic moving gradients / camera drift)
|
|
229
|
+
const captionFilter = this._captionFilter(caption);
|
|
164
230
|
let inputArgs = [];
|
|
165
231
|
let vf = '';
|
|
166
232
|
|
|
167
|
-
if (
|
|
168
|
-
|
|
169
|
-
inputArgs = ['-stream_loop', '-1', '-i', mediaSource, '-i', voicePath];
|
|
170
|
-
const scaleFilter = `scale=${renderWidth}:${renderHeight}:force_original_aspect_ratio=increase:flags=lanczos,crop=${renderWidth}:${renderHeight}`;
|
|
171
|
-
vf = [scaleFilter, captionFilter].filter(Boolean).join(',');
|
|
172
|
-
} else {
|
|
173
|
-
// Image input with AnimationSystem camera motion
|
|
174
|
-
inputArgs = ['-loop', '1', '-i', mediaSource, '-i', voicePath];
|
|
233
|
+
if (imagePath && fs.existsSync(imagePath)) {
|
|
234
|
+
inputArgs = ['-loop', '1', '-i', imagePath, '-i', voicePath];
|
|
175
235
|
const hint = camera || selectCameraHint(sceneIndex, focalPoint || {});
|
|
176
236
|
const fx = focalPoint ? { focalX: focalPoint.x, focalY: focalPoint.y } : {};
|
|
177
237
|
const cameraFilter = buildCameraFilter(hint, { duration: clampedDur, w: renderWidth, h: renderHeight, ...fx });
|
|
178
238
|
vf = [cameraFilter, captionFilter].filter(Boolean).join(',');
|
|
239
|
+
} else {
|
|
240
|
+
const PALETTES = [
|
|
241
|
+
{ bg: '0d1117', c1: '38bdf8' },
|
|
242
|
+
{ bg: '0f172a', c1: '818cf8' },
|
|
243
|
+
{ bg: '13111c', c1: 'c084fc' },
|
|
244
|
+
{ bg: '0c1a24', c1: '34d399' },
|
|
245
|
+
{ bg: '18181b', c1: 'fbbf24' },
|
|
246
|
+
];
|
|
247
|
+
const pal = PALETTES[sceneIndex % PALETTES.length];
|
|
248
|
+
inputArgs = [
|
|
249
|
+
'-f', 'lavfi', '-i', `gradients=s=${renderWidth}x${renderHeight}:r=30:c0=0x${pal.bg}:c1=0x${pal.c1}:speed=0.5:type=radial`,
|
|
250
|
+
'-i', voicePath,
|
|
251
|
+
];
|
|
252
|
+
const vfFilters = ['vignette=PI/4'];
|
|
253
|
+
if (captionFilter) vfFilters.push(captionFilter);
|
|
254
|
+
vf = vfFilters.join(',');
|
|
179
255
|
}
|
|
180
256
|
|
|
181
257
|
const result = spawnSync('ffmpeg', [
|
|
182
258
|
'-nostdin', '-y', '-threads', '2',
|
|
183
259
|
...inputArgs,
|
|
184
260
|
'-vf', vf || `scale=${renderWidth}:${renderHeight},format=yuv420p`,
|
|
185
|
-
// apad=whole_dur pads audio to exactly clampedDur, preventing mid-word truncation
|
|
186
|
-
// when voice is fractionally shorter than clampedDur and avoiding infinite padding
|
|
187
|
-
// on looped-image inputs. -t then hard-caps output length at clampedDur.
|
|
188
261
|
'-af', `apad=whole_dur=${clampedDur}`,
|
|
189
262
|
'-t', String(clampedDur),
|
|
190
263
|
'-map', '0:v:0', '-map', '1:a:0',
|
|
@@ -192,6 +265,7 @@ class VideoRenderer {
|
|
|
192
265
|
'-profile:v', 'high', '-level', '4.1',
|
|
193
266
|
'-color_range', 'tv', '-colorspace', 'bt709',
|
|
194
267
|
'-color_primaries', 'bt709', '-color_trc', 'bt709',
|
|
268
|
+
'-x264-params', 'colorprim=bt709:transfer=bt709:colormatrix=bt709',
|
|
195
269
|
'-c:a', 'aac', '-b:a', '192k', '-ar', '48000', '-ac', '2',
|
|
196
270
|
'-movflags', '+faststart',
|
|
197
271
|
'-pix_fmt', 'yuv420p',
|
|
@@ -199,7 +273,7 @@ class VideoRenderer {
|
|
|
199
273
|
], { timeout: 5 * 60 * 1000, encoding: 'utf8' });
|
|
200
274
|
|
|
201
275
|
if (result.status !== 0) {
|
|
202
|
-
const err = `Scene ${sceneIndex} render failed: ${(result.stderr || '').slice(-300)}`;
|
|
276
|
+
const err = `Scene ${sceneIndex} (no-image fallback) render failed: ${(result.stderr || '').slice(-300)}`;
|
|
203
277
|
manifest?.setSceneFailed(sceneIndex, err);
|
|
204
278
|
throw new Error(err);
|
|
205
279
|
}
|
|
@@ -208,6 +282,91 @@ class VideoRenderer {
|
|
|
208
282
|
return outputPath;
|
|
209
283
|
}
|
|
210
284
|
|
|
285
|
+
_renderCodeAnimationScene({
|
|
286
|
+
sceneIndex,
|
|
287
|
+
imagePath,
|
|
288
|
+
voicePath,
|
|
289
|
+
caption,
|
|
290
|
+
narration,
|
|
291
|
+
visualDescription,
|
|
292
|
+
clampedDur,
|
|
293
|
+
renderWidth,
|
|
294
|
+
renderHeight,
|
|
295
|
+
fps = 24,
|
|
296
|
+
outputPath,
|
|
297
|
+
manifest,
|
|
298
|
+
musicMood,
|
|
299
|
+
}) {
|
|
300
|
+
const htmlContent = generateSceneHtml({
|
|
301
|
+
caption,
|
|
302
|
+
narration,
|
|
303
|
+
visualDescription,
|
|
304
|
+
imagePath,
|
|
305
|
+
duration: clampedDur,
|
|
306
|
+
width: renderWidth,
|
|
307
|
+
height: renderHeight,
|
|
308
|
+
sceneIndex,
|
|
309
|
+
musicMood,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const animHtmlPath = path.join(this.vdir, `scene_${sceneIndex}_anim.html`);
|
|
313
|
+
const framesDir = path.join(this.vdir, `scene_${sceneIndex}_frames`);
|
|
314
|
+
fs.writeFileSync(animHtmlPath, htmlContent, 'utf8');
|
|
315
|
+
fs.mkdirSync(framesDir, { recursive: true });
|
|
316
|
+
|
|
317
|
+
const harnessBin = path.resolve(__dirname, '../harness/bin/harness.mjs');
|
|
318
|
+
const totalFrames = Math.max(1, Math.round(clampedDur * fps));
|
|
319
|
+
const lastFrame = totalFrames - 1;
|
|
320
|
+
|
|
321
|
+
const res = spawnSync(process.execPath, [
|
|
322
|
+
harnessBin,
|
|
323
|
+
'frames',
|
|
324
|
+
'--html', animHtmlPath,
|
|
325
|
+
'--out', framesDir,
|
|
326
|
+
'--fps', String(fps),
|
|
327
|
+
'--first', '0',
|
|
328
|
+
'--last', String(lastFrame),
|
|
329
|
+
'--workers', '4',
|
|
330
|
+
], { encoding: 'utf8', timeout: 5 * 60 * 1000 });
|
|
331
|
+
|
|
332
|
+
if (res.status !== 0) {
|
|
333
|
+
throw new Error(`Harness frame rendering failed: ${(res.stderr || res.stdout || '').slice(-400)}`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const framesPattern = path.join(framesDir, 'f%06d.jpg');
|
|
337
|
+
const encResult = spawnSync('ffmpeg', [
|
|
338
|
+
'-nostdin', '-y', '-threads', '2',
|
|
339
|
+
'-framerate', String(fps),
|
|
340
|
+
'-pattern_type', 'sequence',
|
|
341
|
+
'-i', framesPattern,
|
|
342
|
+
'-i', voicePath,
|
|
343
|
+
'-map', '0:v:0', '-map', '1:a:0',
|
|
344
|
+
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20',
|
|
345
|
+
'-profile:v', 'high', '-level', '4.1',
|
|
346
|
+
'-color_range', 'tv', '-colorspace', 'bt709',
|
|
347
|
+
'-color_primaries', 'bt709', '-color_trc', 'bt709',
|
|
348
|
+
'-x264-params', 'colorprim=bt709:transfer=bt709:colormatrix=bt709',
|
|
349
|
+
'-pix_fmt', 'yuv420p',
|
|
350
|
+
'-c:a', 'aac', '-b:a', '192k', '-ar', '48000', '-ac', '2',
|
|
351
|
+
'-af', `apad=whole_dur=${clampedDur}`,
|
|
352
|
+
'-t', String(clampedDur),
|
|
353
|
+
'-movflags', '+faststart',
|
|
354
|
+
outputPath,
|
|
355
|
+
], { timeout: 5 * 60 * 1000, encoding: 'utf8' });
|
|
356
|
+
|
|
357
|
+
if (encResult.status !== 0) {
|
|
358
|
+
throw new Error(`Scene ${sceneIndex} video encode failed: ${(encResult.stderr || '').slice(-300)}`);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
try {
|
|
362
|
+
fs.rmSync(framesDir, { recursive: true, force: true });
|
|
363
|
+
fs.unlinkSync(animHtmlPath);
|
|
364
|
+
} catch (_) {}
|
|
365
|
+
|
|
366
|
+
manifest?.setSceneRendered(sceneIndex, outputPath);
|
|
367
|
+
return outputPath;
|
|
368
|
+
}
|
|
369
|
+
|
|
211
370
|
/**
|
|
212
371
|
* Assemble all scene_N_out.mp4 files into final_video.mp4,
|
|
213
372
|
* then pass through AudioMixer for sidechain ducking + LUFS normalisation.
|
|
@@ -276,19 +435,22 @@ class VideoRenderer {
|
|
|
276
435
|
|
|
277
436
|
_captionFilter(caption) {
|
|
278
437
|
if (!caption || !this.font) return '';
|
|
438
|
+
const resolvedFont = resolveFont(caption) || this.font;
|
|
439
|
+
if (!resolvedFont) return '';
|
|
440
|
+
|
|
441
|
+
const hasIndic = /[\u0D00-\u0D7F\u0900-\u0DFF]/.test(caption);
|
|
279
442
|
// Sanitize caption for ffmpeg drawtext.
|
|
280
|
-
// Strip
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
.replace(/\s+/g, ' ')
|
|
285
|
-
.trim()
|
|
286
|
-
.slice(0, 45)
|
|
287
|
-
.toUpperCase();
|
|
443
|
+
// Strip syntax-breaking characters; preserve full Unicode letters & viramas.
|
|
444
|
+
const safe = Array.from(
|
|
445
|
+
caption.replace(/['"\\:;|<>{}[\]()!@#$%^&*+=]/g, '').replace(/\s+/g, ' ').trim()
|
|
446
|
+
).slice(0, 45).join('');
|
|
288
447
|
if (!safe) return '';
|
|
289
|
-
|
|
448
|
+
|
|
449
|
+
const safeText = hasIndic ? safe : safe.toUpperCase();
|
|
450
|
+
const safeFont = resolvedFont.replace(/\\/g, '/').replace(/:/g, '\\:');
|
|
451
|
+
const shaping = hasIndic ? ':text_shaping=1' : '';
|
|
290
452
|
// Bottom-center, safe zone: 100px from bottom + UI button zone
|
|
291
|
-
return `drawtext=fontfile='${safeFont}':text='${
|
|
453
|
+
return `drawtext=fontfile='${safeFont}'${shaping}:text='${safeText}':fontsize=68:fontcolor=white:bordercolor=black:borderw=4:x=(w-text_w)/2:y=h-180:line_spacing=10`;
|
|
292
454
|
}
|
|
293
455
|
|
|
294
456
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Scene Animator — Code-Driven HTML/Canvas Scene Generator
|
|
4
|
+
*
|
|
5
|
+
* Generates self-contained HTML/Canvas animation scenes conforming to the
|
|
6
|
+
* FrameForge frame-purity contract:
|
|
7
|
+
* - window.DURATION = seconds
|
|
8
|
+
* - window.seek = async (t) => draws frame at time t
|
|
9
|
+
* - Every frame is a pure function of t (no Date.now, no Math.random without seed)
|
|
10
|
+
* - Multilingual typography support: Malayalam, Indic, CJK, Arabic, Latin
|
|
11
|
+
*
|
|
12
|
+
* Supported component themes:
|
|
13
|
+
* - 'code': IDE/terminal window with typing code and cursor
|
|
14
|
+
* - 'chart': Animated bar chart, progress ring, and metric counter
|
|
15
|
+
* - 'nodes': Pulsing network nodes with traveling energy packets
|
|
16
|
+
* - 'diagram': Flowchart steps with directional energy arrows
|
|
17
|
+
* - 'image': Floating Ken-Burns card embedding user-provided image
|
|
18
|
+
* - 'kinetic': Audio spectrum visualizer + isometric geometry
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
|
|
24
|
+
// Multilingual font family stack prioritizing Indic/Malayalam system fonts
|
|
25
|
+
const FONT_STACK = `'Nirmala UI', 'Noto Sans Malayalam', 'Malayalam Sangam MN', 'Kartika', 'Meera', 'Manjari', -apple-system, 'Segoe UI', system-ui, sans-serif`;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Detect the best visual theme from visual description, caption, and media.
|
|
29
|
+
*/
|
|
30
|
+
function detectTheme({ visualDescription = '', caption = '', imagePath = null } = {}) {
|
|
31
|
+
if (imagePath) return 'image';
|
|
32
|
+
const text = `${visualDescription} ${caption}`.toLowerCase();
|
|
33
|
+
if (/\b(code|function|syntax|api|javascript|python|typescript|json|sql|query|endpoint|algorithm|struct|cli|terminal|bash|shell|npm|git)\b/.test(text)) {
|
|
34
|
+
return 'code';
|
|
35
|
+
}
|
|
36
|
+
if (/\b(chart|data|metric|analytics|growth|graph|statistic|stats|revenue|percent|count|number)\b/.test(text)) {
|
|
37
|
+
return 'chart';
|
|
38
|
+
}
|
|
39
|
+
if (/\b(network|node|ai|model|neural|pipeline|architecture|cloud|server|system|mesh|connect)\b/.test(text)) {
|
|
40
|
+
return 'nodes';
|
|
41
|
+
}
|
|
42
|
+
if (/\b(diagram|flow|step|process|workflow|sequence|lifecycle|cycle)\b/.test(text)) {
|
|
43
|
+
return 'diagram';
|
|
44
|
+
}
|
|
45
|
+
return 'kinetic';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Generate a complete self-contained HTML scene string.
|
|
50
|
+
*
|
|
51
|
+
* @param {object} opts
|
|
52
|
+
* @param {string} opts.caption
|
|
53
|
+
* @param {string} [opts.narration]
|
|
54
|
+
* @param {string} [opts.visualDescription]
|
|
55
|
+
* @param {string} [opts.imagePath]
|
|
56
|
+
* @param {number} opts.duration
|
|
57
|
+
* @param {number} [opts.width]
|
|
58
|
+
* @param {number} [opts.height]
|
|
59
|
+
* @param {number} [opts.sceneIndex]
|
|
60
|
+
* @param {string} [opts.musicMood]
|
|
61
|
+
* @returns {string} HTML document
|
|
62
|
+
*/
|
|
63
|
+
function generateSceneHtml({
|
|
64
|
+
caption = '',
|
|
65
|
+
narration = '',
|
|
66
|
+
visualDescription = '',
|
|
67
|
+
imagePath = null,
|
|
68
|
+
duration = 3,
|
|
69
|
+
width = 1920,
|
|
70
|
+
height = 1080,
|
|
71
|
+
sceneIndex = 0,
|
|
72
|
+
musicMood = 'neutral',
|
|
73
|
+
} = {}) {
|
|
74
|
+
const theme = detectTheme({ visualDescription, caption, imagePath });
|
|
75
|
+
const clampedDur = Math.max(1, Number(duration) || 3);
|
|
76
|
+
|
|
77
|
+
// Convert imagePath to data URL if local file exists
|
|
78
|
+
let imageDataUri = '';
|
|
79
|
+
if (imagePath && fs.existsSync(imagePath)) {
|
|
80
|
+
try {
|
|
81
|
+
const ext = path.extname(imagePath).toLowerCase().slice(1) || 'jpeg';
|
|
82
|
+
const mime = ext === 'png' ? 'image/png' : ext === 'webp' ? 'image/webp' : 'image/jpeg';
|
|
83
|
+
const b64 = fs.readFileSync(imagePath).toString('base64');
|
|
84
|
+
imageDataUri = `data:${mime};base64,${b64}`;
|
|
85
|
+
} catch (_) {}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Color palettes based on scene index and music mood
|
|
89
|
+
const PALETTES = [
|
|
90
|
+
{ bg0: '#142036', bg1: '#0b111c', bg2: '#05080f', accent: '#38bdf8', accent2: '#818cf8', tag: 'CYBER TECH' },
|
|
91
|
+
{ bg0: '#231436', bg1: '#110a1c', bg2: '#07040d', accent: '#c084fc', accent2: '#f43f5e', tag: 'DEEP DYNAMICS' },
|
|
92
|
+
{ bg0: '#0e261f', bg1: '#071511', bg2: '#030a08', accent: '#34d399', accent2: '#38bdf8', tag: 'SYSTEM ACTIVE' },
|
|
93
|
+
{ bg0: '#291d0e', bg1: '#150f07', bg2: '#0a0703', accent: '#fbbf24', accent2: '#f97316', tag: 'HIGH IMPACT' },
|
|
94
|
+
{ bg0: '#161d2b', bg1: '#0d131d', bg2: '#06090e', accent: '#60a5fa', accent2: '#a78bfa', tag: 'INTELLIGENCE' },
|
|
95
|
+
];
|
|
96
|
+
const pal = PALETTES[sceneIndex % PALETTES.length];
|
|
97
|
+
|
|
98
|
+
return `<!doctype html>
|
|
99
|
+
<html lang="en">
|
|
100
|
+
<head>
|
|
101
|
+
<meta charset="utf-8">
|
|
102
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
103
|
+
<style>
|
|
104
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
105
|
+
html, body {
|
|
106
|
+
width: 100%;
|
|
107
|
+
height: 100%;
|
|
108
|
+
overflow: hidden;
|
|
109
|
+
background: ${pal.bg2};
|
|
110
|
+
font-family: ${FONT_STACK};
|
|
111
|
+
-webkit-font-smoothing: antialiased;
|
|
112
|
+
}
|
|
113
|
+
canvas {
|
|
114
|
+
width: 100%;
|
|
115
|
+
height: 100%;
|
|
116
|
+
display: block;
|
|
117
|
+
}
|
|
118
|
+
</style>
|
|
119
|
+
</head>
|
|
120
|
+
<body>
|
|
121
|
+
<canvas id="stage" width="${width}" height="${height}"></canvas>
|
|
122
|
+
<script>
|
|
123
|
+
window.DURATION = ${clampedDur};
|
|
124
|
+
const canvas = document.getElementById('stage');
|
|
125
|
+
const ctx = canvas.getContext('2d');
|
|
126
|
+
const W = ${width};
|
|
127
|
+
const H = ${height};
|
|
128
|
+
|
|
129
|
+
// Seeded PRNG for frame purity (same t -> exact same frame)
|
|
130
|
+
function sfc32(a, b, c, d) {
|
|
131
|
+
return function() {
|
|
132
|
+
a >>>= 0; b >>>= 0; c >>>= 0; d >>>= 0;
|
|
133
|
+
let t = (a + b | 0) + d | 0;
|
|
134
|
+
d = d + 1 | 0;
|
|
135
|
+
a = b ^ b >>> 9;
|
|
136
|
+
b = c + (c << 3) | 0;
|
|
137
|
+
c = c << 21 | c >>> 11;
|
|
138
|
+
c = c + t | 0;
|
|
139
|
+
return (t >>> 0) / 4294967296;
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const rng = sfc32(${42 + sceneIndex * 17}, ${1337 + sceneIndex * 31}, ${7 + sceneIndex * 11}, 99);
|
|
144
|
+
const PARTICLES = Array.from({ length: 54 }, () => ({
|
|
145
|
+
x: rng() * W,
|
|
146
|
+
y: rng() * H,
|
|
147
|
+
vx: (rng() - 0.5) * 50,
|
|
148
|
+
vy: (rng() - 0.5) * 35 - 15,
|
|
149
|
+
r: 1.5 + rng() * 3.5,
|
|
150
|
+
alpha: 0.3 + rng() * 0.5
|
|
151
|
+
}));
|
|
152
|
+
|
|
153
|
+
// Spring dynamics function
|
|
154
|
+
function spring(t, delay = 0, tension = 160, friction = 14) {
|
|
155
|
+
const dt = t - delay;
|
|
156
|
+
if (dt <= 0) return 0;
|
|
157
|
+
const omega = Math.sqrt(tension);
|
|
158
|
+
const zeta = friction / (2 * omega);
|
|
159
|
+
if (zeta < 1) {
|
|
160
|
+
const omegaD = omega * Math.sqrt(1 - zeta * zeta);
|
|
161
|
+
return 1 - Math.exp(-zeta * omega * dt) * (Math.cos(omegaD * dt) + (zeta * omega / omegaD) * Math.sin(omegaD * dt));
|
|
162
|
+
}
|
|
163
|
+
return 1 - Math.exp(-omega * dt);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const THEME = ${JSON.stringify(theme)};
|
|
167
|
+
const CAPTION = ${JSON.stringify(caption)};
|
|
168
|
+
const NARRATION = ${JSON.stringify(narration)};
|
|
169
|
+
const TAG = ${JSON.stringify(pal.tag)};
|
|
170
|
+
const ACCENT = ${JSON.stringify(pal.accent)};
|
|
171
|
+
const ACCENT2 = ${JSON.stringify(pal.accent2)};
|
|
172
|
+
const BG0 = ${JSON.stringify(pal.bg0)};
|
|
173
|
+
const BG1 = ${JSON.stringify(pal.bg1)};
|
|
174
|
+
const BG2 = ${JSON.stringify(pal.bg2)};
|
|
175
|
+
const IMAGE_SRC = ${JSON.stringify(imageDataUri)};
|
|
176
|
+
|
|
177
|
+
let cachedImage = null;
|
|
178
|
+
if (IMAGE_SRC) {
|
|
179
|
+
cachedImage = new Image();
|
|
180
|
+
cachedImage.src = IMAGE_SRC;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
window.seek = async (t) => {
|
|
184
|
+
// ── 1. Dynamic Moving Gradient Background ────────────────────────────────
|
|
185
|
+
const cx = W / 2 + Math.sin(t * 0.7) * 160;
|
|
186
|
+
const cy = H / 2 + Math.cos(t * 0.5) * 90;
|
|
187
|
+
const bgGrad = ctx.createRadialGradient(cx, cy, 60, W / 2, H / 2, W * 0.85);
|
|
188
|
+
bgGrad.addColorStop(0, BG0);
|
|
189
|
+
bgGrad.addColorStop(0.55, BG1);
|
|
190
|
+
bgGrad.addColorStop(1, BG2);
|
|
191
|
+
ctx.fillStyle = bgGrad;
|
|
192
|
+
ctx.fillRect(0, 0, W, H);
|
|
193
|
+
|
|
194
|
+
// ── 2. Subtle Moving Perspective Grid ───────────────────────────────────
|
|
195
|
+
ctx.save();
|
|
196
|
+
ctx.strokeStyle = 'rgba(255, 255, 255, 0.04)';
|
|
197
|
+
ctx.lineWidth = 1;
|
|
198
|
+
const gridShift = (t * 30) % 60;
|
|
199
|
+
for (let x = 0; x <= W; x += 60) {
|
|
200
|
+
ctx.beginPath();
|
|
201
|
+
ctx.moveTo(x, 0);
|
|
202
|
+
ctx.lineTo(x, H);
|
|
203
|
+
ctx.stroke();
|
|
204
|
+
}
|
|
205
|
+
for (let y = gridShift; y <= H; y += 60) {
|
|
206
|
+
ctx.beginPath();
|
|
207
|
+
ctx.moveTo(0, y);
|
|
208
|
+
ctx.lineTo(W, y);
|
|
209
|
+
ctx.stroke();
|
|
210
|
+
}
|
|
211
|
+
ctx.restore();
|
|
212
|
+
|
|
213
|
+
// ── 3. Floating Dust & Energy Particles ──────────────────────────────────
|
|
214
|
+
ctx.save();
|
|
215
|
+
for (let i = 0; i < PARTICLES.length; i++) {
|
|
216
|
+
const p = PARTICLES[i];
|
|
217
|
+
const px = (p.x + p.vx * t + W * 10) % W;
|
|
218
|
+
const py = (p.y + p.vy * t + H * 10) % H;
|
|
219
|
+
const pulse = 0.4 + 0.6 * Math.sin(t * 3.2 + i * 0.5);
|
|
220
|
+
ctx.fillStyle = ACCENT;
|
|
221
|
+
ctx.globalAlpha = p.alpha * pulse * 0.7;
|
|
222
|
+
ctx.beginPath();
|
|
223
|
+
ctx.arc(px, py, p.r, 0, Math.PI * 2);
|
|
224
|
+
ctx.fill();
|
|
225
|
+
}
|
|
226
|
+
ctx.restore();
|
|
227
|
+
|
|
228
|
+
// ── 4. Central Graphic Card matching Theme ──────────────────────────────
|
|
229
|
+
ctx.save();
|
|
230
|
+
const cardSpring = spring(t, 0.08, 140, 13);
|
|
231
|
+
const cardScale = Math.min(1.04, Math.max(0, cardSpring));
|
|
232
|
+
const cardY = H * 0.40 + (1 - cardSpring) * 50;
|
|
233
|
+
ctx.translate(W / 2, cardY);
|
|
234
|
+
ctx.scale(cardScale, cardScale);
|
|
235
|
+
|
|
236
|
+
const cardW = Math.round(W * 0.55);
|
|
237
|
+
const cardH = Math.round(H * 0.38);
|
|
238
|
+
|
|
239
|
+
// Card backdrop with glassmorphic border
|
|
240
|
+
ctx.fillStyle = 'rgba(11, 17, 28, 0.82)';
|
|
241
|
+
ctx.strokeStyle = 'rgba(255, 255, 255, 0.12)';
|
|
242
|
+
ctx.lineWidth = 2;
|
|
243
|
+
ctx.beginPath();
|
|
244
|
+
ctx.roundRect(-cardW / 2, -cardH / 2, cardW, cardH, 20);
|
|
245
|
+
ctx.fill();
|
|
246
|
+
ctx.stroke();
|
|
247
|
+
|
|
248
|
+
// Accent edge highlight
|
|
249
|
+
ctx.strokeStyle = ACCENT;
|
|
250
|
+
ctx.lineWidth = 2;
|
|
251
|
+
ctx.beginPath();
|
|
252
|
+
ctx.moveTo(-cardW / 2 + 30, -cardH / 2);
|
|
253
|
+
ctx.lineTo(cardW / 2 - 30, -cardH / 2);
|
|
254
|
+
ctx.stroke();
|
|
255
|
+
|
|
256
|
+
if (THEME === 'code') {
|
|
257
|
+
// IDE Titlebar
|
|
258
|
+
ctx.fillStyle = 'rgba(255, 255, 255, 0.05)';
|
|
259
|
+
ctx.beginPath();
|
|
260
|
+
ctx.roundRect(-cardW / 2, -cardH / 2, cardW, 40, [20, 20, 0, 0]);
|
|
261
|
+
ctx.fill();
|
|
262
|
+
// Window dots
|
|
263
|
+
ctx.fillStyle = '#ff5f56'; ctx.beginPath(); ctx.arc(-cardW / 2 + 25, -cardH / 2 + 20, 6, 0, Math.PI * 2); ctx.fill();
|
|
264
|
+
ctx.fillStyle = '#ffbd2e'; ctx.beginPath(); ctx.arc(-cardW / 2 + 45, -cardH / 2 + 20, 6, 0, Math.PI * 2); ctx.fill();
|
|
265
|
+
ctx.fillStyle = '#27c93f'; ctx.beginPath(); ctx.arc(-cardW / 2 + 65, -cardH / 2 + 20, 6, 0, Math.PI * 2); ctx.fill();
|
|
266
|
+
// Tab title
|
|
267
|
+
ctx.font = '14px monospace';
|
|
268
|
+
ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
|
|
269
|
+
ctx.textAlign = 'left';
|
|
270
|
+
ctx.fillText('scene-pipeline.js', -cardW / 2 + 90, -cardH / 2 + 25);
|
|
271
|
+
|
|
272
|
+
// Code lines with typing effect
|
|
273
|
+
const codeLines = [
|
|
274
|
+
'import { FrameForge } from "@amg555/frameforge";',
|
|
275
|
+
'const engine = new FrameForge.AnimationEngine({ t });',
|
|
276
|
+
'await engine.renderScene({',
|
|
277
|
+
' status: "ACTIVE_24FPS",',
|
|
278
|
+
' fidelity: "BROADCAST_BT709"',
|
|
279
|
+
'});'
|
|
280
|
+
];
|
|
281
|
+
ctx.font = '18px "Fira Code", monospace';
|
|
282
|
+
const typeProg = Math.min(1, t / (window.DURATION * 0.75));
|
|
283
|
+
const totalChars = codeLines.join('\\n').length;
|
|
284
|
+
let charsToShow = Math.floor(totalChars * typeProg);
|
|
285
|
+
|
|
286
|
+
let lineY = -cardH / 2 + 80;
|
|
287
|
+
for (let l = 0; l < codeLines.length; l++) {
|
|
288
|
+
const line = codeLines[l];
|
|
289
|
+
const sliceLen = Math.min(line.length, charsToShow);
|
|
290
|
+
const textToDraw = line.slice(0, sliceLen);
|
|
291
|
+
charsToShow = Math.max(0, charsToShow - line.length - 1);
|
|
292
|
+
|
|
293
|
+
// Line number
|
|
294
|
+
ctx.fillStyle = 'rgba(255, 255, 255, 0.25)';
|
|
295
|
+
ctx.fillText(String(l + 1).padStart(2, '0'), -cardW / 2 + 30, lineY);
|
|
296
|
+
// Syntax text
|
|
297
|
+
ctx.fillStyle = l === 0 ? ACCENT : l === 1 ? ACCENT2 : '#e2e8f0';
|
|
298
|
+
ctx.fillText(textToDraw, -cardW / 2 + 75, lineY);
|
|
299
|
+
lineY += 32;
|
|
300
|
+
}
|
|
301
|
+
} else if (THEME === 'chart') {
|
|
302
|
+
// Animated bar chart + metrics
|
|
303
|
+
const bars = 8;
|
|
304
|
+
const barW = (cardW - 140) / bars;
|
|
305
|
+
const startX = -cardW / 2 + 70;
|
|
306
|
+
const baseLine = cardH / 2 - 50;
|
|
307
|
+
|
|
308
|
+
for (let b = 0; b < bars; b++) {
|
|
309
|
+
const barSpring = spring(t, 0.15 + b * 0.05, 120, 12);
|
|
310
|
+
const targetH = 50 + ((b * 37) % 140);
|
|
311
|
+
const curH = targetH * Math.min(1.05, Math.max(0, barSpring));
|
|
312
|
+
const bx = startX + b * barW;
|
|
313
|
+
const by = baseLine - curH;
|
|
314
|
+
|
|
315
|
+
const barGrad = ctx.createLinearGradient(0, by, 0, baseLine);
|
|
316
|
+
barGrad.addColorStop(0, ACCENT);
|
|
317
|
+
barGrad.addColorStop(1, ACCENT2);
|
|
318
|
+
ctx.fillStyle = barGrad;
|
|
319
|
+
ctx.beginPath();
|
|
320
|
+
ctx.roundRect(bx, by, barW - 12, curH, 6);
|
|
321
|
+
ctx.fill();
|
|
322
|
+
}
|
|
323
|
+
// Animated Counter
|
|
324
|
+
const counterVal = Math.round(spring(t, 0.2, 100, 10) * 98.4);
|
|
325
|
+
ctx.font = 'bold 36px monospace';
|
|
326
|
+
ctx.fillStyle = '#ffffff';
|
|
327
|
+
ctx.textAlign = 'right';
|
|
328
|
+
ctx.fillText(counterVal + '% METRIC GAIN', cardW / 2 - 40, -cardH / 2 + 50);
|
|
329
|
+
} else if (THEME === 'nodes') {
|
|
330
|
+
// Interconnected pulsing network nodes
|
|
331
|
+
const nodeCoords = [
|
|
332
|
+
[-cardW * 0.35, -30], [-cardW * 0.15, 40], [0, -50],
|
|
333
|
+
[cardW * 0.15, 30], [cardW * 0.35, -20]
|
|
334
|
+
];
|
|
335
|
+
// Connections
|
|
336
|
+
ctx.strokeStyle = 'rgba(56, 189, 248, 0.35)';
|
|
337
|
+
ctx.lineWidth = 2;
|
|
338
|
+
for (let i = 0; i < nodeCoords.length - 1; i++) {
|
|
339
|
+
ctx.beginPath();
|
|
340
|
+
ctx.moveTo(nodeCoords[i][0], nodeCoords[i][1]);
|
|
341
|
+
ctx.lineTo(nodeCoords[i + 1][0], nodeCoords[i + 1][1]);
|
|
342
|
+
ctx.stroke();
|
|
343
|
+
|
|
344
|
+
// Energy packet along connection
|
|
345
|
+
const packetPos = (t * 0.8 + i * 0.25) % 1;
|
|
346
|
+
const ex = nodeCoords[i][0] + (nodeCoords[i + 1][0] - nodeCoords[i][0]) * packetPos;
|
|
347
|
+
const ey = nodeCoords[i][1] + (nodeCoords[i + 1][1] - nodeCoords[i][1]) * packetPos;
|
|
348
|
+
ctx.fillStyle = ACCENT;
|
|
349
|
+
ctx.beginPath();
|
|
350
|
+
ctx.arc(ex, ey, 5, 0, Math.PI * 2);
|
|
351
|
+
ctx.fill();
|
|
352
|
+
}
|
|
353
|
+
// Nodes
|
|
354
|
+
for (let i = 0; i < nodeCoords.length; i++) {
|
|
355
|
+
const [nx, ny] = nodeCoords[i];
|
|
356
|
+
const pulse = 1 + 0.25 * Math.sin(t * 4 + i);
|
|
357
|
+
ctx.fillStyle = ACCENT2;
|
|
358
|
+
ctx.beginPath();
|
|
359
|
+
ctx.arc(nx, ny, 14 * pulse, 0, Math.PI * 2);
|
|
360
|
+
ctx.fill();
|
|
361
|
+
ctx.fillStyle = '#ffffff';
|
|
362
|
+
ctx.beginPath();
|
|
363
|
+
ctx.arc(nx, ny, 6, 0, Math.PI * 2);
|
|
364
|
+
ctx.fill();
|
|
365
|
+
}
|
|
366
|
+
} else if (THEME === 'image' && cachedImage && cachedImage.complete && cachedImage.naturalWidth > 0) {
|
|
367
|
+
// Ken-Burns floating image
|
|
368
|
+
const zoom = 1.0 + (t / window.DURATION) * 0.07;
|
|
369
|
+
ctx.save();
|
|
370
|
+
ctx.beginPath();
|
|
371
|
+
ctx.roundRect(-cardW / 2 + 12, -cardH / 2 + 12, cardW - 24, cardH - 24, 14);
|
|
372
|
+
ctx.clip();
|
|
373
|
+
ctx.scale(zoom, zoom);
|
|
374
|
+
ctx.drawImage(cachedImage, -cardW / 2 + 12, -cardH / 2 + 12, cardW - 24, cardH - 24);
|
|
375
|
+
ctx.restore();
|
|
376
|
+
} else {
|
|
377
|
+
// Kinetic Audio Spectrum Bars
|
|
378
|
+
const bars = 28;
|
|
379
|
+
const barW = (cardW - 80) / bars;
|
|
380
|
+
const startX = -cardW / 2 + 40;
|
|
381
|
+
for (let b = 0; b < bars; b++) {
|
|
382
|
+
const phase = t * 4.5 + b * 0.3;
|
|
383
|
+
const hVal = 25 + 95 * Math.abs(Math.sin(phase) * Math.cos(phase * 0.8));
|
|
384
|
+
const bx = startX + b * barW;
|
|
385
|
+
const by = 20 - hVal / 2;
|
|
386
|
+
|
|
387
|
+
const barGrad = ctx.createLinearGradient(0, by, 0, by + hVal);
|
|
388
|
+
barGrad.addColorStop(0, ACCENT);
|
|
389
|
+
barGrad.addColorStop(1, ACCENT2);
|
|
390
|
+
ctx.fillStyle = barGrad;
|
|
391
|
+
ctx.beginPath();
|
|
392
|
+
ctx.roundRect(bx, by, barW - 6, hVal, 4);
|
|
393
|
+
ctx.fill();
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Tag Badge
|
|
398
|
+
ctx.fillStyle = 'rgba(255, 255, 255, 0.08)';
|
|
399
|
+
ctx.beginPath();
|
|
400
|
+
ctx.roundRect(-cardW / 2 + 25, -cardH / 2 + 20, 140, 30, 15);
|
|
401
|
+
ctx.fill();
|
|
402
|
+
ctx.font = 'bold 13px system-ui, sans-serif';
|
|
403
|
+
ctx.fillStyle = ACCENT;
|
|
404
|
+
ctx.textAlign = 'center';
|
|
405
|
+
ctx.fillText(TAG, -cardW / 2 + 95, -cardH / 2 + 40);
|
|
406
|
+
|
|
407
|
+
ctx.restore();
|
|
408
|
+
|
|
409
|
+
// ── 5. Kinetic Typography with Multilingual / Malayalam Support ─────────
|
|
410
|
+
ctx.save();
|
|
411
|
+
const textSpring = spring(t, 0.22, 150, 13);
|
|
412
|
+
const textY = H * 0.77 + (1 - textSpring) * 45;
|
|
413
|
+
const textAlpha = Math.min(1, Math.max(0, textSpring));
|
|
414
|
+
|
|
415
|
+
ctx.globalAlpha = textAlpha;
|
|
416
|
+
ctx.textAlign = 'center';
|
|
417
|
+
|
|
418
|
+
// Primary Caption / Headline
|
|
419
|
+
ctx.font = 'bold 54px ' + ${JSON.stringify(FONT_STACK)};
|
|
420
|
+
ctx.shadowColor = 'rgba(0, 0, 0, 0.95)';
|
|
421
|
+
ctx.shadowBlur = 20;
|
|
422
|
+
ctx.shadowOffsetY = 6;
|
|
423
|
+
ctx.fillStyle = '#ffffff';
|
|
424
|
+
ctx.fillText(CAPTION, W / 2, textY);
|
|
425
|
+
|
|
426
|
+
// Subtitle / Narration preview
|
|
427
|
+
if (NARRATION) {
|
|
428
|
+
ctx.font = '400 28px ' + ${JSON.stringify(FONT_STACK)};
|
|
429
|
+
ctx.fillStyle = 'rgba(226, 232, 240, 0.88)';
|
|
430
|
+
ctx.shadowBlur = 12;
|
|
431
|
+
const cleanNarr = NARRATION.length > 95 ? NARRATION.slice(0, 95) + '...' : NARRATION;
|
|
432
|
+
ctx.fillText(cleanNarr, W / 2, textY + 62);
|
|
433
|
+
}
|
|
434
|
+
ctx.restore();
|
|
435
|
+
|
|
436
|
+
// ── 6. Progress Scrubber ────────────────────────────────────────────────
|
|
437
|
+
ctx.save();
|
|
438
|
+
const progress = Math.min(1, Math.max(0, t / window.DURATION));
|
|
439
|
+
ctx.fillStyle = 'rgba(255, 255, 255, 0.08)';
|
|
440
|
+
ctx.fillRect(0, H - 8, W, 8);
|
|
441
|
+
|
|
442
|
+
const progGrad = ctx.createLinearGradient(0, 0, W, 0);
|
|
443
|
+
progGrad.addColorStop(0, ACCENT);
|
|
444
|
+
progGrad.addColorStop(1, ACCENT2);
|
|
445
|
+
ctx.fillStyle = progGrad;
|
|
446
|
+
ctx.fillRect(0, H - 8, W * progress, 8);
|
|
447
|
+
ctx.restore();
|
|
448
|
+
};
|
|
449
|
+
window.ready = true;
|
|
450
|
+
</script>
|
|
451
|
+
</body>
|
|
452
|
+
</html>`;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
module.exports = {
|
|
456
|
+
generateSceneHtml,
|
|
457
|
+
detectTheme,
|
|
458
|
+
FONT_STACK,
|
|
459
|
+
};
|
package/src/workflows/assets.js
CHANGED
|
@@ -88,6 +88,10 @@ async function runAssets({ productionId, scenes, parallelism = 1, skipTts = fals
|
|
|
88
88
|
caption: scene.caption,
|
|
89
89
|
camera: scene.camera_hint || scene.camera || 'static',
|
|
90
90
|
focalPoint: scene.focal_point || null,
|
|
91
|
+
narration: scene.narration || '',
|
|
92
|
+
visualDescription: scene.visual_description || scene.narration || '',
|
|
93
|
+
musicMood: scene.music_mood || 'neutral',
|
|
94
|
+
language: scene.language || '',
|
|
91
95
|
};
|
|
92
96
|
}));
|
|
93
97
|
results.push(...batchResults);
|
package/src/workflows/render.js
CHANGED
|
@@ -42,13 +42,18 @@ async function runRender({ productionId, sceneAssets, withMusic = true, musicVol
|
|
|
42
42
|
const idx = asset.sceneId - 1;
|
|
43
43
|
try {
|
|
44
44
|
const scenePath = renderer.renderScene({
|
|
45
|
-
sceneIndex:
|
|
46
|
-
imagePath:
|
|
47
|
-
videoPath:
|
|
48
|
-
voicePath:
|
|
49
|
-
caption:
|
|
50
|
-
|
|
51
|
-
|
|
45
|
+
sceneIndex: idx,
|
|
46
|
+
imagePath: asset.imagePath,
|
|
47
|
+
videoPath: asset.videoPath,
|
|
48
|
+
voicePath: asset.voicePath,
|
|
49
|
+
caption: asset.caption || '',
|
|
50
|
+
narration: asset.narration || '',
|
|
51
|
+
visualDescription: asset.visualDescription || '',
|
|
52
|
+
visualType: asset.visualType || 'code_animation',
|
|
53
|
+
musicMood: asset.musicMood || 'neutral',
|
|
54
|
+
language: asset.language || '',
|
|
55
|
+
camera: asset.camera || 'static',
|
|
56
|
+
focalPoint: asset.focalPoint || null,
|
|
52
57
|
renderWidth,
|
|
53
58
|
renderHeight,
|
|
54
59
|
});
|