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