@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 +2 -2
- package/scripts/deep-validate.js +2 -2
- package/src/cli/make.js +26 -11
- package/src/mcp-server/server.js +6 -2
- package/src/video-renderer/renderer.js +5 -5
- package/src/video-renderer/scene-animator.js +49 -26
- package/src/workflows/assets.js +2 -1
- package/src/workflows/ingest.js +81 -6
- package/src/workflows/render.js +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amg555/frameforge-mcp",
|
|
3
|
-
"version": "2.4.
|
|
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",
|
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.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.
|
|
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
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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');
|
package/src/mcp-server/server.js
CHANGED
|
@@ -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.
|
|
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
|
|
197
|
-
const
|
|
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|
|
|
390
|
-
||
|
|
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 '
|
|
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(
|
|
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 = '
|
|
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' : '
|
|
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' : '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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
|
-
//
|
|
2469
|
-
|
|
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:
|
|
2503
|
+
x: charX,
|
|
2472
2504
|
y: cy + 140,
|
|
2473
|
-
scale: 1.
|
|
2505
|
+
scale: 1.35,
|
|
2474
2506
|
hairStyle: 'wild',
|
|
2475
|
-
expression: '
|
|
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
|
-
|
|
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 ───────────────────────────────────────────────────────────
|
package/src/workflows/assets.js
CHANGED
|
@@ -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 || '
|
|
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,
|
package/src/workflows/ingest.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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 };
|
package/src/workflows/render.js
CHANGED
|
@@ -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 || '
|
|
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,
|