@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 +32 -0
- package/package.json +1 -1
- package/scripts/deep-validate.js +2 -2
- package/src/cli/make.js +5 -0
- package/src/mcp-server/server.js +6 -2
- package/src/video-renderer/renderer.js +18 -8
- package/src/video-renderer/scene-animator.js +80 -5
- package/src/workflows/render.js +2 -0
- package/src/workflows/script.js +4 -4
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
|
+
"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"
|
package/scripts/deep-validate.js
CHANGED
|
@@ -20,7 +20,7 @@ check('batch handling', code.includes('Array.isArray(body)'));
|
|
|
20
20
|
check('ping handler', code.includes("method === 'ping'"));
|
|
21
21
|
check('notifications/init', code.includes("notifications/initialized"));
|
|
22
22
|
check('body size limit', code.includes('MAX_BODY_BYTES'));
|
|
23
|
-
check('SERVER_INFO version 2.4.
|
|
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.
|
|
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
|
});
|
package/src/mcp-server/server.js
CHANGED
|
@@ -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.
|
|
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', '
|
|
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', '
|
|
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', '
|
|
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', '
|
|
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
|
-
|
|
746
|
-
|
|
747
|
-
const
|
|
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 -
|
|
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,
|
package/src/workflows/render.js
CHANGED
|
@@ -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
|
});
|
package/src/workflows/script.js
CHANGED
|
@@ -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
|
|
120
|
-
// No ceiling — long-form documentaries, lectures, and podcasts
|
|
121
|
-
const targetScenes = targetDuration ? Math.max(
|
|
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),',
|