@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,68 @@
1
+ /** With no caption element in the frame, the bottom of the frame is assumed to hold the captions: the Reels safe zone. */
2
+ export declare const DEFAULT_CAPTION_BAND = 0.22;
3
+ export interface ArcThresholds {
4
+ /** The first drawn element after this many seconds is a warning. */
5
+ diagramWarnSeconds: number;
6
+ /** After this many seconds it is an error. */
7
+ diagramErrorSeconds: number;
8
+ /** A run of identical sampled frames longer than this is a warning. */
9
+ staticSeconds: number;
10
+ }
11
+ export declare const DEFAULT_ARC_THRESHOLDS: ArcThresholds;
12
+ /** A rectangle in frame pixels. */
13
+ export interface Rect {
14
+ x: number;
15
+ y: number;
16
+ width: number;
17
+ height: number;
18
+ }
19
+ /** What one sampled frame contributes to the arc checks. */
20
+ export interface ArcSample {
21
+ /** Seconds from the start of the lesson. */
22
+ time: number;
23
+ beatId: string;
24
+ /** Share of the unmasked pixels drawn beyond the background, 0 to 1. */
25
+ drawn: number;
26
+ /** Share of the unmasked pixels that moved since the previous sample, or null for the first one. */
27
+ changedFromPrevious: number | null;
28
+ }
29
+ /** What is kept of a frame so the next one can be compared with it. */
30
+ export interface ArcSignature {
31
+ data: Buffer;
32
+ mask: Uint8Array;
33
+ width: number;
34
+ height: number;
35
+ }
36
+ export interface ArcFinding {
37
+ beatId: string;
38
+ check: "diagram-late" | "static-stretch";
39
+ severity: "warning" | "error";
40
+ message: string;
41
+ }
42
+ /** Rectangles to ignore in a frame: the captions' own boxes when the page has them, otherwise the bottom band. */
43
+ export declare function captionMaskRects(found: Rect[], frame: {
44
+ width: number;
45
+ height: number;
46
+ }): Rect[];
47
+ /** Pure: the share of unmasked pixels that sit further than DRAWN_DISTANCE from the frame's background colour. */
48
+ export declare function drawnShare(data: Buffer, mask: Uint8Array, width: number, height: number): number;
49
+ /** Pure: the share of pixels, unmasked in both frames, that moved by more than MOVED_DISTANCE. */
50
+ export declare function movedShare(a: ArcSignature, b: ArcSignature): number;
51
+ /**
52
+ * Reads one screenshot into what the arc checks need. `previous` is the signature of the sample before it, so
53
+ * the caller keeps one frame in memory, not all of them.
54
+ */
55
+ export declare function measureArcFrame(shot: string, captionRects: Rect[], previous: ArcSignature | null): Promise<{
56
+ drawn: number;
57
+ changedFromPrevious: number | null;
58
+ signature: ArcSignature;
59
+ }>;
60
+ /**
61
+ * Pure: the findings, given the samples in lesson order and the lesson's length.
62
+ *
63
+ * `diagram-late` reads the first sampled frame with anything drawn. `static-stretch` finds each run of sampled
64
+ * frames that did not change, and estimates how long the hold really lasted: the true edges lie between a held
65
+ * sample and its changed neighbour, so each is taken as the midpoint, and the lesson's own start and end bound a
66
+ * run that touches them. A 6 s hold sampled twice is therefore still about 6 s, not the 4 s between its samples.
67
+ */
68
+ export declare function judgeLessonArc(samples: ArcSample[], lessonSeconds: number, thresholds?: ArcThresholds): ArcFinding[];
@@ -0,0 +1,188 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.DEFAULT_ARC_THRESHOLDS = exports.DEFAULT_CAPTION_BAND = void 0;
7
+ exports.captionMaskRects = captionMaskRects;
8
+ exports.drawnShare = drawnShare;
9
+ exports.movedShare = movedShare;
10
+ exports.measureArcFrame = measureArcFrame;
11
+ exports.judgeLessonArc = judgeLessonArc;
12
+ const sharp_1 = __importDefault(require("sharp"));
13
+ /**
14
+ * The two rules of a lesson's shape (SCRIPT.md, "The arc") that are about the picture, not the words:
15
+ * the diagram is on screen early, and the viewer never waits long with nothing changing.
16
+ *
17
+ * Both are judged from the frames validate_scenes already samples in render mode, so there is no extra
18
+ * render pass. Each sampled frame is reduced to two numbers, taken with the caption region masked:
19
+ * how much of it is drawn beyond the background, and how much of it moved since the previous sample.
20
+ */
21
+ /** Frame width the "has anything been drawn" measure runs at: wide enough that a thin axis line survives. */
22
+ const CONTENT_WIDTH = 540;
23
+ /** Frame width two samples are compared at; averaging away encoder noise is the point of the small size. */
24
+ const SIGNATURE_WIDTH = 270;
25
+ /** A pixel counts as drawn when it differs from the frame's background colour by more than this in any channel. The Void's 5% hairline grid is about 13, so it does not count. */
26
+ const DRAWN_DISTANCE = 40;
27
+ /** Share of the unmasked pixels that must be drawn before a frame counts as having a diagram, about 250 pixels of a 540x960 frame. */
28
+ const DRAWN_MIN_SHARE = 0.0005;
29
+ /** Per-channel change that counts as a moved pixel, and the share of moved pixels below which two samples are identical (frameDiff's numbers). */
30
+ const MOVED_DISTANCE = 12;
31
+ const IDENTICAL_MAX_SHARE = 0.002;
32
+ /** With no caption element in the frame, the bottom of the frame is assumed to hold the captions: the Reels safe zone. */
33
+ exports.DEFAULT_CAPTION_BAND = 0.22;
34
+ exports.DEFAULT_ARC_THRESHOLDS = { diagramWarnSeconds: 15, diagramErrorSeconds: 25, staticSeconds: 8 };
35
+ /** Rectangles to ignore in a frame: the captions' own boxes when the page has them, otherwise the bottom band. */
36
+ function captionMaskRects(found, frame) {
37
+ if (found.length > 0)
38
+ return found;
39
+ const band = Math.round(frame.height * exports.DEFAULT_CAPTION_BAND);
40
+ return [{ x: 0, y: frame.height - band, width: frame.width, height: band }];
41
+ }
42
+ function maskFor(rects, scale, width, height) {
43
+ const mask = new Uint8Array(width * height);
44
+ for (const r of rects) {
45
+ const x0 = Math.max(0, Math.floor(r.x * scale));
46
+ const y0 = Math.max(0, Math.floor(r.y * scale));
47
+ const x1 = Math.min(width, Math.ceil((r.x + r.width) * scale));
48
+ const y1 = Math.min(height, Math.ceil((r.y + r.height) * scale));
49
+ for (let y = y0; y < y1; y++)
50
+ mask.fill(1, y * width + x0, y * width + x1);
51
+ }
52
+ return mask;
53
+ }
54
+ /** The background colour of a frame: the most common colour once each channel is rounded to a multiple of 8. */
55
+ function backgroundOf(data, mask, pixels) {
56
+ const counts = new Map();
57
+ let best = 0;
58
+ let bestCount = 0;
59
+ for (let p = 0; p < pixels; p++) {
60
+ if (mask[p])
61
+ continue;
62
+ const key = ((data[p * 3] >> 3) << 10) | ((data[p * 3 + 1] >> 3) << 5) | (data[p * 3 + 2] >> 3);
63
+ const n = (counts.get(key) ?? 0) + 1;
64
+ counts.set(key, n);
65
+ if (n > bestCount) {
66
+ best = key;
67
+ bestCount = n;
68
+ }
69
+ }
70
+ return [((best >> 10) & 31) * 8 + 4, ((best >> 5) & 31) * 8 + 4, (best & 31) * 8 + 4];
71
+ }
72
+ /** Pure: the share of unmasked pixels that sit further than DRAWN_DISTANCE from the frame's background colour. */
73
+ function drawnShare(data, mask, width, height) {
74
+ const pixels = width * height;
75
+ const [br, bg, bb] = backgroundOf(data, mask, pixels);
76
+ let considered = 0;
77
+ let drawn = 0;
78
+ for (let p = 0; p < pixels; p++) {
79
+ if (mask[p])
80
+ continue;
81
+ considered++;
82
+ const i = p * 3;
83
+ if (Math.max(Math.abs(data[i] - br), Math.abs(data[i + 1] - bg), Math.abs(data[i + 2] - bb)) > DRAWN_DISTANCE)
84
+ drawn++;
85
+ }
86
+ return considered === 0 ? 0 : drawn / considered;
87
+ }
88
+ /** Pure: the share of pixels, unmasked in both frames, that moved by more than MOVED_DISTANCE. */
89
+ function movedShare(a, b) {
90
+ if (a.width !== b.width || a.height !== b.height)
91
+ return 1;
92
+ const pixels = a.width * a.height;
93
+ let considered = 0;
94
+ let moved = 0;
95
+ for (let p = 0; p < pixels; p++) {
96
+ if (a.mask[p] || b.mask[p])
97
+ continue;
98
+ considered++;
99
+ const i = p * 3;
100
+ if (Math.abs(a.data[i] - b.data[i]) > MOVED_DISTANCE ||
101
+ Math.abs(a.data[i + 1] - b.data[i + 1]) > MOVED_DISTANCE ||
102
+ Math.abs(a.data[i + 2] - b.data[i + 2]) > MOVED_DISTANCE) {
103
+ moved++;
104
+ }
105
+ }
106
+ return considered === 0 ? 0 : moved / considered;
107
+ }
108
+ /**
109
+ * Reads one screenshot into what the arc checks need. `previous` is the signature of the sample before it, so
110
+ * the caller keeps one frame in memory, not all of them.
111
+ */
112
+ async function measureArcFrame(shot, captionRects, previous) {
113
+ const meta = await (0, sharp_1.default)(shot).metadata();
114
+ const frame = { width: meta.width ?? CONTENT_WIDTH, height: meta.height ?? CONTENT_WIDTH };
115
+ const rects = captionMaskRects(captionRects, frame);
116
+ const wide = await (0, sharp_1.default)(shot).removeAlpha().resize({ width: CONTENT_WIDTH }).raw().toBuffer({ resolveWithObject: true });
117
+ const wideMask = maskFor(rects, wide.info.width / frame.width, wide.info.width, wide.info.height);
118
+ const drawn = drawnShare(wide.data, wideMask, wide.info.width, wide.info.height);
119
+ const small = await (0, sharp_1.default)(shot).removeAlpha().resize({ width: SIGNATURE_WIDTH }).raw().toBuffer({ resolveWithObject: true });
120
+ const signature = {
121
+ data: small.data,
122
+ mask: maskFor(rects, small.info.width / frame.width, small.info.width, small.info.height),
123
+ width: small.info.width,
124
+ height: small.info.height,
125
+ };
126
+ return { drawn, changedFromPrevious: previous ? movedShare(previous, signature) : null, signature };
127
+ }
128
+ const seconds = (s) => `${Math.round(s * 10) / 10} s`;
129
+ /**
130
+ * Pure: the findings, given the samples in lesson order and the lesson's length.
131
+ *
132
+ * `diagram-late` reads the first sampled frame with anything drawn. `static-stretch` finds each run of sampled
133
+ * frames that did not change, and estimates how long the hold really lasted: the true edges lie between a held
134
+ * sample and its changed neighbour, so each is taken as the midpoint, and the lesson's own start and end bound a
135
+ * run that touches them. A 6 s hold sampled twice is therefore still about 6 s, not the 4 s between its samples.
136
+ */
137
+ function judgeLessonArc(samples, lessonSeconds, thresholds = exports.DEFAULT_ARC_THRESHOLDS) {
138
+ const findings = [];
139
+ if (samples.length === 0)
140
+ return findings;
141
+ const firstDrawn = samples.find((s) => s.drawn >= DRAWN_MIN_SHARE);
142
+ if (!firstDrawn) {
143
+ findings.push({
144
+ beatId: "*",
145
+ check: "diagram-late",
146
+ severity: "error",
147
+ message: `No sampled frame has anything drawn beyond the background and the captions, so the diagram never ` +
148
+ `appears. The arc wants its first element on screen inside ${thresholds.diagramWarnSeconds} s.`,
149
+ });
150
+ }
151
+ else if (firstDrawn.time > thresholds.diagramWarnSeconds) {
152
+ const error = firstDrawn.time > thresholds.diagramErrorSeconds;
153
+ findings.push({
154
+ beatId: firstDrawn.beatId,
155
+ check: "diagram-late",
156
+ severity: error ? "error" : "warning",
157
+ message: `The first thing drawn beyond the background and the captions is in a frame ${seconds(firstDrawn.time)} into the ` +
158
+ `lesson. The arc wants the diagram's first element inside ${thresholds.diagramWarnSeconds} s (an error past ` +
159
+ `${thresholds.diagramErrorSeconds} s): a viewer facing an empty frame that long scrolls on.`,
160
+ });
161
+ }
162
+ let i = 1;
163
+ while (i < samples.length) {
164
+ const held = (n) => samples[n].changedFromPrevious !== null && samples[n].changedFromPrevious <= IDENTICAL_MAX_SHARE;
165
+ if (!held(i)) {
166
+ i++;
167
+ continue;
168
+ }
169
+ const first = i - 1;
170
+ let last = i;
171
+ while (last + 1 < samples.length && held(last + 1))
172
+ last++;
173
+ const start = first > 0 ? (samples[first - 1].time + samples[first].time) / 2 : 0;
174
+ const end = last < samples.length - 1 ? (samples[last].time + samples[last + 1].time) / 2 : lessonSeconds;
175
+ if (end - start > thresholds.staticSeconds) {
176
+ findings.push({
177
+ beatId: samples[first].beatId,
178
+ check: "static-stretch",
179
+ severity: "warning",
180
+ message: `Nothing changes for about ${seconds(end - start)} (from about ${seconds(start)} to ${seconds(end)}; frames sampled ` +
181
+ `across it match, captions aside). The arc wants something to move at least every ${thresholds.staticSeconds} s: ` +
182
+ `bring the next element in sooner, add a Spark move, or split the beat.`,
183
+ });
184
+ }
185
+ i = last + 1;
186
+ }
187
+ return findings;
188
+ }
package/dist/server.d.ts CHANGED
@@ -2,7 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import type { OpenvidstudioConfig } from "./config";
3
3
  export * from "./api";
