@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.
- package/package.json +3 -3
- package/plugins/video-pipeline/skills/html-renderer/references/motion-craft.md +91 -0
- package/plugins/video-pipeline/skills/html-renderer/references/render-contract.md +65 -0
- package/plugins/video-pipeline/skills/video-pipeline/references/motion-craft.md +91 -0
- package/plugins/video-pipeline/skills/video-pipeline/references/render-contract.md +65 -0
- package/scripts/deep-validate.js +8 -2
- package/src/harness/bin/harness.mjs +32 -2
- package/src/harness/lib/verify-quality.mjs +251 -0
- package/src/mcp-server/server.js +222 -2
- package/src/video-renderer/scene-animator.js +873 -9
|
@@ -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
|
+
}
|
package/src/mcp-server/server.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* FrameForge MCP Server — MCP 2024-11-05 spec compliant
|
|
3
3
|
*
|
|
4
|
-
* Exposes
|
|
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.
|
|
1864
|
+
version: '2.4.9',
|
|
1645
1865
|
};
|
|
1646
1866
|
|
|
1647
1867
|
const CAPABILITIES = {
|