@amg555/frameforge-mcp 2.3.2 → 2.3.4

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.
Files changed (49) hide show
  1. package/.mcp.json +2 -4
  2. package/CHANGELOG.md +24 -1
  3. package/CLAUDE.md +104 -26
  4. package/README.md +17 -12
  5. package/SECURITY.md +1 -3
  6. package/package.json +7 -3
  7. package/plugins/video-pipeline/.claude-plugin/plugin.json +19 -0
  8. package/plugins/video-pipeline/skills/html-renderer/SKILL.md +292 -0
  9. package/plugins/video-pipeline/skills/video-pipeline/SKILL.md +308 -0
  10. package/plugins/video-pipeline/skills/video-pipeline/assets/project/ANIMATION_GUIDE.md +257 -0
  11. package/plugins/video-pipeline/skills/video-pipeline/assets/project/STORYBOARD.md +77 -0
  12. package/plugins/video-pipeline/skills/video-pipeline/assets/project/index.html +80 -0
  13. package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/diagram-author.md +43 -0
  14. package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/orchestrator.md +56 -0
  15. package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/scene-author.md +42 -0
  16. package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.css +140 -0
  17. package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.js +870 -0
  18. package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/virtual-clock.js +138 -0
  19. package/plugins/video-pipeline/skills/video-pipeline/references/mcp-tools.md +167 -0
  20. package/plugins/video-pipeline/skills/video-pipeline/references/providers.md +174 -0
  21. package/scripts/db-migrate.js +112 -0
  22. package/scripts/deep-validate.js +326 -0
  23. package/scripts/generate-initial-assets.js +342 -0
  24. package/scripts/html-renderer/new-project.mjs +408 -0
  25. package/scripts/html-renderer/transcribe.py +294 -0
  26. package/src/asset-library/README.md +2 -2
  27. package/src/asset-library/asset-library.js +1 -1
  28. package/src/cli/doctor.js +12 -5
  29. package/src/cli/make.js +52 -71
  30. package/src/cli/setup-keys.js +15 -17
  31. package/src/fact-checker/review-gate.js +5 -1
  32. package/src/harness/bin/harness.mjs +324 -0
  33. package/src/harness/lib/browser.mjs +137 -0
  34. package/src/harness/lib/capture.mjs +161 -0
  35. package/src/harness/lib/encode.mjs +89 -0
  36. package/src/harness/lib/jobs.mjs +135 -0
  37. package/src/harness/lib/pixels.mjs +137 -0
  38. package/src/harness/lib/sheets.mjs +164 -0
  39. package/src/harness/lib/verify-purity.mjs +119 -0
  40. package/src/harness/test/fixtures/broken-scene.html +27 -0
  41. package/src/harness/test/fixtures/reference-scene.html +23 -0
  42. package/src/harness/test/fixtures/sample-script.json +18 -0
  43. package/src/mcp-server/server.js +285 -144
  44. package/src/pipeline/telemetry.js +1 -0
  45. package/src/utils/log-redaction.js +5 -3
  46. package/src/video-renderer/renderer.js +12 -4
  47. package/src/worker/server-render-free.js +2 -3
  48. package/src/workflows/assets.js +20 -43
  49. package/src/workflows/ingest.js +5 -5
