@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,661 @@
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.CAPTION_HOLD_SECONDS = exports.DEFAULT_DUCK_DB = exports.AUDIO_SAMPLE_RATE = exports.DEFAULT_CANVAS_FPS = exports.DEFAULT_CANVAS_HEIGHT = exports.DEFAULT_CANVAS_WIDTH = void 0;
37
+ exports.captionChunks = captionChunks;
38
+ exports.chunksForBeat = chunksForBeat;
39
+ exports.readCanvasProject = readCanvasProject;
40
+ exports.bootScript = bootScript;
41
+ exports.openExplainer = openExplainer;
42
+ exports.captureFrame = captureFrame;
43
+ exports.renderScore = renderScore;
44
+ exports.toStereo = toStereo;
45
+ exports.mixVoice = mixVoice;
46
+ exports.wavBytes = wavBytes;
47
+ exports.sourceWarnings = sourceWarnings;
48
+ exports.resolveCanvasInput = resolveCanvasInput;
49
+ exports.runCanvasExplainer = runCanvasExplainer;
50
+ const node_child_process_1 = require("node:child_process");
51
+ const crypto = __importStar(require("node:crypto"));
52
+ const fs = __importStar(require("node:fs"));
53
+ const os = __importStar(require("node:os"));
54
+ const path = __importStar(require("node:path"));
55
+ const node_events_1 = require("node:events");
56
+ const capture_1 = require("@openvidstudio/capture");
57
+ const util_1 = require("./util");
58
+ const generateNarration_1 = require("./tools/generateNarration");
59
+ /**
60
+ * Turns a canvas explainer into a video. A canvas explainer is one self-contained HTML file with a canvas
61
+ * and three promises: `window.seek(t)` draws the frame at time `t` (seconds) as a pure function of `t`,
62
+ * `window.DURATION` is its length in seconds, and `window.scoreSound(kit)` is its Web Audio score, rendered
63
+ * offline through `window.SOUND.renderOffline(scoreSound, seconds, sampleRate, { voiceover })`.
64
+ *
65
+ * The renderer loads the file in headless Chromium, walks the frames, pipes the canvas PNGs into ffmpeg,
66
+ * renders the score, optionally mixes the narration clips of a project over it, and muxes the two. It can run
67
+ * from the MCP tool (tools/renderCanvasExplainer.ts) or standalone:
68
+ *
69
+ * node dist/canvasExplainer.js --html explainer.html --out out.mp4 [--width 1280 --height 720 --fps 30 --video name]
70
+ */
71
+ exports.DEFAULT_CANVAS_WIDTH = 1280;
72
+ exports.DEFAULT_CANVAS_HEIGHT = 720;
73
+ exports.DEFAULT_CANVAS_FPS = 30;
74
+ exports.AUDIO_SAMPLE_RATE = 48000;
75
+ /** How far the score sits under a narration clip while it plays, in dB. At least 6 is promised. */
76
+ exports.DEFAULT_DUCK_DB = 9;
77
+ /** Seconds a caption stays after its last word is said, if the next one has not started. */
78
+ exports.CAPTION_HOLD_SECONDS = 0.3;
79
+ /** Sentence-end and comma rules of LessonCaptions (packages/stem), so a canvas lesson captions the same way. */
80
+ function captionChunks(words, maxWords = 6) {
81
+ const limit = Math.max(1, Math.floor(maxWords));
82
+ const chunks = [];
83
+ let from = 0;
84
+ for (let i = 0; i < words.length; i++) {
85
+ const size = i - from + 1;
86
+ const last = i === words.length - 1;
87
+ if (last || /[.?!:…]["')\]]*$/.test(words[i]) || (/[,;]["')\]]*$/.test(words[i]) && size >= 3) || size >= limit) {
88
+ chunks.push([from, i + 1]);
89
+ from = i + 1;
90
+ }
91
+ }
92
+ return chunks;
93
+ }
94
+ /** Pure: the caption chunks of one beat, timed from word times when they are known and evenly by length when not. */
95
+ function chunksForBeat(beat, spokenSeconds, words) {
96
+ const tokens = beat.text.split(/\s+/).filter(Boolean);
97
+ if (tokens.length === 0)
98
+ return [];
99
+ const exact = words !== undefined && words.length === tokens.length;
100
+ const spoken = Math.min(Math.max(0.1, spokenSeconds), Math.max(0.1, beat.end - beat.start));
101
+ let startAt;
102
+ let endAt;
103
+ if (exact) {
104
+ startAt = words.map((w) => beat.start + w.startMs / 1000);
105
+ endAt = words.map((w) => beat.start + w.endMs / 1000);
106
+ }
107
+ else {
108
+ const weight = tokens.map((t) => t.length + 1);
109
+ const total = weight.reduce((a, b) => a + b, 0);
110
+ let acc = 0;
111
+ startAt = [];
112
+ endAt = [];
113
+ tokens.forEach((_, i) => {
114
+ startAt.push(beat.start + (acc / total) * spoken);
115
+ acc += weight[i];
116
+ endAt.push(beat.start + (acc / total) * spoken);
117
+ });
118
+ }
119
+ const ranges = captionChunks(tokens);
120
+ return ranges.map(([from, to], k) => {
121
+ const lastEnd = endAt[to - 1];
122
+ const nextStart = k + 1 < ranges.length ? startAt[ranges[k + 1][0]] : Infinity;
123
+ return { beatId: beat.id, from: startAt[from], to: Math.min(lastEnd + exports.CAPTION_HOLD_SECONDS, nextStart, beat.end), text: tokens.slice(from, to).join(" ") };
124
+ });
125
+ }
126
+ /** Reads a project's beats.json into what a canvas explainer needs. */
127
+ function readCanvasProject(projectRoot, videoName) {
128
+ const name = (0, util_1.sanitizeSegment)(videoName, "videoName");
129
+ const file = path.join(projectRoot, "src", "videos", name, "beats.json");
130
+ if (!fs.existsSync(file))
131
+ throw new Error(`No beats.json at ${file}. Write the beats (and run fit_beats after generate_narration) first.`);
132
+ const doc = JSON.parse(fs.readFileSync(file, "utf8"));
133
+ const fps = doc.fps && doc.fps > 0 ? doc.fps : 30;
134
+ const narration = doc.narration === "none" ? "none" : "voice";
135
+ const beats = (doc.beats ?? []).map((b) => ({ id: b.id, start: b.start / fps, end: (b.start + b.duration) / fps, text: typeof b.vo === "string" ? b.vo : "" }));
136
+ const clips = [];
137
+ const chunks = [];
138
+ if (narration === "voice") {
139
+ const voDir = path.join(projectRoot, "public", "audio", "vo");
140
+ for (const beat of beats) {
141
+ const clip = path.join(voDir, `${beat.id}.mp3`);
142
+ const hasClip = fs.existsSync(clip);
143
+ if (hasClip)
144
+ clips.push({ beatId: beat.id, start: beat.start, file: clip });
145
+ if (beat.text.trim() === "")
146
+ continue;
147
+ const spoken = hasClip ? (0, generateNarration_1.probeDuration)(clip) : beat.end - beat.start;
148
+ let words;
149
+ const wordsFile = path.join(voDir, `${beat.id}.words.json`);
150
+ if (fs.existsSync(wordsFile)) {
151
+ try {
152
+ words = JSON.parse(fs.readFileSync(wordsFile, "utf8"));
153
+ }
154
+ catch {
155
+ words = undefined;
156
+ }
157
+ }
158
+ chunks.push(...chunksForBeat(beat, spoken > 0 ? spoken : beat.end - beat.start, words));
159
+ }
160
+ }
161
+ return { beats, narration, clips, chunks };
162
+ }
163
+ // ---------------------------------------------------------------------------------------------
164
+ // The page: boot script, captions helper, probes
165
+ /** Runs before the explainer's own scripts: the size, the beats, the captions helper. */
166
+ function bootScript(cfg) {
167
+ const lines = [];
168
+ lines.push(`window.OVS = ${JSON.stringify({ width: cfg.width, height: cfg.height, fps: cfg.fps, voiceover: cfg.voiceover })};`);
169
+ if (cfg.beats)
170
+ lines.push(`window.BEATS = ${JSON.stringify(cfg.beats)};`);
171
+ lines.push(`window.__ovsCaptionBoxes = [];`);
172
+ lines.push(`(function () {
173
+ var chunks = ${JSON.stringify(cfg.chunks)};
174
+ var W = ${cfg.width}, H = ${cfg.height};
175
+ function at(t) {
176
+ for (var i = 0; i < chunks.length; i++) if (t >= chunks[i].from && t < chunks[i].to) return chunks[i];
177
+ return null;
178
+ }
179
+ function wrap(ctx, words, maxWidth) {
180
+ var lines = [], line = "";
181
+ for (var i = 0; i < words.length; i++) {
182
+ var next = line ? line + " " + words[i] : words[i];
183
+ if (line && ctx.measureText(next).width > maxWidth) { lines.push(line); line = words[i]; } else line = next;
184
+ }
185
+ if (line) lines.push(line);
186
+ return lines;
187
+ }
188
+ // Draws the caption for time t inside the safe area: above the bottom 22% (14% at the top) of a tall
189
+ // canvas, 8% either way on a wide one. An explainer calls it at the end of seek(t) to caption in its own
190
+ // font and colours (opts.font, opts.color, opts.background, opts.position); the renderer calls it itself
191
+ // for an explainer that did not. Returns the box it drew, or null when no caption is on screen at t.
192
+ function draw(ctx, t, opts) {
193
+ window.CAPTIONS.__drawn = true;
194
+ var chunk = at(t);
195
+ if (!chunk) return null;
196
+ opts = opts || {};
197
+ var portrait = H > W;
198
+ var bottomInset = Math.round(H * (portrait ? 0.22 : 0.08)), topInset = Math.round(H * (portrait ? 0.14 : 0.08));
199
+ var size = opts.size || Math.round(Math.min(W, H) * 0.048);
200
+ var family = opts.font || "sans-serif";
201
+ var maxWidth = W * 0.8, lines;
202
+ ctx.save();
203
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
204
+ for (;;) {
205
+ ctx.font = "600 " + size + "px " + family;
206
+ lines = wrap(ctx, chunk.text.split(/\\s+/), maxWidth);
207
+ if (lines.length <= 2 || size <= 12) break;
208
+ size -= 2;
209
+ }
210
+ var padX = Math.round(size * 0.5), padY = Math.round(size * 0.3), lineH = Math.round(size * 1.25);
211
+ var w = 0;
212
+ for (var i = 0; i < lines.length; i++) w = Math.max(w, ctx.measureText(lines[i]).width);
213
+ var boxW = Math.round(w + 2 * padX), boxH = lines.length * lineH + 2 * padY;
214
+ var x = Math.round((W - boxW) / 2);
215
+ var y = opts.position === "top" ? topInset : H - bottomInset - boxH;
216
+ ctx.fillStyle = opts.background || "rgba(7,6,11,0.72)";
217
+ var r = Math.round(size * 0.4);
218
+ ctx.beginPath();
219
+ ctx.moveTo(x + r, y); ctx.arcTo(x + boxW, y, x + boxW, y + boxH, r); ctx.arcTo(x + boxW, y + boxH, x, y + boxH, r);
220
+ ctx.arcTo(x, y + boxH, x, y, r); ctx.arcTo(x, y, x + boxW, y, r); ctx.closePath(); ctx.fill();
221
+ ctx.fillStyle = opts.color || "#F3EFE6";
222
+ ctx.textAlign = "center"; ctx.textBaseline = "alphabetic";
223
+ window.__ovsCaptionDrawing = true;
224
+ for (var j = 0; j < lines.length; j++) ctx.fillText(lines[j], W / 2, y + padY + j * lineH + Math.round(size * 0.95));
225
+ window.__ovsCaptionDrawing = false;
226
+ ctx.restore();
227
+ var box = { x: x, y: y, width: boxW, height: boxH };
228
+ window.__ovsCaptionBoxes.push(box);
229
+ return box;
230
+ }
231
+ window.CAPTIONS = { chunks: chunks, at: at, draw: draw, __drawn: false };
232
+ })();`);
233
+ if (cfg.probes) {
234
+ lines.push(`(function () {
235
+ var P = (window.__ovsTexts = []);
236
+ ["fillText", "strokeText"].forEach(function (name) {
237
+ var orig = CanvasRenderingContext2D.prototype[name];
238
+ CanvasRenderingContext2D.prototype[name] = function (text, x, y) {
239
+ try {
240
+ var m = this.getTransform();
241
+ var match = /(\\d+(?:\\.\\d+)?)px/.exec(this.font);
242
+ var size = (match ? parseFloat(match[1]) : 10) * Math.hypot(m.a, m.b);
243
+ P.push({ text: String(text), size: size, x: m.a * x + m.c * y + m.e, y: m.b * x + m.d * y + m.f, caption: !!window.__ovsCaptionDrawing });
244
+ } catch (e) {}
245
+ return orig.apply(this, arguments);
246
+ };
247
+ });
248
+ var S = (window.__ovsSources = []);
249
+ function hook(proto) {
250
+ var start = proto.start, stop = proto.stop;
251
+ proto.start = function (when, offset, duration) {
252
+ var rec = { start: when || 0, stop: null, duration: duration === undefined ? null : duration, bufferSeconds: null, loop: false };
253
+ try {
254
+ if (this.buffer) { rec.bufferSeconds = this.buffer.duration / Math.max(0.001, this.playbackRate ? this.playbackRate.value : 1); rec.loop = !!this.loop; }
255
+ } catch (e) {}
256
+ this.__ovs = rec;
257
+ S.push(rec);
258
+ return start.apply(this, arguments);
259
+ };
260
+ proto.stop = function (when) {
261
+ if (this.__ovs) this.__ovs.stop = when || 0;
262
+ return stop.apply(this, arguments);
263
+ };
264
+ }
265
+ hook(AudioScheduledSourceNode.prototype);
266
+ if (typeof AudioBufferSourceNode !== "undefined" && AudioBufferSourceNode.prototype.hasOwnProperty("start")) hook(AudioBufferSourceNode.prototype);
267
+ })();`);
268
+ }
269
+ return lines.join("\n");
270
+ }
271
+ /** Loads the file and checks it keeps its three promises. */
272
+ async function openExplainer(browser, htmlPath, cfg) {
273
+ const page = await browser.newPage({ viewport: { width: cfg.width, height: cfg.height }, deviceScaleFactor: 1 });
274
+ const errors = [];
275
+ page.on("pageerror", (err) => errors.push(String(err && err.message ? err.message : err)));
276
+ try {
277
+ await page.addInitScript({
278
+ content: bootScript({ width: cfg.width, height: cfg.height, fps: cfg.fps, voiceover: cfg.voiceover, beats: cfg.project ? cfg.project.beats : null, chunks: cfg.project ? cfg.project.chunks : [], probes: cfg.probes }),
279
+ });
280
+ await page.goto(`file://${htmlPath}`, { waitUntil: "load" });
281
+ await page.evaluate(() => document.fonts.ready.then(() => true));
282
+ const present = await page.evaluate(() => {
283
+ const w = window;
284
+ return { seek: typeof w.seek, duration: typeof w.DURATION, score: typeof w.scoreSound, canvas: document.querySelector("canvas") !== null };
285
+ });
286
+ const missing = [];
287
+ if (present.seek !== "function")
288
+ missing.push("window.seek(t) (a function that draws the frame at time t)");
289
+ if (present.duration !== "number")
290
+ missing.push("window.DURATION (the length in seconds, a number)");
291
+ if (cfg.requireScore && present.score !== "function")
292
+ missing.push("window.scoreSound(kit) (a function that scores the sound)");
293
+ if (!present.canvas)
294
+ missing.push("a <canvas> element");
295
+ if (missing.length > 0) {
296
+ throw new Error(`${path.basename(htmlPath)} is not a canvas explainer: it does not expose ${missing.join(", ")}. ` +
297
+ `An explainer is one HTML file with a canvas, window.seek(t), window.DURATION and window.scoreSound(kit)` +
298
+ (errors.length > 0 ? ` (the page reported: ${errors[0]})` : "") +
299
+ `.`);
300
+ }
301
+ const ownDuration = await page.evaluate(() => window.DURATION);
302
+ // A beats file decides the length: DURATION is the last beat's end.
303
+ if (cfg.project && cfg.project.beats.length > 0) {
304
+ const lastEnd = Math.max(...cfg.project.beats.map((b) => b.end));
305
+ await page.evaluate((d) => (window.DURATION = d), lastEnd);
306
+ }
307
+ const durationSeconds = await page.evaluate(() => window.DURATION);
308
+ if (!(durationSeconds > 0) || !Number.isFinite(durationSeconds))
309
+ throw new Error(`window.DURATION is ${durationSeconds}; it must be a positive number of seconds.`);
310
+ const resized = await page.evaluate(([w, h]) => {
311
+ const c = document.querySelector("canvas");
312
+ if (c.width === w && c.height === h)
313
+ return false;
314
+ c.width = w;
315
+ c.height = h;
316
+ return true;
317
+ }, [cfg.width, cfg.height]);
318
+ return { page, durationSeconds, width: cfg.width, height: cfg.height, resized, ownDuration, errors };
319
+ }
320
+ catch (err) {
321
+ await page.close().catch(() => undefined);
322
+ throw err;
323
+ }
324
+ }
325
+ /** Seeks to `t` and returns the canvas as PNG bytes. An explainer that did not draw captions gets them from the renderer. */
326
+ async function captureFrame(page, t) {
327
+ const b64 = await page.evaluate(async (time) => {
328
+ const w = window;
329
+ if (w.CAPTIONS)
330
+ w.CAPTIONS.__drawn = false;
331
+ await w.seek(time);
332
+ const canvas = document.querySelector("canvas");
333
+ if (w.CAPTIONS && w.CAPTIONS.chunks.length > 0 && !w.CAPTIONS.__drawn) {
334
+ const ctx = canvas.getContext("2d");
335
+ if (ctx)
336
+ w.CAPTIONS.draw(ctx, time);
337
+ }
338
+ return canvas.toDataURL("image/png").slice("data:image/png;base64,".length);
339
+ }, t);
340
+ return Buffer.from(b64, "base64");
341
+ }
342
+ /** Renders the explainer's score through its sound kit. */
343
+ async function renderScore(page, durationSeconds, sampleRate, voiceover) {
344
+ const out = await page.evaluate(async ([duration, rate, vo]) => {
345
+ const w = window;
346
+ if (!w.SOUND || typeof w.SOUND.renderOffline !== "function") {
347
+ throw new Error("window.SOUND.renderOffline is missing, so the score cannot be rendered. Include the sound kit in the explainer, or render with audio: false.");
348
+ }
349
+ const r = (await w.SOUND.renderOffline(w.scoreSound, duration, rate, { voiceover: vo }));
350
+ let sampleRate = rate;
351
+ let channels = 2;
352
+ let data;
353
+ const any = r;
354
+ if (any && typeof any.getChannelData === "function") {
355
+ channels = any.numberOfChannels ?? 1;
356
+ sampleRate = any.sampleRate ?? sampleRate;
357
+ const chans = Array.from({ length: channels }, (_, c) => any.getChannelData(c));
358
+ data = new Float32Array(chans[0].length * channels);
359
+ for (let i = 0; i < chans[0].length; i++)
360
+ for (let c = 0; c < channels; c++)
361
+ data[i * channels + c] = chans[c][i];
362
+ }
363
+ else if (Array.isArray(r) && r[0] instanceof Float32Array) {
364
+ channels = r.length;
365
+ data = new Float32Array(r[0].length * channels);
366
+ for (let i = 0; i < r[0].length; i++)
367
+ for (let c = 0; c < channels; c++)
368
+ data[i * channels + c] = r[c][i];
369
+ }
370
+ else if (r instanceof Float32Array) {
371
+ data = r;
372
+ }
373
+ else if (any && (any.samples instanceof Float32Array || any.data instanceof Float32Array)) {
374
+ data = (any.samples ?? any.data);
375
+ if (typeof any.channels === "number")
376
+ channels = any.channels;
377
+ if (typeof any.sampleRate === "number")
378
+ sampleRate = any.sampleRate;
379
+ }
380
+ else {
381
+ throw new Error("SOUND.renderOffline returned something that is not audio: expected an AudioBuffer, an array of channel Float32Arrays, an interleaved Float32Array, or { samples, sampleRate, channels }.");
382
+ }
383
+ const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
384
+ let bin = "";
385
+ for (let i = 0; i < bytes.length; i += 0x8000)
386
+ bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
387
+ return { sampleRate, channels, b64: btoa(bin) };
388
+ }, [durationSeconds, sampleRate, voiceover]);
389
+ const buf = Buffer.from(out.b64, "base64");
390
+ const data = new Float32Array(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength));
391
+ return { sampleRate: out.sampleRate, channels: out.channels, data };
392
+ }
393
+ /** Pure: `audio` as stereo, so mixing and writing deal with one shape. */
394
+ function toStereo(audio) {
395
+ if (audio.channels === 2)
396
+ return audio;
397
+ const frames = Math.floor(audio.data.length / audio.channels);
398
+ const out = new Float32Array(frames * 2);
399
+ for (let i = 0; i < frames; i++) {
400
+ out[i * 2] = audio.data[i * audio.channels];
401
+ out[i * 2 + 1] = audio.data[i * audio.channels + (audio.channels > 1 ? 1 : 0)];
402
+ }
403
+ return { sampleRate: audio.sampleRate, channels: 2, data: out };
404
+ }
405
+ /** Decodes a clip to interleaved stereo float at `sampleRate`. */
406
+ function decodeClip(file, sampleRate) {
407
+ // ffmpeg's own mono to stereo upmix takes 3 dB off, so a mono narration clip (what a speech engine writes) would
408
+ // sit quieter in the mix than it was recorded. Copy the channel to both sides instead.
409
+ const channels = Number.parseInt((0, node_child_process_1.spawnSync)("ffprobe", ["-v", "error", "-select_streams", "a:0", "-show_entries", "stream=channels", "-of", "default=nw=1:nk=1", file], { encoding: "utf8" }).stdout.trim(), 10);
410
+ const layout = channels === 1 ? ["-af", "pan=stereo|c0=c0|c1=c0"] : ["-ac", "2"];
411
+ const r = (0, node_child_process_1.spawnSync)("ffmpeg", ["-v", "error", "-i", file, ...layout, "-f", "f32le", "-ar", String(sampleRate), "-"], { maxBuffer: 1 << 30 });
412
+ if (r.status !== 0 || !r.stdout)
413
+ throw new Error(`ffmpeg could not decode the narration clip ${file}: ${String(r.stderr ?? "").slice(0, 300)}`);
414
+ return new Float32Array(r.stdout.buffer.slice(r.stdout.byteOffset, r.stdout.byteOffset + r.stdout.byteLength));
415
+ }
416
+ /**
417
+ * Mixes narration clips over a score. Each clip starts at its beat's start (to the sample), and the score is
418
+ * ducked by `duckDb` while any clip plays: eased down over 40 ms and back up over 250 ms, so it does not pump.
419
+ */
420
+ function mixVoice(score, clips, duckDb) {
421
+ const stereo = toStereo(score);
422
+ const rate = stereo.sampleRate;
423
+ const frames = stereo.data.length / 2;
424
+ const voice = new Float32Array(stereo.data.length);
425
+ const spans = [];
426
+ const report = [];
427
+ for (const clip of clips) {
428
+ const pcm = decodeClip(clip.file, rate);
429
+ const startSample = Math.round(clip.start * rate);
430
+ const clipFrames = pcm.length / 2;
431
+ const n = Math.max(0, Math.min(clipFrames, frames - startSample));
432
+ for (let i = 0; i < n; i++) {
433
+ voice[(startSample + i) * 2] += pcm[i * 2];
434
+ voice[(startSample + i) * 2 + 1] += pcm[i * 2 + 1];
435
+ }
436
+ spans.push([startSample, startSample + n]);
437
+ report.push({ beatId: clip.beatId, startSeconds: clip.start, startSample, durationSeconds: clipFrames / rate });
438
+ }
439
+ const target = new Float32Array(frames).fill(1);
440
+ const duck = 10 ** (-duckDb / 20);
441
+ for (const [a, b] of spans)
442
+ target.fill(duck, a, b);
443
+ const attack = Math.exp(-1 / (0.04 * rate));
444
+ const release = Math.exp(-1 / (0.25 * rate));
445
+ const out = new Float32Array(stereo.data.length);
446
+ let gain = 1;
447
+ for (let i = 0; i < frames; i++) {
448
+ const k = target[i] < gain ? attack : release;
449
+ gain = target[i] + (gain - target[i]) * k;
450
+ out[i * 2] = stereo.data[i * 2] * gain + voice[i * 2];
451
+ out[i * 2 + 1] = stereo.data[i * 2 + 1] * gain + voice[i * 2 + 1];
452
+ }
453
+ let peak = 0;
454
+ for (let i = 0; i < out.length; i++)
455
+ peak = Math.max(peak, Math.abs(out[i]));
456
+ if (peak > 0.98)
457
+ for (let i = 0; i < out.length; i++)
458
+ out[i] = (out[i] * 0.98) / peak;
459
+ return { audio: { sampleRate: rate, channels: 2, data: out }, clips: report };
460
+ }
461
+ /** A 32-bit float WAV, which ffmpeg reads directly. */
462
+ function wavBytes(audio) {
463
+ const dataBytes = audio.data.length * 4;
464
+ const header = Buffer.alloc(44);
465
+ header.write("RIFF", 0);
466
+ header.writeUInt32LE(36 + dataBytes, 4);
467
+ header.write("WAVEfmt ", 8);
468
+ header.writeUInt32LE(16, 16);
469
+ header.writeUInt16LE(3, 20);
470
+ header.writeUInt16LE(audio.channels, 22);
471
+ header.writeUInt32LE(audio.sampleRate, 24);
472
+ header.writeUInt32LE(audio.sampleRate * audio.channels * 4, 28);
473
+ header.writeUInt16LE(audio.channels * 4, 32);
474
+ header.writeUInt16LE(32, 34);
475
+ header.write("data", 36);
476
+ header.writeUInt32LE(dataBytes, 40);
477
+ return Buffer.concat([header, Buffer.from(audio.data.buffer, audio.data.byteOffset, dataBytes)]);
478
+ }
479
+ // ---------------------------------------------------------------------------------------------
480
+ // Rendering
481
+ const MATH_RANDOM = /Math\s*\.\s*random\b/;
482
+ /** The warnings that need only the source. */
483
+ function sourceWarnings(html, file) {
484
+ const warnings = [];
485
+ if (MATH_RANDOM.test(html)) {
486
+ warnings.push(`${path.basename(file)} calls Math.random. A frame must be a pure function of t, so a drawing that draws a random number ` +
487
+ `will not look the same twice. Use a seeded generator keyed by t or by an index.`);
488
+ }
489
+ return warnings;
490
+ }
491
+ function resolveCanvasInput(input) {
492
+ const projectRoot = (0, util_1.resolveProjectRoot)(input.projectRoot);
493
+ const htmlPath = path.resolve(projectRoot, input.htmlPath);
494
+ if (!fs.existsSync(htmlPath))
495
+ throw new Error(`No explainer at ${htmlPath}.`);
496
+ if (path.isAbsolute(input.outPath)) {
497
+ if (/[;&|`$<>\n\r%^"]/.test(input.outPath))
498
+ throw new Error(`outPath "${input.outPath}" contains disallowed characters.`);
499
+ }
500
+ else {
501
+ (0, util_1.sanitizeRelativeOutPath)(projectRoot, input.outPath, "outPath");
502
+ }
503
+ const outPath = path.resolve(projectRoot, input.outPath);
504
+ const width = input.width ?? exports.DEFAULT_CANVAS_WIDTH;
505
+ const height = input.height ?? exports.DEFAULT_CANVAS_HEIGHT;
506
+ const fps = input.fps ?? exports.DEFAULT_CANVAS_FPS;
507
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width < 16 || height < 16)
508
+ throw new Error(`width and height must be whole numbers of at least 16, got ${width}x${height}.`);
509
+ if (width % 2 !== 0 || height % 2 !== 0)
510
+ throw new Error(`width and height must be even for H.264, got ${width}x${height}.`);
511
+ if (!(fps > 0) || fps > 120)
512
+ throw new Error(`fps must be between 1 and 120, got ${fps}.`);
513
+ return { projectRoot, htmlPath, outPath, width, height, fps };
514
+ }
515
+ async function encodeFrames(page, r, frames, tmpVideo, onProgress) {
516
+ const ff = (0, node_child_process_1.spawn)("ffmpeg", ["-v", "error", "-y", "-f", "image2pipe", "-framerate", String(r.fps), "-c:v", "png", "-i", "-", "-an", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-preset", "veryfast", "-crf", "18", "-movflags", "+faststart", tmpVideo], { stdio: ["pipe", "ignore", "pipe"] });
517
+ let stderr = "";
518
+ ff.stderr.on("data", (c) => (stderr += c));
519
+ let ffError = null;
520
+ ff.stdin.on("error", (err) => (ffError = err));
521
+ const closed = new Promise((resolve) => ff.on("close", resolve));
522
+ const hashes = {};
523
+ const hash = (b) => crypto.createHash("sha256").update(b).digest("hex");
524
+ const middle = Math.floor((frames - 1) / 2);
525
+ try {
526
+ for (let i = 0; i < frames; i++) {
527
+ const png = await captureFrame(page, i / r.fps);
528
+ if (i === 0)
529
+ hashes.first = hash(png);
530
+ if (i === middle)
531
+ hashes.middle = hash(png);
532
+ if (i === frames - 1)
533
+ hashes.last = hash(png);
534
+ if (ffError)
535
+ break;
536
+ if (!ff.stdin.write(png))
537
+ await (0, node_events_1.once)(ff.stdin, "drain");
538
+ if (i % 5 === 0 || i === frames - 1)
539
+ onProgress?.({ framesRendered: i + 1, totalFrames: frames });
540
+ }
541
+ }
542
+ finally {
543
+ ff.stdin.end();
544
+ }
545
+ const code = await closed;
546
+ if (code !== 0 || ffError)
547
+ throw new Error(`ffmpeg failed encoding the frames (exit ${code}): ${stderr.slice(-400) || String(ffError)}`);
548
+ return { first: hashes.first, middle: hashes.middle, last: hashes.last };
549
+ }
550
+ async function runCanvasExplainer(input, opts = {}) {
551
+ const r = resolveCanvasInput(input);
552
+ const project = input.videoName !== undefined ? readCanvasProject(r.projectRoot, input.videoName) : null;
553
+ const audioOn = input.audio !== false;
554
+ const audioOpts = input.audio === false || input.audio === undefined ? {} : input.audio;
555
+ const withNarration = project !== null && project.narration === "voice";
556
+ const voiceover = audioOpts.voiceover ?? withNarration;
557
+ const sampleRate = audioOpts.sampleRate ?? exports.AUDIO_SAMPLE_RATE;
558
+ const duckDb = audioOpts.duckDb ?? exports.DEFAULT_DUCK_DB;
559
+ if (voiceover && duckDb < 6)
560
+ throw new Error(`duckDb is ${duckDb}; the score has to sit at least 6 dB under the voice.`);
561
+ const captionsOn = (input.captions ?? true) && withNarration && project.chunks.length > 0;
562
+ const renderProject = project && { ...project, chunks: captionsOn ? project.chunks : [] };
563
+ const warnings = sourceWarnings(fs.readFileSync(r.htmlPath, "utf8"), r.htmlPath);
564
+ let findings = [];
565
+ if (input.validate !== false) {
566
+ const { validateCanvasExplainer } = await Promise.resolve().then(() => __importStar(require("./canvasValidate")));
567
+ const verdict = await validateCanvasExplainer({ projectRoot: r.projectRoot, htmlPath: r.htmlPath, width: r.width, height: r.height, fps: r.fps, videoName: input.videoName, audio: audioOn });
568
+ const errors = verdict.findings.filter((f) => f.severity === "error");
569
+ if (errors.length > 0) {
570
+ throw new Error(`Pre-flight refused to render ${path.basename(r.htmlPath)}: ${errors.map((f) => `[${f.check}] ${f.message}`).join(" | ")} ` +
571
+ `Fix these, or pass validate: false to render anyway.`);
572
+ }
573
+ findings = verdict.findings;
574
+ }
575
+ const browser = await (0, capture_1.launchChromium)();
576
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "ovs-canvas-"));
577
+ try {
578
+ const opened = await openExplainer(browser, r.htmlPath, { width: r.width, height: r.height, fps: r.fps, voiceover, project: renderProject, probes: false, requireScore: audioOn });
579
+ if (opened.resized)
580
+ warnings.push(`The canvas was not ${r.width}x${r.height}, so it was resized to that before the first frame. Size the canvas from window.OVS.width and window.OVS.height.`);
581
+ if (project && project.beats.length > 0 && Math.abs(opened.ownDuration - opened.durationSeconds) > 1 / r.fps) {
582
+ warnings.push(`The explainer's own DURATION is ${opened.ownDuration}s but the beats end at ${opened.durationSeconds}s, which is what was rendered. Read window.BEATS and set DURATION from the last beat's end.`);
583
+ }
584
+ const frames = Math.max(1, Math.round(opened.durationSeconds * r.fps));
585
+ opts.onProgress?.({ framesRendered: 0, totalFrames: frames });
586
+ fs.mkdirSync(path.dirname(r.outPath), { recursive: true });
587
+ const tmpVideo = path.join(scratch, "video.mp4");
588
+ const frameHashes = await encodeFrames(opened.page, r, frames, tmpVideo, opts.onProgress);
589
+ let audioInfo = { included: false, sampleRate: null, voiceover: false, voiceClips: [], duckDb: null };
590
+ if (audioOn) {
591
+ const score = await renderScore(opened.page, opened.durationSeconds, sampleRate, voiceover);
592
+ let finalAudio = toStereo(score);
593
+ let clips = [];
594
+ if (voiceover && project && project.clips.length > 0) {
595
+ const mixed = mixVoice(score, project.clips, duckDb);
596
+ finalAudio = mixed.audio;
597
+ clips = mixed.clips;
598
+ }
599
+ const wav = path.join(scratch, "audio.wav");
600
+ fs.writeFileSync(wav, wavBytes(finalAudio));
601
+ const mux = (0, node_child_process_1.spawnSync)("ffmpeg", ["-v", "error", "-y", "-i", tmpVideo, "-i", wav, "-map", "0:v:0", "-map", "1:a:0", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-t", String(frames / r.fps), r.outPath], { encoding: "utf8" });
602
+ if (mux.status !== 0)
603
+ throw new Error(`ffmpeg failed muxing the audio: ${(mux.stderr ?? "").slice(-400)}`);
604
+ audioInfo = { included: true, sampleRate: finalAudio.sampleRate, voiceover, voiceClips: clips, duckDb: clips.length > 0 ? duckDb : null };
605
+ if (voiceover && project && project.clips.length === 0 && withNarration)
606
+ warnings.push("The beats have narration but no clips were found under public/audio/vo/, so the video has the score alone. Run generate_narration first.");
607
+ }
608
+ else {
609
+ fs.copyFileSync(tmpVideo, r.outPath);
610
+ }
611
+ await opened.page.close().catch(() => undefined);
612
+ return {
613
+ success: true,
614
+ outPath: r.outPath,
615
+ width: r.width,
616
+ height: r.height,
617
+ fps: r.fps,
618
+ durationSeconds: opened.durationSeconds,
619
+ frames,
620
+ frameHashes,
621
+ audio: audioInfo,
622
+ captions: { burned: captionsOn, chunks: captionsOn ? project.chunks.length : 0 },
623
+ warnings,
624
+ findings,
625
+ };
626
+ }
627
+ finally {
628
+ await browser.close().catch(() => undefined);
629
+ fs.rmSync(scratch, { recursive: true, force: true });
630
+ }
631
+ }
632
+ // ---------------------------------------------------------------------------------------------
633
+ // Standalone
634
+ function argValue(args, name) {
635
+ const i = args.indexOf(`--${name}`);
636
+ return i >= 0 ? args[i + 1] : undefined;
637
+ }
638
+ if (require.main === module) {
639
+ const args = process.argv.slice(2);
640
+ const html = argValue(args, "html");
641
+ const out = argValue(args, "out");
642
+ if (!html || !out) {
643
+ process.stderr.write("usage: node canvasExplainer.js --html explainer.html --out out.mp4 [--width 1280] [--height 720] [--fps 30] [--video name] [--project dir] [--silent] [--no-validate]\n");
644
+ process.exit(2);
645
+ }
646
+ const num = (n) => (argValue(args, n) !== undefined ? Number(argValue(args, n)) : undefined);
647
+ runCanvasExplainer({
648
+ projectRoot: argValue(args, "project"),
649
+ htmlPath: html,
650
+ outPath: out,
651
+ width: num("width"),
652
+ height: num("height"),
653
+ fps: num("fps"),
654
+ videoName: argValue(args, "video"),
655
+ audio: args.includes("--silent") ? false : undefined,
656
+ validate: !args.includes("--no-validate"),
657
+ }).then((result) => process.stdout.write(`${JSON.stringify(result, null, 2)}\n`), (err) => {
658
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
659
+ process.exit(1);
660
+ });
661
+ }