@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +2 -2
  3. package/out/frames/f000000.jpg +0 -0
  4. package/out/frames/f000001.jpg +0 -0
  5. package/out/frames/f000002.jpg +0 -0
  6. package/out/frames/f000003.jpg +0 -0
  7. package/out/frames/f000004.jpg +0 -0
  8. package/out/frames/f000005.jpg +0 -0
  9. package/out/frames/f000006.jpg +0 -0
  10. package/out/frames/f000007.jpg +0 -0
  11. package/out/frames/f000008.jpg +0 -0
  12. package/out/frames/f000009.jpg +0 -0
  13. package/out/frames/f000010.jpg +0 -0
  14. package/out/frames/f000011.jpg +0 -0
  15. package/out/frames/f000012.jpg +0 -0
  16. package/out/frames/f000013.jpg +0 -0
  17. package/out/frames/f000014.jpg +0 -0
  18. package/out/frames/f000015.jpg +0 -0
  19. package/out/frames/f000016.jpg +0 -0
  20. package/out/frames/f000017.jpg +0 -0
  21. package/out/frames/f000018.jpg +0 -0
  22. package/out/frames/f000019.jpg +0 -0
  23. package/out/frames/f000020.jpg +0 -0
  24. package/out/frames/f000021.jpg +0 -0
  25. package/out/frames/f000022.jpg +0 -0
  26. package/out/frames/f000023.jpg +0 -0
  27. package/out/frames/f000024.jpg +0 -0
  28. package/out/frames/f000025.jpg +0 -0
  29. package/out/frames/f000026.jpg +0 -0
  30. package/out/frames/f000027.jpg +0 -0
  31. package/out/frames/f000028.jpg +0 -0
  32. package/out/frames/f000029.jpg +0 -0
  33. package/out/frames/f000030.jpg +0 -0
  34. package/out/frames/f000031.jpg +0 -0
  35. package/out/frames/f000032.jpg +0 -0
  36. package/out/frames/f000033.jpg +0 -0
  37. package/out/frames/f000034.jpg +0 -0
  38. package/out/frames/f000035.jpg +0 -0
  39. package/out/frames/f000036.jpg +0 -0
  40. package/out/frames/f000037.jpg +0 -0
  41. package/out/frames/f000038.jpg +0 -0
  42. package/out/frames/f000039.jpg +0 -0
  43. package/out/frames/f000040.jpg +0 -0
  44. package/out/frames/f000041.jpg +0 -0
  45. package/out/frames/f000042.jpg +0 -0
  46. package/out/frames/f000043.jpg +0 -0
  47. package/out/frames/f000044.jpg +0 -0
  48. package/out/frames/f000045.jpg +0 -0
  49. package/out/frames/f000046.jpg +0 -0
  50. package/out/frames/f000047.jpg +0 -0
  51. package/package.json +2 -2
  52. package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.css +2 -2
  53. package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.js +1 -1
  54. package/scripts/deep-validate.js +2 -2
  55. package/scripts/html-renderer/new-project.mjs +2 -2
  56. package/src/harness/bin/harness.mjs +20 -7
  57. package/src/mcp-server/server.js +1 -1
  58. package/src/video-renderer/renderer.js +222 -60
  59. package/src/video-renderer/scene-animator.js +459 -0
  60. package/src/workflows/assets.js +4 -0
  61. 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
  [![npm](https://img.shields.io/npm/v/@amg555/frameforge-mcp.svg?label=npm)](https://www.npmjs.com/package/@amg555/frameforge-mcp)
10
10
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
11
11
  [![Node.js](https://img.shields.io/badge/node-%3E%3D18.0.0-brightgreen.svg)](https://nodejs.org/)
12
- [![Version](https://img.shields.io/badge/version-2.3.4-blue.svg)](CHANGELOG.md)
12
+ [![Version](https://img.shields.io/badge/version-2.3.5-blue.svg)](CHANGELOG.md)
13
13
  [![MCP Spec](https://img.shields.io/badge/MCP-2024--11--05-blue.svg)](https://modelcontextprotocol.io/)
14
- [![Tests](https://img.shields.io/badge/tests-512%20passing-brightgreen.svg)](#testing)
14
+ [![Tests](https://img.shields.io/badge/tests-539%20passing-brightgreen.svg)](#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.4",
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,
@@ -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.4', code.includes("version: '2.3.4'"));
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.4', cfWorker.includes("'2.3.4'"));
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 [k, v] = arg.slice(2).split('=');
43
- flags[k] = v ?? true;
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 totalFrames = Math.round(duration * fps);
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 [0..${totalFrames - 1}] (${duration}s @ ${fps}fps, workers=${workers})`);
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: 0,
227
- last: totalFrames - 1,
239
+ first,
240
+ last,
228
241
  fps,
229
242
  workers,
230
243
  width: parseInt(flags.width || '1920', 10),
@@ -1372,7 +1372,7 @@ Operations (used with action="edit"):
1372
1372
 
1373
1373
  const SERVER_INFO = {
1374
1374
  name: 'video-pipeline',
1375
- version: '2.3.4',
1375
+ version: '2.3.5',
1376
1376
  };
1377
1377
 
1378
1378
  const CAPABILITIES = {
@@ -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: image or video clip + voice → scene_N_out.mp4
109
+ * Render a single scene: code-driven animated video or clip + voice → scene_N_out.mp4
84
110
  *
85
111
  * @param {{
86
- * sceneIndex: number,
87
- * imagePath?: string,
88
- * videoPath?: string, // optional user clip / B-roll
89
- * voicePath: string,
90
- * caption?: string,
91
- * camera?: string, // camera_hint from scene schema v1.0.0
92
- * focalPoint?: { x: number, y: number },
93
- * manifest?: ArtifactManifest // optional — enables idempotency
94
- * renderWidth?: number, // default 1920
95
- * renderHeight?: number, // default 1080
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({ sceneIndex, imagePath, videoPath, voicePath, caption = '', camera, focalPoint, manifest, renderWidth = 1920, renderHeight = 1080 } = {}) {
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 mediaSource = videoPath || imagePath;
124
- const captionFilter = this._captionFilter(caption);
166
+ const isVideoInput = Boolean(videoPath) || (videoPath && /\.(mp4|mov|webm|mkv|m4v)$/i.test(videoPath));
125
167
 
126
- // No image and no video: procedural motion-graphics scene.
127
- // Cycles through sleek modern palettes with vignette shading.
128
- if (!mediaSource) {
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(',');
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
- '-f', 'lavfi', '-i', `color=c=#${bgColor}:s=${renderWidth}x${renderHeight}:r=30`,
138
- '-i', voicePath,
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
- '-vf', vf,
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
- '-pix_fmt', 'yuv420p',
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
- throw new Error(`Scene ${sceneIndex} (no-image fallback) render failed: ${(result.stderr || '').slice(-300)}`);
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
- const isVideoInput = Boolean(videoPath) || /\.(mp4|mov|webm|mkv|m4v)$/i.test(mediaSource);
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 (isVideoInput) {
168
- // Loop video clip if shorter than duration, scale/crop to target resolution
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 all characters that need ffmpeg escaping or that break the drawtext
281
- // filter syntax. After stripping : and %, re-escaping them is a no-op — removed.
282
- const safe = caption
283
- .replace(/['"\\:;|<>{}[\]()!@#$%^&*+=]/g, '')
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
- const safeFont = this.font.replace(/\\/g, '/').replace(/:/g, '\\:');
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='${safe}':fontsize=68:fontcolor=white:bordercolor=black:borderw=4:x=(w-text_w)/2:y=h-180:line_spacing=10`;
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
+ };
@@ -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);
@@ -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: idx,
46
- imagePath: asset.imagePath,
47
- videoPath: asset.videoPath,
48
- voicePath: asset.voicePath,
49
- caption: asset.caption || '',
50
- camera: asset.camera || 'static',
51
- focalPoint: asset.focalPoint || null,
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
  });