@@ -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
- * PROVIDER_IMAGE=pollinations|falai|replicate|dalle|webhook
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, ImageProvider } = require('../pipeline/providers');
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 image ─────────────────────────────────────────
123
+ // ── Tool 3: Generate scene visual ─────────────────────────────────────────
125
124
  generate_scene_image: {
126
- description: 'Generate an image for one scene using the configured image provider. Pass imageStyle to control the visual aesthetic (default: clean flat illustration). No hardcoded art style.',
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', 'visualDescription'],
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 — used directly as image prompt' },
134
- imageStyle: { type: 'string', description: 'Visual aesthetic, e.g. "flat design", "photorealistic", "anime", "watercolour sketch". Default: clean flat illustration.' },
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
- imageStyle = 'clean flat illustration, white background, bold lines',
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
- const prompt = `${description.trim()}, ${imageStyle}`;
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
- const stat = await fs.stat(outputPath);
161
- await telemetry.track({ productionId, stage: 'Asset_Generation', provider: process.env.PROVIDER_IMAGE || 'pollinations', operation: 'scene_image_generation', units: 1 });
162
-
163
- return { productionId, sceneIndex, imagePath: outputPath, sizeBytes: stat.size };
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: 'Send media or scenes to an external platform (VoxForge, Runway, ElevenLabs, Remotion, etc.). Configured via EXTERNAL_PLATFORM_* env vars or setup-keys wizard.',
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 media files | render: send scenes for rendering | status: poll job | download: fetch output' },
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
- const adapter = new ExternalAdapter();
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('External platform not configured. Run: node src/cli/setup-keys.js to set EXTERNAL_PLATFORM_URL and EXTERNAL_PLATFORM_KEY.');
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 just renders it. No internal LLM, no research stage. This is the primary path for Claude Code, IBM Bob, Cursor, and any agentic IDE.
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": "A description used as the AI image prompt",
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: { 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? }' },
498
- title: { type: 'string', description: 'Video title' },
499
- 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).' },
500
- 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' },
501
- 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.' },
502
- skipImages: { type: 'boolean', default: false, description: 'Set true if all scenes already have image_path or video_path' },
503
- skipTts: { type: 'boolean', default: false, description: 'Set true if all scenes already have audio_path' },
504
- imageStyle: { type: 'string', description: 'Visual aesthetic for AI-generated images, e.g. "photorealistic", "anime", "flat design". Only used when skipImages is false.' },
505
- parallelism: { type: 'number', default: 1, description: 'Concurrent image generation requests (default: 1). Increase to 2-4 only when using a reliable image provider like falai or replicate. Keep at 1 for Pollinations to avoid rate limits.' },
506
- withMusic: { type: 'boolean', default: true },
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, skipImages = false, skipTts = false, imageStyle, parallelism = 1, withMusic = true, musicVolume = 0.07, productionId }) => {
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: imageStyle && !s.image_path && !s.video_path
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, skipImages, skipTts, imageWidth: renderWidth, imageHeight: renderHeight, voice: resolvedVoice });
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) {
@@ -770,7 +808,7 @@ Copies the engine template into outDir and generates a starter edit.js from your
770
808
 
771
809
  Use this when:
772
810
  - You want Remotion-style motion graphics (kinetic text, springs, morphing layouts)
773
- - Pollinations/image providers are unavailable and you want real visual quality
811
+ - You want broadcast-grade visual quality with zero diffusion artifacts
774
812
  - The user asks for animated video, code-rendered video, or "make it look good"
775
813
 
776
814
  After scaffolding, the agent edits edit.js in outDir to animate each scene, then calls render_animation.`,
@@ -811,6 +849,13 @@ After scaffolding, the agent edits edit.js in outDir to animate each scene, then
811
849
  ['reel/engine.css', 'reel/engine.css'],
812
850
  ['reel/virtual-clock.js', 'reel/virtual-clock.js'],
813
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'],
814
859
  ['scripts/look.mjs', 'scripts/look.mjs'],
815
860
  ['scripts/storyboard-validator.mjs', 'scripts/storyboard-validator.mjs'],
816
861
  ['scripts/mix_audio.py', 'scripts/mix_audio.py'],
@@ -862,9 +907,7 @@ After scaffolding, the agent edits edit.js in outDir to animate each scene, then
862
907
  copied.push('index.html (generated fallback)');
863
908
  }
864
909
 
865
- // Generate starter edit.js from scenes
866
- // Each scene becomes one layer() with opacity in/out and a text() for its caption.
867
- // The agent is expected to replace this with real animation code.
910
+ // Generate starter edit.js from scenes with contextual code-driven animation components
868
911
  let duration = 0;
869
912
  const sceneBlocks = scenes.map((scene, idx) => {
870
913
  const wordsPerSec = 2.5;
@@ -874,6 +917,29 @@ After scaffolding, the agent edits edit.js in outDir to animate each scene, then
874
917
  duration = Math.round((duration + dur) * 10) / 10;
875
918
  const caption = (scene.caption || `Scene ${idx + 1}`).replace(/'/g, "\\'");
876
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
+
877
943
  return [
878
944
  `// ─── Scene ${idx + 1}: ${start}s–${duration}s ────────────────────────────────`,
879
945
  `// Visual: ${visual}`,
@@ -884,7 +950,7 @@ After scaffolding, the agent edits edit.js in outDir to animate each scene, then
884
950
  ` const fadeOut = lt > ${dur} - 0.3 ? 1 - (lt - (${dur} - 0.3)) / 0.3 : 1;`,
885
951
  ` return { opacity: fadeIn * fadeOut };`,
886
952
  `});`,
887
- `at(${start}, text('${caption}', { font: '48px/1.3 sans-serif', color: '#fff', x: 'center', y: ${height / 2} }));`,
953
+ componentCode,
888
954
  '',
889
955
  ].join('\n');
890
956
  });
@@ -905,7 +971,7 @@ After scaffolding, the agent edits edit.js in outDir to animate each scene, then
905
971
  ` * Never use Math.random(), Date.now(), setTimeout, or module-level mutable counters.`,
906
972
  ` * Use hash(i) from engine.js for deterministic randomness.`,
907
973
  ` */`,
908
- `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';`,
909
975
  ``,