4
4
  export declare const SERVER_NAME = "openvidstudio";
5
- export declare const SERVER_VERSION = "1.3.1";
5
+ export declare const SERVER_VERSION = "1.4.0";
6
6
  /**
7
7
  * Assembly only, mirrors pepiros/mcp/server.ts's shape: transport lives in the entry
8
8
  * point (stdio.ts), not here, so the same server instance could later be served over a
package/dist/server.js CHANGED
@@ -22,7 +22,7 @@ const mcp_1 = require("./tools/mcp");
22
22
  // The non-MCP entry points, for callers with no agent in the loop (CI, cron).
23
23
  __exportStar(require("./api"), exports);
24
24
  exports.SERVER_NAME = "openvidstudio";
25
- exports.SERVER_VERSION = "1.3.1";
25
+ exports.SERVER_VERSION = "1.4.0";
26
26
  /**
27
27
  * Assembly only, mirrors pepiros/mcp/server.ts's shape: transport lives in the entry
28
28
  * point (stdio.ts), not here, so the same server instance could later be served over a
package/dist/stdio.js CHANGED
@@ -27,7 +27,15 @@ async function main() {
27
27
  await server.connect(transport);
28
28
  process.stderr.write("openvidstudio-mcp: connected over stdio\n");
29
29
  }
30
- main().catch((err) => {
31
- process.stderr.write(`openvidstudio-mcp: fatal: ${err instanceof Error ? err.stack : String(err)}\n`);
32
- process.exit(1);
33
- });
30
+ // `openvidstudio-mcp install <client>` writes the server into a client's MCP config and exits. It is
31
+ // the only mode that prints to stdout; a normal start (no arguments) never does.
32
+ if (process.argv[2] === "install") {
33
+ const { installCli } = require("./install");
34
+ process.exit(installCli(process.argv.slice(3)));
35
+ }
36
+ else {
37
+ main().catch((err) => {
38
+ process.stderr.write(`openvidstudio-mcp: fatal: ${err instanceof Error ? err.stack : String(err)}\n`);
39
+ process.exit(1);
40
+ });
41
+ }
@@ -25,12 +25,37 @@ export interface ConceptCard {
25
25
  referenceSpoken?: string;
26
26
  /** The longest the lesson may run, in seconds. Default 120. */
