@amg555/frameforge-mcp 2.3.7 → 2.3.9
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 +22 -0
- package/README.md +1 -1
- package/out/check/purity_report.json +79 -0
- package/package.json +1 -1
- package/scripts/deep-validate.js +2 -2
- package/src/harness/bin/harness.mjs +3 -1
- package/src/mcp-server/server.js +1 -1
- package/src/pipeline/providers.js +56 -19
- package/src/schema/scene.schema.json +3 -0
- package/src/video-renderer/scene-animator.js +15 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,28 @@ All notable changes to FrameForge are documented here.
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
## [2.3.9] — 2026-11-09
|
|
8
|
+
|
|
9
|
+
### Enhanced
|
|
10
|
+
- **Code-Driven Animation Primitives (`src/video-renderer/scene-animator.js`)**:
|
|
11
|
+
- Expanded `wrapCodeInHarnessHtml` runtime environment with standard animation and easing helpers: `clamp`, `lerp`, `easeIn`, `easeOut`, `easeInOut`, and `boilSeed` (12fps hand-drawn wobble).
|
|
12
|
+
- Explicitly registered `html`, `code`, and `animation_code` in `src/schema/scene.schema.json` for first-class schema validation and tool discovery.
|
|
13
|
+
- Verified pure-function-of-time canvas code execution pipeline: scene code → Playwright virtual clock headless capture → FFmpeg BT.709 H.264/AAC MP4 encode.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## [2.3.8] — 2026-11-09
|
|
18
|
+
|
|
19
|
+
### Fixed & Enhanced
|
|
20
|
+
- **Multilingual Neural TTS Voice Routing (`src/pipeline/providers.js`)**:
|
|
21
|
+
- Automatically routes multilingual text to native neural voices: Malayalam (`ml-IN-MidhunNeural` / `ml-IN-SobhanaNeural`), Hindi (`hi-IN-MadhurNeural` / `hi-IN-SwaraNeural`), Tamil, Telugu, Chinese, Japanese, and Arabic.
|
|
22
|
+
- Eliminated Windows `shell: true` path space splitting and DEP0190 warnings in `_edgeTTS` by writing text to a UTF-8 temporary file and passing `--file` with `shell: false`.
|
|
23
|
+
- **Harness CLI Positional Arguments (`src/harness/bin/harness.mjs`)**:
|
|
24
|
+
- Added support for positional `.html` scene files (e.g. `node harness.mjs verify my-scene.html`).
|
|
25
|
+
- Added automatic fallback to `reference-scene.html` fixture so `npm run harness:verify` runs cleanly without requiring a pre-existing root `index.html`.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
7
29
|
## [2.3.7] — 2026-11-09
|
|
8
30
|
|
|
9
31
|
### Added
|
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
[](https://www.npmjs.com/package/@amg555/frameforge-mcp)
|
|
10
10
|
[](LICENSE)
|
|
11
11
|
[](https://nodejs.org/)
|
|
12
|
-
[](CHANGELOG.md)
|
|
13
13
|
[](https://modelcontextprotocol.io/)
|
|
14
14
|
[](#testing)
|
|
15
15
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
{
|
|
2
|
+
"passed": true,
|
|
3
|
+
"timesTested": 9,
|
|
4
|
+
"results": [
|
|
5
|
+
{
|
|
6
|
+
"time": 0.1,
|
|
7
|
+
"a": "8d21a5d5a07ed0d7",
|
|
8
|
+
"b": "8d21a5d5a07ed0d7",
|
|
9
|
+
"c": "8d21a5d5a07ed0d7",
|
|
10
|
+
"ok": true,
|
|
11
|
+
"failure": null
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"time": 0.3,
|
|
15
|
+
"a": "8d21a5d5a07ed0d7",
|
|
16
|
+
"b": "8d21a5d5a07ed0d7",
|
|
17
|
+
"c": "8d21a5d5a07ed0d7",
|
|
18
|
+
"ok": true,
|
|
19
|
+
"failure": null
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"time": 0.56,
|
|
23
|
+
"a": "8d21a5d5a07ed0d7",
|
|
24
|
+
"b": "8d21a5d5a07ed0d7",
|
|
25
|
+
"c": "8d21a5d5a07ed0d7",
|
|
26
|
+
"ok": true,
|
|
27
|
+
"failure": null
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"time": 0.8,
|
|
31
|
+
"a": "8d21a5d5a07ed0d7",
|
|
32
|
+
"b": "8d21a5d5a07ed0d7",
|
|
33
|
+
"c": "8d21a5d5a07ed0d7",
|
|
34
|
+
"ok": true,
|
|
35
|
+
"failure": null
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"time": 1,
|
|
39
|
+
"a": "8d21a5d5a07ed0d7",
|
|
40
|
+
"b": "8d21a5d5a07ed0d7",
|
|
41
|
+
"c": "8d21a5d5a07ed0d7",
|
|
42
|
+
"ok": true,
|
|
43
|
+
"failure": null
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"time": 1.24,
|
|
47
|
+
"a": "8d21a5d5a07ed0d7",
|
|
48
|
+
"b": "8d21a5d5a07ed0d7",
|
|
49
|
+
"c": "8d21a5d5a07ed0d7",
|
|
50
|
+
"ok": true,
|
|
51
|
+
"failure": null
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"time": 1.5,
|
|
55
|
+
"a": "8d21a5d5a07ed0d7",
|
|
56
|
+
"b": "8d21a5d5a07ed0d7",
|
|
57
|
+
"c": "8d21a5d5a07ed0d7",
|
|
58
|
+
"ok": true,
|
|
59
|
+
"failure": null
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"time": 1.76,
|
|
63
|
+
"a": "8d21a5d5a07ed0d7",
|
|
64
|
+
"b": "8d21a5d5a07ed0d7",
|
|
65
|
+
"c": "8d21a5d5a07ed0d7",
|
|
66
|
+
"ok": true,
|
|
67
|
+
"failure": null
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"time": 1.9,
|
|
71
|
+
"a": "8d21a5d5a07ed0d7",
|
|
72
|
+
"b": "8d21a5d5a07ed0d7",
|
|
73
|
+
"c": "8d21a5d5a07ed0d7",
|
|
74
|
+
"ok": true,
|
|
75
|
+
"failure": null
|
|
76
|
+
}
|
|
77
|
+
],
|
|
78
|
+
"framesSha256": "82dff4b5de7e0d9d"
|
|
79
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amg555/frameforge-mcp",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.9",
|
|
4
4
|
"description": "FrameForge — AI video production engine for agentic IDEs. npx @amg555/frameforge-mcp. FFmpeg + TTS run on your machine. 19 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.3.
|
|
23
|
+
check('SERVER_INFO version 2.3.9', code.includes("version: '2.3.9'"));
|
|
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'));
|
|
@@ -295,7 +295,7 @@ check('cf-worker has checkRateLimit', cfWorker.includes('checkRateLimit'));
|
|
|
295
295
|
check('cf-worker hashes token (SHA-256)', cfWorker.includes('SHA-256'));
|
|
296
296
|
check('cf-worker uses RATE_LIMITS KV', cfWorker.includes('env.RATE_LIMITS'));
|
|
297
297
|
check('cf-worker returns 429 on limit', cfWorker.includes('429'));
|
|
298
|
-
check('cf-worker version 2.3.
|
|
298
|
+
check('cf-worker version 2.3.9', cfWorker.includes("'2.3.9'"));
|
|
299
299
|
check('wrangler.toml has kv_namespaces',fs.readFileSync('./cloudflare-mcp/wrangler.toml','utf8').includes('kv_namespaces'));
|
|
300
300
|
|
|
301
301
|
// ── plugins/skills present ────────────────────────────────────────────────
|
|
@@ -64,7 +64,9 @@ function resolveProjectFile(filename, fallback) {
|
|
|
64
64
|
return inCwd;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
const
|
|
67
|
+
const positionalHtml = extras.find(e => typeof e === 'string' && e.endsWith('.html'));
|
|
68
|
+
const fixtureFallback = path.resolve(__dirname, '../test/fixtures/reference-scene.html');
|
|
69
|
+
const HTML_FILE = flags.html ? path.resolve(CWD, flags.html) : (positionalHtml ? path.resolve(CWD, positionalHtml) : resolveProjectFile('index.html', resolveProjectFile('studio.html', fixtureFallback)));
|
|
68
70
|
const OUT_DIR = flags.outDir ? path.resolve(CWD, flags.outDir) : path.join(CWD, 'out');
|
|
69
71
|
const CHECK_DIR = path.join(OUT_DIR, 'check');
|
|
70
72
|
const FRAMES_DIR = flags.frames ? path.resolve(CWD, flags.frames) : flags.out ? path.resolve(CWD, flags.out) : path.join(OUT_DIR, 'frames');
|
package/src/mcp-server/server.js
CHANGED
|
@@ -368,10 +368,38 @@ class TTSProvider {
|
|
|
368
368
|
* picks by gender preference (TTS_VOICE_GENDER=male|female|default).
|
|
369
369
|
*
|
|
370
370
|
* @param {string} [explicitVoice] voice passed by caller (takes priority)
|
|
371
|
+
* @param {string} [text] narration text for language detection
|
|
371
372
|
* @returns {string}
|
|
372
373
|
*/
|
|
373
|
-
selectNarratorVoice(explicitVoice) {
|
|
374
|
+
selectNarratorVoice(explicitVoice, text = '') {
|
|
374
375
|
if (explicitVoice) return explicitVoice;
|
|
376
|
+
|
|
377
|
+
// Language detection for edge-tts
|
|
378
|
+
if (this.name === 'edge-tts' && text) {
|
|
379
|
+
const gender = (process.env.TTS_VOICE_GENDER || 'default').toLowerCase();
|
|
380
|
+
if (/[\u0D00-\u0D7F]/.test(text)) {
|
|
381
|
+
return gender === 'female' ? 'ml-IN-SobhanaNeural' : 'ml-IN-MidhunNeural';
|
|
382
|
+
}
|
|
383
|
+
if (/[\u0900-\u097F]/.test(text)) {
|
|
384
|
+
return gender === 'female' ? 'hi-IN-SwaraNeural' : 'hi-IN-MadhurNeural';
|
|
385
|
+
}
|
|
386
|
+
if (/[\u0B80-\u0BFF]/.test(text)) {
|
|
387
|
+
return gender === 'female' ? 'ta-IN-PallaviNeural' : 'ta-IN-ValluvarNeural';
|
|
388
|
+
}
|
|
389
|
+
if (/[\u0C00-\u0C7F]/.test(text)) {
|
|
390
|
+
return gender === 'female' ? 'te-IN-ShrutiNeural' : 'te-IN-MohanNeural';
|
|
391
|
+
}
|
|
392
|
+
if (/[\u4E00-\u9FFF]/.test(text)) {
|
|
393
|
+
return gender === 'female' ? 'zh-CN-XiaoxiaoNeural' : 'zh-CN-YunjianNeural';
|
|
394
|
+
}
|
|
395
|
+
if (/[\u3040-\u30FF]/.test(text)) {
|
|
396
|
+
return gender === 'female' ? 'ja-JP-NanamiNeural' : 'ja-JP-KeitaNeural';
|
|
397
|
+
}
|
|
398
|
+
if (/[\u0600-\u06FF]/.test(text)) {
|
|
399
|
+
return gender === 'female' ? 'ar-SA-ZariyahNeural' : 'ar-SA-HamedNeural';
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
375
403
|
const voices = NARRATOR_VOICES[this.name] || NARRATOR_VOICES['edge-tts'];
|
|
376
404
|
const gender = (process.env.TTS_VOICE_GENDER || 'default').toLowerCase();
|
|
377
405
|
return voices[gender] || voices.default;
|
|
@@ -384,7 +412,7 @@ class TTSProvider {
|
|
|
384
412
|
* @returns {Promise<string>} resolves with outputPath
|
|
385
413
|
*/
|
|
386
414
|
async synthesize({ text, voice, outputPath }) {
|
|
387
|
-
const resolvedVoice = this.selectNarratorVoice(voice);
|
|
415
|
+
const resolvedVoice = this.selectNarratorVoice(voice, text);
|
|
388
416
|
switch (this.name) {
|
|
389
417
|
case 'edge-tts': return this._edgeTTS({ text, voice: resolvedVoice, outputPath });
|
|
390
418
|
case 'elevenlabs': return this._elevenLabs({ text, voice: resolvedVoice, outputPath });
|
|
@@ -464,27 +492,36 @@ class TTSProvider {
|
|
|
464
492
|
|
|
465
493
|
async _edgeTTS({ text, voice, outputPath }) {
|
|
466
494
|
const rawPath = outputPath.replace(/\.[^.]+$/, '_raw.mp3');
|
|
467
|
-
const
|
|
495
|
+
const txtPath = rawPath + '.txt';
|
|
496
|
+
const safeVoice = voice || this.selectNarratorVoice(null, text);
|
|
468
497
|
// No artificial rate manipulation — use the voice's natural cadence.
|
|
469
498
|
// Only a mild rate adjustment from env (e.g. TTS_RATE='+0%') is supported.
|
|
470
499
|
const rate = this._overrides.rate || process.env.TTS_RATE || '+0%';
|
|
471
500
|
return withRetry(async () => {
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
501
|
+
await fs.writeFile(txtPath, text, 'utf8');
|
|
502
|
+
try {
|
|
503
|
+
await new Promise((resolve, reject) => {
|
|
504
|
+
const proc = spawn('edge-tts', ['--voice', safeVoice, '--rate', rate, '--file', txtPath, '--write-media', rawPath], { timeout: 40000, shell: false });
|
|
505
|
+
let stderr = '';
|
|
506
|
+
proc.stderr?.on('data', d => { stderr += d; });
|
|
507
|
+
proc.on('close', code => code === 0 ? resolve() : reject(new Error(`edge-tts exited ${code}: ${stderr}`)));
|
|
508
|
+
proc.on('error', reject);
|
|
509
|
+
});
|
|
510
|
+
// Normalise audio via FFmpeg — trim trailing silence, normalise levels
|
|
511
|
+
await new Promise((resolve, reject) => {
|
|
512
|
+
const proc = spawn('ffmpeg', ['-nostdin', '-y', '-threads', '2',
|
|
513
|
+
'-i', rawPath,
|
|
514
|
+
'-af', 'silenceremove=stop_periods=-1:stop_duration=0.3:stop_threshold=-40dB,highpass=f=80,volume=1.0,dynaudnorm=f=150:g=5:p=0.95',
|
|
515
|
+
'-ar', '48000', '-ac', '2', '-c:a', 'aac', '-b:a', '192k', outputPath], { timeout: 30000 });
|
|
516
|
+
let stderr = '';
|
|
517
|
+
proc.stderr?.on('data', d => { stderr += d; });
|
|
518
|
+
proc.on('close', code => code === 0 ? resolve() : reject(new Error(`ffmpeg exited ${code}: ${stderr}`)));
|
|
519
|
+
proc.on('error', reject);
|
|
520
|
+
});
|
|
521
|
+
} finally {
|
|
522
|
+
await fs.unlink(txtPath).catch(() => {});
|
|
523
|
+
await fs.unlink(rawPath).catch(() => {});
|
|
524
|
+
}
|
|
488
525
|
return outputPath;
|
|
489
526
|
}, { maxAttempts: 3, baseDelayMs: 3000, label: 'TTS(edge-tts)' });
|
|
490
527
|
}
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
"video_path": { "type": "string", "description": "Optional path to user-supplied video clip or B-roll" },
|
|
13
13
|
"image_path": { "type": "string", "description": "Optional path to user-supplied static image" },
|
|
14
14
|
"audio_path": { "type": "string", "description": "Optional path to user-supplied pre-recorded voiceover" },
|
|
15
|
+
"html": { "type": "string", "description": "Optional self-contained HTML animation page conforming to window.seek(t)" },
|
|
16
|
+
"code": { "type": "string", "description": "Optional canvas drawing JavaScript code executed for time t in window.seek" },
|
|
17
|
+
"animation_code": { "type": "string", "description": "Alias for code" },
|
|
15
18
|
"visual_description": { "type": "string", "minLength": 1 },
|
|
16
19
|
"character": {
|
|
17
20
|
"type": "object",
|
|
@@ -406,6 +406,21 @@ function sfc32(a, b, c, d) {
|
|
|
406
406
|
}
|
|
407
407
|
const hash = (i) => sfc32(i * 19 + 42, i * 29 + 1337, i * 13 + 7, 99)();
|
|
408
408
|
|
|
409
|
+
// Animation Primitives & Easing
|
|
410
|
+
function clamp(val, min = 0, max = 1) { return Math.max(min, Math.min(max, val)); }
|
|
411
|
+
function lerp(t, a, b, t0 = 0, t1 = 1) {
|
|
412
|
+
if (t <= t0) return a;
|
|
413
|
+
if (t >= t1) return b;
|
|
414
|
+
return a + (b - a) * ((t - t0) / (t1 - t0));
|
|
415
|
+
}
|
|
416
|
+
function easeIn(t) { return clamp(t, 0, 1) ** 2; }
|
|
417
|
+
function easeOut(t) { const c = clamp(t, 0, 1); return 1 - (1 - c) ** 2; }
|
|
418
|
+
function easeInOut(t) { const c = clamp(t, 0, 1); return c < 0.5 ? 2 * c * c : 1 - (-2 * c + 2) ** 2 / 2; }
|
|
419
|
+
function boilSeed(t, seed = 0) {
|
|
420
|
+
const frame12 = Math.floor(t * 12);
|
|
421
|
+
return hash(seed * 1000 + frame12);
|
|
422
|
+
}
|
|
423
|
+
|
|
409
424
|
// Spring dynamics
|
|
410
425
|
function spring(t, delay = 0, tension = 180, friction = 14) {
|
|
411
426
|
const dt = t - delay;
|