910
976
  `reel({ width: ${width}, height: ${height}, fps: ${fps}, duration: ${duration} });`,
911
977
  ``,
@@ -1016,7 +1082,7 @@ Requires: npm install playwright && npx playwright install chromium (one-time se
1016
1082
  required: ['projectDir', 'mode'],
1017
1083
  properties: {
1018
1084
  projectDir: { type: 'string', description: 'The directory created by scaffold_animation_project' },
1019
- mode: { type: 'string', enum: ['sheet', 'stills', 'strip', 'storyboard', 'crop', 'full', 'mux', 'kit', 'look'], description: 'Render mode' },
1085
+ mode: { type: 'string', enum: ['sheet', 'stills', 'strip', 'storyboard', 'crop', 'full', 'mux', 'kit', 'look', 'verify', 'doctor', 'pixels'], description: 'Render mode' },
1020
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.' },
1021
1087
  audioPath: { type: 'string', description: 'Audio file to mux in (for mode="full" or "mux")' },
1022
1088
  workers: { type: 'number', default: 4, description: 'Parallel Playwright pages for mode="full"' },
@@ -1060,7 +1126,11 @@ Requires: npm install playwright && npx playwright install chromium (one-time se
1060
1126
  }
1061
1127
  }
1062
1128
 
1063
- const args = [renderScript, mode];
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];
1064
1134
  if ((mode === 'stills' || mode === 'strip' || mode === 'crop') && sanitisedTimestamps.length > 0) {
1065
1135
  args.push(...sanitisedTimestamps);
1066
1136
  }
@@ -1103,6 +1173,15 @@ Requires: npm install playwright && npx playwright install chromium (one-time se
1103
1173
  }
1104
1174
  }
1105
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
+
1106
1185
  const passed = exitCode === 0;
1107
1186
 
1108
1187
  return {
@@ -1110,6 +1189,7 @@ Requires: npm install playwright && npx playwright install chromium (one-time se
1110
1189
  passed,
1111
1190
  exitCode,
1112
1191
  outputPath,
1192
+ purityReport,
1113
1193
  projectDir,
1114
1194
  stdout: stdout.slice(-2000),
1115
1195
  stderr: passed ? undefined : stderr.slice(-1000),
@@ -1120,108 +1200,169 @@ Requires: npm install playwright && npx playwright install chromium (one-time se
1120
1200
  },
1121
1201
  },
1122
1202
 
1123
- // ── Tool 18: Export to editing app ────────────────────────────────────────
1124
- // Send a finished animation project to any local editing app via MCP bridge.
1125
- // Works with Premiere Pro, DaVinci Resolve, CapCut, After Effects, or Remotion.
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.
1126
1231
  export_to_editor: {
1127
- description: `Send a finished FrameForge animation to a local editing app for post-production.
1128
-
1129
- Supported editors (configure via env or pass editorUrl directly):
1130
- "premiere" — Adobe Premiere Pro via local REST bridge (PREMIERE_BRIDGE_URL, default :7777)
1131
- "resolve" — DaVinci Resolve Remote API (RESOLVE_BRIDGE_URL, default :8080)
1132
- "capcut" — CapCut Desktop via MCP bridge (CAPCUT_BRIDGE_URL, default :9090)
1133
- "aftereffects" — Adobe After Effects via AEIO bridge (AE_BRIDGE_URL, default :7878)
1134
- "remotion" — Remotion local dev server (REMOTION_BASE_URL, default :3001)
1135
- "custom" — any HTTP endpoint (pass editorUrl)
1136
-
1137
- What this does:
1138
- 1. Imports the rendered MP4 (and optionally the audio stem) into the editor
1139
- 2. Exports an EDL/XML timeline from the animation project (render_animation kit mode)
1140
- 3. Returns the editor's import confirmation or render job ID
1141
-
1142
- The bridge servers are lightweight companion apps you run locally alongside the editor.
1143
- They are not installed by FrameForge — see docs/editor-bridges/ for setup instructions.`,
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`,
1144
1258
  inputSchema: {
1145
1259
  type: 'object',
1146
- required: ['projectDir'],
1260
+ required: ['editor'],
1147
1261
  properties: {
1148
- projectDir: { type: 'string', description: 'The animation project directory (from scaffold_animation_project)' },
1149
- editor: { type: 'string', enum: ['premiere', 'resolve', 'capcut', 'aftereffects', 'remotion', 'custom'], default: 'resolve', description: 'Target editing app' },
1150
- editorUrl: { type: 'string', description: 'Override the editor bridge URL (e.g. http://localhost:8080). Useful for custom or remote bridges.' },
1151
- audioPath: { type: 'string', description: 'Audio stem to import alongside the video (optional)' },
1152
- importOnly: { type: 'boolean', default: false, description: 'If true, only import the clip — do not trigger an export/render in the editor.' },
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")' },
1153
1272
  },
1154
1273
  },
1155
- handler: async ({ projectDir, editor = 'resolve', editorUrl, audioPath, importOnly = false }) => {
1274
+ handler: async ({ editor = 'resolve', action = 'import', projectDir, videoPath, audioPath, timelineName, operations = [], jobId, editorUrl, exportPath }) => {
1156
1275
  const syncFs = require('fs');
1157
1276
  const pathMod = require('path');
1158
1277
  const { ExternalAdapter } = require('../pipeline/external-adapter');
1159
1278
 
1160
- if (!projectDir) throw new Error('projectDir is required');
1161
- if (!syncFs.existsSync(projectDir)) throw new Error(`Project directory not found: ${projectDir}`);
1279
+ if (!editor) throw new Error('editor is required');
1162
1280
 
1163
- // Find the most recently rendered MP4 in out/
1164
- const outDir = pathMod.join(projectDir, 'out');
1165
- if (!syncFs.existsSync(outDir)) {
1166
- throw new Error(`No out/ directory in ${projectDir}. Run render_animation with mode="full" first.`);
1167
- }
1168
- const mp4Files = syncFs.readdirSync(outDir)
1169
- .filter(f => f.endsWith('.mp4') && !f.includes('_preview'))
1170
- .map(f => pathMod.join(outDir, f))
1171
- .sort((a, b) => syncFs.statSync(b).mtimeMs - syncFs.statSync(a).mtimeMs);
1281
+ // ── Resolve video path ────────────────────────────────────────────────
1282
+ let masterPath = videoPath || null;
1283
+ let edlPath = null;
1172
1284
 
1173
- if (mp4Files.length === 0) {
1174
- throw new Error(`No rendered MP4 found in ${outDir}. Run render_animation with mode="full" first.`);
1175
- }
1176
- const masterPath = mp4Files[0];
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.`);
1177
1289
 
1178
- // Also find the EDL from kit mode (generate it now if missing)
1179
- const edlFiles = syncFs.readdirSync(outDir).filter(f => f.endsWith('.edl') || f.endsWith('.xml'));
1180
- let edlPath = edlFiles.length > 0 ? pathMod.join(outDir, edlFiles[0]) : null;
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];
1181
1296
 
