@openvidstudio/mcp-server 1.3.1 → 1.4.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 (57) hide show
  1. package/dist/canvasExplainer.d.ts +198 -0
  2. package/dist/canvasExplainer.js +661 -0
  3. package/dist/canvasValidate.d.ts +76 -0
  4. package/dist/canvasValidate.js +263 -0
  5. package/dist/config.d.ts +6 -0
  6. package/dist/config.js +2 -0
  7. package/dist/install.d.ts +63 -0
  8. package/dist/install.js +225 -0
  9. package/dist/lessonArc.d.ts +68 -0
  10. package/dist/lessonArc.js +188 -0
  11. package/dist/server.d.ts +1 -1
  12. package/dist/server.js +1 -1
  13. package/dist/stdio.js +12 -4
  14. package/dist/tools/checkScript.d.ts +31 -1
  15. package/dist/tools/checkScript.js +183 -6
  16. package/dist/tools/fitBeats.d.ts +69 -0
  17. package/dist/tools/fitBeats.js +229 -0
  18. package/dist/tools/generateNarration.d.ts +19 -0
  19. package/dist/tools/generateNarration.js +48 -5
  20. package/dist/tools/index.js +6 -0
  21. package/dist/tools/initProject.js +6 -1
  22. package/dist/tools/preflight.js +20 -1
  23. package/dist/tools/renderCanvasExplainer.d.ts +2 -0
  24. package/dist/tools/renderCanvasExplainer.js +56 -0
  25. package/dist/tools/renderVideo.d.ts +3 -0
  26. package/dist/tools/renderVideo.js +13 -4
  27. package/dist/tools/validateBeats.d.ts +2 -0
  28. package/dist/tools/validateBeats.js +5 -5
  29. package/dist/tools/validateCanvasExplainer.d.ts +2 -0
  30. package/dist/tools/validateCanvasExplainer.js +35 -0
  31. package/dist/tools/validateScenes.d.ts +6 -0
  32. package/dist/tools/validateScenes.js +27 -6
  33. package/dist/tools/validateScenesRender.d.ts +8 -0
  34. package/dist/tools/validateScenesRender.js +31 -0
  35. package/dist/tools/writeDescription.d.ts +6 -0
  36. package/dist/tools/writeDescription.js +64 -2
  37. package/docs/API.md +291 -0
  38. package/docs/NARRATION.md +12 -5
  39. package/docs/PIPELINE.md +46 -0
  40. package/docs/SCRIPT.md +63 -3
  41. package/docs/SUPERNOVA.md +53 -15
  42. package/docs/TOOLS.md +4 -1
  43. package/package.json +4 -4
  44. package/templates/default/openvidstudio-core/package.json +9 -9
  45. package/templates/default/openvidstudio-stem/package.json +5 -5
  46. package/templates/default/openvidstudio-stem/src/ComplexityCurve.tsx +108 -0
  47. package/templates/default/openvidstudio-stem/src/Derivation.tsx +334 -0
  48. package/templates/default/openvidstudio-stem/src/Equation.tsx +3 -3
  49. package/templates/default/openvidstudio-stem/src/GraphViz.tsx +249 -0
  50. package/templates/default/openvidstudio-stem/src/LessonCaptions.tsx +135 -0
  51. package/templates/default/openvidstudio-stem/src/MatrixGrid.tsx +187 -0
  52. package/templates/default/openvidstudio-stem/src/Orbit.tsx +241 -0
  53. package/templates/default/openvidstudio-stem/src/TreeViz.tsx +241 -0
  54. package/templates/default/openvidstudio-stem/src/VectorField.tsx +231 -0
  55. package/templates/default/openvidstudio-stem/src/Wave.tsx +234 -0
  56. package/templates/default/openvidstudio-stem/src/index.ts +21 -0
  57. package/templates/default/package.json +10 -10
