@amg555/frameforge-mcp 2.4.8 → 2.4.10

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.
@@ -0,0 +1,252 @@
1
+ /**
2
+ * verify-quality.mjs — Closed-Loop Visual QA & Frame Scoring Gate
3
+ *
4
+ * Implements the reference Opus 5.5 / Reanimate closed-loop verification contract:
5
+ * 1. Samples keyframes at key timestamps across the timeline.
6
+ * 2. Evaluates each frame against 4 quality metrics (content presence, luminance,
7
+ * corner confinement, and motion delta).
8
+ * 3. Assigns a 1–10 quality score per frame.
9
+ * 4. Identifies the worst frames with actionable diagnostic issues.
10
+ * 5. Generates a visual review contact sheet if requested.
11
+ */
12
+
13
+ import { createHash } from 'crypto';
14
+ import fs from 'fs';
15
+ import path from 'path';
16
+ import { openPageWithVirtualClock, captureFrame } from './capture.mjs';
17
+ import { analyzeRgbBuffer } from './pixels.mjs';
18
+ import { renderSheet } from './sheets.mjs';
19
+
20
+ /**
21
+ * Score a single RGB24 frame on a 1–10 scale.
22
+ * @param {Buffer} rgbBuf
23
+ * @param {number} width
24
+ * @param {number} height
25
+ * @returns {{ score: number, issues: string[], analysis: object }}
26
+ */
27
+ export function scoreFrameBuffer(rgbBuf, width, height) {
28
+ const analysis = analyzeRgbBuffer(rgbBuf, width, height);
29
+ const issues = [];
30
+ let score = 10;
31
+
32
+ // Fatal: blank / pitch black frame
33
+ if (analysis.isBlank) {
34
+ issues.push('Fatal: Blank or black frame detected (no visible content)');
35
+ return { score: 1, issues, analysis };
36
+ }
37
+
38
+ // Blown frame (overexposed flash / pure white screen)
39
+ if (analysis.isBlown) {
40
+ issues.push('Overexposed: Blown frame (bright pixel share > 92%)');
41
+ score -= 6;
42
+ }
43
+
44
+ // Corner confinement (content stuck in one corner / PPT style)
45
+ if (analysis.isCornerConfined) {
46
+ issues.push('Unbalanced: Visual content confined to a single corner');
47
+ score -= 3;
48
+ }
49
+
50
+ // Low contrast / muddy frame
51
+ const meanLum = 0.2126 * analysis.meanColor[0] + 0.7152 * analysis.meanColor[1] + 0.0722 * analysis.meanColor[2];
52
+ if (meanLum < 15 && analysis.brightPixelShare < 0.02) {
53
+ issues.push('Low contrast: Scene appears extremely dark');
54
+ score -= 2;
55
+ }
56
+
57
+ // Bounding box coverage check (too sparse / tiny dot)
58
+ if (analysis.bbox) {
59
+ const [minX, minY, maxX, maxY] = analysis.bbox;
60
+ const boxW = maxX - minX;
61
+ const boxH = maxY - minY;
62
+ const areaShare = (boxW * boxH) / (width * height);
63
+ if (areaShare < 0.08) {
64
+ issues.push('Sparse: Content covers less than 8% of the stage');
65
+ score -= 2;
66
+ }
67
+ }
68
+
69
+ return {
70
+ score: Math.max(1, Math.min(10, score)),
71
+ issues,
72
+ analysis,
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Run closed-loop visual QA across an animation HTML page.
78
+ *
79
+ * @param {object} opts
80
+ * @param {object} opts.browser Playwright browser instance
81
+ * @param {string} opts.htmlPath Path to HTML animation file
82
+ * @param {number} [opts.duration] Scene duration in seconds (reads window.DURATION by default)
83
+ * @param {number[]} [opts.times] Custom timestamps to sample (defaults to 5 points: 10%, 30%, 50%, 70%, 90%)
84
+ * @param {number} [opts.width=1920] Frame width
85
+ * @param {number} [opts.height=1080] Frame height
86
+ * @param {string} [opts.sheetPath] Optional path to write a contact sheet JPG
87
+ * @returns {Promise<{
88
+ * overallScore: number,
89
+ * passed: boolean,
90
+ * motionDelta: number,
91
+ * sampledFrames: Array<{ time: number, score: number, issues: string[] }>,
92
+ * worstFrames: Array<{ time: number, score: number, issues: string[] }>,
93
+ * sheetPath?: string
94
+ * }>}
95
+ */
96
+ export async function verifySceneQuality({
97
+ browser,
98
+ htmlPath,
99
+ duration = null,
100
+ times = null,
101
+ width = 1280,
102
+ height = 720,
103
+ sheetPath = null,
104
+ }) {
105
+ const page = await openPageWithVirtualClock(browser, htmlPath, { width, height });
106
+
107
+ // Resolve duration from page if not provided
108
+ const resolvedDuration = duration || (await page.evaluate(() => window.DURATION || 3));
109
+
110
+ const sampleTimes = times || [
111
+ parseFloat((resolvedDuration * 0.10).toFixed(2)),
112
+ parseFloat((resolvedDuration * 0.30).toFixed(2)),
113
+ parseFloat((resolvedDuration * 0.50).toFixed(2)),
114
+ parseFloat((resolvedDuration * 0.70).toFixed(2)),
115
+ parseFloat((resolvedDuration * 0.90).toFixed(2)),
116
+ ];
117
+
118
+ const sampledFrames = [];
119
+ const rawBuffers = [];
120
+
121
+ for (const t of sampleTimes) {
122
+ const pngBuf = await captureFrame(page, t, { type: 'image/png' });
123
+ rawBuffers.push(pngBuf);
124
+
125
+ // Analyze via pixel inspector
126
+ // Extract raw RGB24 using Playwright screenshot bytes or canvas
127
+ const frameEval = await page.evaluate(async (time) => {
128
+ await window.__seek(time);
129
+ const canvas = document.querySelector('canvas') || document.getElementById('stage');
130
+ if (canvas) {
131
+ const ctx = canvas.getContext('2d');
132
+ const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
133
+ const data = imgData.data;
134
+ const total = canvas.width * canvas.height;
135
+ let rSum = 0, gSum = 0, bSum = 0, brightCount = 0;
136
+ let minX = canvas.width, minY = canvas.height, maxX = -1, maxY = -1;
137
+
138
+ for (let i = 0; i < total; i++) {
139
+ const idx = i * 4;
140
+ const r = data[idx], g = data[idx + 1], b = data[idx + 2];
141
+ rSum += r; gSum += g; bSum += b;
142
+ const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
143
+ if (lum > 200) brightCount++;
144
+ if (lum > 30) {
145
+ const x = i % canvas.width;
146
+ const y = Math.floor(i / canvas.width);
147
+ if (x < minX) minX = x;
148
+ if (x > maxX) maxX = x;
149
+ if (y < minY) minY = y;
150
+ if (y > maxY) maxY = y;
151
+ }
152
+ }
153
+
154
+ const meanR = Math.round(rSum / total);
155
+ const meanG = Math.round(gSum / total);
156
+ const meanB = Math.round(bSum / total);
157
+ const brightShare = brightCount / total;
158
+ const hasContent = maxX >= 0 && maxY >= 0;
159
+
160
+ return {
161
+ meanColor: [meanR, meanG, meanB],
162
+ brightPixelShare: brightShare,
163
+ bbox: hasContent ? [minX, minY, maxX, maxY] : null,
164
+ hasContent,
165
+ w: canvas.width,
166
+ h: canvas.height,
167
+ };
168
+ }
169
+ return null;
170
+ }, t);
171
+
172
+ let frameScore = 10;
173
+ const issues = [];
174
+
175
+ if (frameEval) {
176
+ if (!frameEval.hasContent) {
177
+ frameScore = 1;
178
+ issues.push('Blank frame: no canvas content drawn');
179
+ } else {
180
+ if (frameEval.brightPixelShare > 0.92) {
181
+ frameScore -= 6;
182
+ issues.push('Blown frame: over 92% bright pixels');
183
+ }
184
+ if (frameEval.bbox) {
185
+ const [minX, minY, maxX, maxY] = frameEval.bbox;
186
+ const halfW = frameEval.w * 0.55;
187
+ const halfH = frameEval.h * 0.55;
188
+ const inCorner = (maxX < halfW && maxY < halfH) ||
189
+ (minX > frameEval.w - halfW && maxY < halfH) ||
190
+ (maxX < halfW && minY > frameEval.h - halfH) ||
191
+ (minX > frameEval.w - halfW && minY > frameEval.h - halfH);
192
+ if (inCorner) {
193
+ frameScore -= 3;
194
+ issues.push('Corner-confined content');
195
+ }
196
+ }
197
+ }
198
+ }
199
+
200
+ sampledFrames.push({
201
+ time: t,
202
+ score: Math.max(1, Math.min(10, frameScore)),
203
+ issues,
204
+ });
205
+ }
206
+
207
+ // Motion continuity delta: compare hashes of t=30% vs t=70%
208
+ // A static/stuck PPT slide will have hash(t1) === hash(t2)
209
+ const hashEarly = createHash('sha256').update(rawBuffers[1] || rawBuffers[0]).digest('hex');
210
+ const hashLate = createHash('sha256').update(rawBuffers[3] || rawBuffers[rawBuffers.length - 1]).digest('hex');
211
+ const isStuckFrozen = (hashEarly === hashLate);
212
+ let motionDelta = 1.0;
213
+
214
+ if (isStuckFrozen) {
215
+ motionDelta = 0.0;
216
+ sampledFrames.forEach(f => {
217
+ f.score = Math.max(1, f.score - 4);
218
+ f.issues.push('Stuck motion: zero visual delta between early and late frames');
219
+ });
220
+ }
221
+
222
+ // Contact sheet artifact if requested
223
+ let renderedSheetPath = null;
224
+ if (sheetPath) {
225
+ try {
226
+ renderedSheetPath = await renderSheet({
227
+ page,
228
+ times: sampleTimes,
229
+ cols: sampleTimes.length,
230
+ thumbWidth: 320,
231
+ outPath: sheetPath,
232
+ });
233
+ } catch (_) {}
234
+ }
235
+
236
+ await page.close();
237
+
238
+ const totalScore = sampledFrames.reduce((sum, f) => sum + f.score, 0);
239
+ const overallScore = parseFloat((totalScore / sampledFrames.length).toFixed(1));
240
+ const worstFrames = [...sampledFrames].sort((a, b) => a.score - b.score).slice(0, 3);
241
+ const passed = overallScore >= 7.0 && !isStuckFrozen;
242
+
243
+ return {
244
+ overallScore,
245
+ passed,
246
+ motionDelta,
247
+ sampledFrames,
248
+ worstFrames,
249
+ sheetPath: renderedSheetPath,
250
+ };
251
+ }
252
+
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * FrameForge MCP Server — MCP 2024-11-05 spec compliant
3
3
  *
4
- * Exposes 19 video production & autonomous editing tools via MCP.
4
+ * Exposes 23 video production & autonomous editing tools via MCP.
5
5
  * Works with IBM Bob, Cursor, Windsurf, Codex, opencode, Claude Code, or any MCP-capable IDE.
6
6
  * Primary connection: Cloudflare Remote MCP (cloudflare-mcp/).
7
7
  * Alternative: stdio (src/mcp-server/stdio.js) or HTTP (this file).
@@ -96,7 +96,7 @@ const TOOLS = {
96
96
  claims: { type: 'array' },
97
97
  topic: { type: 'string' },
98
98
  style: { type: 'string', default: 'informative', description: 'e.g. "educational", "comedic", "documentary", "tutorial", "vlog"' },
99
- persona: { type: 'string', default: 'video host', description: 'Who the narrator is, e.g. "travel blogger", "tech reviewer"' },
99
+ persona: { type: 'string', default: 'narrator', description: 'Who the narrator is, e.g. "narrator", "travel blogger", "tech reviewer"' },
100
100
  format: { type: 'string', default: 'YouTube video', description: 'e.g. "YouTube short", "Instagram reel", "long-form tutorial"' },
101
101
  language: { type: 'string', default: 'en', description: 'BCP-47 language code for the script' },
102
102
  sceneCount: { type: 'number', default: 55, description: 'Target number of scenes' },
@@ -587,10 +587,11 @@ Example scene:
587
587
  style: { type: 'string', description: 'Visual style/theme override (e.g. cinematic_documentary, finance_executive, cyber_tech, retro_synthwave, nature_organic, hype_sports, minimal_luxury, breaking_news, cosmic_space)' },
588
588
  theme: { type: 'string', description: 'Theme preset alias' },
589
589
  showCaptions: { type: 'boolean', default: false, description: 'Display on-screen synchronized subtitles/captions. Default false (clean canvas without text unless explicitly requested in prompt).' },
590
+ showAnchor: { type: 'boolean', default: false, description: 'Display on-screen anchor/presenter character. Default false (no anchor unless explicitly requested in prompt).' },
590
591
  productionId: { type: 'string', description: 'Optional — reuse an existing productionId' },
591
592
  },
592
593
  },
593
- handler: async ({ scenes, title = 'FrameForge Video', orientation = 'landscape', voice, language, skipTts = false, parallelism = 1, withMusic = true, musicVolume = 0.07, topic = '', style = '', theme = '', showCaptions = false, productionId }) => {
594
+ handler: async ({ scenes, title = 'FrameForge Video', orientation = 'landscape', voice, language, skipTts = false, parallelism = 1, withMusic = true, musicVolume = 0.07, topic = '', style = '', theme = '', showCaptions = false, showAnchor = false, productionId }) => {
594
595
  const { runAssets } = require('../workflows/assets');
595
596
  const { runRender } = require('../workflows/render');
596
597
 
@@ -670,6 +671,7 @@ Example scene:
670
671
  title,
671
672
  language,
672
673
  showCaptions,
674
+ showAnchor,
673
675
  });
674
676
  const renderResult = await runRender({
675
677
  productionId: pid,
@@ -684,6 +686,7 @@ Example scene:
684
686
  title,
685
687
  language,
686
688
  showCaptions,
689
+ showAnchor,
687
690
  });
688
691
 
689
692
  if (!renderResult.passed) {
@@ -1635,13 +1638,233 @@ Allows the agent in Claude Code, Cursor, or any IDE to inspect the visual output
1635
1638
  },
1636
1639
  },
