@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 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
  [![npm](https://img.shields.io/npm/v/@amg555/frameforge-mcp.svg?label=npm)](https://www.npmjs.com/package/@amg555/frameforge-mcp)
10
10
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
11
11
  [![Node.js](https://img.shields.io/badge/node-%3E%3D18.0.0-brightgreen.svg)](https://nodejs.org/)
12
- [![Version](https://img.shields.io/badge/version-2.3.7-blue.svg)](CHANGELOG.md)
12
+ [![Version](https://img.shields.io/badge/version-2.3.8-blue.svg)](CHANGELOG.md)
13
13
  [![MCP Spec](https://img.shields.io/badge/MCP-2024--11--05-blue.svg)](https://modelcontextprotocol.io/)
14
14
  [![Tests](https://img.shields.io/badge/tests-539%20passing-brightgreen.svg)](#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.7",
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"
@@ -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.7', code.includes("version: '2.3.7'"));
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.7', cfWorker.includes("'2.3.7'"));
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 HTML_FILE = flags.html ? path.resolve(CWD, flags.html) : resolveProjectFile('index.html', resolveProjectFile('studio.html'));
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');
@@ -1524,7 +1524,7 @@ Features:
1524
1524
 
1525
1525
  const SERVER_INFO = {
1526
1526
  name: 'video-pipeline',
1527
- version: '2.3.7',
1527
+ version: '2.3.9',
1528
1528
  };
1529
1529
 
1530
1530
  const CAPABILITIES = {
@@ -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 safeVoice = voice || NARRATOR_VOICES['edge-tts'].default;
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
- const _shell = process.platform === 'win32';
473
- await new Promise((resolve, reject) => {
474
- const proc = spawn('edge-tts', ['--voice', safeVoice, '--rate', rate, '--text', text, '--write-media', rawPath], { timeout: 40000, shell: _shell });
475
- proc.on('close', code => code === 0 ? resolve() : reject(new Error(`edge-tts exited ${code}`)));
476
- proc.on('error', reject);
477
- });
478
- // Normalise audio via FFmpeg — trim trailing silence, normalise levels
479
- await new Promise((resolve, reject) => {
480
- const proc = spawn('ffmpeg', ['-nostdin', '-y', '-threads', '2',
481
- '-i', rawPath,
482
- '-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',
483
- '-ar', '48000', '-ac', '2', '-c:a', 'aac', '-b:a', '192k', outputPath], { timeout: 30000, shell: _shell });
484
- proc.on('close', code => code === 0 ? resolve() : reject(new Error(`ffmpeg exited ${code}`)));
485
- proc.on('error', reject);
486
- });
487
- await fs.unlink(rawPath).catch(() => {});
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;