27
27
  maxSeconds?: number;
28
+ /**
29
+ * The lesson's shape. "seven-beat" (the default, and what a card without this field gets) is the arc in SCRIPT.md;
30
+ * "story" is the story-first arc: a real situation told as a problem, a narrator, the key formulas, the formulas
31
+ * applied to the story's numbers, one watch out, then a practice question. The story fields below are only read
32
+ * for "story".
33
+ */
34
+ arc?: "seven-beat" | "story";
35
+ /** Story arc: the one real-world scene the lesson opens on. The first beat has to name it. */
36
+ situation?: string;
37
+ /** Story arc: the narrator's name. */
38
+ character?: string;
39
+ /**
40
+ * Story arc: the key formulas, in the order they are stated. `tex` is what is on screen, `spoken` how it is said
41
+ * (it has to occur in the narration, in this order), `source` where it comes from (a syllabus booklet, a derivation).
42
+ */
43
+ formulas?: StoryFormula[];
44
+ /** Story arc: the original practice question. The last beat ends with it. */
45
+ practiceQuestion?: string;
46
+ /** Story arc: the practice question's answer. It is withheld from the narration (it goes in the pinned comment). */
47
+ answer?: string;
48
+ }
49
+ export interface StoryFormula {
50
+ tex: string;
51
+ spoken: string;
52
+ source: string;
28
53
  }