1637
1640
 
1641
+ // ── Tool 22: Verify Scene Quality ─────────────────────────────────────────
1642
+ // Closed-loop visual QA gate grading scene quality (1–10) across 4 axes
1643
+ verify_scene_quality: {
1644
+ description: `Run closed-loop visual QA grading (1–10 score) on an animated scene HTML file or code.
1645
+ Evaluates 4 critical visual axes: content presence (non-blank), proper exposure (non-blown), layout distribution (not corner-confined), and continuous motion delta (detects stuck/frozen PowerPoint slides). Generates an optional contact sheet and returns frame-by-frame scores, worst frames, and pass/fail gate status.`,
1646
+ inputSchema: {
1647
+ type: 'object',
1648
+ properties: {
1649
+ htmlPath: { type: 'string', description: 'Absolute or relative path to the scene HTML file' },
1650
+ html: { type: 'string', description: 'Inline HTML content if file not on disk' },
1651
+ code: { type: 'string', description: 'Raw JavaScript canvas drawing code (wrapped automatically if provided)' },
1652
+ duration: { type: 'number', default: 3, description: 'Duration of scene in seconds (used if code/html wrapped)' },
1653
+ sampleRate: { type: 'number', default: 2, description: 'Samples per second to evaluate (default: 2 fps)' },
1654
+ sheetPath: { type: 'string', description: 'Optional path where contact sheet image should be written' },
1655
+ },
1656
+ },
1657
+ handler: async ({ htmlPath, html, code, duration = 3, sampleRate = 2, sheetPath }) => {
1658
+ const { verifySceneQuality } = require('../harness/lib/verify-quality.mjs');
1659
+ const { launchHarnessBrowser } = require('../harness/lib/browser.mjs');
1660
+ const syncFs = require('fs');
1661
+ const pathMod = require('path');
1662
+
1663
+ let targetHtmlPath = htmlPath;
1664
+ let tempCreated = false;
1665
+
1666
+ if (!targetHtmlPath) {
1667
+ let content = html;
1668
+ if (!content && code) {
1669
+ const { wrapCodeInHarnessHtml } = require('../video-renderer/scene-animator');
1670
+ content = wrapCodeInHarnessHtml({ code, duration });
1671
+ }
1672
+ if (!content) {
1673
+ throw new Error('Either htmlPath, html, or code must be provided to verify_scene_quality.');
1674
+ }
1675
+ const qaDir = pathMod.join(FILES_DIR, 'qa');
1676
+ syncFs.mkdirSync(qaDir, { recursive: true });
1677
+ targetHtmlPath = pathMod.join(qaDir, `quality_${Date.now()}_${Math.random().toString(36).slice(2, 6)}.html`);
1678
+ syncFs.writeFileSync(targetHtmlPath, content, 'utf8');
1679
+ tempCreated = true;
1680
+ }
1681
+
1682
+ const { browser } = await launchHarnessBrowser();
1683
+ try {
1684
+ const result = await verifySceneQuality({
1685
+ browser,
1686
+ htmlPath: targetHtmlPath,
1687
+ sheetPath,
1688
+ sampleRate,
1689
+ });
1690
+ return result;
1691
+ } finally {
1692
+ await browser.close();
1693
+ if (tempCreated) {
1694
+ try { syncFs.unlinkSync(targetHtmlPath); } catch (_) {}
1695
+ }
1696
+ }
1697
+ },
1698
+ },
1699
+
1700
+ // ── Tool 23: Plan Video Edit ──────────────────────────────────────────────
1701
+ // Plan / Approve / Render video editing workflow: generates edit-plan.md & edit.json
1702
+ plan_video_edit: {
1703
+ description: `Generate a structured Plan/Approve/Render video editing package for raw video footage.
1704
+ Analyzes input clips, segments narrative beats, calculates cut in/out timestamps, assigns transitions, speed ramps, and color grades, and writes:
1705
+ 1. edit-plan.md — Human & agent readable decision list with rationale per cut and story beat breakdown.
1706
+ 2. edit.json — Machine-executable configuration directly runnable via edit_video.`,
1707
+ inputSchema: {
1708
+ type: 'object',
1709
+ required: ['footage'],
1710
+ properties: {
1711
+ footage: { type: ['array', 'string'], description: 'List of video file paths, or a directory path containing footage' },
1712
+ goal: { type: 'string', default: 'Create a dynamic polished montage highlighting key moments', description: 'Narrative goal or editing instructions' },
1713
+ target_duration: { type: 'number', description: 'Desired total output duration in seconds' },
1714
+ orientation: { type: 'string', enum: ['landscape', 'portrait', 'square'], default: 'landscape' },
1715
+ style: { type: 'string', enum: ['cinematic', 'fast_paced', 'documentary', 'vintage', 'cyberpunk', 'vibrant'], default: 'cinematic' },
1716
+ audio_path: { type: 'string', description: 'Optional master voiceover narration audio path' },
1717
+ music_path: { type: 'string', description: 'Optional background music track path' },
1718
+ output_dir: { type: 'string', description: 'Directory to write edit-plan.md and edit.json' },
1719
+ },
1720
+ },
1721
+ handler: async ({ footage, goal = 'Create a dynamic polished montage', target_duration, orientation = 'landscape', style = 'cinematic', audio_path, music_path, output_dir }) => {
1722
+ const syncFs = require('fs');
1723
+ const pathMod = require('path');
1724
+ const { VideoEditor } = require('../video-editor/editor');
1725
+ const editor = new VideoEditor();
1726
+
1727
+ // 1. Resolve clip paths
1728
+ let clipPaths = [];
1729
+ if (typeof footage === 'string') {
1730
+ if (syncFs.existsSync(footage) && syncFs.statSync(footage).isDirectory()) {
1731
+ const validExts = ['.mp4', '.mov', '.mkv', '.webm', '.avi'];
1732
+ clipPaths = syncFs.readdirSync(footage)
1733
+ .filter(f => validExts.includes(pathMod.extname(f).toLowerCase()))
1734
+ .map(f => pathMod.join(footage, f));
1735
+ } else {
1736
+ clipPaths = [footage];
1737
+ }
1738
+ } else if (Array.isArray(footage)) {
1739
+ clipPaths = footage;
1740
+ }
1741
+
1742
+ if (clipPaths.length === 0) {
1743
+ throw new Error('No valid footage clips provided or found in directory.');
1744
+ }
1745
+
1746
+ // 2. Probe clips
1747
+ const probedClips = [];
1748
+ for (const cp of clipPaths) {
1749
+ let meta = { duration: 10, width: 1920, height: 1080 };
1750
+ try {
1751
+ if (syncFs.existsSync(cp)) {
1752
+ meta = editor.probeMedia(cp);
1753
+ }
1754
+ } catch (_) {}
1755
+ probedClips.push({ path: cp, ...meta });
1756
+ }
1757
+
1758
+ // 3. Audio & Duration planning
1759
+ let totalTarget = target_duration;
1760
+ if (!totalTarget && audio_path && syncFs.existsSync(audio_path)) {
1761
+ try {
1762
+ const aMeta = editor.probeMedia(audio_path);
1763
+ totalTarget = aMeta.duration;
1764
+ } catch (_) {}
1765
+ }
1766
+ if (!totalTarget) {
1767
+ totalTarget = style === 'fast_paced' ? 20 : 30;
1768
+ }
1769
+
1770
+ // 4. Style configuration mapping
1771
+ const styleConfig = {
1772
+ cinematic: { avgDuration: 5.0, speed: 1.0, transition: 'dissolve', grade: 'cinematic' },
1773
+ fast_paced: { avgDuration: 2.2, speed: 1.25, transition: 'wipeleft', grade: 'vibrant' },
1774
+ documentary: { avgDuration: 4.5, speed: 1.0, transition: 'fade', grade: 'warm' },
1775
+ vintage: { avgDuration: 3.5, speed: 0.95, transition: 'fade', grade: 'vintage' },
1776
+ cyberpunk: { avgDuration: 2.5, speed: 1.2, transition: 'circleopen', grade: 'cyberpunk' },
1777
+ vibrant: { avgDuration: 3.0, speed: 1.1, transition: 'slideup', grade: 'vibrant' },
1778
+ }[style] || { avgDuration: 4.0, speed: 1.0, transition: 'fade', grade: 'cinematic' };
1779
+
1780
+ // 5. Build Cut Decision List
1781
+ const clipCount = Math.max(1, Math.min(probedClips.length, Math.ceil(totalTarget / styleConfig.avgDuration)));
1782
+ const segDur = totalTarget / clipCount;
1783
+ const plannedClips = [];
1784
+
1785
+ const beatNames = ['Opening Hook', 'Context & Build-up', 'Core Revelation / Climax', 'Resolution & Outro'];
1786
+
1787
+ for (let i = 0; i < clipCount; i++) {
1788
+ const src = probedClips[i % probedClips.length];
1789
+ const clipDur = Number(segDur.toFixed(2));
1790
+ const maxStart = Math.max(0, (src.duration || 10) - clipDur);
1791
+ const startTime = Number((maxStart * 0.25 * (i % 3)).toFixed(2));
1792
+ const endTime = Number((startTime + clipDur).toFixed(2));
1793
+ const beatIndex = Math.min(beatNames.length - 1, Math.floor((i / clipCount) * beatNames.length));
1794
+
1795
+ plannedClips.push({
1796
+ video_path: src.path,
1797
+ start_time: startTime,
1798
+ end_time: endTime,
1799
+ duration: clipDur,
1800
+ speed: styleConfig.speed,
1801
+ color_grade: styleConfig.grade,
1802
+ transition: i === clipCount - 1 ? 'none' : styleConfig.transition,
1803
+ transition_duration: 0.5,
1804
+ beat: beatNames[beatIndex],
1805
+ rationale: `Selected for ${beatNames[beatIndex]} (${clipDur}s) with ${styleConfig.grade} color tone and ${styleConfig.speed}x pacing.`,
1806
+ });
1807
+ }
1808
+
1809
+ // 6. Build edit.json
1810
+ const editJson = {
1811
+ clips: plannedClips,
1812
+ orientation,
1813
+ audio_path: audio_path || null,
1814
+ music_path: music_path || null,
1815
+ music_volume: 0.08,
1816
+ color_grade: styleConfig.grade,
1817
+ transition: styleConfig.transition,
1818
+ target_duration: totalTarget,
1819
+ };
1820
+
1821
+ // 7. Build edit-plan.md
1822
+ let planMd = `# 🎬 Video Edit Decision Plan\n\n`;
1823
+ planMd += `**Goal**: ${goal}\n`;
1824
+ planMd += `**Style**: ${style.toUpperCase()} | **Orientation**: ${orientation.toUpperCase()} | **Target Duration**: ${totalTarget.toFixed(1)}s | **Clips Count**: ${plannedClips.length}\n\n`;
1825
+ planMd += `## 📋 Narrative Beat Architecture\n\n`;
1826
+ planMd += `| Beat | Clip Index | Source Clip | In - Out | Duration | Speed | Color Grade | Transition | Rationale |\n`;
1827
+ planMd += `| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |\n`;
1828
+
1829
+ plannedClips.forEach((c, idx) => {
1830
+ const basename = pathMod.basename(c.video_path);
1831
+ planMd += `| **${c.beat}** | #${idx + 1} | \`${basename}\` | ${c.start_time}s - ${c.end_time}s | ${c.duration}s | ${c.speed}x | ${c.color_grade} | ${c.transition} | ${c.rationale} |\n`;
1832
+ });
1833
+
1834
+ planMd += `\n## 🎵 Audio Architecture\n\n`;
1835
+ planMd += `- **Master Voiceover**: ${audio_path ? `\`${pathMod.basename(audio_path)}\` (Primary, ducks music by 18dB)` : 'None specified (using clip audio)'}\n`;
1836
+ planMd += `- **Background Music**: ${music_path ? `\`${pathMod.basename(music_path)}\` (Ambient, volume: 0.08)` : 'None specified'}\n\n`;
1837
+ planMd += `## 🚀 Execution & Approval\n\n`;
1838
+ planMd += `To execute this edit plan autonomously, call the \`edit_video\` tool with the payload in \`edit.json\`.\n`;
1839
+
1840
+ // 8. Write outputs
1841
+ const outDir = output_dir || pathMod.join(FILES_DIR, 'edits', `edit_${Date.now()}`);
1842
+ syncFs.mkdirSync(outDir, { recursive: true });
1843
+ const planPath = pathMod.join(outDir, 'edit-plan.md');
1844
+ const jsonPath = pathMod.join(outDir, 'edit.json');
1845
+
1846
+ syncFs.writeFileSync(planPath, planMd, 'utf8');
1847
+ syncFs.writeFileSync(jsonPath, JSON.stringify(editJson, null, 2), 'utf8');
1848
+
1849
+ return {
1850
+ planPath,
1851
+ editJsonPath: jsonPath,
1852
+ planMarkdown: planMd,
1853
+ editConfig: editJson,
1854
+ clipCount: plannedClips.length,
1855
+ totalDuration: totalTarget,
1856
+ message: 'Edit plan and configuration generated. Review edit-plan.md, then pass edit.json to edit_video.',
1857
+ };
1858
+ },
1859
+ },
1860
+
1638
1861
  };
