@amg555/frameforge-mcp 2.4.6 → 2.4.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amg555/frameforge-mcp",
3
- "version": "2.4.6",
3
+ "version": "2.4.8",
4
4
  "description": "FrameForge — AI video production engine for agentic IDEs. npx @amg555/frameforge-mcp. FFmpeg + TTS run on your machine. 21 MCP tools.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -31,7 +31,7 @@
31
31
  "start:local-render": "cross-env RENDER_MODE=full WORKER_PORT=3211 node src/worker/server-render-free.js",
32
32
  "db:migrate": "node scripts/db-migrate.js",
33
33
  "validate": "node scripts/deep-validate.js",
34
- "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 src/video-editor/editor.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
+ "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/workflows/ingest.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 src/video-editor/editor.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",
35
35
  "lint": "echo 'No linter configured — add eslint for production'",
36
36
  "check-credentials": "node src/utils/log-redaction.js",
37
37
  "generate-assets": "node scripts/generate-initial-assets.js",
@@ -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.4.6', code.includes("version: '2.4.6'"));
23
+ check('SERVER_INFO version 2.4.8', code.includes("version: '2.4.8'"));
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('edit_video tool defined', code.includes('edit_video'));
@@ -299,7 +299,7 @@ check('cf-worker has checkRateLimit', cfWorker.includes('checkRateLimit'));
299
299
  check('cf-worker hashes token (SHA-256)', cfWorker.includes('SHA-256'));
300
300
  check('cf-worker uses RATE_LIMITS KV', cfWorker.includes('env.RATE_LIMITS'));
301
301
  check('cf-worker returns 429 on limit', cfWorker.includes('429'));
302
- check('cf-worker version 2.4.6', cfWorker.includes("'2.4.6'"));
302
+ check('cf-worker version 2.4.8', cfWorker.includes("'2.4.8'"));
303
303
  check('wrangler.toml has kv_namespaces',fs.readFileSync('./cloudflare-mcp/wrangler.toml','utf8').includes('kv_namespaces'));
304
304
 
305
305
  // ── plugins/skills present ────────────────────────────────────────────────
package/src/cli/make.js CHANGED
@@ -354,21 +354,36 @@ async function runSingleVideo(args, topic, outDir, workflows) {
354
354
  }
355
355
  }
356
356
 