29
54
  export type ScriptFindingSeverity = "error" | "warning" | "note";
30
55
  export interface ScriptFinding {
31
56
  severity: ScriptFindingSeverity;
32
57
  /** Which check produced it. */
33
- check: "sentence-length" | "reading-level" | "glossary" | "new-terms" | "spoken" | "hook" | "pace" | "closing-question" | "length" | "reference";
58
+ check: "sentence-length" | "reading-level" | "glossary" | "new-terms" | "spoken" | "hook" | "pace" | "closing-question" | "length" | "reference" | "story-situation" | "story-formula" | "story-practice" | "story-comment" | "story-subscribe" | "story-answer";
34
59
  beatId: string;
35
60
  message: string;
36
61
  }
@@ -69,6 +94,11 @@ export declare function syllables(word: string): number;
69
94
  export declare function fleschKincaidGrade(text: string): number;
70
95
  /** The `spoken` strings of the Equations in a scene's source. */
71
96
  export declare function spokenStrings(source: string): string[];
97
+ /**
98
+ * The `spoken` strings of each `<Derivation>` in a scene's source, in step order, one list per Derivation. The steps
99
+ * may be written inline (`steps={[{ tex, spoken }, ...]}`) or as a constant in the same file (`steps={STEPS}`).
100
+ */
101
+ export declare function derivationSpoken(source: string): string[][];
72
102
  export declare function checkScriptLogic(beatsJson: unknown, card: ConceptCard, readScene?: (beat: BeatLike, id: string) => string | null): CheckScriptResult;
