@amg555/frameforge-mcp 2.4.3 → 2.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,38 @@ All notable changes to FrameForge are documented here.
4
4
 
5
5
  ---
6
6
 
7
+ ## [2.4.4] — 2026-11-09
8
+
9
+ ### Added
10
+ - **Optional Captions via Prompt / Flag (`src/video-renderer/scene-animator.js`, `src/video-renderer/renderer.js`, `src/cli/make.js`)**:
11
+ - Captions, subtitle pills, and on-screen text are now disabled by default (`SHOW_CAPTIONS = false`).
12
+ - Added `detectCaptionsRequested(text)` to automatically enable captions when explicitly asked in prompt (e.g. `with captions`, `show captions`, `add subtitles`, `subtitles: true`).
13
+ - Added CLI flags `--captions`, `--subtitles`, `--with-captions` to `src/cli/make.js`.
14
+ - Added `showCaptions` option to MCP tools `create_video_from_script` and `publish_video`.
15
+ - Guarded all FFmpeg video-clip subtitle burn-in filters with `effectiveShowCaptions`.
16
+
17
+ - **Multi-Shot Dynamic Camera Director (`src/video-renderer/scene-animator.js`)**:
18
+ - Scenes longer than 6.5s automatically trigger dynamic multi-shot camera cuts every 5.5s (`shotDur = 5.5`).
19
+ - Progresses cleanly through 4 cinematic angles: Wide establishing $\to$ Medium push-in $\to$ Action close-up $\to$ Dynamic high angle using spring transition interpolations (`shotCutSpring`), eliminating static camera stagnation.
20
+
21
+ - **Pacing & Duration Scaling (`src/workflows/script.js`)**:
22
+ - Upgraded target scene count formula to `targetDuration ? Math.max(8, Math.ceil(targetDuration / 4.5)) : Math.max(sceneCount, 55)` to guarantee tight 4-6 second scene pacing matching high-retention video essay benchmarks.
23
+ - LLM instructions explicitly require 12-25 words per scene to prevent monolithic 30-50s static scenes.
24
+
25
+ ### Enhanced
26
+ - **Visual Rendering Quality Polish (`src/video-renderer/scene-animator.js`)**:
27
+ - Ground contact drop shadows (`ellipse(0, legLength, 34, 7)`) anchor all characters to the ground plane.
28
+ - Specular eye highlights and warm rosy cheek blush (`rgba(251, 113, 133, 0.28)`) enhance character expressiveness.
29
+ - Round line caps (`ctx.lineCap = 'round'`, `ctx.lineJoin = 'round'`) for clean, hand-crafted doodle aesthetics.
30
+
31
+ - **Near-Lossless FFmpeg Encoding (`src/video-renderer/renderer.js`)**:
32
+ - Upgraded CRF from 20/22 to `17` (visually lossless).
33
+ - Upgraded encoding preset from `veryfast` to `fast` for superior inter-frame motion estimation.
34
+ - Added `-x264-params aq-mode=3:deblock=-1,-1` to eliminate gradient banding on dark animated canvas backgrounds while preserving razor-sharp vector line art.
35
+ - Upgraded audio output stream to 320 kbps AAC stereo.
36
+
37
+ ---
38
+
7
39
  ## [2.4.3] — 2026-11-09
8
40
 