357
- // ── Script file bypass (--script-file): skip research, load approved JSON ──
357
+ // ── Script file bypass (--script-file): skip research, load approved JSON or Markdown ──
358
358
  if (args.scriptFile && !scenes) {
359
359
  logSection('Loading Approved Script');
360
360
  const absScript = path.resolve(process.cwd(), args.scriptFile);
361
361
  if (!fs.existsSync(absScript)) throw new Error(`Script file not found: ${absScript}`);
362
- const raw = JSON.parse(fs.readFileSync(absScript, 'utf8'));
363
- const scriptData = raw.script || raw;
364
- scenes = scriptData.scenes;
365
- productionId = scriptData.productionId || raw.productionId || `script_${Date.now()}`;
366
- title = scriptData.title || topic || 'Video';
367
- description = scriptData.description || '';
368
- tags = scriptData.tags || [];
369
- thumbnailPrompt = scriptData.thumbnailPrompt || null;
370
- totalWords = scriptData.totalWords || scenes?.reduce((n, s) => n + (s.narration || '').split(/\s+/).filter(Boolean).length, 0) || 0;
371
- log('Script', `✅ Loaded ${scenes?.length} scenes from: ${absScript}`);
362
+ const content = fs.readFileSync(absScript, 'utf8');
363
+
364
+ if (/\.(md|markdown)$/i.test(absScript) || (!content.trim().startsWith('{') && !content.trim().startsWith('['))) {
365
+ const { parseMarkdownScript } = require('../workflows/ingest');
366
+ const parsedMd = parseMarkdownScript(content);
367
+ scenes = parsedMd.scenes;
368
+ productionId = `script_${Date.now()}`;
369
+ title = parsedMd.title || topic || 'Video';
370
+ description = '';
371
+ tags = [];
372
+ thumbnailPrompt = null;
373
+ totalWords = scenes?.reduce((n, s) => n + (s.narration || '').split(/\s+/).filter(Boolean).length, 0) || 0;
374
+ log('Script', `✅ Parsed ${scenes?.length} scenes from Markdown: ${absScript}`);
375
+ } else {
376
+ const raw = JSON.parse(content);
377
+ const scriptData = raw.script || raw;
378
+ scenes = scriptData.scenes;
379
+ productionId = scriptData.productionId || raw.productionId || `script_${Date.now()}`;
380
+ title = scriptData.title || topic || 'Video';
381
+ description = scriptData.description || '';
382
+ tags = scriptData.tags || [];
383
+ thumbnailPrompt = scriptData.thumbnailPrompt || null;
384
+ totalWords = scriptData.totalWords || scenes?.reduce((n, s) => n + (s.narration || '').split(/\s+/).filter(Boolean).length, 0) || 0;
385
+ log('Script', `✅ Loaded ${scenes?.length} scenes from: ${absScript}`);
386
+ }
372
387
 
373
388
  if (args.dryRun) {
374
389
  logSection('Dry Run Complete');
@@ -23,6 +23,20 @@ export const VIRTUAL_CLOCK_INJECTION = `
23
23
 
24
24
  // 1. Deterministic clocks
25
25
  performance.now = () => _now;
26
+ const OriginalDate = window.Date;
27
+ class VirtualDate extends OriginalDate {
28
+ constructor(...args) {
29
+ if (args.length === 0) {
30
+ super(_startDate + _now);
31
+ } else {
32
+ super(...args);
33
+ }
34
+ }
35
+ static now() {
36
+ return Math.floor(_startDate + _now);
37
+ }
38
+ }
39
+ window.Date = VirtualDate;
26
40
  Date.now = () => _startDate + _now;
27
41
 
28
42
  // 2. Deterministic requestAnimationFrame
@@ -60,6 +74,9 @@ export const VIRTUAL_CLOCK_INJECTION = `
60
74
 
61
75
  // 4. Deterministic Seeded PRNG fallback for unseeded Math.random()
62
76
  let _seed = 0x2F6E2B1;
77
+ const resetSeed = (tSec) => {
78
+ _seed = (0x2F6E2B1 ^ Math.imul(Math.floor(tSec * 1000), 0x9E3779B9)) | 0;
79
+ };
63
80
  window.Math.random = () => {
64
81
  _seed = (_seed + 0x6D2B79F5) | 0;
65
82
  let t = Math.imul(_seed ^ (_seed >>> 15), 1 | _seed);
@@ -84,16 +101,30 @@ export const VIRTUAL_CLOCK_INJECTION = `
84
101
  currentRafs.forEach(cb => { try { cb(_now); } catch (_) {} });
85
102
 
86
103
  try {
87
- document.getAnimations().forEach(a => {
88
- if (a.playState !== 'paused') a.pause();
89
- a.currentTime = _now;
90
- });
104
+ if (document.getAnimations) {
105
+ document.getAnimations().forEach(a => {
106
+ if (a.playState !== 'paused') a.pause();
107
+ a.currentTime = _now;
108
+ });
109
+ }
91
110
  } catch (_) {}
92
111
  };
93
112
 
94
113
  // 6. Absolute virtual seek
95
114
  window.__seek = async (t) => {
96
115
  _now = t * 1000;
116
+ resetSeed(t);
117
+
118
+ // Pause & seek CSS animations
119
+ try {
120
+ if (document.getAnimations) {
121
+ document.getAnimations().forEach(a => {
122
+ if (a.playState !== 'paused') a.pause();
123
+ a.currentTime = _now;
124
+ });
125
+ }
126
+ } catch (_) {}
127
+
97
128
  if (window.renderAt) {
98
129
  await window.renderAt(t);
99
130
  } else if (window.seek) {
@@ -355,9 +355,10 @@ const TOOLS = {
355
355
  theme: { type: 'string', description: 'Theme preset alias' },
356
356
  title: { type: 'string', description: 'Video title' },
357
357
  language: { type: 'string', description: 'BCP-47 language hint (e.g. ml, hi, en, ta)' },
358
+ showCaptions: { type: 'boolean', default: false, description: 'Display on-screen synchronized subtitles/captions. Default false.' },
358
359
  },
359
360
  },
360
- handler: async ({ productionId, scenes, orientation = 'landscape', skipImages = false, skipTts = false, withMusic = true, musicVolume = 0.07, topic = '', style = '', theme = '', title = '', language = '' }) => {
361
+ handler: async ({ productionId, scenes, orientation = 'landscape', skipImages = false, skipTts = false, withMusic = true, musicVolume = 0.07, topic = '', style = '', theme = '', title = '', language = '', showCaptions = false }) => {
361
362
  const syncFs = require('fs');
362
363
  const { runAssets } = require('../workflows/assets');
363
364
  const { runRender } = require('../workflows/render');
@@ -641,6 +642,9 @@ Example scene:
641
642
  style: s.style || style || '',
642
643
  theme: s.theme || theme || '',
643
644
  language: s.language || language || '',
645
+ duration: s.duration != null ? Number(s.duration) : null,
646
+ start_time: s.start_time != null ? Number(s.start_time) : (s.startTime != null ? Number(s.startTime) : null),
647
+ end_time: s.end_time != null ? Number(s.end_time) : (s.endTime != null ? Number(s.endTime) : null),
644
648
  character: s.character || { character_id: 'none', pose: 'none', emotion: 'neutral', position: { x: 0.5, y: 0.8, scale: 1.0 } },
645
649
  focal_point: s.focal_point || { x: 0.5, y: 0.35, weight: 0.8 },
646
650
  overlay: s.overlay || { type: 'none' },
@@ -1637,7 +1641,7 @@ Allows the agent in Claude Code, Cursor, or any IDE to inspect the visual output
1637
1641
 
1638
1642
  const SERVER_INFO = {
1639
1643
  name: 'video-pipeline',
1640
- version: '2.4.6',
1644
+ version: '2.4.8',
1641
1645
  };
1642
1646
 
1643
1647
  const CAPABILITIES = {
@@ -148,6 +148,7 @@ class VideoRenderer {
148
148
  voicePath,
149
149
  caption = '',
150
150
  narration = '',
151
+ prompt = '',
151
152
  visualDescription = '',
152
153
  visualType = 'code_animation',
153
154
  topic = '',
@@ -167,16 +168,15 @@ class VideoRenderer {
167
168
  startTime = 0,
168
169
  trimStart,
169
170
  endTime,
171
+ duration = null,
170
172
  trimEnd,
171
173
  speed = 1.0,
172
174
  clipVolume = 0,
173
175
  colorGrade = null,
174
176
  showCaptions = false,
175
177
  } = {}) {
176
- const { detectCaptionsRequested } = require('./scene-animator');
177
178
  const effectiveShowCaptions = Boolean(
178
179
  showCaptions ||
179
- detectCaptionsRequested(`${caption} ${narration} ${topic} ${style} ${visualDescription}`) ||
180
180
  process.env.FRAMEFORGE_SHOW_CAPTIONS === '1'
181
181
  );
182
182
  const outputPath = path.join(this.vdir, `scene_${sceneIndex}_out.mp4`);
@@ -189,12 +189,13 @@ class VideoRenderer {
189
189
 
190
190
  fs.mkdirSync(this.vdir, { recursive: true });
191
191
 
192
- // Duration from voice file.
192
+ // Duration from voice file or explicit scene duration.
193
193
  if (!voicePath || !fs.existsSync(voicePath)) {
194
194
  throw new Error(`Scene ${sceneIndex} render failed: voicePath is missing or does not exist (${voicePath})`);
195
195
  }
196
- const dur = this._getAudioDuration(voicePath);
197
- const clampedDur = Math.max(2, Number.isFinite(dur) ? dur + 0.15 : 3.15);
196
+ const voiceDur = this._getAudioDuration(voicePath);
197
+ const dur = (typeof duration === 'number' && duration > 0) ? Math.max(duration, voiceDur) : voiceDur;
198
+ const clampedDur = Math.max(1, Number.isFinite(dur) ? dur + 0.15 : 3.15);
198
199
 
199
200
  const isVideoInput = Boolean(videoPath) || (videoPath && /\.(mp4|mov|webm|mkv|m4v)$/i.test(videoPath));
200
201
 
@@ -277,6 +278,7 @@ class VideoRenderer {
277
278
  voicePath,
278
279
  caption,
279
280
  narration,
281
+ prompt,
280
282
  visualDescription,
281
283
  visualType,
282
284
  topic,
@@ -362,6 +364,7 @@ class VideoRenderer {
362
364
  voicePath,
363
365
  caption,
364
366
  narration,
367
+ prompt = '',
365
368
  visualDescription,
366
369
  visualType = 'code_animation',
367
370
  topic = '',
@@ -403,6 +406,7 @@ class VideoRenderer {
403
406
  htmlContent = generateSceneHtml({
404
407
  caption,
405
408
  narration,
409
+ prompt,
406
410
  visualDescription,
407
411
  imagePath,
408
412
  topic,
@@ -445,6 +449,19 @@ class VideoRenderer {
445
449
  throw new Error(`Harness frame rendering failed: ${(res.stderr || res.stdout || '').slice(-400)}`);
446
450
  }
447
451
 
452
+ // Closed-loop visual frame inspection (catches blank/blown frames before encoding)
453
+ try {
454
+ const { analyzeImageFile } = require('../harness/lib/pixels.mjs');
455
+ const midFrameIdx = Math.floor(lastFrame / 2);
456
+ const midFrameFile = path.join(framesDir, `f${String(midFrameIdx).padStart(6, '0')}.jpg`);
457
+ if (fs.existsSync(midFrameFile)) {
458
+ const analysis = analyzeImageFile(midFrameFile, renderWidth, renderHeight);
459
+ if (analysis.isBlank) {
460
+ console.warn(`[Renderer] ⚠️ Scene ${sceneIndex} visual warning: blank frame at t=${(midFrameIdx / fps).toFixed(2)}s`);
461
+ }
462
+ }
463
+ } catch (_) {}
464
+
448
465
  const framesPattern = path.join(framesDir, 'f%06d.jpg');
449
466
  const encResult = spawnSync('ffmpeg', [
450
467
  '-nostdin', '-y', '-threads', '2',
@@ -343,7 +343,25 @@ function detectComponentTheme({ visualDescription = '', visual_description = '',
343
343
  return 'doodle_dressing';
344
344
  }
345
345
 
346
- // 10. General Human Character
346
+ // 10. Map / Migration / Ancient World / Out of Africa (Scene 10)
347
+ if (/(?:^|[^\w])(migration|migrating|migrate|map with africa|africa highlighted|colder regions|climate illustrations|out of africa|geographic migration|migration arrows|continent map|world map)(?:[^\w]|$)/i.test(text)
348
+ || /കുടിയേറുന്നതിന്|കുടിയേറ്റം|ആഫ്രിക്ക|ഭൂപടത്തിൽ ആഫ്രിക്ക|മൈഗ്രേഷൻ|തണുപ്പുള്ള പ്രദേശങ്ങളിലേക്ക് കുടിയേറാൻ|ഭൂപടം|ഭൂഖണ്ഡം/.test(text)) {
349
+ return 'doodle_map_migration';
350
+ }
351
+
352
+ // 11. Royal Robes / Social Status / Ornaments / Crown / Wealth & Authority (Scene 16)
353
+ if (/(?:^|[^\w])(royal robe|royal robes|crown|sceptre|ornaments|status and power|hierarchy|wealth authority|noble clothing|decorated garments)(?:[^\w]|$)/i.test(text)
354
+ || /സമ്പത്തിന്റെയും അധികാരത്തിന്റെയും|രാജകീയ|കിരീടം|സാമൂഹിക സ്ഥാനം|അധികാരത്തിന്റെ അടയാളം/.test(text)) {
355
+ return 'doodle_royal_status';
356
+ }
357
+
358
+ // 12. Outro / Subscribe / Notification Bell / Channel Engagement (Scene 25)
359
+ if (/(?:^|[^\w])(subscribe|notification bell|youtube outro|channel subscribe|outro|subscribe button|like and subscribe)(?:[^\w]|$)/i.test(text)
360
+ || /സബ്സ്ക്രൈബ്|ചാനൽ സബ്സ്ക്രൈബ്|സബ്സ്ക്രൈബ് ചെയ്യൂ|ബെൽ ഐക്കൺ|നന്ദി/.test(text)) {
361
+ return 'doodle_outro_subscribe';
362
+ }
363
+
364
+ // 13. General Human Character
347
365
  if (/(?:^|[^\w])(human|person|man|woman|people|child|guy|doodle|character|actor)(?:[^\w]|$)/i.test(text)
348
366
  || /മനുഷ്യൻ|ആളുകൾ|വ്യക്തി/.test(text)) {
349
367
  return 'doodle_general_human';
@@ -385,9 +403,9 @@ function detectComponentTheme({ visualDescription = '', visual_description = '',
385
403
  return 'metric';
386
404
  }
387
405
 
388
- // Timeline / Milestones / Chronology
389
- if (/(?:^|[^\w])(timeline|milestone|chronology|history|evolution|roadmap|phase|journey|centuries|years|era|ancient|century|dynasty|temple|monument)(?:[^\w]|$)/.test(text)
390
- || /ചരിത്ര|പുരാതന|സാമ്രാജ്യ|രാജവംശ|പാരമ്പര്യ|ഇതിഹാസ|इतिहास|प्राचीन|धरोहर/.test(text)) {
406
+ // Timeline / Milestones / Chronology - strictly explicit roadmap/milestone chart requests only
407
+ if (/(?:^|[^\w])(timeline|roadmap|milestone chart|milestone roadmap)(?:[^\w]|$)/i.test(text)
408
+ || /ടൈംലൈൻ|റോഡ്മാപ്പ്/.test(text)) {
391
409
  return 'timeline';
392
410
  }
393
411
 
@@ -417,7 +435,7 @@ function detectComponentTheme({ visualDescription = '', visual_description = '',
417
435
 
418
436
  // Fallbacks matched to aesthetic
419
437
  if (aesthetic === 'cosmic_space') return 'celestial_orbit';
420
- if (aesthetic === 'cinematic_documentary') return 'timeline';
438
+ if (aesthetic === 'cinematic_documentary') return 'doodle_general_human';
421
439
  if (aesthetic === 'finance_executive') return 'chart';
422
440
  if (aesthetic === 'nature_organic') return 'dna_helix';
423
441
  if (aesthetic === 'breaking_news') return 'radar_globe';
@@ -537,6 +555,7 @@ function detectCaptionsRequested(text = '') {
537
555
  * Paints full-canvas cinematic procedural animations — zero PPT card slideshow patterns.
538
556
  */
539
557
  function generateSceneHtml({
558
+ prompt = '',
540
559
  caption = '',
541
560
  narration = '',
542
561
  visualDescription = '',
@@ -557,7 +576,7 @@ function generateSceneHtml({
557
576
  } = {}) {
558
577
  const captionsWanted = Boolean(
559
578
  showCaptions ||
560
- detectCaptionsRequested(`${caption} ${narration} ${topic} ${visualDescription} ${style} ${title}`) ||
579
+ detectCaptionsRequested(prompt || topic) ||
561
580
  process.env.FRAMEFORGE_SHOW_CAPTIONS === '1'
562
581
  );
563
582
  const aestheticKey = detectAesthetic({
@@ -574,6 +593,7 @@ function generateSceneHtml({
574
593
  const aesthetic = AESTHETICS[aestheticKey] || AESTHETICS.cyber_tech;
575
594
  const compTheme = detectComponentTheme({
576
595
  visualDescription,
596
+ prompt,
577
597
  caption,
578
598
  narration,
579
599
  topic,
@@ -933,7 +953,7 @@ function drawMainScene(t) {
933
953
  torsoHeight = 90,
934
954
  legLength = 95,
935
955
  hairStyle = 'wild',
936
- expression = 'talking',
956
+ expression = 'natural',
937
957
  clothing = 'none',
938
958
  armPose = 'natural',
939
959
  showShadow = false,
@@ -1771,7 +1791,7 @@ function drawMainScene(t) {
1771
1791
  y: cy + 140,
1772
1792
  scale: 1.35,
1773
1793
  hairStyle: 'modern',
1774
- expression: beat3 > 0.5 ? 'smiling' : 'talking',
1794
+ expression: beat3 > 0.5 ? 'smiling' : 'natural',
1775
1795
  clothing: 'shirt',
1776
1796
  armPose: beat2 > 0.5 ? 'dress_shirt' : 'natural',
1777
1797
  showShadow: true,
@@ -2089,7 +2109,7 @@ function drawMainScene(t) {
2089
2109
  y: cy + 140,
2090
2110
  scale: 1.35,
2091
2111
  hairStyle: 'wild',
2092
- expression: swatting ? 'shivering' : 'talking',
2112
+ expression: swatting ? 'shivering' : 'focused',
2093
2113
  clothing: 'none',
2094
2114
  armPose: 'hold_spear',
2095
2115
  showShadow: true,
@@ -2135,7 +2155,7 @@ function drawMainScene(t) {
2135
2155
  y: cy + 140,
2136
2156
  scale: 1.35,
2137
2157
  hairStyle: 'wild',
2138
- expression: 'talking',
2158
+ expression: 'focused',
2139
2159
  clothing: 'pelt',
2140
2160
  armPose: 'sewing',
2141
2161
  showShadow: true,
@@ -2299,7 +2319,7 @@ function drawMainScene(t) {
2299
2319
  y: cy + 140,
2300
2320
  scale: 1.3,
2301
2321
  hairStyle: 'wild',
2302
- expression: 'talking',
2322
+ expression: 'focused',
2303
2323
  clothing: 'pelt',
2304
2324
  armPose: greet > 0.5 ? 'peace_gesture' : 'natural',
2305
2325
  showShadow: true,
@@ -2360,7 +2380,7 @@ function drawMainScene(t) {
2360
2380
  y: cy + 130,
2361
2381
  scale: 1.35,
2362
2382
  hairStyle: 'wild',
2363
- expression: 'talking',
2383
+ expression: 'focused',
2364
2384
  clothing: 'pelt',
2365
2385
  armPose: 'thinker',
2366
2386
  seed: 108,
@@ -2465,19 +2485,382 @@ function drawMainScene(t) {
2465
2485
  }
2466
2486
 
2467
2487
  } else if (COMP_THEME === 'doodle_general_human') {
2468
- // General Expressive Illustrated Human Narrator
2469
- sLine(0, cy + 140, W, cy + 140, 10, AMBIENT_LINE, 2);
2488
+ // Dynamic Prehistoric / Ancient Landscape with Travelling Human Explorer (Walking across screen)
2489
+ const walkP = Math.min(1, Math.max(0, t / window.DURATION));
2490
+ const charX = cx - 240 + walkP * 380;
2491
+
2492
+ // Atmospheric warm background with rolling terrain
2493
+ const skyG = ctx.createLinearGradient(0, 0, 0, cy + 140);
2494
+ skyG.addColorStop(0, '#fef3c7');
2495
+ skyG.addColorStop(0.6, '#fde68a');
2496
+ skyG.addColorStop(1, '#fef9c3');
2497
+ ctx.fillStyle = skyG;
2498
+ ctx.fillRect(0, 0, W, cy + 140);
2499
+
2500
+ // Distant ancient hills
2501
+ ctx.fillStyle = 'rgba(217, 119, 6, 0.25)';
2502
+ ctx.beginPath();
2503
+ ctx.moveTo(0, cy + 140);
2504
+ ctx.quadraticCurveTo(W * 0.25, cy + 20, W * 0.5, cy + 80);
2505
+ ctx.quadraticCurveTo(W * 0.75, cy + 30, W, cy + 140);
2506
+ ctx.closePath();
2507
+ ctx.fill();
2508
+
2509
+ // Foreground terrain
2510
+ ctx.fillStyle = '#451a03';
2511
+ ctx.fillRect(0, cy + 140, W, H - (cy + 140));
2512
+ sLine(0, cy + 140, W, cy + 140, 10, '#d97706', 3);
2513
+
2514
+ // Natural grass foliage
2515
+ for (let g = 0; g < 32; g++) {
2516
+ const gx = g * 60;
2517
+ const sway = Math.sin(t * 3.0 + g * 0.5) * 12;
2518
+ sLine(gx, cy + 140, gx + sway, cy + 85, 400 + g, '#b45309', 2);
2519
+ }
2520
+
2470
2521
  drawDoodleCharacter({
2471
- x: cx,
2522
+ x: charX,
2472
2523
  y: cy + 140,
2473
- scale: 1.4,
2524
+ scale: 1.35,
2474
2525
  hairStyle: 'wild',
2475
- expression: 'talking',
2526
+ expression: 'natural',
2476
2527
  clothing: 'pelt',
2477
2528
  armPose: 'natural',
2478
2529
  showShadow: true,
2479
2530
  seed: 110,
2480
2531
  });
2532
+ } else if (COMP_THEME === 'doodle_map_migration') {
2533
+ // Scene 10: Hand-Drawn Migration Map / Out of Africa / Ancient World Routes
2534
+ const mapSpring = spring(t, 0.05, 170, 14);
2535
+ const arrowSpring = spring(t, window.DURATION * 0.35, 180, 15);
2536
+ const walkProg = Math.min(1, Math.max(0, (t - window.DURATION * 0.2) / (window.DURATION * 0.75)));
2537
+
2538
+ // Antique parchment background wash
2539
+ ctx.save();
2540
+ ctx.fillStyle = IS_LIGHT ? '#faf5ec' : '#1c1917';
2541
+ ctx.fillRect(0, 0, W, H);
2542
+
2543
+ // Decorative map grid lines (latitude/longitude in subtle ink)
2544
+ ctx.strokeStyle = IS_LIGHT ? 'rgba(180, 83, 9, 0.12)' : 'rgba(255, 255, 255, 0.1)';
2545
+ ctx.lineWidth = 1.5;
2546
+ ctx.setLineDash([6, 6]);
2547
+ for (let gx = 100; gx < W; gx += 200) {
2548
+ ctx.beginPath(); ctx.moveTo(gx, 0); ctx.lineTo(gx, H); ctx.stroke();
2549
+ }
2550
+ for (let gy = 80; gy < H; gy += 160) {
2551
+ ctx.beginPath(); ctx.moveTo(0, gy); ctx.lineTo(W, gy); ctx.stroke();
2552
+ }
2553
+ ctx.setLineDash([]);
2554
+
2555
+ // Hand-drawn stylized continent outlines (Africa, Europe, Asia)
2556
+ const mapCenter = cx - 40;
2557
+ const mapY = cy - 20;
2558
+
2559
+ // 1. Africa Outline (Highlighted in warm terracotta wash)
2560
+ ctx.save();
2561
+ ctx.fillStyle = 'rgba(217, 119, 6, 0.20)';
2562
+ ctx.beginPath();
2563
+ ctx.moveTo(mapCenter - 140, mapY - 60); // West bulge
2564
+ ctx.bezierCurveTo(mapCenter - 160, mapY + 20, mapCenter - 80, mapY + 140, mapCenter - 40, mapY + 220); // South tip (Good Hope)
2565
+ ctx.bezierCurveTo(mapCenter + 10, mapY + 160, mapCenter + 40, mapY + 60, mapCenter + 60, mapY - 20); // Horn of Africa
2566
+ ctx.bezierCurveTo(mapCenter + 30, mapY - 60, mapCenter - 20, mapY - 80, mapCenter - 140, mapY - 60); // North Coast
2567
+ ctx.closePath();
2568
+ ctx.fill();
2569
+
2570
+ // Outline stroke with ink wobble
2571
+ sLine(mapCenter - 140, mapY - 60, mapCenter - 160, mapY + 20, 710, '#c2410c', 3);
2572
+ sLine(mapCenter - 160, mapY + 20, mapCenter - 40, mapY + 220, 711, '#c2410c', 3);
2573
+ sLine(mapCenter - 40, mapY + 220, mapCenter + 60, mapY - 20, 712, '#c2410c', 3);
2574
+ sLine(mapCenter + 60, mapY - 20, mapCenter - 140, mapY - 60, 713, '#c2410c', 3);
2575
+
2576
+ // Africa label badge
2577
+ ctx.fillStyle = '#b45309';
2578
+ ctx.font = '700 22px ' + FONT_DISPLAY;
2579
+ ctx.textAlign = 'center';
2580
+ ctx.fillText('AFRICA', mapCenter - 40, mapY + 60);
2581
+
2582
+ // Sun icon over Africa
2583
+ sCircle(mapCenter - 90, mapY + 40, 18, 714, '#d97706', 2.5, '#fef08a');
2584
+ for (let r = 0; r < 6; r++) {
2585
+ const ra = (r / 6) * Math.PI * 2 + t * 0.5;
2586
+ const rx1 = mapCenter - 90 + Math.cos(ra) * 22;
2587
+ const ry1 = mapY + 40 + Math.sin(ra) * 22;
2588
+ const rx2 = mapCenter - 90 + Math.cos(ra) * 32;
2589
+ const ry2 = mapY + 40 + Math.sin(ra) * 32;
2590
+ sLine(rx1, ry1, rx2, ry2, 715 + r, '#d97706', 2);
2591
+ }
2592
+
2593
+ // 2. Eurasia / Northern Territory Outline (Colder destination)
2594
+ ctx.fillStyle = 'rgba(56, 189, 248, 0.12)';
2595
+ ctx.beginPath();
2596
+ ctx.moveTo(mapCenter - 130, mapY - 90);
2597
+ ctx.bezierCurveTo(mapCenter - 120, mapY - 200, mapCenter + 60, mapY - 220, mapCenter + 220, mapY - 170); // Northern Eurasia
2598
+ ctx.bezierCurveTo(mapCenter + 320, mapY - 130, mapCenter + 360, mapY - 40, mapCenter + 260, mapY + 10); // East Asia
2599
+ ctx.bezierCurveTo(mapCenter + 140, mapY - 30, mapCenter + 40, mapY - 70, mapCenter - 130, mapY - 90);
2600
+ ctx.closePath();
2601
+ ctx.fill();
2602
+
2603
+ sLine(mapCenter - 130, mapY - 90, mapCenter + 60, mapY - 210, 725, '#0284c7', 3);
2604
+ sLine(mapCenter + 60, mapY - 210, mapCenter + 280, mapY - 160, 726, '#0284c7', 3);
2605
+ sLine(mapCenter + 280, mapY - 160, mapCenter + 240, mapY + 10, 727, '#0284c7', 3);
2606
+
2607
+ // Snowflake climate icons over Eurasia
2608
+ for (let sf = 0; sf < 3; sf++) {
2609
+ const sfx = mapCenter + 60 + sf * 110;
2610
+ const sfy = mapY - 160 + (sf % 2) * 35;
2611
+ sLine(sfx - 14, sfy, sfx + 14, sfy, 730 + sf, '#0284c7', 2);
2612
+ sLine(sfx, sfy - 14, sfx, sfy + 14, 733 + sf, '#0284c7', 2);
2613
+ sLine(sfx - 10, sfy - 10, sfx + 10, sfy + 10, 736 + sf, '#0284c7', 2);
2614
+ sLine(sfx - 10, sfy + 10, sfx + 10, sfy - 10, 739 + sf, '#0284c7', 2);
2615
+ }
2616
+ ctx.restore();
2617
+
2618
+ // 3. Dynamic Migration Route Curves with Pulsing Arrowheads
2619
+ if (arrowSpring > 0.05) {
2620
+ ctx.save();
2621
+ ctx.strokeStyle = '#c2410c';
2622
+ ctx.lineWidth = 4;
2623
+ ctx.setLineDash([10, 8]);
2624
+
2625
+ // Route 1: Out of Africa -> Europe
2626
+ ctx.beginPath();
2627
+ ctx.moveTo(mapCenter + 20, mapY - 40);
2628
+ ctx.quadraticCurveTo(mapCenter - 20, mapY - 120, mapCenter - 60, mapY - 160);
2629
+ ctx.stroke();
2630
+
2631
+ // Route 2: Out of Africa -> Asia
2632
+ ctx.beginPath();
2633
+ ctx.moveTo(mapCenter + 40, mapY - 35);
2634
+ ctx.quadraticCurveTo(mapCenter + 120, mapY - 80, mapCenter + 220, mapY - 120);
2635
+ ctx.stroke();
2636
+ ctx.setLineDash([]);
2637
+
2638
+ // Pulsing migration markers
2639
+ const pulseR = 6 + Math.sin(t * 6) * 3;
2640
+ sCircle(mapCenter + 30, mapY - 40, pulseR, 750, '#ea580c', 2, '#fbbf24');
2641
+ sCircle(mapCenter - 60, mapY - 160, pulseR, 751, '#0284c7', 2, '#38bdf8');
2642
+ sCircle(mapCenter + 220, mapY - 120, pulseR, 752, '#0284c7', 2, '#38bdf8');
2643
+ ctx.restore();
2644
+ }
2645
+
2646
+ // 4. Walking Doodle Explorer on the Migration Trail
2647
+ const trailStartX = mapCenter + 10;
2648
+ const trailStartY = mapY + 20;
2649
+ const trailEndX = mapCenter + 190;
2650
+ const trailEndY = mapY - 100;
2651
+ const curNomadX = trailStartX + (trailEndX - trailStartX) * walkProg;
2652
+ const curNomadY = trailStartY + (trailEndY - trailStartY) * walkProg;
2653
+
2654
+ drawDoodleCharacter({
2655
+ x: curNomadX,
2656
+ y: curNomadY + 120,
2657
+ scale: 1.15,
2658
+ hairStyle: 'wild',
2659
+ expression: 'focused',
2660
+ clothing: walkProg > 0.5 ? 'pelt' : 'none',
2661
+ armPose: 'hold_spear',
2662
+ showShadow: true,
2663
+ seed: 760,
2664
+ });
2665
+
2666
+ } else if (COMP_THEME === 'doodle_royal_status') {
2667
+ // Scene 16: Evolution of Clothing into Royal Mantles, Crowns & Social Status
2668
+ const crownSpring = spring(t, window.DURATION * 0.32, 190, 15);
2669
+ const arrowRise = spring(t, window.DURATION * 0.55, 170, 14);
2670
+
2671
+ // Warm dais platform
2672
+ ctx.fillStyle = IS_LIGHT ? '#faf7f2' : '#1c1917';
2673
+ ctx.fillRect(0, 0, W, H);
2674
+
2675
+ ctx.fillStyle = '#451a03';
2676
+ ctx.fillRect(0, cy + 140, W, H - (cy + 140));
2677
+ sLine(0, cy + 140, W, cy + 140, 770, '#d97706', 3.5);
2678
+
2679
+ // Prehistoric Human on Left (Simple pelt, admiring transformation)
2680
+ drawDoodleCharacter({
2681
+ x: cx - 260,
2682
+ y: cy + 140,
2683
+ scale: 1.3,
2684
+ hairStyle: 'wild',
2685
+ expression: 'curious',
2686
+ clothing: 'pelt',
2687
+ armPose: 'thinker',
2688
+ showShadow: true,
2689
+ seed: 771,
2690
+ });
2691
+
2692
+ // Royal Monarch on Right (Majestic regal cape / robe)
2693
+ const kingX = cx + 220;
2694
+ drawDoodleCharacter({
2695
+ x: kingX,
2696
+ y: cy + 140,
2697
+ scale: 1.35,
2698
+ hairStyle: 'modern',
2699
+ expression: 'smiling',
2700
+ clothing: 'cloak',
2701
+ armPose: 'peace_gesture',
2702
+ showShadow: true,
2703
+ seed: 772,
2704
+ });
2705
+
2706
+ // Ornate Golden Crown with Ruby jewels appearing above Monarch
2707
+ if (crownSpring > 0.05) {
2708
+ ctx.save();
2709
+ ctx.translate(kingX, cy - 20);
2710
+ ctx.scale(crownSpring, crownSpring);
2711
+
2712
+ // Golden crown base & peaks
2713
+ ctx.fillStyle = '#f59e0b';
2714
+ ctx.beginPath();
2715
+ ctx.moveTo(-36, 0);
2716
+ ctx.lineTo(-40, -32);
2717
+ ctx.lineTo(-20, -18);
2718
+ ctx.lineTo(0, -42);
2719
+ ctx.lineTo(20, -18);
2720
+ ctx.lineTo(40, -32);
2721
+ ctx.lineTo(36, 0);
2722
+ ctx.closePath();
2723
+ ctx.fill();
2724
+ ctx.strokeStyle = '#b45309';
2725
+ ctx.lineWidth = 2.5;
2726
+ ctx.stroke();
2727
+
2728
+ // Jewels on crown peaks
2729
+ sCircle(-40, -32, 4, 780, '#b91c1c', 1.5, '#ef4444');
2730
+ sCircle(0, -42, 5, 781, '#b91c1c', 1.5, '#ef4444');
2731
+ sCircle(40, -32, 4, 782, '#b91c1c', 1.5, '#ef4444');
2732
+
2733
+ // Golden Sceptre in hand
2734
+ sLine(42, 20, 52, 130, 785, '#d97706', 4.5);
2735
+ sCircle(42, 16, 10, 786, '#d97706', 2, '#fbbf24');
2736
+ ctx.restore();
2737
+ }
2738
+
2739
+ // Status & Power indicator arrows rising centrally
2740
+ if (arrowRise > 0.05) {
2741
+ ctx.save();
2742
+ ctx.globalAlpha = Math.min(1, arrowRise);
2743
+ for (let arr = 0; arr < 3; arr++) {
2744
+ const arrX = cx - 30 + arr * 40;
2745
+ const arrY = cy + 40 - arr * 35 - Math.sin(t * 4 + arr) * 10;
2746
+ sLine(arrX, arrY + 40, arrX, arrY, 790 + arr, '#d97706', 3.5);
2747
+ sLine(arrX - 12, arrY + 14, arrX, arrY, 793 + arr, '#d97706', 3.5);
2748
+ sLine(arrX + 12, arrY + 14, arrX, arrY, 796 + arr, '#d97706', 3.5);
2749
+ }
2750
+
2751
+ ctx.fillStyle = '#b45309';
2752
+ ctx.font = '700 20px ' + FONT_DISPLAY;
2753
+ ctx.textAlign = 'center';
2754
+ ctx.fillText('STATUS & POWER', cx + 10, cy - 90);
2755
+ ctx.restore();
2756
+ }
2757
+
2758
+ } else if (COMP_THEME === 'doodle_outro_subscribe') {
2759
+ // Scene 25: YouTube Outro / Animated Question Mark / Subscribe & Bell Card
2760
+ const qSpring = spring(t, 0.05, 180, 14);
2761
+ const subSpring = spring(t, window.DURATION * 0.28, 200, 14);
2762
+ const bellSpring = spring(t, window.DURATION * 0.50, 190, 12);
2763
+
2764
+ ctx.fillStyle = IS_LIGHT ? '#faf7f2' : '#1c1917';
2765
+ ctx.fillRect(0, 0, W, H);
2766
+
2767
+ // Warm terrain ground
2768
+ ctx.fillStyle = '#451a03';
2769
+ ctx.fillRect(0, cy + 140, W, H - (cy + 140));
2770
+ sLine(0, cy + 140, W, cy + 140, 810, '#d97706', 3.5);
2771
+
2772
+ // Prehistoric character on left drawing with a reed/quill
2773
+ drawDoodleCharacter({
2774
+ x: cx - 280,
2775
+ y: cy + 140,
2776
+ scale: 1.35,
2777
+ hairStyle: 'wild',
2778
+ expression: 'smiling',
2779
+ clothing: 'pelt',
2780
+ armPose: 'peace_gesture',
2781
+ showShadow: true,
2782
+ seed: 811,
2783
+ });
2784
+
2785
+ // Big bold animated question mark drawn in ink
2786
+ if (qSpring > 0.05) {
2787
+ ctx.save();
2788
+ const qX = cx - 80;
2789
+ const qY = cy - 20;
2790
+ ctx.translate(qX, qY);
2791
+ ctx.scale(qSpring, qSpring);
2792
+
2793
+ ctx.strokeStyle = '#c2410c';
2794
+ ctx.lineWidth = 9;
2795
+ ctx.lineCap = 'round';
2796
+ ctx.beginPath();
2797
+ ctx.arc(0, -60, 44, Math.PI, 0, false);
2798
+ ctx.bezierCurveTo(44, -15, 0, 5, 0, 35);
2799
+ ctx.stroke();
2800
+
2801
+ sCircle(0, 72, 8, 815, '#c2410c', 3, '#c2410c');
2802
+ ctx.restore();
2803
+ }
2804
+
2805
+ // Interactive YouTube Subscribe Pill Button & Swinging Notification Bell
2806
+ if (subSpring > 0.05) {
2807
+ ctx.save();
2808
+ const btnX = cx + 220;
2809
+ const btnY = cy - 20;
2810
+ ctx.translate(btnX, btnY);
2811
+ ctx.scale(subSpring, subSpring);
2812
+
2813
+ // Red Subscribe Pill
2814
+ ctx.fillStyle = '#dc2626';
2815
+ ctx.beginPath();
2816
+ ctx.roundRect(-140, -32, 280, 64, 32);
2817
+ ctx.fill();
2818
+ ctx.strokeStyle = '#991b1b';
2819
+ ctx.lineWidth = 3;
2820
+ ctx.stroke();
2821
+
2822
+ // White Subscribe text
2823
+ ctx.fillStyle = '#ffffff';
2824
+ ctx.font = '700 24px ' + FONT_DISPLAY;
2825
+ ctx.textAlign = 'center';
2826
+ ctx.textBaseline = 'middle';
2827
+ ctx.fillText('SUBSCRIBE', -15, 0);
2828
+
2829
+ // Play icon triangle
2830
+ ctx.beginPath();
2831
+ ctx.moveTo(-90, -12);
2832
+ ctx.lineTo(-72, 0);
2833
+ ctx.lineTo(-90, 12);
2834
+ ctx.closePath();
2835
+ ctx.fill();
2836
+
2837
+ // Notification Bell swinging on spring
2838
+ if (bellSpring > 0.05) {
2839
+ ctx.save();
2840
+ const bellSwing = Math.sin(t * 9) * 0.25 * Math.exp(-(t % 2.5));
2841
+ ctx.translate(180, 0);
2842
+ ctx.rotate(bellSwing);
2843
+
2844
+ // Golden bell body
2845
+ ctx.fillStyle = '#f59e0b';
2846
+ ctx.beginPath();
2847
+ ctx.arc(0, -12, 14, Math.PI, 0);
2848
+ ctx.quadraticCurveTo(18, 12, 22, 16);
2849
+ ctx.lineTo(-22, 16);
2850
+ ctx.quadraticCurveTo(-18, 12, 0, -12);
2851
+ ctx.closePath();
2852
+ ctx.fill();
2853
+ ctx.strokeStyle = '#b45309';
2854
+ ctx.lineWidth = 2.5;
2855
+ ctx.stroke();
2856
+
2857
+ // Bell clapper
2858
+ sCircle(0, 20, 5, 820, '#b45309', 2, '#d97706');
2859
+ ctx.restore();
2860
+ }
2861
+ ctx.restore();
2862
+ }
2863
+
2481
2864
  } else if (COMP_THEME === 'celestial_orbit') {
2482
2865
  // 3D Celestial Body (Earth) with orbiting satellites & signal pulses
2483
2866
  const earthR = Math.min(W, H) * 0.22;
@@ -2498,7 +2881,7 @@ function drawMainScene(t) {
2498
2881
  ctx.beginPath(); ctx.arc(cx, cy, earthR, 0, Math.PI * 2); ctx.fill();
2499
2882
 
2500
2883
  // Rotating longitude meridians
2501
- ctx.strokeStyle = 'rgba(255, 255, 255, 0.22)';
2884
+ ctx.strokeStyle = IS_LIGHT ? 'rgba(28, 25, 23, 0.35)' : 'rgba(255, 255, 255, 0.22)';
2502
2885
  ctx.lineWidth = 1.5;
2503
2886
  for (let m = 0; m < 4; m++) {
2504
2887
  const mAngle = t * 0.5 + (m * Math.PI) / 4;
@@ -2529,7 +2912,7 @@ function drawMainScene(t) {
2529
2912
  ctx.save();
2530
2913
  ctx.translate(cx, cy);
2531
2914
  ctx.rotate(tilt);
2532
- ctx.strokeStyle = 'rgba(255, 255, 255, 0.14)';
2915
+ ctx.strokeStyle = IS_LIGHT ? 'rgba(28, 25, 23, 0.25)' : 'rgba(255, 255, 255, 0.14)';
2533
2916
  ctx.lineWidth = 1.5;
2534
2917
  ctx.beginPath(); ctx.ellipse(0, 0, a, b, 0, 0, Math.PI * 2); ctx.stroke();
2535
2918
 
@@ -2758,7 +3141,7 @@ function drawMainScene(t) {
2758
3141
  } else if (COMP_THEME === 'radar_globe') {
2759
3142
  // 3D Tactical Wireframe Rotating Radar Globe
2760
3143
  const globeR = 150;
2761
- ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
3144
+ ctx.strokeStyle = IS_LIGHT ? 'rgba(28, 25, 23, 0.35)' : 'rgba(255, 255, 255, 0.15)';
2762
3145
  ctx.lineWidth = 1.5;
2763
3146
 
2764
3147
  // Outer degree ring
@@ -3018,16 +3401,7 @@ function drawCinematicOverlay(t) {
3018
3401
 
3019
3402
  ctx.restore();
3020
3403
 
3021
- // Bottom Progress Indicator (minimal line)
3022
- ctx.save();
3023
- const progress = Math.min(1, Math.max(0, t / window.DURATION));
3024
- ctx.globalAlpha = 0.85;
3025
- const progGrad = ctx.createLinearGradient(0, 0, W, 0);
3026
- progGrad.addColorStop(0, ACCENT);
3027
- progGrad.addColorStop(1, ACCENT2);
3028
- ctx.fillStyle = progGrad;
3029
- ctx.fillRect(0, H - 4, W * progress, 4);
3030
- ctx.restore();
3404
+
3031
3405
  }
3032
3406
 
3033
3407
  // ── Master Seek Hook ───────────────────────────────────────────────────────────
@@ -113,7 +113,7 @@ async function runAssets({
113
113
  visualType,
114
114
  voicePath,
115
115
  caption: scene.caption,
116
- camera: scene.camera_hint || scene.camera || 'static',
116
+ camera: scene.camera_hint || scene.camera || 'auto',
117
117
  focalPoint: scene.focal_point || null,
118
118
  narration: scene.narration || '',
119
119
  prompt: scene.prompt || '',
@@ -126,6 +126,7 @@ async function runAssets({
126
126
  language: scene.language || language || '',
127
127
  html: scene.html || null,
128
128
  code: sceneCode,
129
+ duration: scene.duration != null ? Number(scene.duration) : null,
129
130
  startTime: scene.start_time != null ? scene.start_time : scene.trim_start,
130
131
  endTime: scene.end_time != null ? scene.end_time : scene.trim_end,
131
132
  speed: scene.speed != null ? scene.speed : 1.0,
@@ -131,6 +131,72 @@ function discoverScenesFromDir(mediaDir) {
131
131
  }));
132
132
  }
133
133
 
134
+ /**
135
+ * Parse a markdown-formatted video script (e.g. from Claude/LLM output)
136
+ * into a structured scenes array with timestamps, visual prompts, and narration.
137
+ */
138
+ function parseMarkdownScript(content) {
139
+ if (!content || typeof content !== 'string') return { title: '', scenes: [] };
140
+
141
+ function parseTime(str) {
142
+ const parts = str.split(':').map(Number);
143
+ if (parts.length === 2) return parts[0] * 60 + parts[1];
144
+ if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
145
+ return parseFloat(str) || 0;
146
+ }
147
+
148
+ let title = '';
149
+ const titleMatch = content.match(/^#\s+([^\n]+)/m) || content.match(/\*\*([^*]+)\*\*\s*\n\s*\*\*\d+-Minute/i);
150
+ if (titleMatch) title = titleMatch[1].replace(/^[#\s]+/, '').trim();
151
+
152
+ // Split into scene blocks by ## SCENE XX or ### SCENE XX
153
+ const sceneBlocks = content.split(/(?=^#{2,3}\s+SCENE\s+\d+)/mi);
154
+ const scenes = [];
155
+
156
+ for (const block of sceneBlocks) {
157
+ const matchHeader = block.match(/^#{2,3}\s+SCENE\s+(\d+)(?:\s*[-—–:]\s*([^\n]+))?/i);
158
+ if (!matchHeader) continue;
159
+ const sceneId = parseInt(matchHeader[1], 10);
160
+ const sceneTitle = (matchHeader[2] || '').trim();
161
+
162
+ // Time: mm:ss–mm:ss or mm:ss-mm:ss
163
+ const timeMatch = block.match(/\*\*Time:\s*(\d+:\d+(?:\.\d+)?)\s*[–—\-]\s*(\d+:\d+(?:\.\d+)?)\*\*/i);
164
+ let duration = null;
165
+ let startTime = null;
166
+ let endTime = null;
167
+ if (timeMatch) {
168
+ startTime = parseTime(timeMatch[1]);
169
+ endTime = parseTime(timeMatch[2]);
170
+ duration = Math.max(1, endTime - startTime);
171
+ }
172
+
173
+ // Animation prompt / Visual description
174
+ const promptMatch = block.match(/\*\*(?:ANIMATION PROMPT(?:\s*\([^)]*\))?|VISUAL DESCRIPTION|PROMPT):\*\*\s*([\s\S]*?)(?=\n\s*\*\*[A-Z]|\n\s*#{2,3}|$)/i);
175
+ const visualDescription = promptMatch ? promptMatch[1].trim() : '';
176
+
177
+ // Voice over / Narration
178
+ const voMatch = block.match(/\*\*(?:[A-Za-z\s]+)?(?:VOICE\s*OVER|VOICEOVER|NARRATION|SPEECH):\*\*\s*([\s\S]*?)(?=\n\s*#{2,3}|\n\s*\*\*SCENE|$)/i);
179
+ let narration = voMatch ? voMatch[1].replace(/---[\s\S]*$/, '').trim() : '';
180
+ narration = narration.replace(/\\!/g, '!').trim();
181
+
182
+ scenes.push({
183
+ scene_id: sceneId,
184
+ title: sceneTitle,
185
+ duration,
186
+ start_time: startTime,
187
+ end_time: endTime,
188
+ visual_description: visualDescription,
189
+ prompt: visualDescription,
190
+ narration,
191
+ caption: sceneTitle ? sceneTitle.toUpperCase().slice(0, 40) : '',
192
+ camera_hint: 'auto',
193
+ music_mood: 'neutral',
194
+ });
195
+ }
196
+
197
+ return { title, scenes };
198
+ }
199
+
134
200
  // ── Manifest normalizer ───────────────────────────────────────────────────────
135
201
 
136
202
  /**
@@ -184,7 +250,9 @@ function normaliseIngestScene(raw, index, baseDir = process.cwd()) {
184
250
  sound_effects: Array.isArray(raw.sound_effects) ? raw.sound_effects : [],
185
251
  metadata: raw.metadata || { importance: 'medium', hook_potential: 0.5, standalone: false },
186
252
  // Duration hint — used by renderer if provided (otherwise audio-driven)
187
- duration: typeof raw.duration === 'number' ? raw.duration : null,
253
+ duration: typeof raw.duration === 'number' ? raw.duration : (raw.duration ? Number(raw.duration) : null),
254
+ start_time: typeof raw.start_time === 'number' ? raw.start_time : (raw.startTime != null ? Number(raw.startTime) : null),
255
+ end_time: typeof raw.end_time === 'number' ? raw.end_time : (raw.endTime != null ? Number(raw.endTime) : null),
188
256
  };
189
257
  }
190
258
 
@@ -217,10 +285,17 @@ async function runIngest({ productionId, scenesFile, mediaDir, rawScenes, outDir
217
285
  const absPath = path.resolve(process.cwd(), scenesFile);
218
286
  if (!fs.existsSync(absPath)) throw new Error(`Scenes file not found: ${absPath}`);
219
287
  const content = fs.readFileSync(absPath, 'utf8');
220
- const parsed = JSON.parse(content);
221
- rawList = Array.isArray(parsed) ? parsed : (parsed.scenes || parsed.data || []);
222
- baseDir = path.dirname(absPath);
223
- console.log(`[Ingest] Loaded ${rawList.length} scene(s) from: ${absPath}`);
288
+ if (/\.(md|markdown)$/i.test(absPath) || (!content.trim().startsWith('{') && !content.trim().startsWith('['))) {
289
+ const parsedMd = parseMarkdownScript(content);
290
+ rawList = parsedMd.scenes;
291
+ baseDir = path.dirname(absPath);
292
+ console.log(`[Ingest] Parsed ${rawList.length} scene(s) from Markdown script: ${absPath}`);
293
+ } else {
294
+ const parsed = JSON.parse(content);
295
+ rawList = Array.isArray(parsed) ? parsed : (parsed.scenes || parsed.data || []);
296
+ baseDir = path.dirname(absPath);
297
+ console.log(`[Ingest] Loaded ${rawList.length} scene(s) from: ${absPath}`);
298
+ }
224
299
  } else if (mediaDir) {
225
300
  rawList = discoverScenesFromDir(mediaDir);
226
301
  baseDir = path.resolve(mediaDir);
@@ -299,4 +374,4 @@ async function runIngest({ productionId, scenesFile, mediaDir, rawScenes, outDir
299
374
  };
300
375
  }
301
376
 
302
- module.exports = { runIngest, normaliseIngestScene, discoverScenesFromDir };
377
+ module.exports = { runIngest, normaliseIngestScene, discoverScenesFromDir, parseMarkdownScript };
@@ -61,6 +61,7 @@ async function runRender({
61
61
  voicePath: asset.voicePath,
62
62
  caption: asset.caption || '',
63
63
  narration: asset.narration || '',
64
+ prompt: asset.prompt || '',
64
65
  visualDescription: asset.visualDescription || '',
65
66
  visualType: asset.visualType || 'code_animation',
66
67
  topic: asset.topic || topic || '',
@@ -69,10 +70,11 @@ async function runRender({
69
70
  title: asset.title || title || '',
70
71
  musicMood: asset.musicMood || 'neutral',
71
72
  language: asset.language || language || '',
72
- camera: asset.camera || 'static',
73
+ camera: asset.camera || 'auto',
73
74
  focalPoint: asset.focalPoint || null,
74
75
  html: asset.html || null,
75
76
  code: asset.code || null,
77
+ duration: asset.duration != null ? asset.duration : null,
76
78
  startTime: asset.startTime,
77
79
  endTime: asset.endTime,
78
80
  speed: asset.speed,