@@ -0,0 +1,69 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { type ValidateBeatsResult } from "./validateBeats";
3
+ /**
4
+ * Sizes each beat from the measured voice. A real voice does not read at the pace a word count predicts
5
+ * (plain prose is about 3 words per second, number-heavy lines about 2.2), and validate_beats wants 2.3 to
6
+ * 2.9 words per second over the beat, so a beat sized by words alone overruns or sits half empty. The fix
7
+ * used to be a manual loop: provisional beats, generate_narration, read every spokenSeconds, compute new
8
+ * lengths in a script, rewrite beats.json, generate again. This is that loop's middle.
9
+ *
10
+ * Only `duration` and, to keep the beats contiguous, `start` change. The `vo` is never touched.
11
+ */
12
+ /** Silence kept after the voice, so a line does not end on the cut. */
13
+ export declare const DEFAULT_FIT_PAD_SECONDS = 0.5;
14
+ export interface FitBeatsInput {
15
+ projectRoot?: string;
16
+ videoName: string;
17
+ /** Spoken length of each beat's clip in seconds, as generate_narration reports it. Overrides the clips on disk. */
18
+ spokenSeconds?: Record<string, number>;
19
+ /** generate_narration's result (only `written[].beatId` and `.spokenSeconds` are read). Same as spokenSeconds. */
20
+ narration?: {
21
+ written?: {
22
+ beatId: string;
23
+ spokenSeconds: number;
24
+ language?: string;
25
+ }[];
26
+ };
27
+ /** Seconds of silence after the spoken line. Default 0.5. */
28
+ padSeconds?: number;
29
+ /** Return the proposed durations without writing beats.json. */
30
+ dryRun?: boolean;
31
+ }
32
+ export interface FittedBeat {
33
+ beatId: string;
34
+ /** Duration in frames before and after. */
35
+ before: number;
36
+ after: number;
37
+ words: number;
38
+ /** The measured spoken length, and where it came from. */
39
+ spokenSeconds: number | null;
40
+ source: "report" | "raw-clip" | "final-clip" | null;
41
+ /** Words per second over the new beat length. */
42
+ pace: number | null;
43
+ status: "fitted" | "unchanged" | "unfittable" | "skipped";
44
+ /** "min" or "max" when the pace window, not the voice, decided the length. */
45
+ clampedTo?: "min" | "max";
46
+ note?: string;
47
+ }
48
+ export interface FitBeatsResult {
49
+ videoName: string;
50
+ dryRun: boolean;
51
+ written: boolean;
52
+ path: string;
53
+ beats: FittedBeat[];
54
+ /** Beats whose line cannot fit inside the pace window: shorten the sentence, or lengthen the beat past it. */
55
+ unfittable: FittedBeat[];
56
+ /** Total frames before and after. */
57
+ framesBefore: number;
58
+ framesAfter: number;
59
+ /** validate_beats run on the fitted beats. */
60
+ validation: ValidateBeatsResult;
61
+ }
62
+ /** Pure: the frames a line of `words` words, said in `spoken` seconds, should get. */
63
+ export declare function fitFrames(words: number, spoken: number, fps: number, pad: number): {
64
+ frames: number;
65
+ clampedTo?: "min" | "max";
66
+ overruns: boolean;
67
+ };
68
+ export declare function runFitBeats(input: FitBeatsInput): FitBeatsResult;
69
+ export declare function registerFitBeats(server: McpServer): void;
@@ -0,0 +1,229 @@
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.DEFAULT_FIT_PAD_SECONDS = void 0;
37
+ exports.fitFrames = fitFrames;
38
+ exports.runFitBeats = runFitBeats;
39
+ exports.registerFitBeats = registerFitBeats;
40
+ const fs = __importStar(require("node:fs"));
41
+ const path = __importStar(require("node:path"));
42
+ const zod_1 = require("zod");
43
+ const util_1 = require("../util");
44
+ const mcp_1 = require("./mcp");
45
+ const validateBeats_1 = require("./validateBeats");
46
+ const generateNarration_1 = require("./generateNarration");
47
+ /**
48
+ * Sizes each beat from the measured voice. A real voice does not read at the pace a word count predicts
49
+ * (plain prose is about 3 words per second, number-heavy lines about 2.2), and validate_beats wants 2.3 to
50
+ * 2.9 words per second over the beat, so a beat sized by words alone overruns or sits half empty. The fix
51
+ * used to be a manual loop: provisional beats, generate_narration, read every spokenSeconds, compute new
52
+ * lengths in a script, rewrite beats.json, generate again. This is that loop's middle.
53
+ *
54
+ * Only `duration` and, to keep the beats contiguous, `start` change. The `vo` is never touched.
55
+ */
56
+ /** Silence kept after the voice, so a line does not end on the cut. */
57
+ exports.DEFAULT_FIT_PAD_SECONDS = 0.5;
58
+ /** Pure: the frames a line of `words` words, said in `spoken` seconds, should get. */
59
+ function fitFrames(words, spoken, fps, pad) {
60
+ const lo = Math.ceil((words / validateBeats_1.MAX_PACE) * fps - 1e-9);
61
+ const hi = Math.floor((words / validateBeats_1.MIN_PACE) * fps + 1e-9);
62
+ const wanted = Math.round((spoken + pad) * fps);
63
+ let frames = wanted;
64
+ let clampedTo;
65
+ if (frames < lo) {
66
+ frames = lo;
67
+ clampedTo = "min";
68
+ }
69
+ else if (frames > hi && hi >= lo) {
70
+ frames = hi;
71
+ clampedTo = "max";
72
+ }
73
+ // The clip is stretched by at most TEMPO_CEIL, so a beat shorter than that cannot hold it.
74
+ const overruns = frames / fps < spoken / generateNarration_1.TEMPO_CEIL - 1e-9;
75
+ return { frames, clampedTo, overruns };
76
+ }
77
+ function spokenFromDisk(projectRoot, beatId) {
78
+ const raw = path.join(projectRoot, ".openvidstudio", "narration", `${beatId}.raw`);
79
+ if (fs.existsSync(raw)) {
80
+ const s = (0, generateNarration_1.probeDuration)(raw);
81
+ if (s > 0)
82
+ return { seconds: s, source: "raw-clip" };
83
+ }
84
+ // The fitted clip has been stretched by an unknown tempo, so it is an estimate, and said to be one.
85
+ const finalClip = path.join(projectRoot, "public", "audio", "vo", `${beatId}.mp3`);
86
+ if (fs.existsSync(finalClip)) {
87
+ const s = (0, generateNarration_1.probeDuration)(finalClip);
88
+ if (s > 0)
89
+ return { seconds: s, source: "final-clip" };
90
+ }
91
+ return null;
92
+ }
93
+ function runFitBeats(input) {
94
+ const videoName = (0, util_1.sanitizeSegment)(input.videoName, "videoName");
95
+ const projectRoot = (0, util_1.resolveProjectRoot)(input.projectRoot);
96
+ const beatsFile = path.join(projectRoot, "src", "videos", videoName, "beats.json");
97
+ if (!fs.existsSync(beatsFile))
98
+ throw new Error(`No beats.json at ${beatsFile}. Run write_beats_file first.`);
99
+ const doc = JSON.parse(fs.readFileSync(beatsFile, "utf8"));
100
+ const fps = doc.fps && doc.fps > 0 ? doc.fps : 30;
101
+ const pad = input.padSeconds ?? exports.DEFAULT_FIT_PAD_SECONDS;
102
+ const silent = doc.narration === "none";
103
+ const reported = new Map();
104
+ for (const clip of input.narration?.written ?? []) {
105
+ if (clip.language === undefined || clip.language === "en" || !reported.has(clip.beatId))
106
+ reported.set(clip.beatId, clip.spokenSeconds);
107
+ }
108
+ for (const [id, seconds] of Object.entries(input.spokenSeconds ?? {}))
109
+ reported.set(id, seconds);
110
+ const unknown = [...reported.keys()].filter((id) => !doc.beats.some((b) => b.id === id));
111
+ if (unknown.length > 0)
112
+ throw new Error(`spokenSeconds names beats that are not in beats.json: ${unknown.join(", ")}.`);
113
+ const fitted = [];
114
+ let shift = 0;
115
+ const next = doc.beats.map((beat) => {
116
+ const words = (0, validateBeats_1.wordCount)(beat.vo ?? "");
117
+ const base = { beatId: beat.id, before: beat.duration, after: beat.duration, words, spokenSeconds: null, source: null, pace: null, status: "skipped" };
118
+ const moved = { ...beat, start: beat.start + shift };
119
+ const skip = (note) => {
120
+ fitted.push({ ...base, note });
121
+ return moved;
122
+ };
123
+ if (silent)
124
+ return skip(`narration is "none", so there is no voice to fit.`);
125
+ if (words === 0)
126
+ return skip("no vo on this beat.");
127
+ let spoken = null;
128
+ let source = null;
129
+ const given = reported.get(beat.id);
130
+ if (given !== undefined) {
131
+ spoken = given;
132
+ source = "report";
133
+ }
134
+ else {
135
+ const disk = spokenFromDisk(projectRoot, beat.id);
136
+ if (disk) {
137
+ spoken = disk.seconds;
138
+ source = disk.source;
139
+ }
140
+ }
141
+ if (spoken === null || !(spoken > 0))
142
+ return skip("no measured voice for this beat. Run generate_narration first, or pass spokenSeconds.");
143
+ const fit = fitFrames(words, spoken, fps, pad);
144
+ const entry = {
145
+ ...base,
146
+ after: fit.frames,
147
+ spokenSeconds: Number(spoken.toFixed(2)),
148
+ source,
149
+ pace: Number((words / (fit.frames / fps)).toFixed(2)),
150
+ status: fit.frames === beat.duration ? "unchanged" : "fitted",
151
+ ...(fit.clampedTo ? { clampedTo: fit.clampedTo } : {}),
152
+ };
153
+ if (source === "final-clip") {
154
+ entry.note = "Measured from the fitted clip, which generate_narration has already stretched, so this is an estimate. Run generate_narration with overwrite to measure the raw voice.";
155
+ }
156
+ if (fit.overruns) {
157
+ entry.status = "unfittable";
158
+ const maxWords = Math.max(1, Math.floor(((fit.frames / fps) * generateNarration_1.TEMPO_CEIL * words) / spoken));
159
+ const needSeconds = spoken / generateNarration_1.TEMPO_CEIL;
160
+ entry.note =
161
+ `The voice needs ${spoken.toFixed(2)}s (${needSeconds.toFixed(2)}s at the fastest natural stretch), but ${words} words only ` +
162
+ `allow a beat of ${(fit.frames / fps).toFixed(2)}s at ${validateBeats_1.MIN_PACE} words/sec. Shorten this line to about ${maxWords} words or fewer, ` +
163
+ `or lengthen the beat to ${Math.ceil(needSeconds * fps)} frames and accept a pace under ${validateBeats_1.MIN_PACE} words/sec (validate_beats will say so).`;
164
+ // Closest legal length, so the rest of the lesson still lines up; the finding is the report.
165
+ }
166
+ else if (fit.clampedTo === "min") {
167
+ entry.note = `The voice is quicker than ${validateBeats_1.MAX_PACE} words/sec, so the beat was held at ${(fit.frames / fps).toFixed(2)}s, the shortest the pace window allows. Add words to the line if the gap is noticeable.`;
168
+ }
169
+ else if (fit.clampedTo === "max") {
170
+ entry.note = `The voice would take longer than the ${validateBeats_1.MIN_PACE} words/sec window, so the beat was held at ${(fit.frames / fps).toFixed(2)}s, the longest it allows.`;
171
+ }
172
+ fitted.push(entry);
173
+ shift += fit.frames - beat.duration;
174
+ return { ...moved, duration: fit.frames };
175
+ });
176
+ const fittedDoc = { ...doc, beats: next };
177
+ const validation = (0, validateBeats_1.validateBeatsLogic)(fittedDoc);
178
+ const dryRun = input.dryRun === true;
179
+ const framesBefore = doc.beats.reduce((n, b) => n + b.duration, 0);
180
+ const framesAfter = next.reduce((n, b) => n + b.duration, 0);
181
+ const changed = fitted.some((b) => b.after !== b.before);
182
+ // A beats.json that would not change is left alone.
183
+ let written = false;
184
+ if (!dryRun && changed) {
185
+ fs.writeFileSync(beatsFile, JSON.stringify(fittedDoc, null, 2) + "\n", "utf8");
186
+ written = true;
187
+ }
188
+ return {
189
+ videoName,
190
+ dryRun,
191
+ written,
192
+ path: beatsFile,
193
+ beats: fitted,
194
+ unfittable: fitted.filter((b) => b.status === "unfittable"),
195
+ framesBefore,
196
+ framesAfter,
197
+ validation,
198
+ };
199
+ }
200
+ function registerFitBeats(server) {
201
+ server.registerTool("fit_beats", {
202
+ title: "Size the beats from the measured voice",
203
+ description: "Sets each beat's duration to the length its narration actually takes, so validate_beats' 2.3 to 2.9 " +
204
+ "words per second holds with a real voice. A voice reads plain prose at about 3 words per second and " +
205
+ "number-heavy lines at about 2.2, so beats sized from word counts overrun or sit half empty; this replaces the " +
206
+ "manual loop of reading generate_narration's spokenSeconds, computing new lengths and rewriting beats.json. " +
207
+ "Run generate_narration on provisional beats first. Each beat becomes the spoken length plus a pad (padSeconds, " +
208
+ "default 0.5), clamped into the pace window words/2.9 to words/2.3 and rounded to whole frames, and later " +
209
+ "beats' start frames move so the beats stay contiguous. The spoken length comes from spokenSeconds or " +
210
+ "generate_narration's result (narration), else the raw clips generate_narration keeps under " +
211
+ ".openvidstudio/narration/, else the fitted clips. Beats whose line cannot fit even at the clamp (the voice " +
212
+ "needs more than 2.3 words per second allows) are listed in unfittable with the number of words to cut or the " +
213
+ "frames to lengthen the beat to; they are set to the longest legal length meanwhile. Never changes vo. dryRun " +
214
+ "returns the proposed durations without writing beats.json. Re-runs validate_beats on the result and returns it as " +
215
+ "validation. After fitting, run generate_narration again (overwrite: true) so the clips are stretched to the new lengths.",
216
+ inputSchema: {
217
+ projectRoot: zod_1.z.string().optional(),
218
+ videoName: zod_1.z.string().min(1),
219
+ spokenSeconds: zod_1.z.record(zod_1.z.string(), zod_1.z.number().positive()).optional().describe("Beat id to the voice's spoken length in seconds."),
220
+ narration: zod_1.z
221
+ .object({ written: zod_1.z.array(zod_1.z.object({ beatId: zod_1.z.string(), spokenSeconds: zod_1.z.number().positive(), language: zod_1.z.string().optional() }).passthrough()).optional() })
222
+ .passthrough()
223
+ .optional()
224
+ .describe("generate_narration's result, instead of spokenSeconds."),
225
+ padSeconds: zod_1.z.number().min(0).optional().describe("Silence after the spoken line. Default 0.5."),
226
+ dryRun: zod_1.z.boolean().optional().describe("Return the proposed durations without writing beats.json."),
227
+ },
228
+ }, async (input) => (0, mcp_1.runTool)("fit_beats", () => runFitBeats(input)));
229
+ }
@@ -116,6 +116,9 @@ export interface GenerateNarrationResult {
116
116
  /** Word timings for scenes that point at the word being spoken (useNarrationWords). See NARRATION.md. */
117
117
  wordTimings: WordTimingsReport;
118
118
  }
