@openvidstudio/mcp-server 0.1.0

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 (94) hide show
  1. package/dist/beats.d.ts +47 -0
  2. package/dist/beats.js +9 -0
  3. package/dist/capture.d.ts +116 -0
  4. package/dist/capture.js +148 -0
  5. package/dist/config.d.ts +58 -0
  6. package/dist/config.js +130 -0
  7. package/dist/paths.d.ts +20 -0
  8. package/dist/paths.js +57 -0
  9. package/dist/scenes/pngSize.d.ts +11 -0
  10. package/dist/scenes/pngSize.js +75 -0
  11. package/dist/scenes/templates.d.ts +42 -0
  12. package/dist/scenes/templates.js +468 -0
  13. package/dist/server.d.ts +16 -0
  14. package/dist/server.js +37 -0
  15. package/dist/stdio.d.ts +2 -0
  16. package/dist/stdio.js +33 -0
  17. package/dist/tools/captureScreenRecording.d.ts +28 -0
  18. package/dist/tools/captureScreenRecording.js +181 -0
  19. package/dist/tools/captureScreenshot.d.ts +34 -0
  20. package/dist/tools/captureScreenshot.js +148 -0
  21. package/dist/tools/contactSheet.d.ts +36 -0
  22. package/dist/tools/contactSheet.js +116 -0
  23. package/dist/tools/generateNarration.d.ts +54 -0
  24. package/dist/tools/generateNarration.js +185 -0
  25. package/dist/tools/importHiggsfieldClip.d.ts +41 -0
  26. package/dist/tools/importHiggsfieldClip.js +108 -0
  27. package/dist/tools/index.d.ts +13 -0
  28. package/dist/tools/index.js +47 -0
  29. package/dist/tools/initProject.d.ts +37 -0
  30. package/dist/tools/initProject.js +166 -0
  31. package/dist/tools/mcp.d.ts +33 -0
  32. package/dist/tools/mcp.js +36 -0
  33. package/dist/tools/planBeats.d.ts +40 -0
  34. package/dist/tools/planBeats.js +143 -0
  35. package/dist/tools/preflight.d.ts +30 -0
  36. package/dist/tools/preflight.js +170 -0
  37. package/dist/tools/qcExtractFrames.d.ts +25 -0
  38. package/dist/tools/qcExtractFrames.js +129 -0
  39. package/dist/tools/renderVideo.d.ts +43 -0
  40. package/dist/tools/renderVideo.js +120 -0
  41. package/dist/tools/scaffoldScene.d.ts +23 -0
  42. package/dist/tools/scaffoldScene.js +147 -0
  43. package/dist/tools/stitchComposition.d.ts +24 -0
  44. package/dist/tools/stitchComposition.js +230 -0
  45. package/dist/tools/validateBeats.d.ts +14 -0
  46. package/dist/tools/validateBeats.js +164 -0
  47. package/dist/tools/validateScenes.d.ts +31 -0
  48. package/dist/tools/validateScenes.js +191 -0
  49. package/dist/tools/writeBeatsFile.d.ts +20 -0
  50. package/dist/tools/writeBeatsFile.js +81 -0
  51. package/dist/util.d.ts +43 -0
  52. package/dist/util.js +163 -0
  53. package/docs/API.md +344 -0
  54. package/docs/CAPTURE.md +106 -0
  55. package/docs/HIGGSFIELD.md +118 -0
  56. package/docs/NARRATION.md +111 -0
  57. package/docs/OVERVIEW.md +112 -0
  58. package/docs/PIPELINE.md +173 -0
  59. package/docs/PLANNING.md +206 -0
  60. package/docs/SCRIPT.md +50 -0
  61. package/docs/STYLE.md +88 -0
  62. package/package.json +54 -0
  63. package/templates/default/CLAUDE.md +63 -0
  64. package/templates/default/openvidstudio-core/package.json +51 -0
  65. package/templates/default/openvidstudio-core/scripts/gen-sfx.sh +74 -0
  66. package/templates/default/openvidstudio-core/src/ChecklistPanel.tsx +97 -0
  67. package/templates/default/openvidstudio-core/src/CinematicScene.tsx +53 -0
  68. package/templates/default/openvidstudio-core/src/CodePanel.tsx +122 -0
  69. package/templates/default/openvidstudio-core/src/CursorActor.tsx +90 -0
  70. package/templates/default/openvidstudio-core/src/DeviceFrame.tsx +74 -0
  71. package/templates/default/openvidstudio-core/src/KineticText.tsx +142 -0
  72. package/templates/default/openvidstudio-core/src/RepoCta.tsx +128 -0
  73. package/templates/default/openvidstudio-core/src/TerminalReplay.tsx +263 -0
  74. package/templates/default/openvidstudio-core/src/camera.tsx +141 -0
  75. package/templates/default/openvidstudio-core/src/index.ts +60 -0
  76. package/templates/default/openvidstudio-core/src/look.tsx +90 -0
  77. package/templates/default/openvidstudio-core/src/motion.ts +50 -0
  78. package/templates/default/openvidstudio-core/src/sfx.tsx +106 -0
  79. package/templates/default/openvidstudio-core/src/tokens.ts +41 -0
  80. package/templates/default/package.json +31 -0
  81. package/templates/default/public/sfx/bell.wav +0 -0
  82. package/templates/default/public/sfx/blip.wav +0 -0
  83. package/templates/default/public/sfx/click.wav +0 -0
  84. package/templates/default/public/sfx/key_a.wav +0 -0
  85. package/templates/default/public/sfx/key_b.wav +0 -0
  86. package/templates/default/public/sfx/key_c.wav +0 -0
  87. package/templates/default/public/sfx/key_enter.wav +0 -0
  88. package/templates/default/public/sfx/music-bed.mp3 +0 -0
  89. package/templates/default/public/sfx/success.wav +0 -0
  90. package/templates/default/public/sfx/whoosh.wav +0 -0
  91. package/templates/default/remotion.config.ts +13 -0
  92. package/templates/default/src/Root.tsx +9 -0
  93. package/templates/default/src/index.ts +4 -0
  94. package/templates/default/tsconfig.json +16 -0
