@amg555/frameforge-mcp 2.4.6 → 2.4.7

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.7",
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.7', code.includes("version: '2.4.7'"));
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.7', cfWorker.includes("'2.4.7'"));
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');
@@ -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.7',
1641
1645
  };
1642
1646
 
1643
1647
  const CAPABILITIES = {
@@ -167,16 +167,15 @@ class VideoRenderer {
167
167
  startTime = 0,
168
168
  trimStart,
169
169
  endTime,
170
+ duration = null,
170
171
  trimEnd,
171
172
  speed = 1.0,
172
173
  clipVolume = 0,
173
174
  colorGrade = null,
174
175
  showCaptions = false,
175
176
  } = {}) {
176
- const { detectCaptionsRequested } = require('./scene-animator');
177
177
  const effectiveShowCaptions = Boolean(
178
178
  showCaptions ||
179
- detectCaptionsRequested(`${caption} ${narration} ${topic} ${style} ${visualDescription}`) ||
180
179
  process.env.FRAMEFORGE_SHOW_CAPTIONS === '1'
181
180
  );
182
181
  const outputPath = path.join(this.vdir, `scene_${sceneIndex}_out.mp4`);
@@ -189,12 +188,13 @@ class VideoRenderer {
189
188
 
190
189
  fs.mkdirSync(this.vdir, { recursive: true });
191
190
 
192
- // Duration from voice file.
191
+ // Duration from voice file or explicit scene duration.
193
192
  if (!voicePath || !fs.existsSync(voicePath)) {
194
193
  throw new Error(`Scene ${sceneIndex} render failed: voicePath is missing or does not exist (${voicePath})`);
195
194
  }
196
- const dur = this._getAudioDuration(voicePath);
197
- const clampedDur = Math.max(2, Number.isFinite(dur) ? dur + 0.15 : 3.15);
195
+ const voiceDur = this._getAudioDuration(voicePath);
196
+ const dur = (typeof duration === 'number' && duration > 0) ? Math.max(duration, voiceDur) : voiceDur;
197
+ const clampedDur = Math.max(1, Number.isFinite(dur) ? dur + 0.15 : 3.15);
198
198
 
199
199
  const isVideoInput = Boolean(videoPath) || (videoPath && /\.(mp4|mov|webm|mkv|m4v)$/i.test(videoPath));
200
200
 
@@ -385,9 +385,9 @@ function detectComponentTheme({ visualDescription = '', visual_description = '',
385
385
  return 'metric';
386
386
  }
387
387
 
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)) {
388
+ // Timeline / Milestones / Chronology - strictly explicit roadmap/milestone chart requests only
389
+ if (/(?:^|[^\w])(timeline|roadmap|milestone chart|milestone roadmap)(?:[^\w]|$)/i.test(text)
390
+ || /ടൈംലൈൻ|റോഡ്മാപ്പ്/.test(text)) {
391
391
  return 'timeline';
392
392
  }
393
393
 
@@ -417,7 +417,7 @@ function detectComponentTheme({ visualDescription = '', visual_description = '',
417
417
 
418
418
  // Fallbacks matched to aesthetic
419
419
  if (aesthetic === 'cosmic_space') return 'celestial_orbit';
420
- if (aesthetic === 'cinematic_documentary') return 'timeline';
420
+ if (aesthetic === 'cinematic_documentary') return 'doodle_general_human';
421
421
  if (aesthetic === 'finance_executive') return 'chart';
422
422
  if (aesthetic === 'nature_organic') return 'dna_helix';
423
423
  if (aesthetic === 'breaking_news') return 'radar_globe';
@@ -537,6 +537,7 @@ function detectCaptionsRequested(text = '') {
537
537
  * Paints full-canvas cinematic procedural animations — zero PPT card slideshow patterns.
538
538
  */
539
539
  function generateSceneHtml({
540
+ prompt = '',
540
541
  caption = '',
541
542
  narration = '',
542
543
  visualDescription = '',
@@ -557,7 +558,7 @@ function generateSceneHtml({
557
558
  } = {}) {
558
559
  const captionsWanted = Boolean(
559
560
  showCaptions ||
560
- detectCaptionsRequested(`${caption} ${narration} ${topic} ${visualDescription} ${style} ${title}`) ||
561
+ detectCaptionsRequested(prompt || topic) ||
561
562
  process.env.FRAMEFORGE_SHOW_CAPTIONS === '1'
562
563
  );
563
564
  const aestheticKey = detectAesthetic({
@@ -933,7 +934,7 @@ function drawMainScene(t) {
933
934
  torsoHeight = 90,
934
935
  legLength = 95,
935
936
  hairStyle = 'wild',
936
- expression = 'talking',
937
+ expression = 'natural',
937
938
  clothing = 'none',
938
939
  armPose = 'natural',
939
940
  showShadow = false,
@@ -1771,7 +1772,7 @@ function drawMainScene(t) {
1771
1772
  y: cy + 140,
1772
1773
  scale: 1.35,
1773
1774
  hairStyle: 'modern',
1774
- expression: beat3 > 0.5 ? 'smiling' : 'talking',
1775
+ expression: beat3 > 0.5 ? 'smiling' : 'natural',
1775
1776
  clothing: 'shirt',
1776
1777
  armPose: beat2 > 0.5 ? 'dress_shirt' : 'natural',
1777
1778
  showShadow: true,
@@ -2089,7 +2090,7 @@ function drawMainScene(t) {
2089
2090
  y: cy + 140,
2090
2091
  scale: 1.35,
2091
2092
  hairStyle: 'wild',
2092
- expression: swatting ? 'shivering' : 'talking',
2093
+ expression: swatting ? 'shivering' : 'focused',
2093
2094
  clothing: 'none',
2094
2095
  armPose: 'hold_spear',
2095
2096
  showShadow: true,
@@ -2135,7 +2136,7 @@ function drawMainScene(t) {
2135
2136
  y: cy + 140,
2136
2137
  scale: 1.35,
2137
2138
  hairStyle: 'wild',
2138
- expression: 'talking',
2139
+ expression: 'focused',
2139
2140
  clothing: 'pelt',
2140
2141
  armPose: 'sewing',
2141
2142
  showShadow: true,
@@ -2299,7 +2300,7 @@ function drawMainScene(t) {
2299
2300
  y: cy + 140,
2300
2301
  scale: 1.3,
2301
2302
  hairStyle: 'wild',
2302
- expression: 'talking',
2303
+ expression: 'focused',
2303
2304
  clothing: 'pelt',
2304
2305
  armPose: greet > 0.5 ? 'peace_gesture' : 'natural',
2305
2306
  showShadow: true,
@@ -2360,7 +2361,7 @@ function drawMainScene(t) {
2360
2361
  y: cy + 130,
2361
2362
  scale: 1.35,
2362
2363
  hairStyle: 'wild',
2363
- expression: 'talking',
2364
+ expression: 'focused',
2364
2365
  clothing: 'pelt',
2365
2366
  armPose: 'thinker',
2366
2367
  seed: 108,
@@ -2465,14 +2466,45 @@ function drawMainScene(t) {
2465
2466
  }
2466
2467
 
2467
2468
  } 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);
2469
+ // Dynamic Prehistoric / Ancient Landscape with Travelling Human Explorer (Walking across screen)
2470
+ const walkP = Math.min(1, Math.max(0, t / window.DURATION));
2471
+ const charX = cx - 240 + walkP * 380;
2472
+
2473
+ // Atmospheric warm background with rolling terrain
2474
+ const skyG = ctx.createLinearGradient(0, 0, 0, cy + 140);
2475
+ skyG.addColorStop(0, '#fef3c7');
2476
+ skyG.addColorStop(0.6, '#fde68a');
2477
+ skyG.addColorStop(1, '#fef9c3');
2478
+ ctx.fillStyle = skyG;
2479
+ ctx.fillRect(0, 0, W, cy + 140);
2480
+
2481
+ // Distant ancient hills
2482
+ ctx.fillStyle = 'rgba(217, 119, 6, 0.25)';
2483
+ ctx.beginPath();
2484
+ ctx.moveTo(0, cy + 140);
2485
+ ctx.quadraticCurveTo(W * 0.25, cy + 20, W * 0.5, cy + 80);
2486
+ ctx.quadraticCurveTo(W * 0.75, cy + 30, W, cy + 140);
2487
+ ctx.closePath();
2488
+ ctx.fill();
2489
+
2490
+ // Foreground terrain
2491
+ ctx.fillStyle = '#451a03';
2492
+ ctx.fillRect(0, cy + 140, W, H - (cy + 140));
2493
+ sLine(0, cy + 140, W, cy + 140, 10, '#d97706', 3);
2494
+
2495
+ // Natural grass foliage
2496
+ for (let g = 0; g < 32; g++) {
2497
+ const gx = g * 60;
2498
+ const sway = Math.sin(t * 3.0 + g * 0.5) * 12;
2499
+ sLine(gx, cy + 140, gx + sway, cy + 85, 400 + g, '#b45309', 2);
2500
+ }
2501
+
2470
2502
  drawDoodleCharacter({
2471
- x: cx,
2503
+ x: charX,
2472
2504
  y: cy + 140,
2473
- scale: 1.4,
2505
+ scale: 1.35,
2474
2506
  hairStyle: 'wild',
2475
- expression: 'talking',
2507
+ expression: 'natural',
2476
2508
  clothing: 'pelt',
2477
2509
  armPose: 'natural',
2478
2510
  showShadow: true,
@@ -3018,16 +3050,7 @@ function drawCinematicOverlay(t) {
3018
3050
 
3019
3051
  ctx.restore();
3020
3052
 
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();
3053
+
3031
3054
  }
3032
3055
 
3033
3056
  // ── 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 };
@@ -69,10 +69,11 @@ async function runRender({
69
69
  title: asset.title || title || '',
70
70
  musicMood: asset.musicMood || 'neutral',
71
71
  language: asset.language || language || '',
72
- camera: asset.camera || 'static',
72
+ camera: asset.camera || 'auto',
73
73
  focalPoint: asset.focalPoint || null,
74
74
  html: asset.html || null,
75
75
  code: asset.code || null,
76
+ duration: asset.duration != null ? asset.duration : null,
76
77
  startTime: asset.startTime,
77
78
  endTime: asset.endTime,
78
79
  speed: asset.speed,