9
41
  ### Enhanced
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amg555/frameforge-mcp",
3
- "version": "2.4.3",
3
+ "version": "2.4.4",
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"
@@ -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.3', code.includes("version: '2.4.3'"));
23
+ check('SERVER_INFO version 2.4.4', code.includes("version: '2.4.4'"));
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.3', cfWorker.includes("'2.4.3'"));
302
+ check('cf-worker version 2.4.4', cfWorker.includes("'2.4.4'"));
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
@@ -99,6 +99,7 @@ function parseArgs(argv) {
99
99
  topic: '', topicsFile: '', language: 'en',
100
100
  style: 'educational comedy', channels: ['file'],
101
101
  orientation: 'landscape',
102
+ captions: false, subtitles: false,
102
103
  scenesFile: '', scriptFile: '', mediaDir: '',
103
104
  shorts: true, dryRun: false,
104
105
  noResearch: false, noScript: false, noImages: false, noTts: false, noPublish: false,
@@ -115,6 +116,9 @@ function parseArgs(argv) {
115
116
  case '--language': args.language = (argv[++i] || 'en').toLowerCase(); break;
116
117
  case '--orientation': args.orientation = (argv[++i] || 'landscape').toLowerCase(); break;
117
118
  case '--portrait': args.orientation = 'portrait'; break;
119
+ case '--captions':
120
+ case '--subtitles':
121
+ case '--with-captions': args.captions = true; break;
118
122
  case '--style': args.style = argv[++i] || args.style; break;
119
123
  case '--channels': args.channels = (argv[++i] || 'file').split(',').map(s => s.trim()); break;
120
124
  case '--no-research': args.noResearch = true; break;
@@ -436,6 +440,7 @@ async function runSingleVideo(args, topic, outDir, workflows) {
436
440
  title,
437
441
  style: args.style || '',
438
442
  language: args.language,
443
+ showCaptions: Boolean(args.captions || args.subtitles),
439
444
  renderWidth,
440
445
  renderHeight,
441
446
  });
@@ -420,6 +420,7 @@ const TOOLS = {
420
420
  theme,
421
421
  title,
422
422
  language,
423
+ showCaptions,
423
424
  });
424
425
 
425
426
  if (!renderResult.passed) {
@@ -584,10 +585,11 @@ Example scene:
584
585
  topic: { type: 'string', description: 'Overall topic/genre for visual aesthetic and background motion (e.g. "Kerala history", "crypto finance", "AI robotics")' },
585
586
  style: { type: 'string', description: 'Visual style/theme override (e.g. cinematic_documentary, finance_executive, cyber_tech, retro_synthwave, nature_organic, hype_sports, minimal_luxury, breaking_news, cosmic_space)' },
586
587
  theme: { type: 'string', description: 'Theme preset alias' },
588
+ showCaptions: { type: 'boolean', default: false, description: 'Display on-screen synchronized subtitles/captions. Default false (clean canvas without text unless explicitly requested in prompt).' },
587
589
  productionId: { type: 'string', description: 'Optional — reuse an existing productionId' },
588
590
  },
589
591
  },
590
- handler: async ({ scenes, title = 'FrameForge Video', orientation = 'landscape', voice, language, skipTts = false, parallelism = 1, withMusic = true, musicVolume = 0.07, topic = '', style = '', theme = '', productionId }) => {
592
+ handler: async ({ scenes, title = 'FrameForge Video', orientation = 'landscape', voice, language, skipTts = false, parallelism = 1, withMusic = true, musicVolume = 0.07, topic = '', style = '', theme = '', showCaptions = false, productionId }) => {
591
593
  const { runAssets } = require('../workflows/assets');
592
594
  const { runRender } = require('../workflows/render');
593
595
 
@@ -663,6 +665,7 @@ Example scene:
663
665
  theme,
664
666
  title,
665
667
  language,
668
+ showCaptions,
666
669
  });
667
670
  const renderResult = await runRender({
668
671
  productionId: pid,
@@ -676,6 +679,7 @@ Example scene:
676
679
  theme,
677
680
  title,
678
681
  language,
682
+ showCaptions,
679
683
  });
680
684
 
