@amg555/frameforge-mcp 2.3.1 → 2.3.3
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/.mcp.json +2 -4
- package/CHANGELOG.md +20 -1
- package/CLAUDE.md +154 -18
- package/README.md +16 -12
- package/SECURITY.md +1 -3
- package/install.sh +1 -1
- package/package.json +7 -3
- package/plugins/video-pipeline/.claude-plugin/plugin.json +19 -0
- package/plugins/video-pipeline/skills/html-renderer/SKILL.md +292 -0
- package/plugins/video-pipeline/skills/video-pipeline/SKILL.md +308 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/ANIMATION_GUIDE.md +257 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/STORYBOARD.md +77 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/index.html +80 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/diagram-author.md +43 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/orchestrator.md +56 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/scene-author.md +42 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.css +140 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.js +870 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/virtual-clock.js +138 -0
- package/plugins/video-pipeline/skills/video-pipeline/references/mcp-tools.md +167 -0
- package/plugins/video-pipeline/skills/video-pipeline/references/providers.md +163 -0
- package/scripts/db-migrate.js +112 -0
- package/scripts/deep-validate.js +326 -0
- package/scripts/generate-initial-assets.js +342 -0
- package/scripts/html-renderer/new-project.mjs +408 -0
- package/scripts/html-renderer/transcribe.py +294 -0
- package/src/asset-library/README.md +2 -2
- package/src/asset-library/asset-library.js +1 -1
- package/src/cli/doctor.js +31 -1
- package/src/cli/make.js +52 -71
- package/src/cli/setup-keys.js +24 -19
- package/src/fact-checker/review-gate.js +5 -1
- package/src/harness/bin/harness.mjs +324 -0
- package/src/harness/lib/browser.mjs +137 -0
- package/src/harness/lib/capture.mjs +161 -0
- package/src/harness/lib/encode.mjs +89 -0
- package/src/harness/lib/jobs.mjs +135 -0
- package/src/harness/lib/pixels.mjs +137 -0
- package/src/harness/lib/sheets.mjs +164 -0
- package/src/harness/lib/verify-purity.mjs +119 -0
- package/src/harness/test/fixtures/broken-scene.html +27 -0
- package/src/harness/test/fixtures/reference-scene.html +23 -0
- package/src/harness/test/fixtures/sample-script.json +18 -0
- package/src/mcp-server/server.js +318 -66
- package/src/pipeline/external-adapter.js +55 -15
- package/src/pipeline/providers.js +46 -0
- package/src/pipeline/telemetry.js +1 -0
- package/src/utils/log-redaction.js +5 -3
- package/src/video-renderer/renderer.js +12 -4
- package/src/worker/server-render-free.js +2 -3
- package/src/workflows/assets.js +20 -43
- package/src/workflows/ingest.js +5 -5
package/src/mcp-server/server.js
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* Env (optional — only needed for pipeline-internal LLM calls):
|
|
24
24
|
* PROVIDER_LLM=groq|cerebras|openrouter|anthropic|gemini|mistral|together|openai|ollama|webhook
|
|
25
25
|
* PROVIDER_TTS=edge-tts|elevenlabs|azure|openai|webhook
|
|
26
|
-
*
|
|
26
|
+
* Visuals: 100% code-driven animation (default) or user media
|
|
27
27
|
* MCP_PORT=3210
|
|
28
28
|
*/
|
|
29
29
|
|
|
@@ -36,7 +36,7 @@ const os = require('os');
|
|
|
36
36
|
const fs = require('fs').promises;
|
|
37
37
|
const crypto = require('crypto');
|
|
38
38
|
|
|
39
|
-
const { LLMProvider, TTSProvider
|
|
39
|
+
const { LLMProvider, TTSProvider } = require('../pipeline/providers');
|
|
40
40
|
const { StateMachine } = require('../pipeline/state-machine');
|
|
41
41
|
const { RetryPolicy } = require('../pipeline/retry-policy');
|
|
42
42
|
const { Telemetry } = require('../pipeline/telemetry');
|
|
@@ -53,7 +53,6 @@ const USAGE_LOG = path.join(FILES_DIR, 'usage.ndjson');
|
|
|
53
53
|
// Singletons
|
|
54
54
|
const llm = new LLMProvider();
|
|
55
55
|
const tts = new TTSProvider();
|
|
56
|
-
const imageGen = new ImageProvider();
|
|
57
56
|
const sm = new StateMachine({ mode: 'file', stateDir: STATE_DIR });
|
|
58
57
|
const retryPol = new RetryPolicy({ maxAttempts: 3, baseDelayMs: 10000 });
|
|
59
58
|
const telemetry = new Telemetry({ mode: 'file', logPath: USAGE_LOG });
|
|
@@ -121,46 +120,37 @@ const TOOLS = {
|
|
|
121
120
|
},
|
|
122
121
|
},
|
|
123
122
|
|
|
124
|
-
// ── Tool 3: Generate scene
|
|
123
|
+
// ── Tool 3: Generate scene visual ─────────────────────────────────────────
|
|
125
124
|
generate_scene_image: {
|
|
126
|
-
description: '
|
|
125
|
+
description: 'Registers or generates a code-driven visual card for one scene. FrameForge uses 100% code-driven animation and user media instead of diffusion.',
|
|
127
126
|
inputSchema: {
|
|
128
127
|
type: 'object',
|
|
129
|
-
required: ['productionId', 'sceneIndex'
|
|
128
|
+
required: ['productionId', 'sceneIndex'],
|
|
130
129
|
properties: {
|
|
131
130
|
productionId: { type: 'string' },
|
|
132
131
|
sceneIndex: { type: 'number' },
|
|
133
|
-
visualDescription:{ type: 'string', description: 'Scene visual description
|
|
134
|
-
|
|
135
|
-
negativePrompt: { type: 'string', description: 'What to exclude from the image (optional)' },
|
|
136
|
-
seed: { type: 'number', default: 12345 },
|
|
132
|
+
visualDescription:{ type: 'string', description: 'Scene visual description' },
|
|
133
|
+
imagePath: { type: 'string', description: 'Optional user image path' },
|
|
137
134
|
},
|
|
138
135
|
},
|
|
139
136
|
handler: async ({
|
|
140
137
|
productionId,
|
|
141
138
|
sceneIndex,
|
|
142
139
|
visualDescription,
|
|
143
|
-
// Accept legacy 'visualAction' param name for backwards compat
|
|
144
140
|
visualAction,
|
|
145
|
-
|
|
146
|
-
negativePrompt = 'watermark, text, logo, blurry, low quality',
|
|
147
|
-
seed = 12345,
|
|
141
|
+
imagePath = null,
|
|
148
142
|
}) => {
|
|
149
|
-
const vdir = path.join(FILES_DIR, 'video_tmp');
|
|
150
|
-
const outputPath = path.join(vdir, `scene_${sceneIndex}.png`);
|
|
151
|
-
await fs.mkdir(vdir, { recursive: true });
|
|
152
|
-
|
|
153
143
|
const description = visualDescription || visualAction || '';
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
await retryPol.executeStage('Asset_Generation', async () => {
|
|
157
|
-
return imageGen.generate({ prompt, negativePrompt, outputPath, width: 1920, height: 1080, seed: seed + sceneIndex * 37 });
|
|
158
|
-
});
|
|
144
|
+
await telemetry.track({ productionId, stage: 'Asset_Generation', provider: 'code_animation', operation: 'scene_visual_registered', units: 1 });
|
|
159
145
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
146
|
+
return {
|
|
147
|
+
productionId,
|
|
148
|
+
sceneIndex,
|
|
149
|
+
imagePath: imagePath || null,
|
|
150
|
+
visualType: imagePath ? 'user_image' : 'code_animation',
|
|
151
|
+
visualDescription: description,
|
|
152
|
+
message: 'Scene visual ready. 100% code-driven kinetic animation active.',
|
|
153
|
+
};
|
|
164
154
|
},
|
|
165
155
|
},
|
|
166
156
|
|
|
@@ -434,13 +424,29 @@ const TOOLS = {
|
|
|
434
424
|
// ── Tool 10: External platform call ────────────────────────────────────────
|
|
435
425
|
// Bridge to VoxForge, Runway, ElevenLabs, Remotion, or any external platform.
|
|
436
426
|
external_platform_call: {
|
|
437
|
-
description:
|
|
427
|
+
description: `Call any external platform or custom MCP-compatible app via HTTP — no env vars required when you pass platformUrl directly.
|
|
428
|
+
|
|
429
|
+
Works with any app that exposes an HTTP API: your own custom editor, VoxForge, Runway, Kling, Pika, Remotion, ElevenLabs, or anything you build.
|
|
430
|
+
|
|
431
|
+
Actions:
|
|
432
|
+
ingest — upload/import files (POST multipart to the platform)
|
|
433
|
+
edit — send an operations array to drive editing in the app
|
|
434
|
+
render — send scenes or a render job request
|
|
435
|
+
status — poll a job by jobId
|
|
436
|
+
download — fetch a remote URL to a local file
|
|
437
|
+
|
|
438
|
+
Named presets (set EXTERNAL_PLATFORM_NAME to one of these and omit platformUrl):
|
|
439
|
+
voxforge, elevenlabs, runway, kling, pika, remotion, premiere, resolve, capcut, aftereffects`,
|
|
438
440
|
inputSchema: {
|
|
439
441
|
type: 'object',
|
|
440
442
|
required: ['action'],
|
|
441
443
|
properties: {
|
|
442
|
-
action: { type: 'string', enum: ['ingest','render','status','download'], description: 'ingest: upload
|
|
444
|
+
action: { type: 'string', enum: ['ingest','edit','render','status','download'], description: 'ingest: upload files | edit: send operations to drive the app | render: submit render job | status: poll job | download: fetch output' },
|
|
445
|
+
platformUrl: { type: 'string', description: 'Base URL of the target app/platform (e.g. http://localhost:8080 or https://api.myapp.io). Overrides EXTERNAL_PLATFORM_URL. Pass this to connect any custom app without touching .env.' },
|
|
446
|
+
platformKey: { type: 'string', description: 'API key or bearer token for the platform. Overrides EXTERNAL_PLATFORM_KEY.' },
|
|
447
|
+
platformName:{ type: 'string', description: 'Named preset to use (voxforge|elevenlabs|runway|kling|premiere|resolve|capcut|aftereffects|remotion). When set, default paths are applied automatically.' },
|
|
443
448
|
files: { type: 'array', items: { type: 'string' }, description: 'Local file paths (for action=ingest)' },
|
|
449
|
+
operations: { type: 'array', description: 'Edit operations array (for action=edit). Each: { op, clip?, at?, between?, type?, duration?, text?, ... }' },
|
|
444
450
|
scenes: { type: 'array', description: 'Scene array (for action=render)' },
|
|
445
451
|
productionId:{ type: 'string', description: 'productionId (for action=render)' },
|
|
446
452
|
jobId: { type: 'string', description: 'Job ID to poll (for action=status)' },
|
|
@@ -449,27 +455,58 @@ const TOOLS = {
|
|
|
449
455
|
metadata: { type: 'object', description: 'Extra fields forwarded to the platform' },
|
|
450
456
|
},
|
|
451
457
|
},
|
|
452
|
-
handler: async ({ action, files, scenes, productionId, jobId, outputUrl, outputPath, metadata }) => {
|
|
458
|
+
handler: async ({ action, platformUrl, platformKey, platformName, files, operations, scenes, productionId, jobId, outputUrl, outputPath, metadata }) => {
|
|
453
459
|
const { ExternalAdapter } = require('../pipeline/external-adapter');
|
|
454
|
-
|
|
460
|
+
|
|
461
|
+
// Build adapter — per-call platformUrl/platformKey override env vars entirely.
|
|
462
|
+
// This means you can connect any custom app without setting EXTERNAL_PLATFORM_* in .env.
|
|
463
|
+
const adapterConfig = {};
|
|
464
|
+
if (platformName) adapterConfig.name = platformName;
|
|
465
|
+
if (platformUrl) adapterConfig.baseUrl = platformUrl;
|
|
466
|
+
if (platformKey) adapterConfig.apiKey = platformKey;
|
|
467
|
+
|
|
468
|
+
const adapter = new ExternalAdapter(adapterConfig);
|
|
469
|
+
|
|
470
|
+
// If no URL was provided via param or env, give a clear setup message
|
|
455
471
|
if (!adapter.isConfigured()) {
|
|
456
|
-
throw new Error(
|
|
472
|
+
throw new Error(
|
|
473
|
+
'No platform URL configured. Either:\n' +
|
|
474
|
+
' • Pass platformUrl: "http://localhost:8080" (connects any custom app immediately)\n' +
|
|
475
|
+
' • Set EXTERNAL_PLATFORM_URL in .env (persistent)\n' +
|
|
476
|
+
' • Run: npx frameforge-setup (interactive wizard)'
|
|
477
|
+
);
|
|
457
478
|
}
|
|
458
479
|
|
|
459
480
|
switch (action) {
|
|
460
481
|
case 'ingest':
|
|
461
482
|
return adapter.ingestMedia({ files: files || [], metadata: metadata || {} });
|
|
483
|
+
|
|
484
|
+
case 'edit': {
|
|
485
|
+
// Validate operations
|
|
486
|
+
const ops = operations || [];
|
|
487
|
+
if (ops.length === 0) throw new Error('action=edit requires at least one operation in the operations array');
|
|
488
|
+
const validOps = ['cut','trim','transition','color','caption','mute','speed','swap'];
|
|
489
|
+
for (const [i, o] of ops.entries()) {
|
|
490
|
+
if (!o.op) throw new Error(`operations[${i}] missing "op" field`);
|
|
491
|
+
if (!validOps.includes(o.op)) throw new Error(`operations[${i}].op "${o.op}" not valid. Use: ${validOps.join(', ')}`);
|
|
492
|
+
}
|
|
493
|
+
return adapter.renderScenes({ scenes: [], productionId: 'edit', options: { operations: ops, ...(metadata || {}) } });
|
|
494
|
+
}
|
|
495
|
+
|
|
462
496
|
case 'render':
|
|
463
497
|
return adapter.renderScenes({ scenes: scenes || [], productionId, options: metadata || {} });
|
|
498
|
+
|
|
464
499
|
case 'status':
|
|
465
500
|
if (!jobId) throw new Error('action=status requires jobId');
|
|
466
501
|
return adapter.checkStatus(jobId);
|
|
502
|
+
|
|
467
503
|
case 'download':
|
|
468
504
|
if (!outputUrl || !outputPath) throw new Error('action=download requires outputUrl and outputPath');
|
|
469
505
|
await adapter.downloadOutput(outputUrl, outputPath);
|
|
470
506
|
return { outputPath, downloaded: true };
|
|
507
|
+
|
|
471
508
|
default:
|
|
472
|
-
throw new Error(`Unknown action: ${action}. Use ingest|render|status|download`);
|
|
509
|
+
throw new Error(`Unknown action: ${action}. Use ingest|edit|render|status|download`);
|
|
473
510
|
}
|
|
474
511
|
},
|
|
475
512
|
},
|
|
@@ -478,37 +515,41 @@ const TOOLS = {
|
|
|
478
515
|
// Primary path for IBM Bob / Claude Code / Cursor users.
|
|
479
516
|
// The IDE agent writes the scenes array in its own context — no internal LLM needed.
|
|
480
517
|
create_video_from_script: {
|
|
481
|
-
description: `Agent-first video creation. YOU (the IDE agent) provide the complete scenes array — FrameForge
|
|
518
|
+
description: `Agent-first video creation. YOU (the IDE agent) provide the complete scenes array — FrameForge renders it.
|
|
519
|
+
Features:
|
|
520
|
+
- Code-driven animation and kinetic typography by default (broadcast quality, zero AI artifacts).
|
|
521
|
+
- Supports manual user clips (video_path) and manual images (image_path).
|
|
522
|
+
- Optional AI diffusion generation via enableAiImages: true.
|
|
523
|
+
- No internal LLM or research required. Primary path for Claude Code, IBM Bob, Cursor, and any agentic IDE.
|
|
482
524
|
|
|
483
525
|
Example scene:
|
|
484
526
|
{
|
|
485
527
|
"narration": "The text spoken aloud in this scene",
|
|
486
528
|
"caption": "OVERLAY TEXT",
|
|
487
|
-
"visual_description": "
|
|
529
|
+
"visual_description": "Description of the scene visual / animation",
|
|
488
530
|
"camera_hint": "zoom_in",
|
|
489
531
|
"music_mood": "energetic",
|
|
490
532
|
"image_path": "/optional/your/own/image.jpg",
|
|
533
|
+
"video_path": "/optional/your/own/clip.mp4",
|
|
491
534
|
"audio_path": "/optional/your/own/voice.mp3"
|
|
492
535
|
}`,
|
|
493
536
|
inputSchema: {
|
|
494
537
|
type: 'object',
|
|
495
538
|
required: ['scenes'],
|
|
496
539
|
properties: {
|
|
497
|
-
scenes:
|
|
498
|
-
title:
|
|
499
|
-
orientation:
|
|
500
|
-
voice:
|
|
501
|
-
language:
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
musicVolume: { type: 'number', default: 0.07 },
|
|
508
|
-
productionId: { type: 'string', description: 'Optional — reuse an existing productionId' },
|
|
540
|
+
scenes: { type: 'array', description: 'Complete scenes array written by the agent. Each scene: { narration, caption, visual_description, camera_hint?, music_mood?, image_path?, video_path?, audio_path?, voice? }' },
|
|
541
|
+
title: { type: 'string', description: 'Video title' },
|
|
542
|
+
orientation: { type: 'string', enum: ['landscape', 'portrait'], default: 'landscape', description: 'Output orientation. "landscape" = 1920×1080 (16:9, YouTube/desktop). "portrait" = 1080×1920 (9:16, TikTok/Reels/Shorts).' },
|
|
543
|
+
voice: { type: 'string', description: 'edge-tts voice ID for all scenes, e.g. "ml-IN-MidhunNeural" (Malayalam male), "en-US-BrianNeural" (English). Per-scene voice field overrides this. Full list: edge-tts --list-voices' },
|
|
544
|
+
language: { type: 'string', description: 'BCP-47 language hint, e.g. "ml" (Malayalam), "hi" (Hindi), "ta" (Tamil), "te" (Telugu), "en" (English). Auto-selects a default voice for the language when voice is not set.' },
|
|
545
|
+
skipTts: { type: 'boolean', default: false, description: 'Set true if all scenes already have audio_path' },
|
|
546
|
+
parallelism: { type: 'number', default: 1, description: 'Concurrent TTS synthesis requests (default: 1).' },
|
|
547
|
+
withMusic: { type: 'boolean', default: true },
|
|
548
|
+
musicVolume: { type: 'number', default: 0.07 },
|
|
549
|
+
productionId: { type: 'string', description: 'Optional — reuse an existing productionId' },
|
|
509
550
|
},
|
|
510
551
|
},
|
|
511
|
-
handler: async ({ scenes, title = 'FrameForge Video', orientation = 'landscape', voice, language,
|
|
552
|
+
handler: async ({ scenes, title = 'FrameForge Video', orientation = 'landscape', voice, language, skipTts = false, parallelism = 1, withMusic = true, musicVolume = 0.07, productionId }) => {
|
|
512
553
|
const { runAssets } = require('../workflows/assets');
|
|
513
554
|
const { runRender } = require('../workflows/render');
|
|
514
555
|
|
|
@@ -542,15 +583,12 @@ Example scene:
|
|
|
542
583
|
};
|
|
543
584
|
const resolvedVoice = voice || (language ? LANGUAGE_VOICES[language.toLowerCase().slice(0, 2)] : null) || null;
|
|
544
585
|
|
|
545
|
-
// Apply imageStyle to visual_description if provided and skipImages is false
|
|
546
586
|
const processedScenes = scenes.map((s, i) => ({
|
|
547
587
|
scene_id: s.scene_id || i + 1,
|
|
548
588
|
schema_version: '2.0.0',
|
|
549
589
|
narration: s.narration || '',
|
|
550
590
|
caption: s.caption || '',
|
|
551
|
-
visual_description:
|
|
552
|
-
? `${s.visual_description || ''}, ${imageStyle}`.trim()
|
|
553
|
-
: (s.visual_description || ''),
|
|
591
|
+
visual_description: s.visual_description || '',
|
|
554
592
|
camera_hint: s.camera_hint || 'auto',
|
|
555
593
|
music_mood: s.music_mood || 'neutral',
|
|
556
594
|
image_path: s.image_path || null,
|
|
@@ -570,7 +608,7 @@ Example scene:
|
|
|
570
608
|
const renderWidth = orientation === 'portrait' ? 1080 : 1920;
|
|
571
609
|
const renderHeight = orientation === 'portrait' ? 1920 : 1080;
|
|
572
610
|
|
|
573
|
-
const assetsResult = await runAssets({ productionId: pid, scenes: processedScenes, parallelism,
|
|
611
|
+
const assetsResult = await runAssets({ productionId: pid, scenes: processedScenes, parallelism, skipTts, voice: resolvedVoice });
|
|
574
612
|
const renderResult = await runRender({ productionId: pid, sceneAssets: assetsResult.sceneAssets, withMusic, musicVolume, renderWidth, renderHeight });
|
|
575
613
|
|
|
576
614
|
if (!renderResult.passed) {
|
|
@@ -592,14 +630,14 @@ Example scene:
|
|
|
592
630
|
// ── Tool 12: Multi-platform TTS ──────────────────────────────────────────
|
|
593
631
|
// Call any voice platform per-request without changing global .env
|
|
594
632
|
multi_voice_tts: {
|
|
595
|
-
description: 'Convert text to speech using any supported voice platform per-call. Overrides the global PROVIDER_TTS for this one request. Supports: edge-tts, elevenlabs, azure, openai,
|
|
633
|
+
description: 'Convert text to speech using any supported voice platform per-call. Overrides the global PROVIDER_TTS for this one request. Supports: edge-tts, voxforge, elevenlabs, azure, openai, webhook.',
|
|
596
634
|
inputSchema: {
|
|
597
635
|
type: 'object',
|
|
598
636
|
required: ['text', 'outputPath'],
|
|
599
637
|
properties: {
|
|
600
638
|
text: { type: 'string', description: 'Text to synthesise' },
|
|
601
639
|
outputPath: { type: 'string', description: 'Where to save the audio file (.m4a or .mp3)' },
|
|
602
|
-
platform: { type: 'string', enum: ['edge-tts','elevenlabs','azure','openai','webhook'], description: 'TTS platform. Overrides PROVIDER_TTS for this call.' },
|
|
640
|
+
platform: { type: 'string', enum: ['edge-tts','voxforge','elevenlabs','azure','openai','webhook'], description: 'TTS platform. Overrides PROVIDER_TTS for this call. "voxforge" = github.com/amg555/voxforge; uses VOXFORGE_URL + VOXFORGE_KEY.' },
|
|
603
641
|
voice: { type: 'string', description: 'Voice ID. edge-tts: e.g. en-US-GuyNeural. ElevenLabs: voice_id. Azure: short name. OpenAI: alloy/echo/fable/nova/onyx/shimmer.' },
|
|
604
642
|
rate: { type: 'string', description: 'Speaking rate, e.g. "-10%" for slower, "+20%" for faster (edge-tts)' },
|
|
605
643
|
apiKey: { type: 'string', description: 'API key for this call (optional — falls back to env var)' },
|
|
@@ -608,9 +646,11 @@ Example scene:
|
|
|
608
646
|
sceneIndex: { type: 'number', description: 'Optional scene index for naming the output file' },
|
|
609
647
|
},
|
|
610
648
|
},
|
|
611
|
-
handler: async ({ text, outputPath, platform, voice, rate, apiKey, webhookUrl, productionId = 'adhoc', sceneIndex }) => {
|
|
649
|
+
handler: async ({ text, outputPath, platform: _platform, voice, rate, apiKey, webhookUrl, productionId = 'adhoc', sceneIndex }) => {
|
|
612
650
|
const syncFs = require('fs');
|
|
613
651
|
const pathMod = require('path');
|
|
652
|
+
// Normalise platform name so 'voxforge' routes correctly
|
|
653
|
+
const platform = _platform ? _platform.toLowerCase() : undefined;
|
|
614
654
|
|
|
615
655
|
// Guard: outputPath must not escape FILES_DIR — prevent path traversal
|
|
616
656
|
if (outputPath) {
|
|
@@ -768,7 +808,7 @@ Copies the engine template into outDir and generates a starter edit.js from your
|
|
|
768
808
|
|
|
769
809
|
Use this when:
|
|
770
810
|
- You want Remotion-style motion graphics (kinetic text, springs, morphing layouts)
|
|
771
|
-
-
|
|
811
|
+
- You want broadcast-grade visual quality with zero diffusion artifacts
|
|
772
812
|
- The user asks for animated video, code-rendered video, or "make it look good"
|
|
773
813
|
|
|
774
814
|
After scaffolding, the agent edits edit.js in outDir to animate each scene, then calls render_animation.`,
|
|
@@ -809,6 +849,13 @@ After scaffolding, the agent edits edit.js in outDir to animate each scene, then
|
|
|
809
849
|
['reel/engine.css', 'reel/engine.css'],
|
|
810
850
|
['reel/virtual-clock.js', 'reel/virtual-clock.js'],
|
|
811
851
|
['scripts/render.mjs', 'scripts/render.mjs'],
|
|
852
|
+
['scripts/harness.mjs', 'scripts/harness.mjs'],
|
|
853
|
+
['scripts/browser.mjs', 'scripts/browser.mjs'],
|
|
854
|
+
['scripts/verify-purity.mjs', 'scripts/verify-purity.mjs'],
|
|
855
|
+
['scripts/pixels.mjs', 'scripts/pixels.mjs'],
|
|
856
|
+
['scripts/sheets.mjs', 'scripts/sheets.mjs'],
|
|
857
|
+
['scripts/jobs.mjs', 'scripts/jobs.mjs'],
|
|
858
|
+
['scripts/encode.mjs', 'scripts/encode.mjs'],
|
|
812
859
|
['scripts/look.mjs', 'scripts/look.mjs'],
|
|
813
860
|
['scripts/storyboard-validator.mjs', 'scripts/storyboard-validator.mjs'],
|
|
814
861
|
['scripts/mix_audio.py', 'scripts/mix_audio.py'],
|
|
@@ -860,9 +907,7 @@ After scaffolding, the agent edits edit.js in outDir to animate each scene, then
|
|
|
860
907
|
copied.push('index.html (generated fallback)');
|
|
861
908
|
}
|
|
862
909
|
|
|
863
|
-
// Generate starter edit.js from scenes
|
|
864
|
-
// Each scene becomes one layer() with opacity in/out and a text() for its caption.
|
|
865
|
-
// The agent is expected to replace this with real animation code.
|
|
910
|
+
// Generate starter edit.js from scenes with contextual code-driven animation components
|
|
866
911
|
let duration = 0;
|
|
867
912
|
const sceneBlocks = scenes.map((scene, idx) => {
|
|
868
913
|
const wordsPerSec = 2.5;
|
|
@@ -872,6 +917,29 @@ After scaffolding, the agent edits edit.js in outDir to animate each scene, then
|
|
|
872
917
|
duration = Math.round((duration + dur) * 10) / 10;
|
|
873
918
|
const caption = (scene.caption || `Scene ${idx + 1}`).replace(/'/g, "\\'");
|
|
874
919
|
const visual = (scene.visual_description || scene.narration || '').slice(0, 80).replace(/'/g, "\\'");
|
|
920
|
+
const textLower = ((scene.narration || '') + ' ' + (scene.visual_description || '') + ' ' + caption).toLowerCase();
|
|
921
|
+
|
|
922
|
+
let componentCode;
|
|
923
|
+
if (scene.image_path) {
|
|
924
|
+
componentCode = `at(${start}, imageCard(${JSON.stringify(scene.image_path)}, { caption: ${JSON.stringify(caption)}, width: ${Math.round(width * 0.72)}, x: 'center', y: 'center', duration: ${dur} }));`;
|
|
925
|
+
} else if (scene.video_path) {
|
|
926
|
+
componentCode = `at(${start}, wallCard([{ text: ${JSON.stringify(caption)}, size: 28, weight: 800, color: 'var(--accent)' }], { x: 'center', y: ${Math.round(height * 0.78)}, width: ${Math.round(width * 0.8)}, duration: ${dur} }));`;
|
|
927
|
+
} else if (/\b(code|function|syntax|api|javascript|python|typescript|json|sql|query|endpoint|algorithm|struct)\b/.test(textLower)) {
|
|
928
|
+
const snippet = visual.length > 20 ? visual : `// ${caption}\nfunction execute() {\n return "${caption.toLowerCase()}";\n}`;
|
|
929
|
+
componentCode = `at(${start}, codeCard(${JSON.stringify(snippet)}, 'js', { title: ${JSON.stringify(caption)}, width: ${Math.round(width * 0.76)}, x: 'center', y: ${Math.round(height * 0.22)}, duration: ${dur} }));`;
|
|
930
|
+
} else if (/\b(terminal|command|cli|bash|shell|npm|install|curl|git|docker|deploy|run)\b/.test(textLower)) {
|
|
931
|
+
const cmd = caption.toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
|
932
|
+
componentCode = `at(${start}, terminal(['$ frameforge ${cmd}', '${caption} — initializing...', '✓ complete (${dur}s)'], { title: ${JSON.stringify(caption)}, width: ${Math.round(width * 0.74)}, x: 'center', y: ${Math.round(height * 0.24)}, duration: ${dur} }));`;
|
|
933
|
+
} else {
|
|
934
|
+
componentCode = [
|
|
935
|
+
`at(${start}, wallCard([`,
|
|
936
|
+
` { text: ${JSON.stringify(caption)}, size: 36, weight: 800, color: 'var(--accent)' },`,
|
|
937
|
+
` { text: ${JSON.stringify((scene.narration || '').slice(0, 130).replace(/'/g, "\\'"))}, size: 24, weight: 400 }`,
|
|
938
|
+
`], { x: 'center', y: ${Math.round(height * 0.30)}, width: ${Math.round(width * 0.82)}, duration: ${dur} }));`,
|
|
939
|
+
`at(${start}, stamp(${JSON.stringify(caption.slice(0, 16))}, { x: ${Math.round(width * 0.75)}, y: ${Math.round(height * 0.18)}, color: 'var(--accent)', duration: ${dur} }));`,
|
|
940
|
+
].join('\n');
|
|
941
|
+
}
|
|
942
|
+
|
|
875
943
|
return [
|
|
876
944
|
`// ─── Scene ${idx + 1}: ${start}s–${duration}s ────────────────────────────────`,
|
|
877
945
|
`// Visual: ${visual}`,
|
|
@@ -882,7 +950,7 @@ After scaffolding, the agent edits edit.js in outDir to animate each scene, then
|
|
|
882
950
|
` const fadeOut = lt > ${dur} - 0.3 ? 1 - (lt - (${dur} - 0.3)) / 0.3 : 1;`,
|
|
883
951
|
` return { opacity: fadeIn * fadeOut };`,
|
|
884
952
|
`});`,
|
|
885
|
-
|
|
953
|
+
componentCode,
|
|
886
954
|
'',
|
|
887
955
|
].join('\n');
|
|
888
956
|
});
|
|
@@ -903,7 +971,7 @@ After scaffolding, the agent edits edit.js in outDir to animate each scene, then
|
|
|
903
971
|
` * Never use Math.random(), Date.now(), setTimeout, or module-level mutable counters.`,
|
|
904
972
|
` * Use hash(i) from engine.js for deterministic randomness.`,
|
|
905
973
|
` */`,
|
|
906
|
-
`import { reel, layer, at, text, hash } from './reel/engine.js';`,
|
|
974
|
+
`import { reel, layer, at, text, wallCard, codeCard, terminal, imageCard, stamp, chip, hash } from './reel/engine.js';`,
|
|
907
975
|
``,
|
|
908
976
|
`reel({ width: ${width}, height: ${height}, fps: ${fps}, duration: ${duration} });`,
|
|
909
977
|
``,
|
|
@@ -1003,17 +1071,18 @@ Modes:
|
|
|
1003
1071
|
"crop" — focal-point 9:16 mobile crop preview; pass timestamps as [aspect_ratio_string, t]
|
|
1004
1072
|
"full" — full render → out/<title>.mp4
|
|
1005
1073
|
"mux" — re-mux audio only (no re-render, use after changing audio)
|
|
1074
|
+
"kit" — export EDL/XML for Premiere Pro / DaVinci Resolve; run after "full"
|
|
1006
1075
|
"look" — pre-render linter (fonts, clipped text, NaN transforms, missing assets)
|
|
1007
1076
|
|
|
1008
1077
|
Review budget: at least one "sheet" per shot, one "strip" per key motion/transition, one "crop" per story-carrying face.
|
|
1009
|
-
Returns { outputPath, passed } for "full", or { outputPath } for sheet/strip/storyboard.
|
|
1078
|
+
Returns { outputPath, passed } for "full", or { outputPath } for sheet/strip/storyboard/kit.
|
|
1010
1079
|
Requires: npm install playwright && npx playwright install chromium (one-time setup in projectDir).`,
|
|
1011
1080
|
inputSchema: {
|
|
1012
1081
|
type: 'object',
|
|
1013
1082
|
required: ['projectDir', 'mode'],
|
|
1014
1083
|
properties: {
|
|
1015
1084
|
projectDir: { type: 'string', description: 'The directory created by scaffold_animation_project' },
|
|
1016
|
-
mode: { type: 'string', enum: ['sheet', 'stills', 'strip', 'storyboard', 'crop', 'full', 'mux', 'look'], description: 'Render mode' },
|
|
1085
|
+
mode: { type: 'string', enum: ['sheet', 'stills', 'strip', 'storyboard', 'crop', 'full', 'mux', 'kit', 'look', 'verify', 'doctor', 'pixels'], description: 'Render mode' },
|
|
1017
1086
|
timestamps: { type: 'array', items: {}, description: 'For mode="stills": [t1, t2, ...]. For mode="strip": [startSeconds, endSeconds]. For mode="crop": ["9:16", t]. For mode="sheet": omit or pass shot times.' },
|
|
1018
1087
|
audioPath: { type: 'string', description: 'Audio file to mux in (for mode="full" or "mux")' },
|
|
1019
1088
|
workers: { type: 'number', default: 4, description: 'Parallel Playwright pages for mode="full"' },
|
|
@@ -1057,7 +1126,11 @@ Requires: npm install playwright && npx playwright install chromium (one-time se
|
|
|
1057
1126
|
}
|
|
1058
1127
|
}
|
|
1059
1128
|
|
|
1060
|
-
const
|
|
1129
|
+
const harnessScript = pathMod.join(projectDir, 'scripts', 'harness.mjs');
|
|
1130
|
+
const isHarnessMode = ['verify', 'doctor', 'pixels'].includes(mode);
|
|
1131
|
+
const scriptToRun = (isHarnessMode && syncFs.existsSync(harnessScript)) ? harnessScript : renderScript;
|
|
1132
|
+
|
|
1133
|
+
const args = [scriptToRun, mode];
|
|
1061
1134
|
if ((mode === 'stills' || mode === 'strip' || mode === 'crop') && sanitisedTimestamps.length > 0) {
|
|
1062
1135
|
args.push(...sanitisedTimestamps);
|
|
1063
1136
|
}
|
|
@@ -1091,12 +1164,24 @@ Requires: npm install playwright && npx playwright install chromium (one-time se
|
|
|
1091
1164
|
} else if (mode === 'full' || mode === 'mux') {
|
|
1092
1165
|
const mp4 = files.find(f => f.endsWith('.mp4') && !f.includes('_preview'));
|
|
1093
1166
|
if (mp4) outputPath = pathMod.join(outDir, mp4);
|
|
1167
|
+
} else if (mode === 'kit') {
|
|
1168
|
+
const edl = files.find(f => f.endsWith('.edl') || f.endsWith('.xml'));
|
|
1169
|
+
if (edl) outputPath = pathMod.join(outDir, edl);
|
|
1094
1170
|
} else if (mode === 'stills' || mode === 'strip' || mode === 'crop') {
|
|
1095
1171
|
const stills = files.filter(f => f.endsWith('.png') || f.endsWith('.jpg'));
|
|
1096
1172
|
if (stills.length > 0) outputPath = stills.map(f => pathMod.join(outDir, f));
|
|
1097
1173
|
}
|
|
1098
1174
|
}
|
|
1099
1175
|
|
|
1176
|
+
let purityReport = null;
|
|
1177
|
+
const purityReportFile = pathMod.join(outDir, 'check', 'purity_report.json');
|
|
1178
|
+
if (mode === 'verify' && syncFs.existsSync(purityReportFile)) {
|
|
1179
|
+
try {
|
|
1180
|
+
purityReport = JSON.parse(syncFs.readFileSync(purityReportFile, 'utf8'));
|
|
1181
|
+
outputPath = purityReportFile;
|
|
1182
|
+
} catch (_) {}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1100
1185
|
const passed = exitCode === 0;
|
|
1101
1186
|
|
|
1102
1187
|
return {
|
|
@@ -1104,6 +1189,7 @@ Requires: npm install playwright && npx playwright install chromium (one-time se
|
|
|
1104
1189
|
passed,
|
|
1105
1190
|
exitCode,
|
|
1106
1191
|
outputPath,
|
|
1192
|
+
purityReport,
|
|
1107
1193
|
projectDir,
|
|
1108
1194
|
stdout: stdout.slice(-2000),
|
|
1109
1195
|
stderr: passed ? undefined : stderr.slice(-1000),
|
|
@@ -1114,13 +1200,179 @@ Requires: npm install playwright && npx playwright install chromium (one-time se
|
|
|
1114
1200
|
},
|
|
1115
1201
|
},
|
|
1116
1202
|
|
|
1203
|
+
// ── Tool 18: Edit in external app ─────────────────────────────────────────
|
|
1204
|
+
// Import a FrameForge animation into an editing app AND optionally drive
|
|
1205
|
+
// real editing operations — cuts, transitions, color, captions, render.
|
|
1206
|
+
//
|
|
1207
|
+
// How it works per editor:
|
|
1208
|
+
//
|
|
1209
|
+
// resolve — DaVinci Resolve 18.6+ built-in REST API (no bridge needed).
|
|
1210
|
+
// Enable in Preferences → System → General → "IP Remote Control".
|
|
1211
|
+
// Full edit support: import, timeline, cut, color, render.
|
|
1212
|
+
//
|
|
1213
|
+
// premiere — Adobe Premiere Pro via CEP panel companion server.
|
|
1214
|
+
// The companion runs ExtendScript on the Premiere side and
|
|
1215
|
+
// exposes a local REST API. Supports: import, cut, transition,
|
|
1216
|
+
// caption overlay, export.
|
|
1217
|
+
// Install: see docs/editor-bridges/premiere.md
|
|
1218
|
+
//
|
|
1219
|
+
// aftereffects — After Effects via AE REST bridge (ExtendScript server).
|
|
1220
|
+
// Supports: import comp, add effects, render queue.
|
|
1221
|
+
// Install: see docs/editor-bridges/aftereffects.md
|
|
1222
|
+
//
|
|
1223
|
+
// remotion — Remotion IS code: operations are applied by editing the
|
|
1224
|
+
// composition props and calling `npx remotion render`.
|
|
1225
|
+
// No bridge needed — FrameForge writes the prop patches directly.
|
|
1226
|
+
//
|
|
1227
|
+
// capcut — CapCut has no public desktop editing API as of 2026.
|
|
1228
|
+
// Only import + render export are supported (same as export_to_editor).
|
|
1229
|
+
//
|
|
1230
|
+
// custom — Any HTTP endpoint. You control what operations it handles.
|
|
1231
|
+
export_to_editor: {
|
|
1232
|
+
description: `Import a FrameForge animation into an editing app and optionally apply editing operations.
|
|
1233
|
+
|
|
1234
|
+
Supported editors:
|
|
1235
|
+
"resolve" — DaVinci Resolve 18.6+ built-in REST API. Enable in Preferences → System → General → "IP Remote Control". Port default: 8080.
|
|
1236
|
+
"premiere" — Adobe Premiere Pro via CEP companion server. Full timeline editing via ExtendScript. Port default: 7777.
|
|
1237
|
+
"aftereffects" — After Effects via AE REST bridge. Import comp, add effects, render queue. Port default: 7878.
|
|
1238
|
+
"remotion" — Remotion composition — patches props, triggers CLI render. Port default: 3001.
|
|
1239
|
+
"capcut" — CapCut Desktop — import + export only (no programmatic edit API available). Port default: 9090.
|
|
1240
|
+
"custom" — Any HTTP endpoint (pass editorUrl).
|
|
1241
|
+
|
|
1242
|
+
Actions (pass as "action" parameter):
|
|
1243
|
+
"import" — Import the rendered MP4 (and EDL if present) into the editor. Default.
|
|
1244
|
+
"edit" — Apply a list of operations to an existing timeline (see "operations").
|
|
1245
|
+
"render" — Trigger the editor's own render/export pipeline.
|
|
1246
|
+
"status" — Poll a previously started render job (pass jobId).
|
|
1247
|
+
|
|
1248
|
+
Operations (used with action="edit"):
|
|
1249
|
+
{ op: "cut", at: 4.5 } — add a cut at 4.5s
|
|
1250
|
+
{ op: "trim", clip: 0, startDelta: 0.5 } — trim clip 0 start by +0.5s
|
|
1251
|
+
{ op: "transition", between: [0,1], type: "dissolve", duration: 0.5 }
|
|
1252
|
+
{ op: "color", clip: 0, lut: "/path/to/lut.cube" }
|
|
1253
|
+
{ op: "color", clip: 0, grade: { lift: [0,0,0,0], gamma: [1,1,1,1], gain: [1,1,1,1] } }
|
|
1254
|
+
{ op: "caption", at: 2.0, duration: 3.0, text: "Hello", position: "bottom" }
|
|
1255
|
+
{ op: "mute", clip: 1 }
|
|
1256
|
+
{ op: "speed", clip: 0, factor: 0.5 } — 50% slow-motion
|
|
1257
|
+
{ op: "swap", clip: 0, newPath: "/new/clip.mp4" } — replace a clip`,
|
|
1258
|
+
inputSchema: {
|
|
1259
|
+
type: 'object',
|
|
1260
|
+
required: ['editor'],
|
|
1261
|
+
properties: {
|
|
1262
|
+
editor: { type: 'string', enum: ['resolve', 'premiere', 'aftereffects', 'remotion', 'capcut', 'custom'], description: 'Target editing app' },
|
|
1263
|
+
action: { type: 'string', enum: ['import', 'edit', 'render', 'status'], default: 'import', description: 'What to do: import clip, apply edit operations, trigger render, or poll status' },
|
|
1264
|
+
projectDir: { type: 'string', description: 'FrameForge animation project directory. Required for action=import (finds the rendered MP4 and EDL automatically).' },
|
|
1265
|
+
videoPath: { type: 'string', description: 'Explicit MP4 path to import (overrides auto-detection from projectDir)' },
|
|
1266
|
+
audioPath: { type: 'string', description: 'Audio stem to import alongside the video' },
|
|
1267
|
+
timelineName: { type: 'string', description: 'Timeline/sequence name to create or target in the editor' },
|
|
1268
|
+
operations: { type: 'array', description: 'Edit operations for action="edit". Each op: { op, clip?, at?, between?, type?, duration?, text?, position?, lut?, grade?, factor?, newPath? }' },
|
|
1269
|
+
jobId: { type: 'string', description: 'Render job ID to poll (for action="status")' },
|
|
1270
|
+
editorUrl: { type: 'string', description: 'Override the editor bridge URL. Useful for non-default ports or remote bridges.' },
|
|
1271
|
+
exportPath: { type: 'string', description: 'Where the editor should save the export (for action="render")' },
|
|
1272
|
+
},
|
|
1273
|
+
},
|
|
1274
|
+
handler: async ({ editor = 'resolve', action = 'import', projectDir, videoPath, audioPath, timelineName, operations = [], jobId, editorUrl, exportPath }) => {
|
|
1275
|
+
const syncFs = require('fs');
|
|
1276
|
+
const pathMod = require('path');
|
|
1277
|
+
const { ExternalAdapter } = require('../pipeline/external-adapter');
|
|
1278
|
+
|
|
1279
|
+
if (!editor) throw new Error('editor is required');
|
|
1280
|
+
|
|
1281
|
+
// ── Resolve video path ────────────────────────────────────────────────
|
|
1282
|
+
let masterPath = videoPath || null;
|
|
1283
|
+
let edlPath = null;
|
|
1284
|
+
|
|
1285
|
+
if (!masterPath && projectDir) {
|
|
1286
|
+
if (!syncFs.existsSync(projectDir)) throw new Error(`Project directory not found: ${projectDir}`);
|
|
1287
|
+
const outDir = pathMod.join(projectDir, 'out');
|
|
1288
|
+
if (!syncFs.existsSync(outDir)) throw new Error(`No out/ in ${projectDir}. Run render_animation mode="full" first.`);
|
|
1289
|
+
|
|
1290
|
+
const mp4s = syncFs.readdirSync(outDir)
|
|
1291
|
+
.filter(f => f.endsWith('.mp4') && !f.includes('_preview'))
|
|
1292
|
+
.map(f => pathMod.join(outDir, f))
|
|
1293
|
+
.sort((a, b) => syncFs.statSync(b).mtimeMs - syncFs.statSync(a).mtimeMs);
|
|
1294
|
+
if (mp4s.length === 0) throw new Error(`No MP4 in ${outDir}. Run render_animation mode="full" first.`);
|
|
1295
|
+
masterPath = mp4s[0];
|
|
1296
|
+
|
|
1297
|
+
const edls = syncFs.readdirSync(outDir).filter(f => f.endsWith('.edl') || f.endsWith('.xml'));
|
|
1298
|
+
if (edls.length > 0) edlPath = pathMod.join(outDir, edls[0]);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
// ── Configure adapter ─────────────────────────────────────────────────
|
|
1302
|
+
const adapterConfig = editorUrl ? { name: editor, baseUrl: editorUrl } : { name: editor };
|
|
1303
|
+
const adapter = new ExternalAdapter(adapterConfig);
|
|
1304
|
+
|
|
1305
|
+
// ── Dispatch action ───────────────────────────────────────────────────
|
|
1306
|
+
const callAdapter = async (method, payload) => {
|
|
1307
|
+
try {
|
|
1308
|
+
return await method(payload);
|
|
1309
|
+
} catch (err) {
|
|
1310
|
+
throw new Error(
|
|
1311
|
+
`Could not reach ${editor} bridge at ${adapter.baseUrl}.\n` +
|
|
1312
|
+
`Ensure the bridge is running. Error: ${err.message}\n` +
|
|
1313
|
+
`Setup docs: docs/editor-bridges/${editor}.md`
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1316
|
+
};
|
|
1317
|
+
|
|
1318
|
+
if (action === 'import') {
|
|
1319
|
+
if (!masterPath) throw new Error('Provide projectDir or videoPath for action="import"');
|
|
1320
|
+
const files = [masterPath];
|
|
1321
|
+
if (audioPath && syncFs.existsSync(audioPath)) files.push(audioPath);
|
|
1322
|
+
if (edlPath) files.push(edlPath);
|
|
1323
|
+
const result = await callAdapter(
|
|
1324
|
+
p => adapter.ingestMedia(p),
|
|
1325
|
+
{ files, metadata: { source: 'frameforge', timelineName: timelineName || pathMod.basename(masterPath, '.mp4'), edlPath: edlPath || '' } }
|
|
1326
|
+
);
|
|
1327
|
+
return { editor, action, bridgeUrl: adapter.baseUrl, importedFiles: files.map(f => pathMod.basename(f)), masterPath, edlPath, result,
|
|
1328
|
+
message: `Imported into ${editor}. ${edlPath ? 'EDL/XML timeline included.' : 'Tip: run render_animation mode="kit" for an EDL.'}` };
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
if (action === 'edit') {
|
|
1332
|
+
if (operations.length === 0) throw new Error('Provide at least one operation for action="edit"');
|
|
1333
|
+
// Validate each operation has an "op" field
|
|
1334
|
+
for (const [i, o] of operations.entries()) {
|
|
1335
|
+
if (!o.op) throw new Error(`operations[${i}] is missing required field "op"`);
|
|
1336
|
+
const valid = ['cut','trim','transition','color','caption','mute','speed','swap'];
|
|
1337
|
+
if (!valid.includes(o.op)) throw new Error(`operations[${i}].op "${o.op}" is not valid. Use: ${valid.join(', ')}`);
|
|
1338
|
+
}
|
|
1339
|
+
const body = { operations, timelineName: timelineName || 'FrameForge', source: 'frameforge' };
|
|
1340
|
+
const result = await callAdapter(
|
|
1341
|
+
p => adapter.renderScenes({ scenes: [], productionId: 'edit', options: p }),
|
|
1342
|
+
body
|
|
1343
|
+
);
|
|
1344
|
+
return { editor, action, bridgeUrl: adapter.baseUrl, operationCount: operations.length, result,
|
|
1345
|
+
message: `Applied ${operations.length} edit operation(s) in ${editor}.` };
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
if (action === 'render') {
|
|
1349
|
+
const body = { exportPath: exportPath || '', timelineName: timelineName || 'FrameForge', source: 'frameforge' };
|
|
1350
|
+
const result = await callAdapter(
|
|
1351
|
+
p => adapter.renderScenes({ scenes: [], productionId: 'render', options: p }),
|
|
1352
|
+
body
|
|
1353
|
+
);
|
|
1354
|
+
const newJobId = result?.jobId || result?.id || result?.data?.id;
|
|
1355
|
+
return { editor, action, bridgeUrl: adapter.baseUrl, jobId: newJobId, result,
|
|
1356
|
+
message: newJobId ? `Render started in ${editor}. Poll with action="status" jobId="${newJobId}".` : `Render triggered in ${editor}.` };
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
if (action === 'status') {
|
|
1360
|
+
if (!jobId) throw new Error('Provide jobId for action="status"');
|
|
1361
|
+
const result = await callAdapter(p => adapter.checkStatus(p), jobId);
|
|
1362
|
+
return { editor, action, jobId, status: result.status, outputUrl: result.outputUrl, result };
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
throw new Error(`Unknown action: ${action}. Use import | edit | render | status`);
|
|
1366
|
+
},
|
|
1367
|
+
},
|
|
1368
|
+
|
|
1117
1369
|
};
|
|
1118
1370
|
|
|
1119
1371
|
// ── MCP protocol handler (MCP 2024-11-05 spec) ───────────────────────────
|
|
1120
1372
|
|
|
1121
1373
|
const SERVER_INFO = {
|
|
1122
1374
|
name: 'video-pipeline',
|
|
1123
|
-
version: '2.3.
|
|
1375
|
+
version: '2.3.3',
|
|
1124
1376
|
};
|
|
1125
1377
|
|
|
1126
1378
|
const CAPABILITIES = {
|