@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,36 @@
1
+ "use strict";
2
+ /**
3
+ * MCP content + error-wrapping helpers, matching pepiros/mcp/tools/index.ts's pattern:
4
+ * MCP wants `{ content: [...] }`, JSON text content keeps it parseable on the model's side.
5
+ */
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.json = json;
8
+ exports.errorText = errorText;
9
+ exports.runTool = runTool;
10
+ function json(value) {
11
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] };
12
+ }
13
+ function errorText(message) {
14
+ return { content: [{ type: "text", text: message }], isError: true };
15
+ }
16
+ /**
17
+ * Every tool handler routes through this: run the pure logic function, wrap a
18
+ * successful result as MCP JSON content, and turn any thrown error into MCP
19
+ * error content instead of letting it escape uncaught. A "no, this is invalid"
20
+ * result (validate_beats' `{ valid: false, errors }`, scaffold_scene refusing an
21
+ * existing file) is handled by the logic function returning/throwing normally --
22
+ * this wrapper is what guarantees neither path ever reaches the transport as an
23
+ * unhandled rejection. The diagnostic also goes to stderr: this process's own
24
+ * stdout carries nothing but MCP protocol frames.
25
+ */
26
+ async function runTool(toolName, fn) {
27
+ try {
28
+ const result = await fn();
29
+ return json(result);
30
+ }
31
+ catch (err) {
32
+ const message = err instanceof Error ? err.message : String(err);
33
+ process.stderr.write(`openvidstudio-mcp: ${toolName} failed: ${message}\n`);
34
+ return errorText(message);
35
+ }
36
+ }
@@ -0,0 +1,40 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export type ArcName = "simple" | "packed" | "tutorial";
3
+ export interface PlanBeatsInput {
4
+ projectRoot?: string;
5
+ targetDurationSeconds: number;
6
+ arc?: ArcName;
7
+ /** One demo beat is planned per feature named here. */
8
+ features?: string[];
9
+ appUrl?: string;
10
+ fps?: number;
11
+ }
12
+ export interface PlannedBeat {
13
+ id: string;
14
+ start: number;
15
+ duration: number;
16
+ seconds: number;
17
+ captureMethod: "screenshot" | "recording" | "dom-demo" | "higgsfield";
18
+ /** Inclusive word count range that will pass validate_beats for this duration. */
19
+ wordBudget: {
20
+ min: number;
21
+ max: number;
22
+ suggested: number;
23
+ };
24
+ purpose: string;
25
+ }
26
+ export interface PlanBeatsResult {
27
+ fps: number;
28
+ totalFrames: number;
29
+ totalSeconds: number;
30
+ arc: ArcName;
31
+ totalWordBudget: {
32
+ min: number;
33
+ max: number;
34
+ suggested: number;
35
+ };
36
+ beats: PlannedBeat[];
37
+ notes: string[];
38
+ }
39
+ export declare function runPlanBeats(input: PlanBeatsInput): PlanBeatsResult;
40
+ export declare function registerPlanBeats(server: McpServer): void;
@@ -0,0 +1,143 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runPlanBeats = runPlanBeats;
4
+ exports.registerPlanBeats = registerPlanBeats;
5
+ const zod_1 = require("zod");
6
+ const util_1 = require("../util");
7
+ const config_1 = require("../config");
8
+ const mcp_1 = require("./mcp");
9
+ /**
10
+ * validate_beats enforces SCRIPT.md's 2.3-2.9 words/sec as a hard floor and ceiling,
11
+ * per beat. SCRIPT.md reads like guidance, so the usual first draft gets rejected,
12
+ * and often for being too *sparse*, which is not the failure anyone expects from a
13
+ * pacing rule. The other repeat rejection is `screenshot` beats missing `url` and
14
+ * `interactions`, which no doc states up front.
15
+ *
16
+ * This hands back a skeleton that already satisfies both, with the word budget stated
17
+ * per beat, so narration is written to a number instead of guessed and revised.
18
+ */
19
+ const PACE_MIN = 2.3;
20
+ const PACE_MAX = 2.9;
21
+ function slotsFor(arc, features) {
22
+ const demo = features.length ? features : ["feature"];
23
+ if (arc === "simple") {
24
+ return [
25
+ { id: "hook", weight: 8, method: "screenshot", purpose: "What it is, in one line, over the real product" },
26
+ { id: "problem", weight: 11, method: "dom-demo", purpose: "What is broken today" },
27
+ ...demo.slice(0, 3).map((f, i) => ({
28
+ id: `demo-${slug(f) || i + 1}`,
29
+ weight: 13,
30
+ method: "screenshot",
31
+ purpose: `Show ${f} working`,
32
+ })),
33
+ { id: "differentiator", weight: 10, method: "dom-demo", purpose: "Why this rather than the obvious alternative" },
34
+ { id: "cta", weight: 9, method: "dom-demo", purpose: "Where to get it" },
35
+ ];
36
+ }
37
+ if (arc === "tutorial") {
38
+ return [
39
+ { id: "hook", weight: 8, method: "screenshot", purpose: "What the viewer will be able to do by the end" },
40
+ { id: "prereqs", weight: 8, method: "dom-demo", purpose: "What must exist first" },
41
+ { id: "install", weight: 9, method: "dom-demo", purpose: "Getting it onto the machine" },
42
+ { id: "setup", weight: 9, method: "dom-demo", purpose: "Wiring it up" },
43
+ ...demo.map((f, i) => ({
44
+ id: `step-${slug(f) || i + 1}`,
45
+ weight: 11,
46
+ method: "screenshot",
47
+ purpose: `Walk through ${f}`,
48
+ })),
49
+ { id: "result", weight: 7, method: "dom-demo", purpose: "The finished artefact" },
50
+ { id: "recap", weight: 6, method: "dom-demo", purpose: "The steps as one reference card" },
51
+ { id: "cta", weight: 6, method: "dom-demo", purpose: "Where to get it" },
52
+ ];
53
+ }
54
+ // packed: one dense hero beat that states everything, then one demo per claim.
55
+ return [
56
+ { id: "hook", weight: 15, method: "screenshot", purpose: "Everything the product does, stated once, for a cold viewer" },
57
+ ...demo.map((f, i) => ({
58
+ id: `demo-${slug(f) || i + 1}`,
59
+ weight: 12,
60
+ method: "screenshot",
61
+ purpose: `Back the hook's claim about ${f}`,
62
+ })),
63
+ { id: "differentiator", weight: 10, method: "dom-demo", purpose: "The one thing nothing else does" },
64
+ { id: "cta", weight: 8, method: "dom-demo", purpose: "Where to get it" },
65
+ ];
66
+ }
67
+ function slug(s) {
68
+ return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 24);
69
+ }
70
+ function runPlanBeats(input) {
71
+ const projectRoot = (0, util_1.resolveProjectRoot)(input.projectRoot);
72
+ const config = (0, config_1.loadConfig)(projectRoot);
73
+ const fps = input.fps ?? config.videoConfig.fps ?? 30;
74
+ const arc = input.arc ?? "simple";
75
+ const appUrl = input.appUrl ?? config.capture.appUrl;
76
+ const totalFrames = Math.round(input.targetDurationSeconds * fps);
77
+ const slots = slotsFor(arc, input.features ?? []);
78
+ const weightTotal = slots.reduce((a, s) => a + s.weight, 0);
79
+ const beats = [];
80
+ let start = 0;
81
+ slots.forEach((slot, i) => {
82
+ // Last beat absorbs the rounding so the total lands exactly on target.
83
+ const raw = Math.round((slot.weight / weightTotal) * totalFrames);
84
+ const duration = i === slots.length - 1 ? totalFrames - start : raw;
85
+ const seconds = duration / fps;
86
+ const min = Math.ceil(PACE_MIN * seconds);
87
+ const max = Math.floor(PACE_MAX * seconds);
88
+ beats.push({
89
+ id: slot.id,
90
+ start,
91
+ duration,
92
+ seconds: Number(seconds.toFixed(2)),
93
+ captureMethod: slot.method,
94
+ wordBudget: { min, max, suggested: Math.round((min + max) / 2) },
95
+ purpose: slot.purpose,
96
+ });
97
+ start += duration;
98
+ });
99
+ const notes = [
100
+ `Word budgets are the range validate_beats will accept for each duration. Write to the suggested ` +
101
+ `number: it sits mid band, so a small edit later does not push the beat out of range.`,
102
+ `Every beat with captureMethod "screenshot" also needs visual.url and visual.interactions (an array, ` +
103
+ `which may be empty). validate_beats rejects the beat without them, and no other doc states this up front.`,
104
+ `Capture beats default to ${appUrl}. Change capture.appUrl in openvidstudio.config.json, or set a url ` +
105
+ `per beat.`,
106
+ `Beats must stay contiguous: each start equals the previous start plus its duration. These already are.`,
107
+ ];
108
+ const totalBudget = beats.reduce((a, b) => ({
109
+ min: a.min + b.wordBudget.min,
110
+ max: a.max + b.wordBudget.max,
111
+ suggested: a.suggested + b.wordBudget.suggested,
112
+ }), { min: 0, max: 0, suggested: 0 });
113
+ return {
114
+ fps,
115
+ totalFrames,
116
+ totalSeconds: Number((totalFrames / fps).toFixed(2)),
117
+ arc,
118
+ totalWordBudget: totalBudget,
119
+ beats,
120
+ notes,
121
+ };
122
+ }
123
+ function registerPlanBeats(server) {
124
+ server.registerTool("plan_beats", {
125
+ title: "Plan a beat skeleton with word budgets before writing narration",
126
+ description: "Given a target duration and the features to show, returns a contiguous beat skeleton with a " +
127
+ "capture method and a narration word budget per beat. The budget is the range validate_beats will " +
128
+ "actually accept, which matters because SCRIPT.md's 2.3 to 2.9 words per second reads like guidance " +
129
+ "but is enforced as a hard floor and ceiling per beat, and first drafts are commonly rejected for " +
130
+ "being too sparse rather than too long. Writing to a stated number avoids that round trip. Three " +
131
+ "arcs: simple (single feature, hook to CTA), packed (one dense hero beat then one demo per claim), " +
132
+ "tutorial (prerequisites, install, setup, steps, recap). Output feeds straight into validate_beats " +
133
+ "once narration is written in.",
134
+ inputSchema: {
135
+ projectRoot: zod_1.z.string().optional(),
136
+ targetDurationSeconds: zod_1.z.number().positive(),
137
+ arc: zod_1.z.enum(["simple", "packed", "tutorial"]).optional(),
138
+ features: zod_1.z.array(zod_1.z.string()).optional(),
139
+ appUrl: zod_1.z.string().optional(),
140
+ fps: zod_1.z.number().positive().optional(),
141
+ },
142
+ }, async (input) => (0, mcp_1.runTool)("plan_beats", () => runPlanBeats(input)));
143
+ }
@@ -0,0 +1,30 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ /**
3
+ * The pipeline assumes its environment. Without this, a missing ffmpeg or an
4
+ * undownloaded Chromium surfaces several steps in, as a symptom rather than a fix,
5
+ * often after a long render has already started.
6
+ *
7
+ * Every failing check states the fix, with the command for the caller's platform.
8
+ */
9
+ export interface PreflightInput {
10
+ projectRoot?: string;
11
+ /** Overrides the app url in openvidstudio.config.json. */
12
+ appUrl?: string;
13
+ }
14
+ export interface Check {
15
+ name: string;
16
+ ok: boolean;
17
+ detail: string;
18
+ /** Present when the check failed: what to actually run. */
19
+ fix?: string;
20
+ /** A failure that stops the pipeline, versus one that only limits it. */
21
+ blocking: boolean;
22
+ }
23
+ export interface PreflightResult {
24
+ ok: boolean;
25
+ platform: string;
26
+ checks: Check[];
27
+ blockers: string[];
28
+ }
29
+ export declare function runPreflight(input: PreflightInput): Promise<PreflightResult>;
30
+ export declare function registerPreflight(server: McpServer): void;
@@ -0,0 +1,170 @@
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.runPreflight = runPreflight;
37
+ exports.registerPreflight = registerPreflight;
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 config_1 = require("../config");
44
+ const mcp_1 = require("./mcp");
45
+ function which(cmd) {
46
+ const probe = process.platform === "win32" ? "where" : "which";
47
+ const r = (0, node_child_process_1.spawnSync)(probe, [cmd], { encoding: "utf8" });
48
+ if (r.status !== 0)
49
+ return null;
50
+ return (r.stdout ?? "").split(/\r?\n/)[0]?.trim() || null;
51
+ }
52
+ function platformFix(win, mac, linux) {
53
+ if (process.platform === "win32")
54
+ return win;
55
+ if (process.platform === "darwin")
56
+ return mac;
57
+ return linux;
58
+ }
59
+ async function urlResponds(url, timeoutMs = 4000) {
60
+ try {
61
+ const ctl = new AbortController();
62
+ const t = setTimeout(() => ctl.abort(), timeoutMs);
63
+ const res = await fetch(url, { signal: ctl.signal, redirect: "follow" });
64
+ clearTimeout(t);
65
+ return res.ok || (res.status >= 200 && res.status < 500);
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ async function runPreflight(input) {
72
+ const projectRoot = (0, util_1.resolveProjectRoot)(input.projectRoot);
73
+ const config = (0, config_1.loadConfig)(projectRoot);
74
+ const appUrl = input.appUrl ?? config.capture.appUrl;
75
+ const checks = [];
76
+ const nodeMajor = Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10);
77
+ checks.push({
78
+ name: "node",
79
+ ok: nodeMajor >= 18,
80
+ detail: `node ${process.versions.node}`,
81
+ fix: nodeMajor >= 18 ? undefined : "Install Node 18 or newer from nodejs.org.",
82
+ blocking: true,
83
+ });
84
+ const ffmpeg = which("ffmpeg");
85
+ checks.push({
86
+ name: "ffmpeg",
87
+ ok: Boolean(ffmpeg),
88
+ detail: ffmpeg ? `found at ${ffmpeg}` : "not on PATH",
89
+ fix: ffmpeg
90
+ ? undefined
91
+ : platformFix("winget install --id Gyan.FFmpeg -e", "brew install ffmpeg", "sudo apt update && sudo apt install -y ffmpeg"),
92
+ blocking: true,
93
+ });
94
+ // Playwright keeps its browsers in a per-user cache; the surest check is asking it.
95
+ const pw = (0, node_child_process_1.spawnSync)(process.platform === "win32" ? "npx.cmd" : "npx", ["playwright", "--version"], { cwd: projectRoot, encoding: "utf8" });
96
+ const pwOk = pw.status === 0;
97
+ checks.push({
98
+ name: "playwright",
99
+ ok: pwOk,
100
+ detail: pwOk ? (pw.stdout ?? "").trim() : "playwright not resolvable in this project",
101
+ fix: pwOk ? undefined : "npm install playwright && npx playwright install chromium",
102
+ blocking: true,
103
+ });
104
+ const cacheDirs = [
105
+ path.join(process.env.LOCALAPPDATA ?? "", "ms-playwright"),
106
+ path.join(process.env.HOME ?? "", ".cache", "ms-playwright"),
107
+ path.join(process.env.HOME ?? "", "Library", "Caches", "ms-playwright"),
108
+ ].filter(Boolean);
109
+ const chromium = cacheDirs.some((d) => fs.existsSync(d) && fs.readdirSync(d).some((e) => e.startsWith("chromium")));
110
+ checks.push({
111
+ name: "chromium",
112
+ ok: chromium,
113
+ detail: chromium ? "browser binaries present" : "no chromium build in the Playwright cache",
114
+ fix: chromium ? undefined : "npx playwright install chromium",
115
+ blocking: true,
116
+ });
117
+ const responds = await urlResponds(appUrl);
118
+ checks.push({
119
+ name: "app",
120
+ ok: responds,
121
+ detail: responds ? `${appUrl} responds` : `${appUrl} did not respond`,
122
+ fix: responds
123
+ ? undefined
124
+ : `Start the app you want to film, then set capture.appUrl in openvidstudio.config.json if the port differs.`,
125
+ blocking: true,
126
+ });
127
+ let writable = false;
128
+ try {
129
+ const probe = path.join(projectRoot, ".openvidstudio-write-probe");
130
+ fs.writeFileSync(probe, "");
131
+ fs.rmSync(probe);
132
+ writable = true;
133
+ }
134
+ catch {
135
+ writable = false;
136
+ }
137
+ checks.push({
138
+ name: "writable",
139
+ ok: writable,
140
+ detail: writable ? `${projectRoot} is writable` : `cannot write to ${projectRoot}`,
141
+ fix: writable ? undefined : "Check permissions on the project directory.",
142
+ blocking: true,
143
+ });
144
+ const narrationCmd = config.narration.engine === "edge-tts" ? "edge-tts" : config.narration.engine;
145
+ const narration = Boolean(which(narrationCmd)) || Boolean(which("python"));
146
+ checks.push({
147
+ name: "narration",
148
+ ok: narration,
149
+ detail: narration ? `${config.narration.engine} available` : `${config.narration.engine} not found`,
150
+ fix: narration ? undefined : "pip install edge-tts",
151
+ blocking: false,
152
+ });
153
+ const blockers = checks.filter((c) => !c.ok && c.blocking).map((c) => `${c.name}: ${c.fix ?? c.detail}`);
154
+ return { ok: blockers.length === 0, platform: process.platform, checks, blockers };
155
+ }
156
+ function registerPreflight(server) {
157
+ server.registerTool("preflight", {
158
+ title: "Check the machine can actually run the pipeline",
159
+ description: "Verifies node, ffmpeg, Playwright and its Chromium build, that the app you want to film is " +
160
+ "responding, that the project directory is writable, and that a narration engine is available. " +
161
+ "Every failure names the fix with the command for this platform, rather than describing a symptom. " +
162
+ "Run it before init_project on a new machine: without it a missing dependency surfaces several " +
163
+ "steps later, often after a long render has already started. Narration is reported as non blocking, " +
164
+ "since a silent video still renders.",
165
+ inputSchema: {
166
+ projectRoot: zod_1.z.string().optional(),
167
+ appUrl: zod_1.z.string().optional(),
168
+ },
169
+ }, async (input) => (0, mcp_1.runTool)("preflight", () => runPreflight(input)));
170
+ }
@@ -0,0 +1,25 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export interface QcExtractFramesInput {
3
+ projectRoot?: string;
4
+ videoName: string;
5
+ videoPath?: string;
6
+ }
7
+ export interface QcFrame {
8
+ beatId: string;
9
+ midPath: string;
10
+ endPath: string;
11
+ }
12
+ export interface QcExtractFramesResult {
13
+ frames: QcFrame[];
14
+ }
15
+ /** Pure argv builder, unit-testable without spawning ffmpeg. */
16
+ export declare function buildFfmpegFrameArgs(videoPath: string, timestampSeconds: number, outFile: string): string[];
17
+ /**
18
+ * Wraps PIPELINE.md's QC frame-extraction step. For every beat, computes both its
19
+ * midpoint frame AND its end frame (a static crop that looks fine early can push
20
+ * content off-frame once the camera has zoomed in, so the end matters too, not just
21
+ * the midpoint), converts to timestamps using the beats file's fps, and shells out to
22
+ * ffmpeg via spawn with an argv array.
23
+ */
24
+ export declare function runQcExtractFrames(input: QcExtractFramesInput): Promise<QcExtractFramesResult>;
25
+ export declare function registerQcExtractFrames(server: McpServer): void;
@@ -0,0 +1,129 @@
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.buildFfmpegFrameArgs = buildFfmpegFrameArgs;
37
+ exports.runQcExtractFrames = runQcExtractFrames;
38
+ exports.registerQcExtractFrames = registerQcExtractFrames;
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
+ /** Pure argv builder, unit-testable without spawning ffmpeg. */
45
+ function buildFfmpegFrameArgs(videoPath, timestampSeconds, outFile) {
46
+ return [
47
+ "-nostdin",
48
+ "-ss",
49
+ timestampSeconds.toFixed(3),
50
+ "-i",
51
+ videoPath,
52
+ "-frames:v",
53
+ "1",
54
+ "-q:v",
55
+ "3",
56
+ outFile,
57
+ "-y",
58
+ ];
59
+ }
60
+ /**
61
+ * Wraps PIPELINE.md's QC frame-extraction step. For every beat, computes both its
62
+ * midpoint frame AND its end frame (a static crop that looks fine early can push
63
+ * content off-frame once the camera has zoomed in, so the end matters too, not just
64
+ * the midpoint), converts to timestamps using the beats file's fps, and shells out to
65
+ * ffmpeg via spawn with an argv array.
66
+ */
67
+ async function runQcExtractFrames(input) {
68
+ const videoName = (0, util_1.sanitizeSegment)(input.videoName, "videoName");
69
+ const projectRoot = (0, util_1.resolveProjectRoot)(input.projectRoot);
70
+ const videoDir = path.join(projectRoot, "src", "videos", videoName);
71
+ const beatsPath = path.join(videoDir, "beats.json");
72
+ if (!fs.existsSync(beatsPath)) {
73
+ throw new Error(`${beatsPath} does not exist. Call write_beats_file for "${videoName}" first.`);
74
+ }
75
+ const beatsData = JSON.parse(fs.readFileSync(beatsPath, "utf8"));
76
+ const beats = beatsData.beats;
77
+ if (!Array.isArray(beats) || beats.length === 0) {
78
+ throw new Error(`${beatsPath} has no beats.`);
79
+ }
80
+ const fps = beatsData.fps ?? 30;
81
+ const videoPathRel = input.videoPath ?? path.join("out", `${videoName}.mp4`);
82
+ (0, util_1.sanitizeRelativeOutPath)(projectRoot, videoPathRel, "videoPath");
83
+ const videoPathAbs = path.join(projectRoot, videoPathRel);
84
+ if (!fs.existsSync(videoPathAbs)) {
85
+ throw new Error(`${videoPathAbs} does not exist. Call render_video first (or pass an explicit videoPath).`);
86
+ }
87
+ const outDirRel = path.join("out", "qc", videoName);
88
+ (0, util_1.sanitizeRelativeOutPath)(projectRoot, outDirRel, "outDir");
89
+ const outDirAbs = path.join(projectRoot, outDirRel);
90
+ fs.mkdirSync(outDirAbs, { recursive: true });
91
+ const frames = [];
92
+ const failures = [];
93
+ for (const beat of beats) {
94
+ const midFrame = beat.start + beat.duration / 2;
95
+ const endFrame = beat.start + beat.duration - 1;
96
+ const midPath = path.join(outDirAbs, `${beat.id}-mid.jpg`);
97
+ const endPath = path.join(outDirAbs, `${beat.id}-end.jpg`);
98
+ const midResult = await (0, util_1.spawnCapture)("ffmpeg", buildFfmpegFrameArgs(videoPathAbs, midFrame / fps, midPath), projectRoot);
99
+ if (midResult.code !== 0) {
100
+ failures.push(`${beat.id} (mid): ffmpeg exited ${midResult.code}: ${midResult.stderr.slice(-500)}`);
101
+ }
102
+ const endResult = await (0, util_1.spawnCapture)("ffmpeg", buildFfmpegFrameArgs(videoPathAbs, endFrame / fps, endPath), projectRoot);
103
+ if (endResult.code !== 0) {
104
+ failures.push(`${beat.id} (end): ffmpeg exited ${endResult.code}: ${endResult.stderr.slice(-500)}`);
105
+ }
106
+ frames.push({ beatId: beat.id, midPath, endPath });
107
+ }
108
+ if (failures.length > 0) {
109
+ throw new Error(`ffmpeg frame extraction failed for:\n${failures.join("\n")}`);
110
+ }
111
+ return { frames };
112
+ }
113
+ function registerQcExtractFrames(server) {
114
+ server.registerTool("qc_extract_frames", {
115
+ title: "Extract QC frames for every beat",
116
+ description: "Wraps PIPELINE.md's QC frame-extraction step. Reads beats.json, and for every beat computes its " +
117
+ "midpoint frame AND its end frame (PIPELINE.md's QC checklist specifically calls out checking the " +
118
+ "end of every real-screenshot/recording beat, not just the midpoint), converts frame numbers to " +
119
+ "timestamps using the beats file's fps, and shells out to ffmpeg per beat via child_process.spawn " +
120
+ "with an argv array (never a shell string). Default videoPath is out/<videoName>.mp4, default " +
121
+ "output dir is out/qc/<videoName>/. Returns { frames: [{ beatId, midPath, endPath }] } so the " +
122
+ "calling agent knows exactly which paths to Read as images for its own visual QC pass.",
123
+ inputSchema: {
124
+ projectRoot: zod_1.z.string().optional(),
125
+ videoName: zod_1.z.string().min(1),
126
+ videoPath: zod_1.z.string().optional(),
127
+ },
128
+ }, async (input) => (0, mcp_1.runTool)("qc_extract_frames", () => runQcExtractFrames(input)));
129
+ }
@@ -0,0 +1,43 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export interface RenderVideoInput {
3
+ projectRoot?: string;
4
+ videoName: string;
5
+ compositionId?: string;
6
+ outPath?: string;
7
+ /**
8
+ * Draft renders at half resolution with a cheaper encode. A five minute video is
9
+ * 9000 frames and tens of minutes at full quality, which is far too slow a loop
10
+ * for checking whether the video is right.
11
+ */
12
+ draft?: boolean;
13
+ /**
14
+ * Parallel render workers. Left unset, Remotion takes as much of the machine as it
15
+ * can: one render here spawned 25 Chrome workers and starved everything else on the
16
+ * box, including a second pipeline step running at the same time.
17
+ */
18
+ concurrency?: number;
19
+ }
20
+ export interface RenderVideoResult {
21
+ success: boolean;
22
+ outPath: string;
23
+ draft: boolean;
24
+ elapsedSeconds: number;
25
+ stdout: string;
26
+ stderr: string;
27
+ }
28
+ export interface RenderCommand {
29
+ command: string;
30
+ args: string[];
31
+ }
32
+ /**
33
+ * Pure argv builder, unit-testable without spawning a real process. On Windows, npx is
34
+ * a .cmd shim -- Node's spawn() can only execute that via an internal cmd.exe hop even
35
+ * with shell:false, which is why compositionId/outPath both get sanitized against
36
+ * cmd.exe metacharacters before ever reaching this function (see util.ts).
37
+ */
38
+ export declare function buildRenderCommand(compositionId: string, outPath: string, opts?: {
39
+ draft?: boolean;
40
+ concurrency?: number;
41
+ }): RenderCommand;
42
+ export declare function runRenderVideo(input: RenderVideoInput): Promise<RenderVideoResult>;
43
+ export declare function registerRenderVideo(server: McpServer): void;