73
103
  export declare function runCheckScript(input: CheckScriptInput): CheckScriptResult;
74
104
  export declare function registerCheckScript(server: McpServer): void;
@@ -38,6 +38,7 @@ exports.splitSentences = splitSentences;
38
38
  exports.syllables = syllables;
39
39
  exports.fleschKincaidGrade = fleschKincaidGrade;
40
40
  exports.spokenStrings = spokenStrings;
41
+ exports.derivationSpoken = derivationSpoken;
41
42
  exports.checkScriptLogic = checkScriptLogic;
42
43
  exports.runCheckScript = runCheckScript;
43
44
  exports.registerCheckScript = registerCheckScript;
@@ -83,6 +84,11 @@ function fleschKincaidGrade(text) {
83
84
  return 0.39 * (words.length / sentences.length) + 11.8 * (syl / words.length) - 15.59;
84
85
  }
85
86
  const normalise = (text) => text.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim().replace(/\s+/g, " ");
87
+ const STOP_WORDS = new Set("the and for with that this from have has had was were are but not you your his her its our their they them then than into onto over under about what when where which while will would could should can did does out off one all any too very just also more most some such only own same each both few other how why who whom".split(" "));
88
+ /** The distinct content words of a text: normalised, three letters or more, no stop words. */
89
+ function contentWords(text) {
90
+ return new Set(normalise(text).split(" ").filter((w) => w.length >= 3 && !STOP_WORDS.has(w)));
91
+ }
86
92
  const NUMBER_WORDS = /\b(zero|one|two|three|four|five|six|seven|eight|nine|ten|hundred|thousand|million|billion|half|double|twice)\b/i;
87
93
  const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
88
94
  const wordRe = (term) => new RegExp(`(?<![A-Za-z0-9])${escapeRe(term)}(?![A-Za-z0-9])`, "i");
@@ -105,12 +111,145 @@ function spokenStrings(source) {
105
111
  out.push(m[1] ?? m[2] ?? m[3] ?? m[4] ?? m[5] ?? "");
106
112
  return out;
107
113
  }