681
685
  if (!renderResult.passed) {
@@ -1633,7 +1637,7 @@ Allows the agent in Claude Code, Cursor, or any IDE to inspect the visual output
1633
1637
 
1634
1638
  const SERVER_INFO = {
1635
1639
  name: 'video-pipeline',
1636
- version: '2.4.3',
1640
+ version: '2.4.4',
1637
1641
  };
1638
1642
 
1639
1643
  const CAPABILITIES = {
@@ -171,7 +171,14 @@ class VideoRenderer {
171
171
  speed = 1.0,
172
172
  clipVolume = 0,
173
173
  colorGrade = null,
174
+ showCaptions = false,
174
175
  } = {}) {
176
+ const { detectCaptionsRequested } = require('./scene-animator');
177
+ const effectiveShowCaptions = Boolean(
178
+ showCaptions ||
179
+ detectCaptionsRequested(`${caption} ${narration} ${topic} ${style} ${visualDescription}`) ||
180
+ process.env.FRAMEFORGE_SHOW_CAPTIONS === '1'
181
+ );
175
182
  const outputPath = path.join(this.vdir, `scene_${sceneIndex}_out.mp4`);
176
183
 
177
184
  // Idempotency: skip if already rendered and manifest says so
@@ -197,7 +204,7 @@ class VideoRenderer {
197
204
  const startSec = parseTimestamp(trimStart != null ? trimStart : startTime);
198
205
  const sp = buildSpeedFilters(speed);
199
206
 
200
- const captionFilter = this._captionFilter(caption);
207
+ const captionFilter = effectiveShowCaptions ? this._captionFilter(caption) : '';
201
208
  const scaleFilter = `scale=${renderWidth}:${renderHeight}:force_original_aspect_ratio=increase:flags=lanczos,crop=${renderWidth}:${renderHeight}`;
202
209
  const colorFilter = buildColorFilter(colorGrade);
203
210
 
@@ -285,6 +292,7 @@ class VideoRenderer {
285
292
  outputPath,
286
293
  manifest,
287
294
  musicMood,
295
+ showCaptions: effectiveShowCaptions,
288
296
  });
289
297
  } catch (err) {
290
298
  console.warn(`[Renderer] Code-driven browser scene render failed (${err.message}), falling back to procedural animation`);
@@ -292,7 +300,7 @@ class VideoRenderer {
292
300
  }
293
301
 
294
302
  // Priority 3: Procedural dynamic animated fallback (dynamic moving gradients / camera drift)
295
- const captionFilter = this._captionFilter(caption);
303
+ const captionFilter = effectiveShowCaptions ? this._captionFilter(caption) : '';
296
304
  let inputArgs = [];
297
305
  let vf = '';
298
306
 
@@ -324,12 +332,12 @@ class VideoRenderer {
324
332
  '-af', `apad=whole_dur=${clampedDur}`,
325
333
  '-t', String(clampedDur),
326
334
  '-map', '0:v:0', '-map', '1:a:0',
327
- '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '22',
335
+ '-c:v', 'libx264', '-preset', 'fast', '-crf', '17',
328
336
  '-profile:v', 'high', '-level', '4.1',
329
337
  '-color_range', 'tv', '-colorspace', 'bt709',
330
338
  '-color_primaries', 'bt709', '-color_trc', 'bt709',
331
- '-x264-params', 'colorprim=bt709:transfer=bt709:colormatrix=bt709',
332
- '-c:a', 'aac', '-b:a', '192k', '-ar', '48000', '-ac', '2',
339
+ '-x264-params', 'colorprim=bt709:transfer=bt709:colormatrix=bt709:aq-mode=3:deblock=-1,-1',
340
+ '-c:a', 'aac', '-b:a', '320k', '-ar', '48000', '-ac', '2',
333
341
  '-movflags', '+faststart',
334
342
  '-pix_fmt', 'yuv420p',
335
343
  outputPath,
@@ -366,6 +374,7 @@ class VideoRenderer {
366
374
  outputPath,
367
375
  manifest,
368
376
  musicMood,
377
+ showCaptions = false,
369
378
  }) {
370
379
  let htmlContent;
371
380
  if (html && typeof html === 'string' && html.trim().length > 0) {
@@ -399,6 +408,7 @@ class VideoRenderer {
399
408
  height: renderHeight,
400
409
  sceneIndex,
401
410
  musicMood,
411
+ showCaptions,
402
412
  });
403
413
  }
404
414
 
@@ -434,13 +444,13 @@ class VideoRenderer {
434
444
  '-i', framesPattern,
435
445
  '-i', voicePath,
436
446
  '-map', '0:v:0', '-map', '1:a:0',
437
- '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20',
447
+ '-c:v', 'libx264', '-preset', 'fast', '-crf', '17',
438
448
  '-profile:v', 'high', '-level', '4.1',
439
449
  '-color_range', 'tv', '-colorspace', 'bt709',
440
450
  '-color_primaries', 'bt709', '-color_trc', 'bt709',
441
- '-x264-params', 'colorprim=bt709:transfer=bt709:colormatrix=bt709',
451
+ '-x264-params', 'colorprim=bt709:transfer=bt709:colormatrix=bt709:aq-mode=3:deblock=-1,-1',
442
452
  '-pix_fmt', 'yuv420p',
443
- '-c:a', 'aac', '-b:a', '192k', '-ar', '48000', '-ac', '2',
453
+ '-c:a', 'aac', '-b:a', '320k', '-ar', '48000', '-ac', '2',
444
454
  '-af', `apad=whole_dur=${clampedDur}`,
445
455
  '-t', String(clampedDur),
446
456
  '-movflags', '+faststart',
@@ -497,6 +497,13 @@ window.seek = async (t) => {
497
497
  </html>`;
498
498
  }
499
499
 
500
+ // ── Captions Requested Detector ───────────────────────────────────────────────
501
+ /** Returns true ONLY if caller explicitly asks for on-screen captions or subtitles in prompt. */
502
+ function detectCaptionsRequested(text = '') {
503
+ if (!text || typeof text !== 'string') return false;
504
+ return /(?:with|show|display|include|enable|add)\s+(?:captions?|subtitles?|sub-titles?|subs?)|(?:captions?|subtitles?)\s*[:=]\s*(?:true|yes|1|on|always)/i.test(text);
505
+ }
506
+
500
507
  // ── HTML Scene Code Generator ──────────────────────────────────────────────────
501
508
  /**
502
509
  * Generate a complete, self-contained HTML scene conforming to window.seek(t).
@@ -517,7 +524,13 @@ function generateSceneHtml({
517
524
  width = 1920,
518
525
  height = 1080,
519
526
  sceneIndex = 0,
527
+ showCaptions = false,
520
528
  } = {}) {
529
+ const captionsWanted = Boolean(
530
+ showCaptions ||
531
+ detectCaptionsRequested(`${caption} ${narration} ${topic} ${visualDescription} ${style} ${title}`) ||
532
+ process.env.FRAMEFORGE_SHOW_CAPTIONS === '1'
533
+ );
521
534
  const aestheticKey = detectAesthetic({
522
535
  topic,
523
536
  style,
@@ -627,6 +640,7 @@ function spring(t, delay = 0, tension = ${aesthetic.springTension}, friction = $
627
640
  return 1 - Math.exp(-omega * dt);
628
641
  }
629
642
 
643
+ const SHOW_CAPTIONS = ${JSON.stringify(captionsWanted)};
630
644
  const AESTHETIC = ${JSON.stringify(aestheticKey)};
631
645
  const BG_TYPE = ${JSON.stringify(aesthetic.backgroundType)};
632
646
  const COMP_THEME = ${JSON.stringify(compTheme)};
@@ -742,9 +756,47 @@ function drawMainScene(t) {
742
756
  ctx.save();
743
757
  const p = Math.min(1, Math.max(0, t / window.DURATION));
744
758
  const enterSpring = spring(t, 0.05, 170, 13);
745
- const camZoom = 1.0 + Math.sin(t * 0.45) * 0.02 + p * 0.04;
746
- const camPanX = Math.sin(t * 0.35) * 12;
747
- const camPanY = Math.cos(t * 0.28) * 6;
759
+
760
+ // Multi-Shot Dynamic Director: cut camera framing every 5-7s to keep pacing energetic
761
+ const shotDur = 5.5;
762
+ const shotIdx = Math.floor(t / shotDur);
763
+ const shotLocalT = t - shotIdx * shotDur;
764
+ const shotCutSpring = spring(shotLocalT, 0.02, 220, 18);
765
+ const shotProgress = (t % shotDur) / shotDur;
766
+
767
+ let targetZoom = 1.0;
768
+ let targetPanX = 0;
769
+ let targetPanY = 0;
770
+
771
+ if (window.DURATION > 6.5) {
772
+ const angle = shotIdx % 4;
773
+ if (angle === 1) {
774
+ targetZoom = 1.14 + shotProgress * 0.03;
775
+ targetPanX = -40 + Math.sin(t * 0.4) * 8;
776
+ targetPanY = -12;
777
+ } else if (angle === 2) {
778
+ targetZoom = 1.20 + shotProgress * 0.03;
779
+ targetPanX = 40 + Math.cos(t * 0.4) * 8;
780
+ targetPanY = -18;
781
+ } else if (angle === 3) {
782
+ targetZoom = 1.08 + shotProgress * 0.02;
783
+ targetPanX = Math.sin(t * 0.3) * 12;
784
+ targetPanY = 12;
785
+ } else {
786
+ targetZoom = 1.02 + shotProgress * 0.03;
787
+ targetPanX = Math.sin(t * 0.3) * 12;
788
+ targetPanY = Math.cos(t * 0.25) * 6;
789
+ }
790
+ } else {
791
+ targetZoom = 1.0 + Math.sin(t * 0.45) * 0.02 + p * 0.04;
792
+ targetPanX = Math.sin(t * 0.35) * 12;
793
+ targetPanY = Math.cos(t * 0.28) * 6;
794
+ }
795
+
796
+ const camZoom = 1.0 + (targetZoom - 1.0) * Math.min(1, Math.max(0, shotCutSpring));
797
+ const camPanX = targetPanX * Math.min(1, Math.max(0, shotCutSpring));
798
+ const camPanY = targetPanY * Math.min(1, Math.max(0, shotCutSpring));
799
+
748
800
  ctx.translate(W / 2 + camPanX, H * 0.50 + camPanY);
749
801
  ctx.scale(Math.min(1.04, Math.max(0.88, enterSpring)) * camZoom, Math.min(1.04, Math.max(0.88, enterSpring)) * camZoom);
750
802
  ctx.translate(-W / 2, -H * 0.50);
@@ -828,6 +880,12 @@ function drawMainScene(t) {
828
880
  ctx.translate(x, y);
829
881
  ctx.scale(scale, scale);
830
882
 
883
+ // Soft ground contact shadow
884
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.28)';
885
+ ctx.beginPath();
886
+ ctx.ellipse(0, legLength, 34, 7, 0, 0, Math.PI * 2);
887
+ ctx.fill();
888
+
831
889
  const blink = (t % 3.2 < 0.14) ? 0.15 : 1.0;
832
890
  const mouthTalk = Math.abs(Math.sin(t * 14)) * 4;
833
891
  const torsoTopY = -torsoHeight;
@@ -904,6 +962,22 @@ function drawMainScene(t) {
904
962
  sCircle(-12, eyeY, Math.max(1, eyeR), seed + 23, '#0f172a', 2, '#0f172a');
905
963
  sCircle(12, eyeY, Math.max(1, eyeR), seed + 24, '#0f172a', 2, '#0f172a');
906
964
 
965
+ // Specular eye highlights
966
+ if (blink > 0.4) {
967
+ ctx.fillStyle = '#ffffff';
968
+ ctx.beginPath();
969
+ ctx.arc(-14, eyeY - 1.5, 1.2, 0, Math.PI * 2);
970
+ ctx.arc(10, eyeY - 1.5, 1.2, 0, Math.PI * 2);
971
+ ctx.fill();
972
+ }
973
+
974
+ // Warm cheek blush
975
+ ctx.fillStyle = 'rgba(251, 113, 133, 0.28)';
976
+ ctx.beginPath();
977
+ ctx.ellipse(-14, headCenterY + 12, 6, 3.5, 0, 0, Math.PI * 2);
978
+ ctx.ellipse(14, headCenterY + 12, 6, 3.5, 0, 0, Math.PI * 2);
979
+ ctx.fill();
980
+
907
981
  // Eyebrows
908
982
  if (expression === 'shivering') {
909
983
  sLine(-18, eyeY - 12 + wb(seed, 2), -6, eyeY - 7 + wb(seed, 2), seed + 25, '#0f172a', 2.5);
@@ -2084,8 +2158,8 @@ function drawMainScene(t) {
2084
2158
  function drawCinematicOverlay(t) {
2085
2159
  ctx.save();
2086
2160
 
2087
- // Subtitles / Spoken Narration - dynamic sentence progression across time
2088
- if (NARRATION && NARRATION.trim().length > 0) {
2161
+ // Subtitles / Spoken Narration - ONLY rendered when explicitly asked in prompt or options
2162
+ if (SHOW_CAPTIONS && NARRATION && NARRATION.trim().length > 0) {
2089
2163
  // Split narration into natural spoken sentence chunks
2090
2164
  const sentences = NARRATION.split(/[.!?;|]+/).map(s => s.trim()).filter(Boolean);
2091
2165
 
@@ -2234,6 +2308,7 @@ module.exports = {
2234
2308
  generateSceneCodeWithLLM,
2235
2309
  detectAesthetic,
2236
2310
  detectComponentTheme,
2311
+ detectCaptionsRequested,
2237
2312
  getScriptFontStack,
2238
2313
  AESTHETICS,
2239
2314
  REGIONAL_FONTS,
@@ -36,6 +36,7 @@ async function runRender({
36
36
  theme = '',
37
37
  title = '',
38
38
  language = '',
39
+ showCaptions = false,
39
40
  }) {
40
41
  const sm = new StateMachine({ mode: 'file', stateDir: STATE_DIR });
41
42
  const retryPol = new RetryPolicy({ maxAttempts: 2, baseDelayMs: 15000 });
@@ -77,6 +78,7 @@ async function runRender({
77
78
  speed: asset.speed,
78
79
  clipVolume: asset.clipVolume,
79
80
  colorGrade: asset.colorGrade,
81
+ showCaptions: asset.showCaptions !== undefined ? asset.showCaptions : showCaptions,
80
82
  renderWidth,
81
83
  renderHeight,
82
84
  });
@@ -116,9 +116,9 @@ async function runScript({
116
116
 
117
117
  const facts = claims.slice(0, 8).map(c => c.safe_wording || c.claim).join('; ');
118
118
 
119
- // If targetDuration is given, adjust scene count guidance (≈ 5s/scene).
120
- // No ceiling — long-form documentaries, lectures, and podcasts may need hundreds of scenes.
121
- const targetScenes = targetDuration ? Math.max(5, Math.round(targetDuration / 5)) : sceneCount;
119
+ // If targetDuration is given, adjust scene count guidance (≈ 4.5-5s per scene for high retention).
120
+ // No ceiling — long-form documentaries, lectures, and podcasts scale dynamically.
121
+ const targetScenes = targetDuration ? Math.max(8, Math.ceil(targetDuration / 4.5)) : Math.max(sceneCount, 55);
122
122
 
123
123
  const raw = await retryPol.executeStage('Script', async () => {
124
124
  const system = [
@@ -136,7 +136,7 @@ async function runScript({
136
136
  `STYLE: ${style}`,
137
137
  `LANGUAGE: ${language}`,
138
138
  '',
139
- `Write exactly ${targetScenes} scenes (schema_version "2.0.0"). Each scene must have:`,
139
+ `Write exactly ${targetScenes} scenes (schema_version "2.0.0"). High visual pacing: 4-6 seconds per scene (12-25 spoken words). Never write monolithic 30-50s static scenes. Each scene must have:`,
140
140
  'scene_id (integer), schema_version "2.0.0",',
141
141
  'narration (15-35 words in the specified language, spoken aloud),',
142
142
  'caption (3-6 words UPPERCASE, text overlay),',