119
+ /** Beyond this, a pitch-preserving stretch starts to sound processed. */
120
+ export declare const TEMPO_FLOOR = 0.9;
121
+ export declare const TEMPO_CEIL = 1.12;
119
122
  interface BeatLike {
120
123
  id: string;
121
124
  duration: number;
@@ -127,6 +130,22 @@ interface BeatLike {
127
130
  export declare function sanitizeLanguage(tag: string): string;
128
131
  /** The line for one language: the original for the base, a translation for anything else. */
129
132
  export declare function lineFor(beat: BeatLike, language: string, baseLanguage: string): string | undefined;
133
+ export declare function probeDuration(file: string): number;
134
+ /** The last non-empty line of a process's stderr, trimmed to something a warning can carry. */
135
+ export declare function lastErrorLine(stderr: string | undefined | null): string;
136
+ export interface EdgeTtsProbe {
137
+ ok: boolean;
138
+ /** The installed version, when it could be read. */
139
+ version?: string;
140
+ /** Present when ok is false: the last stderr line from edge-tts. */
141
+ error?: string;
142
+ }
143
+ /**
144
+ * Asks edge-tts to speak one short word to a temp file. Microsoft's service refuses connections from
145
+ * some versions of the client (a 403 from speech.platform.bing.com), and the only symptom otherwise is a
146
+ * narration run that fails on every beat.
147
+ */
148
+ export declare function probeEdgeTts(): EdgeTtsProbe;
130
149
  export declare function runGenerateNarration(input: GenerateNarrationInput): GenerateNarrationResult;
131
150
  export declare function registerGenerateNarration(server: McpServer): void;
132
151
  export {};
@@ -33,20 +33,25 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.TEMPO_CEIL = exports.TEMPO_FLOOR = void 0;
36
37
  exports.sanitizeLanguage = sanitizeLanguage;
37
38
  exports.lineFor = lineFor;
39
+ exports.probeDuration = probeDuration;
40
+ exports.lastErrorLine = lastErrorLine;
41
+ exports.probeEdgeTts = probeEdgeTts;
38
42
  exports.runGenerateNarration = runGenerateNarration;
39
43
  exports.registerGenerateNarration = registerGenerateNarration;
40
44
  const node_child_process_1 = require("node:child_process");
41
45
  const fs = __importStar(require("node:fs"));
46
+ const os = __importStar(require("node:os"));
42
47
  const path = __importStar(require("node:path"));
43
48
  const zod_1 = require("zod");
44
49
  const util_1 = require("../util");
45
50
  const paths_1 = require("../paths");
46
51
  const mcp_1 = require("./mcp");
47
52
  /** Beyond this, a pitch-preserving stretch starts to sound processed. */
48
- const TEMPO_FLOOR = 0.9;
49
- const TEMPO_CEIL = 1.12;
53
+ exports.TEMPO_FLOOR = 0.9;
54
+ exports.TEMPO_CEIL = 1.12;
50
55
  const DEFAULT_VOICE = {
51
56
  "edge-tts": "en-US-AndrewNeural",
52
57
  say: "Alex",
@@ -111,7 +116,41 @@ function readWords(file) {
111
116
  return null;
112
117
  }
113
118
  }
119
+ /** The last non-empty line of a process's stderr, trimmed to something a warning can carry. */
120
+ function lastErrorLine(stderr) {
121
+ const lines = (stderr ?? "").split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
122
+ const line = lines[lines.length - 1] ?? "";
123
+ return line.length > 300 ? `${line.slice(0, 297)}...` : line;
124
+ }
125
+ /** Runs the edge-tts command, or its Python module when the command is not on PATH. */
126
+ function runEdgeTts(args) {
127
+ const r = (0, node_child_process_1.spawnSync)("edge-tts", args, { encoding: "utf8" });
128
+ return r.error ? (0, node_child_process_1.spawnSync)("python", ["-m", "edge_tts", ...args], { encoding: "utf8" }) : r;
129
+ }
130
+ /**
131
+ * Asks edge-tts to speak one short word to a temp file. Microsoft's service refuses connections from
132
+ * some versions of the client (a 403 from speech.platform.bing.com), and the only symptom otherwise is a
133
+ * narration run that fails on every beat.
134
+ */
135
+ function probeEdgeTts() {
136
+ const versionRun = runEdgeTts(["--version"]);
137
+ const version = versionRun.error || versionRun.status !== 0 ? undefined : (versionRun.stdout ?? "").trim().split(/\r?\n/)[0] || undefined;
138
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ovs-edge-probe-"));
139
+ const out = path.join(dir, "probe.mp3");
140
+ try {
141
+ const r = runEdgeTts(["--voice", "en-US-AndrewNeural", "--text", "Hello", "--write-media", out]);
142
+ const produced = fs.existsSync(out) && fs.statSync(out).size > 0;
143
+ if (!r.error && r.status === 0 && produced)
144
+ return { ok: true, version };
145
+ const error = r.error ? r.error.message : lastErrorLine(r.stderr) || `edge-tts exited with status ${r.status} and wrote no audio`;
146
+ return { ok: false, version, error };
147
+ }
148
+ finally {
149
+ fs.rmSync(dir, { recursive: true, force: true });
150
+ }
151
+ }
114
152
  function synthesize(engine, voice, text, out, omni = {}, wordsOut) {
153
+ let lastError = "";
115
154
  for (let attempt = 0; attempt < 3; attempt++) {
116
155
  fs.rmSync(out, { force: true });
117
156
  let r;
@@ -121,6 +160,7 @@ function synthesize(engine, voice, text, out, omni = {}, wordsOut) {
121
160
  if (!r.error && r.status === 0 && fs.existsSync(out) && probeDuration(out) > 0.3) {
122
161
  return { ok: true, words: readWords(wordsOut) ?? undefined };
123
162
  }
163
+ lastError = lastErrorLine(r.stderr) || lastError;
124
164
  // fall through to the plain command line, which gives audio and no timings
125
165
  }
126
166
  if (engine === "edge-tts") {
@@ -160,8 +200,11 @@ function synthesize(engine, voice, text, out, omni = {}, wordsOut) {
160
200
  // be undecodable, so this validates by actually probing the file.
161
201
  if (!r.error && fs.existsSync(out) && probeDuration(out) > 0.3)
162
202
  return { ok: true };
203
+ lastError = (r.error ? r.error.message : lastErrorLine(r.stderr)) || lastError;
163
204
  }
164
- return { ok: false, error: `no usable audio after 3 attempts` };
205
+ const cause = lastError ? ` (${lastError})` : "";
206
+ const hint = engine === "edge-tts" && lastError ? " Run preflight to check edge-tts; pip install -U edge-tts often fixes a refused connection." : "";
207
+ return { ok: false, error: `no usable audio after 3 attempts${cause}.${hint}` };
165
208
  }
166
209
  function runGenerateNarration(input) {
167
210
  const videoName = (0, util_1.sanitizeSegment)(input.videoName, "videoName");
@@ -254,7 +297,7 @@ function runGenerateNarration(input) {
254
297
  const beatSeconds = beat.duration / fps;
255
298
  const target = beatSeconds - pad;
256
299
  const wanted = spoken / target;
257
- const tempo = Math.min(TEMPO_CEIL, Math.max(TEMPO_FLOOR, wanted));
300
+ const tempo = Math.min(exports.TEMPO_CEIL, Math.max(exports.TEMPO_FLOOR, wanted));
258
301
  const r = (0, node_child_process_1.spawnSync)("ffmpeg", ["-v", "error", "-i", raw, "-filter:a", `atempo=${tempo.toFixed(4)}`,
259
302
  "-c:a", "libmp3lame", "-q:a", "3", finalPath, "-y"], { encoding: "utf8" });
260
303
  if (r.status !== 0) {
@@ -280,7 +323,7 @@ function runGenerateNarration(input) {
280
323
  // is a shorter translation, not a faster one.
281
324
  clip.note =
282
325
  `Runs ${(finalSeconds - beatSeconds).toFixed(2)}s past the beat. Shorten the line or ` +
283
- `lengthen the beat; stretching further would sound processed.`;
326
+ `lengthen the beat (fit_beats sizes every beat from the measured voice); stretching further would sound processed.`;
284
327
  warnings.push(`${beat.id}: narration overruns its beat by ${(finalSeconds - beatSeconds).toFixed(2)}s`);
285
328
  }
286
329
  else if (clip.needsAttention) {
@@ -41,6 +41,9 @@ const renderStills_1 = require("./renderStills");
41
41
  const collectFacts_1 = require("./collectFacts");
42
42
  const finalizeVideo_1 = require("./finalizeVideo");
43
43
  const checkScript_1 = require("./checkScript");
44
+ const fitBeats_1 = require("./fitBeats");
45
+ const renderCanvasExplainer_1 = require("./renderCanvasExplainer");
46
+ const validateCanvasExplainer_1 = require("./validateCanvasExplainer");
44
47
  /**
45
48
  * Registers all of this package's tools. All but one need no server-startup config: each
46
49
  * resolves a project's own openvidstudio.config.json itself, per call, off projectRoot.
@@ -92,6 +95,9 @@ function registerTools(server, config) {
92
95
  (0, collectFacts_1.registerCollectFacts)(server);
93
96
  (0, finalizeVideo_1.registerFinalizeVideo)(server);
94
97
  (0, checkScript_1.registerCheckScript)(server);
98
+ (0, fitBeats_1.registerFitBeats)(server);
99
+ (0, renderCanvasExplainer_1.registerRenderCanvasExplainer)(server);
100
+ (0, validateCanvasExplainer_1.registerValidateCanvasExplainer)(server);
95
101
  if (config?.hasHiggsfield === true) {
96
102
  (0, importHiggsfieldClip_1.registerImportHiggsfieldClip)(server);
97
103
  }
@@ -45,6 +45,11 @@ const stitchComposition_1 = require("./stitchComposition");
45
45
  const mcp_1 = require("./mcp");
46
46
  const STEM_DIR = "openvidstudio-stem";
47
47
  const STEM_DEPENDENCY = "@openvidstudio/stem";
48
+ /** output/ collects renders on the maintainer's machine; only its two tracked files belong in a new project. */
49
+ const OUTPUT_FILES = new Set(["output/README.md", "output/.gitignore"]);
50
+ function isStrayOutputFile(rel) {
51
+ return rel.startsWith("output/") && !OUTPUT_FILES.has(rel);
52
+ }
48
53
  const SUPERNOVA_VIDEO_CONFIG = { fps: 30, width: 1080, height: 1920 };
49
54
  /** Adds or removes the stem dependency in the project's package.json, leaving the rest as it is. */
50
55
  function setStemDependency(projectRoot, present) {
@@ -100,7 +105,7 @@ function runInitProject(input) {
100
105
  const supernova = input.mode === "supernova";
101
106
  const configExisted = fs.existsSync((0, config_1.configPath)(projectRoot));
102
107
  // The vendored stem package is part of the bundled shell but belongs only to supernova projects.
103
- (0, util_1.copyTemplateTree)(paths_1.TEMPLATE_DIR, projectRoot, supernova ? undefined : (rel) => rel === STEM_DIR);
108
+ (0, util_1.copyTemplateTree)(paths_1.TEMPLATE_DIR, projectRoot, (rel) => (!supernova && rel === STEM_DIR) || isStrayOutputFile(rel));
104
109
  if (supernova)
105
110
  installStem(projectRoot);
106
111
  else if (!alreadyScaffolded)
@@ -47,6 +47,7 @@ const zod_1 = require("zod");
47
47
  const capture_1 = require("@openvidstudio/capture");
48
48
  const util_1 = require("../util");
49
49
  const config_1 = require("../config");
50
+ const generateNarration_1 = require("./generateNarration");
50
51
  const stitchComposition_1 = require("./stitchComposition");
51
52
  const mcp_1 = require("./mcp");
52
53
  function which(cmd) {
@@ -281,6 +282,21 @@ async function runPreflight(input) {
281
282
  fix: narration ? undefined : "pip install edge-tts",
282
283
  blocking: false,
283
284
  });
285
+ // Having edge-tts installed does not mean it can synthesize: Microsoft's service refuses some client
286
+ // versions with a 403, and the first symptom is a narration run that fails on every beat. So speak one
287
+ // word. Skipped for any other engine, and when the command is missing (the narration check above says so).
288
+ if (config.narration.engine === "edge-tts" && narration) {
289
+ const probe = (0, generateNarration_1.probeEdgeTts)();
290
+ checks.push({
291
+ name: "edge-tts",
292
+ ok: probe.ok,
293
+ detail: probe.ok
294
+ ? `edge-tts ${probe.version ?? "(version unknown)"} synthesized a test word`
295
+ : `edge-tts ${probe.version ?? "(version unknown)"} could not synthesize: ${probe.error}`,
296
+ fix: probe.ok ? undefined : "pip install -U edge-tts (Microsoft refuses some older versions with a 403; 7.3 or newer works)",
297
+ blocking: false,
298
+ });
299
+ }
284
300
  // compose_score needs a real python with its DSP stack importable, not just a python on
285
301
  // PATH, so this checks the same import line the tool itself depends on rather than
286
302
  // guessing from the interpreter's presence alone. Same python-then-python3 fallback order
@@ -323,7 +339,10 @@ function registerPreflight(server) {
323
339
  "so a render reuses that browser instead of downloading its own, which fails with no network). " +
324
340
  "Every failure names the fix with the command for this platform, rather than describing a symptom. " +
325
341
  "Run it before init_project on a new machine: without it a missing dependency surfaces several " +
326
- "steps later, often after a long render has already started. Narration is reported as non blocking, " +
342
+ "steps later, often after a long render has already started. When the narration engine is edge-tts, it " +
343
+ "also speaks one short word to a temp file (the edge-tts check), because Microsoft refuses some client " +
344
+ "versions with a 403 and the command existing proves nothing; a failure shows the installed version, " +
345
+ "the error line and pip install -U edge-tts. Narration is reported as non blocking, " +
327
346
  "since a silent video still renders. Pass benchmark: true to also render ~60 frames of a fixed " +
328
347
  "benchmark composition (init_project seeds it, so this works even before any real video is planned) " +
329
348
  "and report real frames/sec plus CPU count in benchmarkResult -- off by default since it costs a " +
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerRenderCanvasExplainer(server: McpServer): void;
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerRenderCanvasExplainer = registerRenderCanvasExplainer;
4
+ const zod_1 = require("zod");
5
+ const jobs_1 = require("../jobs");
6
+ const canvasExplainer_1 = require("../canvasExplainer");
7
+ const mcp_1 = require("./mcp");
8
+ function registerRenderCanvasExplainer(server) {
9
+ server.registerTool("render_canvas_explainer", {
10
+ title: "Render an HTML canvas explainer to a video",
11
+ description: "Turns a canvas explainer into an mp4. An explainer is one self-contained HTML file with a canvas that exposes " +
12
+ "window.seek(t) (draw the frame at time t in seconds, a pure function of t), window.DURATION (seconds) and " +
13
+ "window.scoreSound(kit) (a Web Audio score), with window.SOUND.renderOffline(scoreSound, seconds, sampleRate, " +
14
+ "{ voiceover }) to render it; a file that does not expose these is refused with the missing ones named. Loads the file " +
15
+ "in headless Chromium, waits for fonts, walks every frame (round(DURATION * fps), 1280x720 at 30 fps by default, " +
16
+ "width 720 and height 1280 for a Reel, both even), pipes the canvas PNGs into ffmpeg (H.264, yuv420p), renders the score, " +
17
+ "and muxes the two. Pass videoName to use the project's beats: src/videos/<videoName>/beats.json becomes window.BEATS " +
18
+ "(an array of { id, start, end, text } in seconds, after fit_beats), DURATION becomes the last beat's end, " +
19
+ "public/audio/vo/<beatId>.mp3 is mixed in at each beat's start over the score rendered with voiceover: true and the score " +
20
+ "ducked at least 6 dB (audio.duckDb, default 9) under the voice, and the beats' text is burned in as captions broken at " +
21
+ "sentence ends inside the safe areas, through window.CAPTIONS.draw(ctx, t, opts) which the explainer can call to caption in its " +
22
+ "own style (the renderer draws them itself if it does not); narration: \"none\" renders silent narration with the full-mix " +
23
+ "sound. audio: false renders no sound. A pre-flight runs validate_canvas_explainer first and refuses to render on an error " +
24
+ "(validate: false skips it). Warns when the file calls Math.random. Returns the frame count, the SHA-256 of the first, middle " +
25
+ "and last frame (render twice and compare them to check the purity promise) and what was mixed. Pass async: true to return a " +
26
+ "jobId at once and poll job_status, as render_video does.",
27
+ inputSchema: {
28
+ projectRoot: zod_1.z.string().optional(),
29
+ htmlPath: zod_1.z.string().min(1).describe("The explainer, absolute or relative to the project root."),
30
+ outPath: zod_1.z.string().min(1).describe("The mp4 to write, relative to the project root or absolute."),
31
+ width: zod_1.z.number().int().positive().optional().describe("Default 1280. Use 720 with height 1280 for a Reel."),
32
+ height: zod_1.z.number().int().positive().optional().describe("Default 720."),
33
+ fps: zod_1.z.number().positive().optional().describe("Default 30."),
34
+ audio: zod_1.z
35
+ .union([zod_1.z.literal(false), zod_1.z.object({ voiceover: zod_1.z.boolean().optional(), sampleRate: zod_1.z.number().int().positive().optional(), duckDb: zod_1.z.number().min(6).optional() })])
36
+ .optional()
37
+ .describe("false for no sound, or { voiceover, sampleRate, duckDb }. Voiceover defaults to on when videoName has narration."),
38
+ videoName: zod_1.z.string().optional().describe("Use this project video's beats.json, narration clips and captions."),
39
+ captions: zod_1.z.boolean().optional().describe("Burn the beats' text in as captions. Default true when there is narration."),
40
+ validate: zod_1.z.boolean().optional().describe("Run the pre-flight first. Default true."),
41
+ async: zod_1.z.boolean().optional(),
42
+ },
43
+ }, async (input) => (0, mcp_1.runTool)("render_canvas_explainer", async () => {
44
+ const args = { projectRoot: input.projectRoot, htmlPath: input.htmlPath, outPath: input.outPath, width: input.width, height: input.height, fps: input.fps, audio: input.audio, videoName: input.videoName, captions: input.captions, validate: input.validate };
45
+ if (input.async === true) {
46
+ // Checked now, so a bad call never "succeeds" with a jobId that immediately errors.
47
+ (0, canvasExplainer_1.resolveCanvasInput)(args);
48
+ const job = (0, jobs_1.createJob)("render_canvas_explainer", 0);
49
+ (0, canvasExplainer_1.runCanvasExplainer)(args, { onProgress: (p) => (0, jobs_1.updateJobProgress)(job.id, p) })
50
+ .then((result) => (0, jobs_1.completeJob)(job.id, result))
51
+ .catch((err) => (0, jobs_1.failJob)(job.id, err instanceof Error ? err.message : String(err)));
52
+ return { jobId: job.id };
53
+ }
54
+ return (0, canvasExplainer_1.runCanvasExplainer)(args);
55
+ }));
56
+ }
@@ -62,6 +62,8 @@ export interface RenderVideoResult {
62
62
  stderr: string;
63
63
  /** True when src/brand.ts existed at render time (whether from this call's skipBrandLock or a real extract_brand run). */
64
64
  brandLocked: boolean;
65
+ /** Set when the brand-lock gate was skipped on purpose: a Supernova lesson has no brand to extract. */
66
+ brandLockSkipReason?: string;
65
67
  /** Incremental renders only: beats whose cached segment was reused untouched. */
66
68
  reusedBeats?: string[];
67
69
  /** Incremental renders only: beats that were actually re-rendered. */
@@ -91,6 +93,7 @@ export interface PreparedRenderVideo {
91
93
  compositionId: string;
92
94
  outPathRel: string;
93
95
  brandLocked: boolean;
96
+ brandLockSkipReason?: string;
94
97
  }
95
98
  /**
96
99
  * The synchronous half of render_video: validate input and the brand-lock gate, throwing