114
+ /** The text between the bracket at `open` and its match, strings respected, or null if it never closes. */
115
+ function balanced(source, open) {
116
+ const pairs = { "[": "]", "{": "}", "(": ")" };
117
+ const stack = [];
118
+ let quote = null;
119
+ for (let i = open; i < source.length; i++) {
120
+ const ch = source[i];
121
+ if (quote) {
122
+ if (ch === "\\")
123
+ i++;
124
+ else if (ch === quote)
125
+ quote = null;
126
+ continue;
127
+ }
128
+ if (ch === '"' || ch === "'" || ch === "`")
129
+ quote = ch;
130
+ else if (ch in pairs)
131
+ stack.push(pairs[ch]);
132
+ else if (ch === stack[stack.length - 1]) {
133
+ stack.pop();
134
+ if (stack.length === 0)
135
+ return source.slice(open, i + 1);
136
+ }
137
+ }
138
+ return null;
139
+ }
140
+ /**
141
+ * The `spoken` strings of each `<Derivation>` in a scene's source, in step order, one list per Derivation. The steps
142
+ * may be written inline (`steps={[{ tex, spoken }, ...]}`) or as a constant in the same file (`steps={STEPS}`).
143
+ */
144
+ function derivationSpoken(source) {
145
+ const out = [];
146
+ for (const tag of source.matchAll(/<Derivation\b/g)) {
147
+ const open = tag.index ?? 0;
148
+ const props = source.slice(open, open + 20000);
149
+ const steps = /\bsteps\s*=\s*\{/.exec(props);
150
+ if (!steps)
151
+ continue;
152
+ const braceAt = open + steps.index + steps[0].length - 1;
153
+ const inner = balanced(source, braceAt);
154
+ if (inner === null)
155
+ continue;
156
+ let list = inner;
157
+ const ident = /^\{\s*([A-Za-z_$][\w$]*)\s*\}$/.exec(inner);
158
+ if (ident) {
159
+ const decl = new RegExp(`\\b${ident[1]}\\b\\s*(?::[^=]+)?=\\s*\\[`).exec(source);
160
+ list = decl ? balanced(source, decl.index + decl[0].length - 1) : null;
161
+ }
162
+ if (list === null)
163
+ continue;
164
+ const found = [];
165
+ for (const m of list.matchAll(/\bspoken\s*:\s*(?:"([^"]*)"|'([^']*)'|`([^`]*)`)/g))
166
+ found.push(m[1] ?? m[2] ?? m[3] ?? "");
167
+ out.push(found);
168
+ }
169
+ return out;
170
+ }
171
+ /**
172
+ * The checks for the story arc. They look at the narration as a whole and at its first and last beat, and never
173
+ * count beats, so a story lesson may take as many beats as it needs.
174
+ */
175
+ function checkStoryArc(spoken, card, add) {
176
+ if (spoken.length === 0)
177
+ return;
178
+ const idOf = (beat, i) => String(beat.id ?? `beat ${i + 1}`);
179
+ const first = spoken[0];
180
+ const last = spoken[spoken.length - 1];
181
+ const lastId = idOf(last, spoken.length - 1);
182
+ // beat 1 names the situation: it shares at least two content words with it
183
+ if (typeof card.situation !== "string" || card.situation.trim().length === 0) {
184
+ add("warning", "story-situation", idOf(first, 0), `The story arc needs a situation on the concept card, so the first beat cannot be checked for naming it.`);
185
+ }
186
+ else {
187
+ const wanted = contentWords(card.situation);
188
+ const said = contentWords(first.vo);
189
+ const shared = [...wanted].filter((w) => said.has(w));
190
+ const need = Math.min(2, wanted.size);
191
+ if (shared.length < need) {
192
+ add("error", "story-situation", idOf(first, 0), `The first beat shares ${shared.length} content word${shared.length === 1 ? "" : "s"} with the situation "${card.situation}", and the story arc needs ${need}. Open on the situation, told as a problem.`);
193
+ }
194
+ }
195
+ // each formula's spoken form is in the narration, in the order the card gives them
196
+ const beatText = spoken.map((b) => normalise(b.vo));
197
+ const heard = beatText.join(" ");
198
+ const starts = [];
199
+ beatText.reduce((at, t) => (starts.push(at), at + t.length + 1), 0);
200
+ const beatAt = (pos) => {
201
+ let k = 0;
202
+ while (k + 1 < starts.length && starts[k + 1] <= pos)
203
+ k++;
204
+ return idOf(spoken[k], k);
205
+ };
206
+ let cursor = 0;
207
+ (card.formulas ?? []).forEach((f, k) => {
208
+ if (typeof f?.spoken !== "string" || normalise(f.spoken).length === 0)
209
+ return;
210
+ const phrase = normalise(f.spoken);
211
+ const at = heard.indexOf(phrase, cursor);
212
+ if (at >= 0) {
213
+ cursor = at + phrase.length;
214
+ }
215
+ else if (heard.includes(phrase)) {
216
+ add("error", "story-formula", beatAt(heard.indexOf(phrase)), `Formula ${k + 1} ("${f.spoken}") is said before the formula above it. The card lists the formulas in the order they are stated, so the narration has to say them in that order.`);
217
+ }
218
+ else {
219
+ add("error", "story-formula", idOf(first, 0), `Formula ${k + 1}, spoken as "${f.spoken}", is not in the narration. The voice has to say each key formula the way the card spells it.`);
220
+ }
221
+ });
222
+ // the last beat ends with the practice question and asks for an answer in the comments
223
+ if (typeof card.practiceQuestion !== "string" || normalise(card.practiceQuestion).length === 0) {
224
+ add("warning", "story-practice", lastId, `The story arc needs a practiceQuestion on the concept card, so the last beat cannot be checked for ending with it.`);
225
+ }
226
+ else if (!normalise(last.vo).endsWith(normalise(card.practiceQuestion))) {
227
+ add("error", "story-practice", lastId, `The last beat does not end with the practice question "${card.practiceQuestion}". End on the question and let the last frame hold.`);
228
+ }
229
+ if (!/comment/i.test(last.vo)) {
230
+ add("error", "story-comment", lastId, `The last beat never asks the viewer to answer in the comments. Add a prompt such as "put your answer in the comments".`);
231
+ }
232
+ // "like and subscribe" anywhere is a warning
233
+ spoken.forEach((b, i) => {
234
+ if (/\blike\s*(?:,|and|&)?\s*(?:and\s+)?subscribe\b/i.test(b.vo)) {
235
+ add("warning", "story-subscribe", idOf(b, i), `This beat says "like and subscribe". The lesson ends on its question and the prompt to comment, nothing else.`);
236
+ }
237
+ });
238
+ // the answer is withheld until the pinned comment
239
+ if (typeof card.answer === "string" && normalise(card.answer).length > 0) {
240
+ const at = beatText.findIndex((t) => ` ${t} `.includes(` ${normalise(card.answer)} `));
241
+ if (at >= 0) {
242
+ add("warning", "story-answer", idOf(spoken[at], at), `The answer "${card.answer}" is in the narration. Withhold it: it belongs in the pinned comment, not in the video.`);
243
+ }
244
+ }
245
+ }
108
246
  function checkScriptLogic(beatsJson, card, readScene = () => null) {
109
247
  const findings = [];
110
248
  const root = (typeof beatsJson === "object" && beatsJson !== null ? beatsJson : {});
111
249
  const fps = typeof root.fps === "number" && root.fps > 0 ? root.fps : 30;
112
250
  const beats = (Array.isArray(root.beats) ? root.beats : []);
113
251
  const add = (severity, check, beatId, message) => findings.push({ severity, check, beatId, message });
252
+ const story = card.arc === "story";
114
253
  const seen = new Set();
115
254
  const spoken = beats.filter((b) => typeof b.vo === "string" && b.vo.length > 0);
116
255
  spoken.forEach((beat, index) => {
@@ -160,6 +299,23 @@ function checkScriptLogic(beatsJson, card, readScene = () => null) {
160
299
  add("error", "spoken", id, `The equation's spoken form "${said}" is not in this beat's narration. The voice and the equation must say the same thing.`);
161
300
  }
162
301
  }
302
+ // A Derivation's steps are said in order: each one after the one before it.
303
+ for (const steps of derivationSpoken(source)) {
304
+ let cursor = 0;
305
+ steps.forEach((said, k) => {
306
+ const phrase = normalise(said);
307
+ const at = heard.indexOf(phrase, cursor);
308
+ if (at >= 0) {
309
+ cursor = at + phrase.length;
310
+ }
311
+ else if (heard.includes(phrase)) {
312
+ add("error", "spoken", id, `The derivation's step ${k + 1} ("${said}") is said in this beat's narration, but before the step above it. The steps are revealed in order, so the voice has to say them in that order.`);
313
+ }
314
+ else {
315
+ add("error", "spoken", id, `The derivation's step ${k + 1} spoken form "${said}" is not in this beat's narration. The voice and the formula must say the same thing.`);
316
+ }
317
+ });
318
+ }
163
319
  }
164
320
  });
165
321
  // terms the card names that the narration never mentions
@@ -192,18 +348,20 @@ function checkScriptLogic(beatsJson, card, readScene = () => null) {
192
348
  if (/^\s*in this video\b/i.test(lead)) {
193
349
  add("error", "hook", id, `The script opens "in this video". Open on a question or a surprising fact, not a promise of what is coming.`);
194
350
  }
195
- else if (!lead.trim().endsWith("?") && !/\d/.test(lead) && !NUMBER_WORDS.test(lead)) {
351
+ else if (!story && !lead.trim().endsWith("?") && !/\d/.test(lead) && !NUMBER_WORDS.test(lead)) {
196
352
  add("warning", "hook", id, `The first sentence is neither a question nor does it hold a number. The hook is a question or a surprising fact.`);
197
353
  }
198
354
  }
199
- // the end: a check question
200
- if (spoken.length > 0) {
355
+ // the end: a check question (a story lesson ends on its practice question instead, checked below)
356
+ if (!story && spoken.length > 0) {
201
357
  const last = spoken[spoken.length - 1];
202
358
  const lastSentence = splitSentences(last.vo).slice(-1)[0] ?? "";
203
359
  if (!lastSentence.trim().endsWith("?")) {
204
360
  add("error", "closing-question", String(last.id ?? "last beat"), `The last beat does not end with a question. End with a one-line recap, then a check question the viewer can answer in their head.`);
205
361
  }
206
362
  }
363
+ if (story)
364
+ checkStoryArc(spoken, card, add);
207
365
  return { ok: !findings.some((f) => f.severity === "error"), beatsChecked: spoken.length, findings };
208
366
  }
209
367
  function runCheckScript(input) {
@@ -226,7 +384,10 @@ function runCheckScript(input) {
226
384
  card = JSON.parse(fs.readFileSync(file, "utf8"));
227
385
  }
228
386
  if (typeof card !== "object" || card === null || typeof card.goal !== "string" || !Array.isArray(card.terms) || typeof card.example !== "string") {
229
- throw new Error("The concept card must be { goal: string, terms: string[], example: string } with the optional reference, referenceSpoken and maxSeconds.");
387
+ throw new Error("The concept card must be { goal: string, terms: string[], example: string } with the optional reference, referenceSpoken, maxSeconds and, for the story arc, arc, situation, character, formulas, practiceQuestion and answer.");
388
+ }
389
+ if (card.arc !== undefined && card.arc !== "seven-beat" && card.arc !== "story") {
390
+ throw new Error(`The concept card's arc must be "seven-beat" or "story", not ${JSON.stringify(card.arc)}.`);
230
391
  }
231
392
  const readScene = (beat, id) => {
232
393
  const rel = typeof beat.scene === "string" && beat.scene.length > 0 ? beat.scene : path.join("scenes", `${(0, util_1.pascalCase)(id)}.tsx`);
@@ -247,17 +408,24 @@ function registerCheckScript(server) {
247
408
  title: "Lint a lesson script against its concept card",
248
409
  description: "Mechanical lint for a Supernova lesson script, no model call: it counts and matches patterns, so it can " +
249
410
  "say a sentence is long or a term was never defined, not that the explanation is good. Reads " +
250
- "src/videos/<videoName>/beats.json and concept.json ({ goal, terms: string[], example, reference?, referenceSpoken?, maxSeconds? }, next to it), or the " +
411
+ "src/videos/<videoName>/beats.json and concept.json ({ goal, terms: string[], example, reference?, referenceSpoken?, maxSeconds?, arc?, situation?, character?, formulas?, practiceQuestion?, answer? }, next to it), or the " +
251
412
  "beatsJson and conceptCard inputs. Returns { ok, beatsChecked, findings } where each finding has a severity " +
252
413
  "(error, warning or note), the check, and the beat it points at. Checks: average sentence length per beat " +
253
414
  "(warning above 16 words), Flesch-Kincaid reading level per beat (warning above grade 9), each concept-card " +
254
415
  "term used with a definition beside it at first mention (note), more than two new terms in a beat (warning), " +
255
- "every Equation's `spoken` string present in its beat's narration (error; the scene is read from the beat's " +
416
+ "every Equation's `spoken` string present in its beat's narration, and every step of a Derivation present and in the order " +
417
+ "the steps are given (error; the scene is read from the beat's " +
256
418
  "scene path or scenes/<BeatId>.tsx), a hook in the first sentence that is a question or holds a number and " +
257
419
  "does not open \"in this video\" (error for that opener, warning otherwise), words per second against the " +
258
420
  "beat length using validate_beats' own budget (error), a last beat that ends with a question (error), the " +
259
421
  "total length against the card's maxSeconds (default 120, error), and, when the card has a reference such as a " +
260
422
  "chapter code, that the first beat's narration says its referenceSpoken (error; warning if referenceSpoken is missing). " +
423
+ "The card's `arc` is \"seven-beat\" (the default) or \"story\". For \"story\" (situation, character, formulas: [{ tex, spoken, source }], " +
424
+ "practiceQuestion, answer), the closing-question check and the hook's warning give way to these, none of which count beats: " +
425
+ "story-situation (the first beat shares at least two content words with the situation, error), story-formula (each formula's spoken form is in the " +
426
+ "narration, in the order given, error), story-practice (the last beat ends with the practice question, error), story-comment (the last beat " +
427
+ "contains a prompt matching /comment/i, error), story-subscribe (\"like and subscribe\" anywhere, warning) and story-answer (the answer is " +
428
+ "in the narration, warning); a missing situation or practiceQuestion is a warning. Length, reference and the per-beat checks apply to both arcs. " +
261
429
  "ok is false only when a finding is an error. See SCRIPT.md's lesson script rules.",
262
430
  inputSchema: {
263
431
  projectRoot: zod_1.z.string().optional(),
@@ -271,6 +439,15 @@ function registerCheckScript(server) {
271
439
  reference: zod_1.z.string().optional(),
272
440
  referenceSpoken: zod_1.z.string().optional(),
273
441
  maxSeconds: zod_1.z.number().positive().optional(),
442
+ arc: zod_1.z.enum(["seven-beat", "story"]).optional().describe('The lesson shape. Default "seven-beat".'),
443
+ situation: zod_1.z.string().optional().describe("Story arc: the real-world scene the first beat names."),
444
+ character: zod_1.z.string().optional().describe("Story arc: the narrator's name."),
445
+ formulas: zod_1.z
446
+ .array(zod_1.z.object({ tex: zod_1.z.string(), spoken: zod_1.z.string(), source: zod_1.z.string() }))
447
+ .optional()
448
+ .describe("Story arc: the key formulas in the order they are stated; each spoken form must be in the narration, in order."),
449
+ practiceQuestion: zod_1.z.string().optional().describe("Story arc: the question the last beat ends with."),
450
+ answer: zod_1.z.string().optional().describe("Story arc: the answer, which must not be in the narration."),
274
451
  })
275
452
  .optional()
276
453
  .describe("The concept card, instead of reading concept.json."),