@amg555/frameforge-mcp 2.4.8 → 2.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,251 @@
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
+ }
@@ -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).
@@ -1635,13 +1635,233 @@ Allows the agent in Claude Code, Cursor, or any IDE to inspect the visual output
1635
1635
  },
1636
1636
  },
1637
1637
 
1638
+ // ── Tool 22: Verify Scene Quality ─────────────────────────────────────────
1639
+ // Closed-loop visual QA gate grading scene quality (1–10) across 4 axes
1640
+ verify_scene_quality: {
1641
+ description: `Run closed-loop visual QA grading (1–10 score) on an animated scene HTML file or code.
1642
+ 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.`,
1643
+ inputSchema: {
1644
+ type: 'object',
1645
+ properties: {
1646
+ htmlPath: { type: 'string', description: 'Absolute or relative path to the scene HTML file' },
1647
+ html: { type: 'string', description: 'Inline HTML content if file not on disk' },
1648
+ code: { type: 'string', description: 'Raw JavaScript canvas drawing code (wrapped automatically if provided)' },
1649
+ duration: { type: 'number', default: 3, description: 'Duration of scene in seconds (used if code/html wrapped)' },
1650
+ sampleRate: { type: 'number', default: 2, description: 'Samples per second to evaluate (default: 2 fps)' },
1651
+ sheetPath: { type: 'string', description: 'Optional path where contact sheet image should be written' },
1652
+ },
1653
+ },
1654
+ handler: async ({ htmlPath, html, code, duration = 3, sampleRate = 2, sheetPath }) => {
1655
+ const { verifySceneQuality } = require('../harness/lib/verify-quality.mjs');
1656
+ const { launchHarnessBrowser } = require('../harness/lib/browser.mjs');
1657
+ const syncFs = require('fs');
1658
+ const pathMod = require('path');
1659
+
1660
+ let targetHtmlPath = htmlPath;
1661
+ let tempCreated = false;
1662
+
1663
+ if (!targetHtmlPath) {
1664
+ let content = html;
1665
+ if (!content && code) {
1666
+ const { wrapCodeInHarnessHtml } = require('../video-renderer/scene-animator');
1667
+ content = wrapCodeInHarnessHtml({ code, duration });
1668
+ }
1669
+ if (!content) {
1670
+ throw new Error('Either htmlPath, html, or code must be provided to verify_scene_quality.');
1671
+ }
1672
+ const qaDir = pathMod.join(FILES_DIR, 'qa');
1673
+ syncFs.mkdirSync(qaDir, { recursive: true });
1674
+ targetHtmlPath = pathMod.join(qaDir, `quality_${Date.now()}_${Math.random().toString(36).slice(2, 6)}.html`);
1675
+ syncFs.writeFileSync(targetHtmlPath, content, 'utf8');
1676
+ tempCreated = true;
1677
+ }
1678
+
1679
+ const { browser } = await launchHarnessBrowser();
1680
+ try {
1681
+ const result = await verifySceneQuality({
1682
+ browser,
1683
+ htmlPath: targetHtmlPath,
1684
+ sheetPath,
1685
+ sampleRate,
1686
+ });
1687
+ return result;
1688
+ } finally {
1689
+ await browser.close();
1690
+ if (tempCreated) {
1691
+ try { syncFs.unlinkSync(targetHtmlPath); } catch (_) {}
1692
+ }
1693
+ }
1694
+ },
1695
+ },
1696
+
1697
+ // ── Tool 23: Plan Video Edit ──────────────────────────────────────────────
1698
+ // Plan / Approve / Render video editing workflow: generates edit-plan.md & edit.json
1699
+ plan_video_edit: {
1700
+ description: `Generate a structured Plan/Approve/Render video editing package for raw video footage.
1701
+ Analyzes input clips, segments narrative beats, calculates cut in/out timestamps, assigns transitions, speed ramps, and color grades, and writes:
1702
+ 1. edit-plan.md — Human & agent readable decision list with rationale per cut and story beat breakdown.
1703
+ 2. edit.json — Machine-executable configuration directly runnable via edit_video.`,
1704
+ inputSchema: {
1705
+ type: 'object',
1706
+ required: ['footage'],
1707
+ properties: {
1708
+ footage: { type: ['array', 'string'], description: 'List of video file paths, or a directory path containing footage' },
1709
+ goal: { type: 'string', default: 'Create a dynamic polished montage highlighting key moments', description: 'Narrative goal or editing instructions' },
1710
+ target_duration: { type: 'number', description: 'Desired total output duration in seconds' },
1711
+ orientation: { type: 'string', enum: ['landscape', 'portrait', 'square'], default: 'landscape' },
1712
+ style: { type: 'string', enum: ['cinematic', 'fast_paced', 'documentary', 'vintage', 'cyberpunk', 'vibrant'], default: 'cinematic' },
1713
+ audio_path: { type: 'string', description: 'Optional master voiceover narration audio path' },
1714
+ music_path: { type: 'string', description: 'Optional background music track path' },
1715
+ output_dir: { type: 'string', description: 'Directory to write edit-plan.md and edit.json' },
1716
+ },
1717
+ },
1718
+ handler: async ({ footage, goal = 'Create a dynamic polished montage', target_duration, orientation = 'landscape', style = 'cinematic', audio_path, music_path, output_dir }) => {
1719
+ const syncFs = require('fs');
1720
+ const pathMod = require('path');
1721
+ const { VideoEditor } = require('../video-editor/editor');
1722
+ const editor = new VideoEditor();
1723
+
1724
+ // 1. Resolve clip paths
1725
+ let clipPaths = [];
1726
+ if (typeof footage === 'string') {
1727
+ if (syncFs.existsSync(footage) && syncFs.statSync(footage).isDirectory()) {
1728
+ const validExts = ['.mp4', '.mov', '.mkv', '.webm', '.avi'];
1729
+ clipPaths = syncFs.readdirSync(footage)
1730
+ .filter(f => validExts.includes(pathMod.extname(f).toLowerCase()))
1731
+ .map(f => pathMod.join(footage, f));
1732
+ } else {
1733
+ clipPaths = [footage];
1734
+ }
1735
+ } else if (Array.isArray(footage)) {
1736
+ clipPaths = footage;
1737
+ }
1738
+
1739
+ if (clipPaths.length === 0) {
1740
+ throw new Error('No valid footage clips provided or found in directory.');
1741
+ }
1742
+
1743
+ // 2. Probe clips
1744
+ const probedClips = [];
1745
+ for (const cp of clipPaths) {
1746
+ let meta = { duration: 10, width: 1920, height: 1080 };
1747
+ try {
1748
+ if (syncFs.existsSync(cp)) {
1749
+ meta = editor.probeMedia(cp);
1750
+ }
1751
+ } catch (_) {}
1752
+ probedClips.push({ path: cp, ...meta });
1753
+ }
1754
+
1755
+ // 3. Audio & Duration planning
1756
+ let totalTarget = target_duration;
1757
+ if (!totalTarget && audio_path && syncFs.existsSync(audio_path)) {
1758
+ try {
1759
+ const aMeta = editor.probeMedia(audio_path);
1760
+ totalTarget = aMeta.duration;
1761
+ } catch (_) {}
1762
+ }
1763
+ if (!totalTarget) {
1764
+ totalTarget = style === 'fast_paced' ? 20 : 30;
1765
+ }
1766
+
1767
+ // 4. Style configuration mapping
1768
+ const styleConfig = {
1769
+ cinematic: { avgDuration: 5.0, speed: 1.0, transition: 'dissolve', grade: 'cinematic' },
1770
+ fast_paced: { avgDuration: 2.2, speed: 1.25, transition: 'wipeleft', grade: 'vibrant' },
1771
+ documentary: { avgDuration: 4.5, speed: 1.0, transition: 'fade', grade: 'warm' },
1772
+ vintage: { avgDuration: 3.5, speed: 0.95, transition: 'fade', grade: 'vintage' },
1773
+ cyberpunk: { avgDuration: 2.5, speed: 1.2, transition: 'circleopen', grade: 'cyberpunk' },
1774
+ vibrant: { avgDuration: 3.0, speed: 1.1, transition: 'slideup', grade: 'vibrant' },
1775
+ }[style] || { avgDuration: 4.0, speed: 1.0, transition: 'fade', grade: 'cinematic' };
1776
+
1777
+ // 5. Build Cut Decision List
1778
+ const clipCount = Math.max(1, Math.min(probedClips.length, Math.ceil(totalTarget / styleConfig.avgDuration)));
1779
+ const segDur = totalTarget / clipCount;
1780
+ const plannedClips = [];
1781
+
1782
+ const beatNames = ['Opening Hook', 'Context & Build-up', 'Core Revelation / Climax', 'Resolution & Outro'];
1783
+
1784
+ for (let i = 0; i < clipCount; i++) {
1785
+ const src = probedClips[i % probedClips.length];
1786
+ const clipDur = Number(segDur.toFixed(2));
1787
+ const maxStart = Math.max(0, (src.duration || 10) - clipDur);
1788
+ const startTime = Number((maxStart * 0.25 * (i % 3)).toFixed(2));
1789
+ const endTime = Number((startTime + clipDur).toFixed(2));
1790
+ const beatIndex = Math.min(beatNames.length - 1, Math.floor((i / clipCount) * beatNames.length));
1791
+
1792
+ plannedClips.push({
1793
+ video_path: src.path,
1794
+ start_time: startTime,
1795
+ end_time: endTime,
1796
+ duration: clipDur,
1797
+ speed: styleConfig.speed,
1798
+ color_grade: styleConfig.grade,
1799
+ transition: i === clipCount - 1 ? 'none' : styleConfig.transition,
1800
+ transition_duration: 0.5,
1801
+ beat: beatNames[beatIndex],
1802
+ rationale: `Selected for ${beatNames[beatIndex]} (${clipDur}s) with ${styleConfig.grade} color tone and ${styleConfig.speed}x pacing.`,
1803
+ });
1804
+ }
1805
+
1806
+ // 6. Build edit.json
1807
+ const editJson = {
1808
+ clips: plannedClips,
1809
+ orientation,
1810
+ audio_path: audio_path || null,
1811
+ music_path: music_path || null,
1812
+ music_volume: 0.08,
1813
+ color_grade: styleConfig.grade,
1814
+ transition: styleConfig.transition,
1815
+ target_duration: totalTarget,
1816
+ };
1817
+
1818
+ // 7. Build edit-plan.md
1819
+ let planMd = `# 🎬 Video Edit Decision Plan\n\n`;
1820
+ planMd += `**Goal**: ${goal}\n`;
1821
+ planMd += `**Style**: ${style.toUpperCase()} | **Orientation**: ${orientation.toUpperCase()} | **Target Duration**: ${totalTarget.toFixed(1)}s | **Clips Count**: ${plannedClips.length}\n\n`;
1822
+ planMd += `## 📋 Narrative Beat Architecture\n\n`;
1823
+ planMd += `| Beat | Clip Index | Source Clip | In - Out | Duration | Speed | Color Grade | Transition | Rationale |\n`;
1824
+ planMd += `| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |\n`;
1825
+
1826
+ plannedClips.forEach((c, idx) => {
1827
+ const basename = pathMod.basename(c.video_path);
1828
+ 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`;
1829
+ });
1830
+
1831
+ planMd += `\n## 🎵 Audio Architecture\n\n`;
1832
+ planMd += `- **Master Voiceover**: ${audio_path ? `\`${pathMod.basename(audio_path)}\` (Primary, ducks music by 18dB)` : 'None specified (using clip audio)'}\n`;
1833
+ planMd += `- **Background Music**: ${music_path ? `\`${pathMod.basename(music_path)}\` (Ambient, volume: 0.08)` : 'None specified'}\n\n`;
1834
+ planMd += `## 🚀 Execution & Approval\n\n`;
1835
+ planMd += `To execute this edit plan autonomously, call the \`edit_video\` tool with the payload in \`edit.json\`.\n`;
1836
+
1837
+ // 8. Write outputs
1838
+ const outDir = output_dir || pathMod.join(FILES_DIR, 'edits', `edit_${Date.now()}`);
1839
+ syncFs.mkdirSync(outDir, { recursive: true });
1840
+ const planPath = pathMod.join(outDir, 'edit-plan.md');
1841
+ const jsonPath = pathMod.join(outDir, 'edit.json');
1842
+
1843
+ syncFs.writeFileSync(planPath, planMd, 'utf8');
1844
+ syncFs.writeFileSync(jsonPath, JSON.stringify(editJson, null, 2), 'utf8');
1845
+
1846
+ return {
1847
+ planPath,
1848
+ editJsonPath: jsonPath,
1849
+ planMarkdown: planMd,
1850
+ editConfig: editJson,
1851
+ clipCount: plannedClips.length,
1852
+ totalDuration: totalTarget,
1853
+ message: 'Edit plan and configuration generated. Review edit-plan.md, then pass edit.json to edit_video.',
1854
+ };
1855
+ },
1856
+ },
1857
+
1638
1858
  };
1639
1859
 
1640
1860
  // ── MCP protocol handler (MCP 2024-11-05 spec) ───────────────────────────
1641
1861
 
1642
1862
  const SERVER_INFO = {
1643
1863
  name: 'video-pipeline',
1644
- version: '2.4.8',
1864
+ version: '2.4.9',
1645
1865
  };
1646
1866
 
1647
1867
  const CAPABILITIES = {