1639
1862
 
1640
1863
  // ── MCP protocol handler (MCP 2024-11-05 spec) ───────────────────────────
1641
1864
 
1642
1865
  const SERVER_INFO = {
1643
1866
  name: 'video-pipeline',
1644
- version: '2.4.8',
1867
+ version: '2.4.10',
1645
1868
  };
1646
1869
 
1647
1870
  const CAPABILITIES = {
@@ -174,11 +174,16 @@ class VideoRenderer {
174
174
  clipVolume = 0,
175
175
  colorGrade = null,
176
176
  showCaptions = false,
177
+ showAnchor = false,
177
178
  } = {}) {
178
179
  const effectiveShowCaptions = Boolean(
179
180
  showCaptions ||
180
181
  process.env.FRAMEFORGE_SHOW_CAPTIONS === '1'
181
182
  );
183
+ const effectiveShowAnchor = Boolean(
184
+ showAnchor ||
185
+ process.env.FRAMEFORGE_SHOW_ANCHOR === '1'
186
+ );
182
187
  const outputPath = path.join(this.vdir, `scene_${sceneIndex}_out.mp4`);
183
188
 
184
189
  // Idempotency: skip if already rendered and manifest says so
@@ -419,7 +424,8 @@ class VideoRenderer {
419
424
  height: renderHeight,
420
425
  sceneIndex,
421
426
  musicMood,
422
- showCaptions,
427
+ showCaptions: effectiveShowCaptions,
428
+ showAnchor: effectiveShowAnchor,
423
429
  camera: effectiveCam,
424
430
  cameraHint: effectiveCam,
425
431
  });