1182
- // Configure the adapter for the target editor
1183
- // editorUrl overrides the preset base URL
1184
- const adapterConfig = editorUrl
1185
- ? { name: editor, baseUrl: editorUrl }
1186
- : { name: editor };
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
+ }
1187
1300
 
1301
+ // ── Configure adapter ─────────────────────────────────────────────────
1302
+ const adapterConfig = editorUrl ? { name: editor, baseUrl: editorUrl } : { name: editor };
1188
1303
  const adapter = new ExternalAdapter(adapterConfig);
1189
1304
 
1190
- // Build the file list to import
1191
- const files = [masterPath];
1192
- if (audioPath && syncFs.existsSync(audioPath)) files.push(audioPath);
1193
- if (edlPath) files.push(edlPath);
1194
-
1195
- let importResult;
1196
- try {
1197
- importResult = await adapter.ingestMedia({
1198
- files,
1199
- metadata: {
1200
- source: 'frameforge',
1201
- projectDir,
1202
- editor,
1203
- importOnly: String(importOnly),
1204
- },
1205
- });
1206
- } catch (err) {
1207
- // If the bridge is not running, give a clear message instead of a raw HTTP error
1208
- throw new Error(
1209
- `Could not reach ${editor} bridge at ${adapter.baseUrl}.\n` +
1210
- `Make sure the bridge is running.\n` +
1211
- `Error: ${err.message}\n\n` +
1212
- `See docs/editor-bridges/${editor}.md for setup instructions.`
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 || '' } }
1213
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.'}` };
1214
1329
  }
1215
1330
 
1216
- return {
1217
- editor,
1218
- bridgeUrl: adapter.baseUrl,
1219
- importedFiles: files.map(f => pathMod.basename(f)),
1220
- masterPath,
1221
- edlPath,
1222
- importResult,
1223
- message: `Imported into ${editor} at ${adapter.baseUrl}. ${edlPath ? 'EDL/XML timeline included.' : 'Tip: run render_animation mode="kit" to also export an EDL timeline.'}`,
1224
- };
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`);
1225
1366
  },
1226
1367
  },
1227
1368
 
@@ -1231,7 +1372,7 @@ They are not installed by FrameForge — see docs/editor-bridges/ for setup inst
1231
1372
 
1232
1373
  const SERVER_INFO = {
1233
1374
  name: 'video-pipeline',
1234
- version: '2.3.2',
1375
+ version: '2.3.4',
1235
1376
  };
1236
1377
 
1237
1378
  const CAPABILITIES = {