@@ -0,0 +1,181 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.buildFfmpegTranscodeArgs = buildFfmpegTranscodeArgs;
37
+ exports.buildFfprobeDurationArgs = buildFfprobeDurationArgs;
38
+ exports.runCaptureScreenRecording = runCaptureScreenRecording;
39
+ exports.registerCaptureScreenRecording = registerCaptureScreenRecording;
40
+ const fs = __importStar(require("node:fs"));
41
+ const os = __importStar(require("node:os"));
42
+ const path = __importStar(require("node:path"));
43
+ const zod_1 = require("zod");
44
+ const util_1 = require("../util");
45
+ const capture_1 = require("../capture");
46
+ const mcp_1 = require("./mcp");
47
+ /**
48
+ * Pure argv builder, unit-testable without spawning ffmpeg. Remotion's
49
+ * OffthreadVideo needs a seekable format, so Playwright's webm output gets
50
+ * transcoded to mp4 (h264/yuv420p, +faststart) -- same spawn/argv-array
51
+ * discipline as render_video/qc_extract_frames.
52
+ */
53
+ function buildFfmpegTranscodeArgs(inputPath, outputPath) {
54
+ return ["-nostdin", "-i", inputPath, "-c:v", "libx264", "-pix_fmt", "yuv420p", "-movflags", "+faststart", outputPath, "-y"];
55
+ }
56
+ /** Pure argv builder for the best-effort ffprobe duration lookup. */
57
+ function buildFfprobeDurationArgs(filePath) {
58
+ return ["-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", filePath];
59
+ }
60
+ /**
61
+ * Best-effort duration via ffprobe. Not a new dependency (ffprobe ships
62
+ * alongside the ffmpeg this tool already requires for the transcode); if it's
63
+ * missing or fails, this silently returns undefined rather than failing the
64
+ * whole capture over a nice-to-have field.
65
+ */
66
+ async function probeDurationMs(filePath, cwd) {
67
+ try {
68
+ const result = await (0, util_1.spawnCapture)("ffprobe", buildFfprobeDurationArgs(filePath), cwd);
69
+ if (result.code !== 0)
70
+ return undefined;
71
+ const seconds = Number.parseFloat(result.stdout.trim());
72
+ return Number.isFinite(seconds) ? Math.round(seconds * 1000) : undefined;
73
+ }
74
+ catch {
75
+ return undefined;
76
+ }
77
+ }
78
+ async function runCaptureScreenRecording(input) {
79
+ const beatId = (0, util_1.sanitizeSegment)(input.beatId, "beatId");
80
+ const projectRoot = (0, util_1.resolveProjectRoot)(input.projectRoot);
81
+ const target = input.viewport ?? capture_1.DEFAULT_VIEWPORT;
82
+ const outPathRel = input.outPath ?? path.join("public", "video", `${beatId}.mp4`);
83
+ (0, util_1.sanitizeRelativeOutPath)(projectRoot, outPathRel, "outPath");
84
+ const outPathAbs = path.join(projectRoot, outPathRel);
85
+ const browser = await (0, capture_1.launchChromium)();
86
+ try {
87
+ // Phase 1: a throwaway context/page to detect+compensate zoom (steps 1-2 of
88
+ // CAPTURE.md, via the shared helper). Playwright's recordVideo.size can only be set
89
+ // at context creation, before any page exists, so the compensated size has to be
90
+ // known before the real recording context is opened -- this probe pass is what
91
+ // makes that possible without guessing.
92
+ const probeContext = await browser.newContext();
93
+ let zoom;
94
+ let compensatedViewport;
95
+ try {
96
+ const probePage = await probeContext.newPage();
97
+ await probePage.setViewportSize(target);
98
+ await probePage.goto(input.url, { waitUntil: "load" });
99
+ const compensation = await (0, capture_1.detectAndCompensateZoom)(probePage, target);
100
+ zoom = compensation.zoom;
101
+ compensatedViewport = compensation.viewport;
102
+ }
103
+ finally {
104
+ await probeContext.close();
105
+ }
106
+ // Phase 2: the real recording context, sized at the compensated (not originally
107
+ // requested) viewport -- same reasoning as capture_screenshot.
108
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ovs-capture-"));
109
+ let webmPath;
110
+ try {
111
+ const recordContext = await browser.newContext({
112
+ viewport: compensatedViewport,
113
+ recordVideo: { dir: tmpDir, size: compensatedViewport },
114
+ });
115
+ try {
116
+ const page = await recordContext.newPage();
117
+ await page.goto(input.url, { waitUntil: "load" });
118
+ await (0, capture_1.replayInteractions)(page, input.interactions);
119
+ const video = page.video();
120
+ if (!video) {
121
+ throw new Error("Playwright did not attach a Video to this page -- recordVideo may not be active.");
122
+ }
123
+ // Closing the context is what flushes the recorded video to disk; it doesn't
124
+ // exist as a real file before this.
125
+ await recordContext.close();
126
+ webmPath = await video.path();
127
+ }
128
+ catch (err) {
129
+ await recordContext.close().catch(() => { });
130
+ throw err;
131
+ }
132
+ fs.mkdirSync(path.dirname(outPathAbs), { recursive: true });
133
+ const transcodeResult = await (0, util_1.spawnCapture)("ffmpeg", buildFfmpegTranscodeArgs(webmPath, outPathAbs), projectRoot);
134
+ if (transcodeResult.code !== 0) {
135
+ throw new Error(`ffmpeg webm->mp4 transcode failed (exit ${transcodeResult.code}): ${transcodeResult.stderr.slice(-1000)}`);
136
+ }
137
+ const durationMs = await probeDurationMs(outPathAbs, projectRoot);
138
+ return {
139
+ outPath: outPathAbs,
140
+ width: compensatedViewport.width,
141
+ height: compensatedViewport.height,
142
+ zoom,
143
+ ...(durationMs !== undefined ? { durationMs } : {}),
144
+ };
145
+ }
146
+ finally {
147
+ fs.rmSync(tmpDir, { recursive: true, force: true });
148
+ }
149
+ }
150
+ finally {
151
+ await browser.close();
152
+ }
153
+ }
154
+ function registerCaptureScreenRecording(server) {
155
+ server.registerTool("capture_screen_recording", {
156
+ title: "Capture a zoom-compensated, full-viewport screen recording",
157
+ description: "Internalizes CAPTURE.md's recording protocol as one atomic call, driving a real headless Chromium " +
158
+ "directly via the `playwright` package (not a separate Playwright MCP server). Runs the same " +
159
+ "zoom-desync detection/compensation as capture_screenshot on a throwaway probe page first (Playwright's " +
160
+ "recordVideo.size can only be set when the context is created, so the compensated size must be known " +
161
+ "before recording starts), then opens the real recording context at that compensated size, navigates, " +
162
+ "and replays `interactions` in array order. v1 scope is fixed full-viewport recordings only -- no " +
163
+ "post-hoc DOM-rect cropping of a moving recording, that's future work. Closing the context flushes " +
164
+ "Playwright's webm to disk, which is then transcoded to mp4 via ffmpeg (spawn, argv array, same " +
165
+ "discipline as render_video/qc_extract_frames) since Remotion's OffthreadVideo needs a seekable " +
166
+ "format; the intermediate webm and temp recording dir are cleaned up after. Default outPath is " +
167
+ "public/video/<beatId>.mp4 under projectRoot, matching scaffold_scene's real-recording convention. " +
168
+ "Returns { outPath, width, height, zoom, durationMs? } -- durationMs is a best-effort ffprobe lookup, " +
169
+ "omitted (not failed) if ffprobe isn't available. Requires Chromium to be installed for Playwright " +
170
+ "first: run \"npx playwright install chromium\" once wherever this package is installed; a missing " +
171
+ "browser fails with a message telling you to do exactly that, not a cryptic native error.",
172
+ inputSchema: {
173
+ projectRoot: zod_1.z.string().optional(),
174
+ beatId: zod_1.z.string().min(1),
175
+ url: zod_1.z.string().min(1),
176
+ viewport: capture_1.viewportSchema.optional(),
177
+ interactions: zod_1.z.array(capture_1.interactionSchema).optional(),
178
+ outPath: zod_1.z.string().optional(),
179
+ },
180
+ }, async (input) => (0, mcp_1.runTool)("capture_screen_recording", () => runCaptureScreenRecording(input)));
181
+ }
@@ -0,0 +1,34 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { type Interaction, type Viewport } from "../capture";
3
+ export interface CaptureScreenshotInput {
4
+ projectRoot?: string;
5
+ beatId: string;
6
+ url: string;
7
+ viewport?: Viewport;
8
+ interactions?: Interaction[];
9
+ cropSelector?: string;
10
+ outPath?: string;
11
+ }
12
+ export interface CaptureScreenshotResult {
13
+ outPath: string;
14
+ width: number;
15
+ height: number;
16
+ zoom: number;
17
+ }
18
+ export interface CropRect {
19
+ x: number;
20
+ y: number;
21
+ width: number;
22
+ height: number;
23
+ }
24
+ /**
25
+ * Port of `vidstudio/scripts/crop-shot.py` (see CAPTURE.md step 6): crops the
26
+ * physical screenshot at `rect * zoom`, then upscales the crop back to
27
+ * `rect`'s own (w, h) with Lanczos resampling, landing on a pixel-accurate,
28
+ * unshrunk capture. Pure function of the screenshot buffer, the DOM rect
29
+ * measured in effective CSS space, and the zoom ratio -- no I/O, unit-testable
30
+ * without a real browser.
31
+ */
32
+ export declare function cropAndUpscale(screenshotBuffer: Buffer, rect: CropRect, zoom: number): Promise<Buffer>;
33
+ export declare function runCaptureScreenshot(input: CaptureScreenshotInput): Promise<CaptureScreenshotResult>;
34
+ export declare function registerCaptureScreenshot(server: McpServer): void;
@@ -0,0 +1,148 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.cropAndUpscale = cropAndUpscale;
40
+ exports.runCaptureScreenshot = runCaptureScreenshot;
41
+ exports.registerCaptureScreenshot = registerCaptureScreenshot;
42
+ const fs = __importStar(require("node:fs"));
43
+ const path = __importStar(require("node:path"));
44
+ const zod_1 = require("zod");
45
+ const sharp_1 = __importDefault(require("sharp"));
46
+ const util_1 = require("../util");
47
+ const capture_1 = require("../capture");
48
+ const mcp_1 = require("./mcp");
49
+ /**
50
+ * Port of `vidstudio/scripts/crop-shot.py` (see CAPTURE.md step 6): crops the
51
+ * physical screenshot at `rect * zoom`, then upscales the crop back to
52
+ * `rect`'s own (w, h) with Lanczos resampling, landing on a pixel-accurate,
53
+ * unshrunk capture. Pure function of the screenshot buffer, the DOM rect
54
+ * measured in effective CSS space, and the zoom ratio -- no I/O, unit-testable
55
+ * without a real browser.
56
+ */
57
+ async function cropAndUpscale(screenshotBuffer, rect, zoom) {
58
+ const left = Math.round(rect.x * zoom);
59
+ const top = Math.round(rect.y * zoom);
60
+ const right = Math.round((rect.x + rect.width) * zoom);
61
+ const bottom = Math.round((rect.y + rect.height) * zoom);
62
+ const finalWidth = Math.round(rect.width);
63
+ const finalHeight = Math.round(rect.height);
64
+ return (0, sharp_1.default)(screenshotBuffer)
65
+ .extract({ left, top, width: right - left, height: bottom - top })
66
+ .resize(finalWidth, finalHeight, { kernel: sharp_1.default.kernel.lanczos3 })
67
+ .png()
68
+ .toBuffer();
69
+ }
70
+ async function runCaptureScreenshot(input) {
71
+ const beatId = (0, util_1.sanitizeSegment)(input.beatId, "beatId");
72
+ const projectRoot = (0, util_1.resolveProjectRoot)(input.projectRoot);
73
+ const target = input.viewport ?? capture_1.DEFAULT_VIEWPORT;
74
+ const outPathRel = input.outPath ?? path.join("public", "images", `${beatId}.png`);
75
+ (0, util_1.sanitizeRelativeOutPath)(projectRoot, outPathRel, "outPath");
76
+ const outPathAbs = path.join(projectRoot, outPathRel);
77
+ const browser = await (0, capture_1.launchChromium)();
78
+ try {
79
+ const context = await browser.newContext();
80
+ try {
81
+ const page = await context.newPage();
82
+ await page.setViewportSize(target);
83
+ // "load" (not "networkidle"): CAPTURE.md's protocol is resize -> navigate -> measure,
84
+ // and this package targets arbitrary dev-server pages, some of which poll/keep a
85
+ // websocket open and would never hit networkidle at all.
86
+ await page.goto(input.url, { waitUntil: "load" });
87
+ const { zoom, viewport: compensatedViewport } = await (0, capture_1.detectAndCompensateZoom)(page, target);
88
+ await (0, capture_1.replayInteractions)(page, input.interactions);
89
+ // Full-viewport screenshot, no fullPage, no element target: CAPTURE.md explains why
90
+ // element-scoped locator().screenshot() is wrong (it re-measures/auto-scrolls at shot
91
+ // time, independent of what was measured a moment earlier). Buffered in memory; only
92
+ // written to disk after an optional crop below.
93
+ const screenshotBuffer = await page.screenshot({ scale: "css" });
94
+ let finalBuffer;
95
+ if (input.cropSelector) {
96
+ const rect = await page.$eval(input.cropSelector, (el) => {
97
+ const r = el.getBoundingClientRect();
98
+ return { x: r.x, y: r.y, width: r.width, height: r.height };
99
+ });
100
+ finalBuffer = await cropAndUpscale(screenshotBuffer, rect, zoom);
101
+ }
102
+ else {
103
+ finalBuffer = screenshotBuffer;
104
+ }
105
+ const meta = await (0, sharp_1.default)(finalBuffer).metadata();
106
+ const width = meta.width ?? compensatedViewport.width;
107
+ const height = meta.height ?? compensatedViewport.height;
108
+ fs.mkdirSync(path.dirname(outPathAbs), { recursive: true });
109
+ fs.writeFileSync(outPathAbs, finalBuffer);
110
+ return { outPath: outPathAbs, width, height, zoom };
111
+ }
112
+ finally {
113
+ await context.close();
114
+ }
115
+ }
116
+ finally {
117
+ await browser.close();
118
+ }
119
+ }
120
+ function registerCaptureScreenshot(server) {
121
+ server.registerTool("capture_screenshot", {
122
+ title: "Capture a zoom-compensated, DOM-rect-cropped screenshot",
123
+ description: "Internalizes CAPTURE.md's full screenshot protocol as one atomic call, driving a real headless " +
124
+ "Chromium directly via the `playwright` package (not a separate Playwright MCP server): navigate, " +
125
+ "measure window.innerWidth/innerHeight against the requested viewport to detect a per-origin zoom " +
126
+ "desync (never hardcoded, measured live every call), re-request a compensated viewport and re-verify " +
127
+ "once if needed (a real 'protocol didn't converge' case fails with a structured error rather than " +
128
+ "proceeding with a wrong crop), replay `interactions` in array order, take a full-viewport screenshot " +
129
+ "(scale: css, no fullPage, no element target -- see CAPTURE.md for why element-scoped screenshots " +
130
+ "bleed in neighboring content), then if `cropSelector` is given, measure its DOM rect and run a " +
131
+ "TypeScript/sharp port of vidstudio/scripts/crop-shot.py (crop at rect*zoom, upscale back to rect's " +
132
+ "own size with Lanczos) so the result is pixel-accurate with no bleed. Default outPath is " +
133
+ "public/images/<beatId>.png under projectRoot, matching scaffold_scene's real-screenshot convention. " +
134
+ "Returns { outPath, width, height, zoom } -- the saved image's actual pixel dimensions and the " +
135
+ "measured zoom ratio, for STYLE.md's frame-sizing formula. Requires Chromium to be installed for " +
136
+ "Playwright first: run \"npx playwright install chromium\" once wherever this package is installed; " +
137
+ "a missing browser fails with a message telling you to do exactly that, not a cryptic native error.",
138
+ inputSchema: {
139
+ projectRoot: zod_1.z.string().optional(),
140
+ beatId: zod_1.z.string().min(1),
141
+ url: zod_1.z.string().min(1),
142
+ viewport: capture_1.viewportSchema.optional(),
143
+ interactions: zod_1.z.array(capture_1.interactionSchema).optional(),
144
+ cropSelector: zod_1.z.string().optional(),
145
+ outPath: zod_1.z.string().optional(),
146
+ },
147
+ }, async (input) => (0, mcp_1.runTool)("capture_screenshot", () => runCaptureScreenshot(input)));
148
+ }
@@ -0,0 +1,36 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ /**
3
+ * A 5 minute video is 9000 frames and takes tens of minutes to render, which is a
4
+ * brutal loop for checking whether the framing is right. Getting it wrong on three
5
+ * scenes meant rendering stills one at a time and tiling them by hand, which is what
6
+ * this automates.
7
+ *
8
+ * Two frames per beat, midpoint and end. The end frame matters as much as the
9
+ * midpoint: a crop that looks fine early pushes the payoff off the bottom once the
10
+ * camera has zoomed in, and PIPELINE.md's QC list calls that out specifically.
11
+ */
12
+ export interface ContactSheetInput {
13
+ projectRoot?: string;
14
+ videoName: string;
15
+ compositionId?: string;
16
+ /** Sample only these beats. */
17
+ beatIds?: string[];
18
+ /** Skip the end-of-beat frame and take midpoints only. Halves the time. */
19
+ midpointOnly?: boolean;
20
+ outPath?: string;
21
+ /** Width of each tile in the sheet. */
22
+ tileWidth?: number;
23
+ }
24
+ export interface ContactSheetResult {
25
+ sheetPath: string;
26
+ frames: {
27
+ beatId: string;
28
+ at: "mid" | "end";
29
+ frame: number;
30
+ file: string;
31
+ }[];
32
+ columns: number;
33
+ failed: string[];
34
+ }
35
+ export declare function runContactSheet(input: ContactSheetInput): ContactSheetResult;
36
+ export declare function registerContactSheet(server: McpServer): void;
@@ -0,0 +1,116 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.runContactSheet = runContactSheet;
37
+ exports.registerContactSheet = registerContactSheet;
38
+ const node_child_process_1 = require("node:child_process");
39
+ const fs = __importStar(require("node:fs"));
40
+ const path = __importStar(require("node:path"));
41
+ const zod_1 = require("zod");
42
+ const util_1 = require("../util");
43
+ const mcp_1 = require("./mcp");
44
+ function runContactSheet(input) {
45
+ const videoName = (0, util_1.sanitizeSegment)(input.videoName, "videoName");
46
+ const projectRoot = (0, util_1.resolveProjectRoot)(input.projectRoot);
47
+ const compositionId = input.compositionId ?? `${(0, util_1.pascalCase)(videoName)}Demo`;
48
+ const tileWidth = input.tileWidth ?? 480;
49
+ const beatsFile = path.join(projectRoot, "src", "videos", videoName, "beats.json");
50
+ if (!fs.existsSync(beatsFile)) {
51
+ throw new Error(`No beats.json at ${beatsFile}. Run write_beats_file first.`);
52
+ }
53
+ const doc = JSON.parse(fs.readFileSync(beatsFile, "utf8"));
54
+ const beats = input.beatIds ? doc.beats.filter((b) => input.beatIds.includes(b.id)) : doc.beats;
55
+ const outDir = path.join(projectRoot, "out", "contact");
56
+ fs.mkdirSync(outDir, { recursive: true });
57
+ const npx = process.platform === "win32" ? "npx.cmd" : "npx";
58
+ const frames = [];
59
+ const failed = [];
60
+ for (const beat of beats) {
61
+ const picks = [
62
+ { at: "mid", frame: beat.start + Math.floor(beat.duration * 0.5) },
63
+ ];
64
+ if (!input.midpointOnly) {
65
+ // Two frames back from the cut, so the shot has fully arrived.
66
+ picks.push({ at: "end", frame: beat.start + Math.max(0, beat.duration - 3) });
67
+ }
68
+ for (const pick of picks) {
69
+ const file = path.join(outDir, `${beat.id}-${pick.at}.png`);
70
+ const r = (0, node_child_process_1.spawnSync)(npx, ["remotion", "still", compositionId, file, `--frame=${pick.frame}`, "--log=error"], { cwd: projectRoot, encoding: "utf8" });
71
+ if (r.status === 0 && fs.existsSync(file)) {
72
+ frames.push({ beatId: beat.id, at: pick.at, frame: pick.frame, file });
73
+ }
74
+ else {
75
+ failed.push(`${beat.id}-${pick.at}`);
76
+ }
77
+ }
78
+ }
79
+ if (frames.length === 0) {
80
+ throw new Error(`No frames rendered. Check that composition "${compositionId}" exists.`);
81
+ }
82
+ const columns = Math.min(4, Math.ceil(Math.sqrt(frames.length)));
83
+ const sheetPath = path.join(projectRoot, input.outPath ?? path.join("out", "contact-sheet.jpg"));
84
+ fs.mkdirSync(path.dirname(sheetPath), { recursive: true });
85
+ const args = ["-v", "error"];
86
+ for (const f of frames)
87
+ args.push("-i", f.file);
88
+ const scaled = frames.map((_, i) => `[${i}]scale=${tileWidth}:-1[t${i}]`).join(";");
89
+ const refs = frames.map((_, i) => `[t${i}]`).join("");
90
+ args.push("-filter_complex", `${scaled};${refs}tile=${columns}x${Math.ceil(frames.length / columns)}:padding=8:color=#0B0E14`, "-frames:v", "1", sheetPath, "-y");
91
+ const tile = (0, node_child_process_1.spawnSync)("ffmpeg", args, { encoding: "utf8" });
92
+ if (tile.status !== 0) {
93
+ throw new Error(`ffmpeg could not tile the frames: ${(tile.stderr ?? "").slice(0, 300)}`);
94
+ }
95
+ return { sheetPath, frames, columns, failed };
96
+ }
97
+ function registerContactSheet(server) {
98
+ server.registerTool("contact_sheet", {
99
+ title: "Render one image showing every beat",
100
+ description: "Renders two stills per beat, midpoint and just before the cut, and tiles them into a single " +
101
+ "image. This is the fastest way to check framing across a whole video: a full render of a five " +
102
+ "minute video is 9000 frames and tens of minutes, which is far too slow a loop for catching a " +
103
+ "cropped panel or a caption that has drifted off screen. The end-of-beat frame matters as much as " +
104
+ "the midpoint, because a crop that looks correct early can push the payoff out of frame once the " +
105
+ "camera has zoomed in. Pass midpointOnly to halve the time, or beatIds to sample a few.",
106
+ inputSchema: {
107
+ projectRoot: zod_1.z.string().optional(),
108
+ videoName: zod_1.z.string().min(1),
109
+ compositionId: zod_1.z.string().optional(),
110
+ beatIds: zod_1.z.array(zod_1.z.string()).optional(),
111
+ midpointOnly: zod_1.z.boolean().optional(),
112
+ outPath: zod_1.z.string().optional(),
113
+ tileWidth: zod_1.z.number().positive().optional(),
114
+ },
115
+ }, async (input) => (0, mcp_1.runTool)("contact_sheet", () => runContactSheet(input)));
116
+ }
@@ -0,0 +1,54 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ /**
3
+ * Narration used to be entirely the caller's problem: PIPELINE.md said "dev
4
+ * provided", stitch_composition read the mp3s if they happened to exist, and that
5
+ * was it. In practice that meant most videos shipped silent, because nobody is going
6
+ * to hand-generate one correctly named clip per beat.
7
+ *
8
+ * Two things this gets right that a naive TTS loop does not.
9
+ *
10
+ * **Pacing.** Every engine reads at its own speed, so a clip rarely lands on its
11
+ * beat. The obvious fix, time-stretching audio to fit, is what makes narration sound
12
+ * artificial: a pitch-preserving stretch past roughly 10% is audible, and at 35% it
13
+ * is the main reason generated voiceover sounds synthetic. So stretching is clamped
14
+ * hard, and when a clip is still outside its beat the mismatch is reported as
15
+ * something to fix in the script or the beat duration instead of being papered over.
16
+ *
17
+ * **Silence.** stitch_composition skips a missing clip with no error, so a half
18
+ * generated narration renders successfully and plays silent in those beats. This
19
+ * reports exactly which beats have audio and which do not.
20
+ */
21
+ export type NarrationEngine = "edge-tts" | "say" | "espeak";
22
+ export interface GenerateNarrationInput {
23
+ projectRoot?: string;
24
+ videoName: string;
25
+ engine?: NarrationEngine;
26
+ voice?: string;
27
+ /** Seconds of silence left at the end of a beat so the cut does not clip the last word. */
28
+ padSeconds?: number;
29
+ /** Regenerate clips that already exist. */
30
+ overwrite?: boolean;
31
+ /** Only these beat ids. */
32
+ beatIds?: string[];
33
+ }
34
+ export interface NarrationClip {
35
+ beatId: string;
36
+ path: string;
37
+ beatSeconds: number;
38
+ spokenSeconds: number;
39
+ finalSeconds: number;
40
+ tempo: number;
41
+ /** True when the clip could not be fitted inside its beat without an audible stretch. */
42
+ needsAttention: boolean;
43
+ note?: string;
44
+ }
45
+ export interface GenerateNarrationResult {
46
+ engine: NarrationEngine;
47
+ voice: string;
48
+ written: NarrationClip[];
49
+ skipped: string[];
50
+ missing: string[];
51
+ warnings: string[];
52
+ }
53
+ export declare function runGenerateNarration(input: GenerateNarrationInput): GenerateNarrationResult;
54
+ export declare function registerGenerateNarration(server: McpServer): void;