@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,249 @@
1
+ import React from "react";
2
+ import { useCurrentFrame, useVideoConfig } from "remotion";
3
+ import { E, tween } from "@openvidstudio/core";
4
+ import { SUBJECT_ACCENT, VOID, bodyStack, displayStack } from "./designSystem";
5
+ import { lineWeight } from "./graph";
6
+ import { Spark } from "./Spark";
7
+ import { walk as walkPath, type Point } from "./paths";
8
+
9
+ export type GraphRole = "unvisited" | "frontier" | "visiting" | "done" | "path";
10
+ export const GRAPH_ROLES: readonly GraphRole[] = ["unvisited", "frontier", "visiting", "done", "path"];
11
+
12
+ /** The fill of each role: dim, accent, warm white, a second accent, ember. Text on a filled node is the void colour. */
13
+ export const GRAPH_ROLE_COLOR: Record<GraphRole, string> = {
14
+ unvisited: VOID.panel,
15
+ frontier: SUBJECT_ACCENT.cs.accent,
16
+ visiting: VOID.text,
17
+ done: SUBJECT_ACCENT.math.accent,
18
+ path: SUBJECT_ACCENT.physics.accentAlt,
19
+ };
20
+
21
+ export interface GraphNode {
22
+ id: string;
23
+ /** Fractions of the layout box, 0..1. Give all nodes both, or none (then they sit on a circle). */
24
+ x?: number;
25
+ y?: number;
26
+ /** Text inside the node. Default: the id. */
27
+ label?: string;
28
+ }
29
+
30
+ /** A snapshot from `frame` on: the latest step at or before the current frame says every node's role. */
31
+ export interface GraphStep {
32
+ frame: number;
33
+ visit?: string;
34
+ frontier?: string[];
35
+ done?: string[];
36
+ path?: string[];
37
+ }
38
+
39
+ export interface GraphVizProps {
40
+ nodes: GraphNode[];
41
+ /** `[from, to]` pairs of node ids. */
42
+ edges: [string, string][];
43
+ directed?: boolean;
44
+ steps?: GraphStep[];
45
+ /** An ordered list of node ids a single Spark hops along. */
46
+ walk?: string[];
47
+ /** Frames to hop, and to rest on each node. Defaults 20 and 20. */
48
+ hop?: number;
49
+ hold?: number;
50
+ /** Frame the Spark starts, default `at`. */
51
+ walkAt?: number;
52
+ /** Frame the graph appears. Default 0. */
53
+ at?: number;
54
+ /** The layout box in px. Default: 80% of the width, from 17% down, 45% of the height. */
55
+ box?: { x: number; y: number; w: number; h: number };
56
+ /** Node radius, px. Default 4.5% of the shorter side. */
57
+ radius?: number;
58
+ /** Draw the legend of roles. Default true. */
59
+ legend?: boolean;
60
+ }
61
+
62
+ export type Adjacency = Record<string, string[]>;
63
+
64
+ /** Adjacency lists from an edge list. Undirected unless `directed`. Neighbours keep edge order. */
65
+ export function adjacencyFrom(edges: [string, string][], directed = false, ids: string[] = []): Adjacency {
66
+ const adj: Adjacency = {};
67
+ for (const id of ids) adj[id] = [];
68
+ for (const [a, b] of edges) {
69
+ (adj[a] ??= []).push(b);
70
+ (adj[b] ??= []);
71
+ if (!directed) adj[b].push(a);
72
+ }
73
+ return adj;
74
+ }
75
+
76
+ /** Breadth-first visiting order from `start`, neighbours taken in their listed order. */
77
+ export function bfsOrder(adjacency: Adjacency, start: string): string[] {
78
+ const seen = new Set([start]);
79
+ const order: string[] = [];
80
+ const queue = [start];
81
+ for (let i = 0; i < queue.length; i++) {
82
+ const v = queue[i];
83
+ order.push(v);
84
+ for (const w of adjacency[v] ?? []) if (!seen.has(w)) { seen.add(w); queue.push(w); }
85
+ }
86
+ return order;
87
+ }
88
+
89
+ /** A shortest path (fewest edges) from `from` to `to`, both included, or null when unreachable. */
90
+ export function shortestPath(adjacency: Adjacency, from: string, to: string): string[] | null {
91
+ const parent = new Map<string, string | null>([[from, null]]);
92
+ const queue = [from];
93
+ for (let i = 0; i < queue.length; i++) {
94
+ const v = queue[i];
95
+ if (v === to) break;
96
+ for (const w of adjacency[v] ?? []) if (!parent.has(w)) { parent.set(w, v); queue.push(w); }
97
+ }
98
+ if (!parent.has(to)) return null;
99
+ const path = [to];
100
+ while (parent.get(path[0]) != null) path.unshift(parent.get(path[0]) as string);
101
+ return path;
102
+ }
103
+
104
+ /**
105
+ * Steps for a real BFS: visiting the k-th node of the order at frame `first + k * every`, with
106
+ * the nodes already visited as done and the discovered-but-unvisited as the frontier. With `goal`,
107
+ * a last step `every` frames after shows the shortest path.
108
+ */
109
+ export function bfsSteps(adjacency: Adjacency, start: string, opts: { first?: number; every?: number; goal?: string } = {}): GraphStep[] {
110
+ const first = opts.first ?? 0;
111
+ const every = opts.every ?? 30;
112
+ const order = bfsOrder(adjacency, start);
113
+ const steps: GraphStep[] = order.map((visit, k) => {
114
+ const visited = new Set(order.slice(0, k + 1));
115
+ const frontier = order.slice(k + 1).filter((v) => order.slice(0, k + 1).some((u) => (adjacency[u] ?? []).includes(v)) && !visited.has(v));
116
+ return { frame: first + k * every, visit, done: order.slice(0, k), frontier };
117
+ });
118
+ if (opts.goal) {
119
+ const path = shortestPath(adjacency, start, opts.goal);
120
+ if (path) steps.push({ frame: first + order.length * every, path });
121
+ }
122
+ return steps;
123
+ }
124
+
125
+ export interface GraphLayout {
126
+ box: { x: number; y: number; w: number; h: number };
127
+ radius: number;
128
+ /** Node centres in px, by id. */
129
+ centers: Record<string, Point>;
130
+ }
131
+
132
+ /** Where the nodes sit. Fractional nodes: box.x + x * box.w. No fractions: evenly round a circle that fits the box. */
133
+ export function graphLayout(nodes: GraphNode[], width: number, height: number, opts: { box?: GraphVizProps["box"]; radius?: number } = {}): GraphLayout {
134
+ const box = opts.box ?? { x: Math.round(width * 0.1), y: Math.round(height * 0.17), w: Math.round(width * 0.8), h: Math.round(height * 0.45) };
135
+ const radius = opts.radius ?? Math.round(Math.min(width, height) * 0.045);
136
+ const centers: Record<string, Point> = {};
137
+ const placed = nodes.every((n) => n.x !== undefined && n.y !== undefined);
138
+ nodes.forEach((n, i) => {
139
+ if (placed) centers[n.id] = { x: box.x + (n.x as number) * box.w, y: box.y + (n.y as number) * box.h };
140
+ else {
141
+ const a = -Math.PI / 2 + (2 * Math.PI * i) / nodes.length;
142
+ const rr = Math.min(box.w, box.h) / 2 - radius;
143
+ centers[n.id] = { x: box.x + box.w / 2 + rr * Math.cos(a), y: box.y + box.h / 2 + rr * Math.sin(a) };
144
+ }
145
+ });
146
+ return { box, radius, centers };
147
+ }
148
+
149
+ const roleAt = (steps: GraphStep[], frame: number): { roles: Map<string, GraphRole>; path: string[] } => {
150
+ const roles = new Map<string, GraphRole>();
151
+ let current: GraphStep | undefined;
152
+ for (const s of steps) if (s.frame <= frame && (!current || s.frame >= current.frame)) current = s;
153
+ if (!current) return { roles, path: [] };
154
+ for (const id of current.frontier ?? []) roles.set(id, "frontier");
155
+ for (const id of current.done ?? []) roles.set(id, "done");
156
+ if (current.visit) roles.set(current.visit, "visiting");
157
+ for (const id of current.path ?? []) roles.set(id, "path");
158
+ return { roles, path: current.path ?? [] };
159
+ };
160
+
161
+ /**
162
+ * Nodes and edges, with node colours that say where a search has got to: dim until reached, violet
163
+ * on the frontier, warm white on the node being visited, blue once done, ember along the final
164
+ * path. The legend names the roles. A `walk` hops one Spark node to node; it is the only glowing
165
+ * thing. Pure in the frame; sizes from the composition.
166
+ */
167
+ export const GraphViz: React.FC<GraphVizProps> = ({ nodes, edges, directed = false, steps = [], walk, hop = 20, hold = 20, walkAt, at = 0, box, radius, legend = true }) => {
168
+ const frame = useCurrentFrame();
169
+ const { width, height } = useVideoConfig();
170
+ if (frame < at) return null;
171
+ const weight = lineWeight(width, height);
172
+ const layout = graphLayout(nodes, width, height, { box, radius });
173
+ const { centers, radius: r } = layout;
174
+ const fontPx = Math.round(r * 0.8);
175
+ const legendPx = Math.round(Math.min(width, height) * 0.027);
176
+ const opacity = tween(frame, [at, at + 12], [0, 1], E.cinematic);
177
+ const { roles, path } = roleAt(steps, frame);
178
+ const onPath = new Set<string>();
179
+ path.forEach((id, i) => { if (i > 0) onPath.add(`${path[i - 1]}|${id}`); });
180
+ const isPathEdge = (a: string, b: string) => onPath.has(`${a}|${b}`) || (!directed && onPath.has(`${b}|${a}`));
181
+ const head = Math.max(weight * 4, r * 0.35);
182
+
183
+ // legend rows, wrapped to the box width
184
+ const items = GRAPH_ROLES.map((role) => ({ role, w: legendPx * 0.9 + legendPx * 0.4 + role.length * legendPx * 0.62 + legendPx * 1.2 }));
185
+ const rows: (typeof items)[] = [[]];
186
+ let rowW = 0;
187
+ for (const it of items) {
188
+ if (rowW + it.w > layout.box.w && rows[rows.length - 1].length) { rows.push([]); rowW = 0; }
189
+ rows[rows.length - 1].push(it);
190
+ rowW += it.w;
191
+ }
192
+ const legendTop = layout.box.y + layout.box.h + Math.round(height * 0.05);
193
+
194
+ // The first node is repeated so that node k is rested on during local frames [k*(hop+hold)+hop, (k+1)*(hop+hold)].
195
+ const stops = walk?.map((id) => centers[id]).filter(Boolean) ?? [];
196
+ const walkPoints = stops.length ? [stops[0], ...stops] : [];
197
+
198
+ return (
199
+ <div style={{ position: "absolute", inset: 0, pointerEvents: "none" }} data-ovs-graphviz="">
200
+ <svg width={width} height={height} style={{ position: "absolute", left: 0, top: 0 }} opacity={opacity < 1 ? opacity : undefined}>
201
+ {edges.map(([a, b]) => {
202
+ const p = centers[a];
203
+ const q = centers[b];
204
+ if (!p || !q) return null;
205
+ const hot = isPathEdge(a, b);
206
+ const d = Math.hypot(q.x - p.x, q.y - p.y) || 1;
207
+ const ux = (q.x - p.x) / d;
208
+ const uy = (q.y - p.y) / d;
209
+ const end = directed ? { x: q.x - ux * r, y: q.y - uy * r } : q;
210
+ const color = hot ? GRAPH_ROLE_COLOR.path : VOID.textDim;
211
+ return (
212
+ <g key={`${a}-${b}`} data-ovs-edge={`${a}-${b}`} data-ovs-edge-hot={hot ? "1" : "0"}>
213
+ <line x1={p.x} y1={p.y} x2={end.x} y2={end.y} data-ovs-edge-line="" stroke={color} strokeWidth={hot ? weight * 2 : weight} strokeLinecap="round" />
214
+ {directed && <polygon points={`${end.x},${end.y} ${end.x - ux * head - uy * head * 0.55},${end.y - uy * head + ux * head * 0.55} ${end.x - ux * head + uy * head * 0.55},${end.y - uy * head - ux * head * 0.55}`} fill={color} />}
215
+ </g>
216
+ );
217
+ })}
218
+ {nodes.map((n) => {
219
+ const c = centers[n.id];
220
+ const role = roles.get(n.id) ?? "unvisited";
221
+ const fill = GRAPH_ROLE_COLOR[role];
222
+ const stroke = role === "unvisited" ? VOID.textDim : role === "visiting" ? SUBJECT_ACCENT.cs.accent : fill;
223
+ return (
224
+ <g key={n.id} data-ovs-node={n.id} data-ovs-role={role}>
225
+ <circle data-ovs-node-circle="" cx={c.x} cy={c.y} r={r - weight / 2} fill={fill} stroke={stroke} strokeWidth={weight} />
226
+ <text x={c.x} y={c.y + fontPx * 0.35} textAnchor="middle" fontSize={fontPx} fontWeight={700} fontFamily={displayStack()} fill={role === "unvisited" ? VOID.text : VOID.base}>{n.label ?? n.id}</text>
227
+ </g>
228
+ );
229
+ })}
230
+ {legend && rows.map((row, ri) => {
231
+ const total = row.reduce((s, it) => s + it.w, 0);
232
+ let x = layout.box.x + (layout.box.w - total) / 2;
233
+ const y = legendTop + ri * legendPx * 1.9;
234
+ return row.map((it) => {
235
+ const sx = x + legendPx * 0.45;
236
+ x += it.w;
237
+ return (
238
+ <g key={it.role} data-ovs-legend={it.role}>
239
+ <circle cx={sx} cy={y} r={legendPx * 0.45} fill={GRAPH_ROLE_COLOR[it.role]} stroke={it.role === "unvisited" ? VOID.textDim : GRAPH_ROLE_COLOR[it.role]} strokeWidth={weight} />
240
+ <text x={sx + legendPx * 0.8} y={y + legendPx * 0.35} fontSize={legendPx} fontFamily={bodyStack()} fill={VOID.textDim}>{it.role}</text>
241
+ </g>
242
+ );
243
+ });
244
+ })}
245
+ </svg>
246
+ {walkPoints.length > 0 && <Spark path={walkPath(walkPoints, { hop, hold })} at={Math.max(at, walkAt ?? at)} trail={10} size={Math.max(4, Math.round(r * 0.3))} subject="cs" />}
247
+ </div>
248
+ );
249
+ };
@@ -0,0 +1,135 @@
1
+ import React from "react";
2
+ import { useCurrentFrame, useVideoConfig } from "remotion";
3
+ import { useNarrationWords } from "@openvidstudio/core";
4
+ import { SUBJECT_ACCENT, VOID, bodyStack, type Subject } from "./designSystem";
5
+
6
+ export interface LessonCaptionsProps {
7
+ /** The beat these captions belong to: its word timings are read with `useNarrationWords(beatId)`. */
8
+ beatId: string;
9
+ /** The beat's narration text, its `vo`. The word timings carry bare words, so the punctuation comes from here. */
10
+ text: string;
11
+ /** Where the block sits: above the bottom safe zone, or below the top one. Default "bottom". */
12
+ position?: "bottom" | "top";
13
+ /** Most words in one caption. Default 6. */
14
+ maxWords?: number;
15
+ /** Left edge in px. Default 10% of the width. */
16
+ left?: number;
17
+ /** Width in px. Default: the frame minus the left edge on both sides. */
18
+ width?: number;
19
+ /** Picks the colour of the word being said. Default "math". */
20
+ subject?: Subject;
21
+ /** Font size in px. Default 4.8% of the shorter side. */
22
+ fontSize?: number;
23
+ }
24
+
25
+ /** A caption never starts or stops mid-sentence: a sentence end always breaks, a comma only after this many words. */
26
+ const COMMA_BREAK_MIN_WORDS = 3;
27
+ const SENTENCE_END = /[.?!:…]["')\]]*$/;
28
+ const CLAUSE_END = /[,;]["')\]]*$/;
29
+
30
+ /**
31
+ * Pure: where each caption starts and ends, as `[from, to)` ranges over the words. A caption breaks after a
32
+ * sentence end, after a comma or semicolon once it holds at least three words, and at `maxWords`. A one word
33
+ * sentence ("Why?") is a caption on its own.
34
+ */
35
+ export function captionChunks(words: string[], maxWords = 6): [number, number][] {
36
+ const limit = Math.max(1, Math.floor(maxWords));
37
+ const chunks: [number, number][] = [];
38
+ let from = 0;
39
+ for (let i = 0; i < words.length; i++) {
40
+ const size = i - from + 1;
41
+ const last = i === words.length - 1;
42
+ if (last || SENTENCE_END.test(words[i]) || (CLAUSE_END.test(words[i]) && size >= COMMA_BREAK_MIN_WORDS) || size >= limit) {
43
+ chunks.push([from, i + 1]);
44
+ from = i + 1;
45
+ }
46
+ }
47
+ return chunks;
48
+ }
49
+
50
+ /** The share of the frame's height kept clear at the bottom and top: Instagram's interface over a vertical reel, a small margin otherwise. */
51
+ export function captionInsets(width: number, height: number): { top: number; bottom: number } {
52
+ return height > width ? { top: Math.round(height * 0.14), bottom: Math.round(height * 0.22) } : { top: Math.round(height * 0.08), bottom: Math.round(height * 0.08) };
53
+ }
54
+
55
+ /**
56
+ * Burned-in captions for a lesson, timed to the narration: the words of the beat's `vo` in short chunks that
57
+ * break at sentence ends, with the word being said picked out. One line in a scene, with no dependency on the
58
+ * scene file:
59
+ *
60
+ * <LessonCaptions beatId="hook" text={vo} />
61
+ *
62
+ * Reads `useNarrationWords(beatId)`. When the text splits into as many words as the timings hold it shows the text
63
+ * with its punctuation; when the counts differ it falls back to the timings' bare words. With no timings at all
64
+ * (an engine that gives none) the words are spread evenly over the scene and the element says so in
65
+ * `data-ovs-captions-timing="estimated"`. The block is tagged `data-ovs-captions`, which validate_scenes uses to
66
+ * ignore the captions when it looks for a diagram or a static stretch. A pure function of the frame.
67
+ */
68
+ export const LessonCaptions: React.FC<LessonCaptionsProps> = ({ beatId, text, position = "bottom", maxWords = 6, left, width: boxWidth, subject = "math", fontSize }) => {
69
+ const frame = useCurrentFrame();
70
+ const { width, height, durationInFrames } = useVideoConfig();
71
+ const narration = useNarrationWords(beatId);
72
+ const tokens = text.split(/\s+/).filter(Boolean);
73
+ const timed = narration.available;
74
+ const words = timed ? (tokens.length === narration.words.length ? tokens : narration.words.map((w) => w.word)) : tokens;
75
+ const timing = !timed ? "estimated" : tokens.length === narration.words.length ? "matched" : "bare";
76
+
77
+ const spoken = timed ? narration.wordAt(frame)?.index ?? -1 : words.length === 0 ? -1 : Math.min(words.length - 1, Math.floor((frame / Math.max(1, durationInFrames)) * words.length));
78
+ const chunks = captionChunks(words, maxWords);
79
+ const chunk = spoken < 0 ? null : chunks.find(([from, to]) => spoken >= from && spoken < to) ?? null;
80
+
81
+ const px = fontSize ?? Math.round(Math.min(width, height) * 0.048);
82
+ const edge = left ?? Math.round(width * 0.1);
83
+ const blockWidth = boxWidth ?? width - 2 * edge;
84
+ const insets = captionInsets(width, height);
85
+ const accent = SUBJECT_ACCENT[subject].accent;
86
+
87
+ return (
88
+ <div
89
+ data-ovs-captions=""
90
+ data-ovs-captions-timing={timing}
91
+ data-ovs-captions-position={position}
92
+ style={{
93
+ position: "absolute",
94
+ left: edge,
95
+ width: blockWidth,
96
+ ...(position === "bottom" ? { bottom: insets.bottom } : { top: insets.top }),
97
+ display: "flex",
98
+ justifyContent: "center",
99
+ pointerEvents: "none",
100
+ }}
101
+ >
102
+ {chunk && (
103
+ <div
104
+ data-ovs-caption=""
105
+ style={{
106
+ display: "flex",
107
+ flexWrap: "wrap",
108
+ justifyContent: "center",
109
+ columnGap: px * 0.28,
110
+ rowGap: px * 0.1,
111
+ maxWidth: "100%",
112
+ padding: `${Math.round(px * 0.3)}px ${Math.round(px * 0.5)}px`,
113
+ borderRadius: Math.round(px * 0.4),
114
+ background: "rgba(7,6,11,0.72)",
115
+ fontFamily: bodyStack(),
116
+ fontSize: px,
117
+ lineHeight: 1.25,
118
+ fontWeight: 600,
119
+ textAlign: "center",
120
+ }}
121
+ >
122
+ {words.slice(chunk[0], chunk[1]).map((w, k) => {
123
+ const index = chunk[0] + k;
124
+ const active = index === spoken;
125
+ return (
126
+ <span key={index} data-ovs-word={index} data-active={active ? "true" : "false"} style={{ color: active ? accent : VOID.text }}>
127
+ {w}
128
+ </span>
129
+ );
130
+ })}
131
+ </div>
132
+ )}
133
+ </div>
134
+ );
135
+ };
@@ -0,0 +1,187 @@
1
+ import React from "react";
2
+ import { useCurrentFrame, useVideoConfig } from "remotion";
3
+ import { E, tween } from "@openvidstudio/core";
4
+ import { SUBJECT_ACCENT, VOID, bodyStack, displayStack, type Subject } from "./designSystem";
5
+ import { lineWeight, type Box } from "./graph";
6
+ import type { Point } from "./paths";
7
+
8
+ export type Matrix2 = [[number, number], [number, number]];
9
+
10
+ export interface MatrixGridProps {
11
+ /** The 2x2 matrix, rows first: `[[a, b], [c, d]]` sends (x, y) to (a x + b y, c x + d y). */
12
+ matrix: number[][];
13
+ /** Composition frame the picture appears and the transform starts. Default 0. */
14
+ at?: number;
15
+ /** Frames the plane takes to go from the identity to the matrix. Default 45. */
16
+ duration?: number;
17
+ /** Animate the transform. With false the plane is shown already transformed. Default true. */
18
+ transform?: boolean;
19
+ /** Shapes the progress 0..1. Default `E.cinematic`; pass `(t) => t` for a linear sweep. */
20
+ ease?: (t: number) => number;
21
+ /** The grid of points: `extent` is how far it reaches on each side of the origin and `spacing` the gap, both in graph units. Default 2 and 1. */
22
+ grid?: { extent?: number; spacing?: number };
23
+ /** Draw the unit square and its image, with the images of i-hat and j-hat as arrows. Default true. */
24
+ unitSquare?: boolean;
25
+ /** Draw the matrix in brackets above (portrait) or beside (landscape) the plane. Default true. */
26
+ showMatrix?: boolean;
27
+ /** Where the plane is drawn. Its centre is the origin. Default: the middle band of the frame. */
28
+ box?: Box;
29
+ /** Pixels per graph unit. Default: fits the grid, the unit square and their images inside the box. */
30
+ scale?: number;
31
+ /** Picks the accent. Default "math". */
32
+ subject?: Subject;
33
+ }
34
+
35
+ export interface MatrixGridGeometry {
36
+ box: Box;
37
+ origin: Point;
38
+ scale: number;
39
+ /** A graph point to composition pixels (y is up in the graph). */
40
+ toScreen: (x: number, y: number) => Point;
41
+ }
42
+
43
+ /** The identity moved `progress` of the way to `m`, entry by entry: the matrix on screen at that moment. */
44
+ export function interpolateMatrix(m: number[][], progress: number): Matrix2 {
45
+ const id = [[1, 0], [0, 1]];
46
+ const at = (r: number, c: number) => id[r][c] + (m[r][c] - id[r][c]) * progress;
47
+ return [[at(0, 0), at(0, 1)], [at(1, 0), at(1, 1)]];
48
+ }
49
+
50
+ /** `m` applied to the point (x, y). */
51
+ export function applyMatrix(m: number[][], x: number, y: number): Point {
52
+ return { x: m[0][0] * x + m[0][1] * y, y: m[1][0] * x + m[1][1] * y };
53
+ }
54
+
55
+ /** The default plane box: the middle band in portrait, the right-hand side in landscape (the matrix goes beside it). */
56
+ export function defaultMatrixBox(width: number, height: number): Box {
57
+ return height > width
58
+ ? { x: Math.round(width * 0.1), y: Math.round(height * 0.3), width: Math.round(width * 0.8), height: Math.round(height * 0.4) }
59
+ : { x: Math.round(width * 0.4), y: Math.round(height * 0.14), width: Math.round(width * 0.56), height: Math.round(height * 0.7) };
60
+ }
61
+
62
+ /** The mapping the picture uses, from the same numbers the component is given, so a test can place any graph point. */
63
+ export function matrixGridGeometry(matrix: number[][], opts: { width: number; height: number; box?: Box; scale?: number; extent?: number }): MatrixGridGeometry {
64
+ const box = opts.box ?? defaultMatrixBox(opts.width, opts.height);
65
+ const extent = opts.extent ?? 2;
66
+ let reach = Math.max(extent, 1);
67
+ for (const sx of [-1, 1]) {
68
+ for (const sy of [-1, 1]) {
69
+ const p = applyMatrix(matrix, sx * extent, sy * extent);
70
+ reach = Math.max(reach, Math.abs(p.x), Math.abs(p.y));
71
+ }
72
+ }
73
+ const scale = opts.scale ?? (Math.min(box.width, box.height) / 2 / (reach * 1.12));
74
+ const origin = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
75
+ return { box, origin, scale, toScreen: (x, y) => ({ x: origin.x + x * scale, y: origin.y - y * scale }) };
76
+ }
77
+
78
+ const fmt = (v: number) => String(Number(v.toFixed(2)));
79
+
80
+ const Arrow: React.FC<{ from: Point; to: Point; color: string; weight: number; name: string }> = ({ from, to, color, weight, name }) => {
81
+ const len = Math.hypot(to.x - from.x, to.y - from.y);
82
+ const dx = len < 1e-6 ? 0 : (to.x - from.x) / len;
83
+ const dy = len < 1e-6 ? 0 : (to.y - from.y) / len;
84
+ const head = Math.min(weight * 5, len * 0.6);
85
+ const base = { x: to.x - dx * head, y: to.y - dy * head };
86
+ const half = head * 0.55;
87
+ return (
88
+ <g data-ovs-basis={name} data-ovs-tip-x={to.x} data-ovs-tip-y={to.y}>
89
+ <line x1={from.x} y1={from.y} x2={base.x} y2={base.y} stroke={color} strokeWidth={weight * 1.6} />
90
+ <polygon points={`${to.x},${to.y} ${base.x - dy * half},${base.y + dx * half} ${base.x + dy * half},${base.y - dx * half}`} fill={color} />
91
+ </g>
92
+ );
93
+ };
94
+
95
+ /**
96
+ * A matrix acting on the plane. A grid of points (and the lines through them), the unit square and
97
+ * the images of i-hat and j-hat move from the identity to the matrix: every entry is interpolated
98
+ * linearly from the identity, so each frame shows the plane under `I + p (M - I)` and the last one
99
+ * exactly `M`. Honest: a point (x, y) is drawn at `toScreen(M * (x, y))`, `scale` px per unit, and
100
+ * the square's orientation flips when the determinant is negative. A pure function of the frame.
101
+ */
102
+ export const MatrixGrid: React.FC<MatrixGridProps> = ({ matrix, at = 0, duration = 45, transform = true, ease = E.cinematic, grid, unitSquare = true, showMatrix = true, box, scale, subject = "math" }) => {
103
+ const frame = useCurrentFrame();
104
+ const { width, height } = useVideoConfig();
105
+ if (matrix.length !== 2 || matrix.some((r) => r.length !== 2)) throw new Error("MatrixGrid: the matrix must be 2x2, like [[2, 1], [0, 1]].");
106
+ if (frame < at) return null;
107
+ const weight = lineWeight(width, height);
108
+ const accent = SUBJECT_ACCENT[subject].accent;
109
+ const extent = grid?.extent ?? 2;
110
+ const spacing = grid?.spacing ?? 1;
111
+ const geo = matrixGridGeometry(matrix, { width, height, box, scale, extent });
112
+ const progress = transform ? tween(frame, [at, at + duration], [0, 1], ease) : 1;
113
+ const m = interpolateMatrix(matrix, progress);
114
+ const show = tween(frame, [at, at + 8], [0, 1], E.cinematic);
115
+ const screen = (x: number, y: number) => {
116
+ const p = applyMatrix(m, x, y);
117
+ return geo.toScreen(p.x, p.y);
118
+ };
119
+ const steps: number[] = [];
120
+ for (let v = -extent; v <= extent + 1e-9; v += spacing) steps.push(Math.abs(v) < 1e-9 ? 0 : Number(v.toFixed(10)));
121
+ const minSide = Math.min(width, height);
122
+ const entryPx = Math.round(minSide * 0.042);
123
+ const colW = entryPx * 2.2;
124
+ const rowH = entryPx * 1.5;
125
+ const portrait = height > width;
126
+ const mc = portrait ? { x: width / 2, y: Math.round(height * 0.22) } : { x: Math.round(width * 0.2), y: Math.round(height * 0.5) };
127
+ const bw = colW * 2 + entryPx * 0.8;
128
+ const bh = rowH * 2;
129
+ const sq = [[0, 0], [1, 0], [1, 1], [0, 1]].map(([x, y]) => screen(x, y));
130
+ const area = sq.reduce((s, p, i) => s + (p.x * sq[(i + 1) % 4].y - sq[(i + 1) % 4].x * p.y), 0);
131
+ // Screen y points down, so a positive area here is a clockwise (flipped) square.
132
+ const flipped = area > 0;
133
+ const o = screen(0, 0);
134
+ const ghost = [[0, 0], [1, 0], [1, 1], [0, 1]].map(([x, y]) => geo.toScreen(x, y));
135
+ const poly = (pts: Point[]) => pts.map((p) => `${p.x},${p.y}`).join(" ");
136
+ const bracket = (side: -1 | 1) => {
137
+ const x = mc.x + side * bw / 2;
138
+ const t = mc.y - bh / 2;
139
+ const b = mc.y + bh / 2;
140
+ const k = entryPx * 0.35;
141
+ return `M${x - side * k},${t} L${x},${t} L${x},${b} L${x - side * k},${b}`;
142
+ };
143
+ return (
144
+ <div style={{ position: "absolute", inset: 0, pointerEvents: "none", opacity: show }} data-ovs-matrixgrid="" data-ovs-progress={progress}>
145
+ <svg width={width} height={height} style={{ position: "absolute", left: 0, top: 0 }}>
146
+ <g data-ovs-gridlines="">
147
+ {steps.map((v) => {
148
+ const a = screen(v, -extent);
149
+ const b = screen(v, extent);
150
+ const c = screen(-extent, v);
151
+ const d = screen(extent, v);
152
+ return (
153
+ <g key={v} stroke={VOID.textDim} strokeOpacity={v === 0 ? 0.55 : 0.28} strokeWidth={weight}>
154
+ <line x1={a.x} y1={a.y} x2={b.x} y2={b.y} />
155
+ <line x1={c.x} y1={c.y} x2={d.x} y2={d.y} />
156
+ </g>
157
+ );
158
+ })}
159
+ </g>
160
+ {steps.flatMap((x) => steps.map((y) => {
161
+ const p = screen(x, y);
162
+ return <circle key={`${x},${y}`} data-ovs-point={`${x},${y}`} data-ovs-sx={p.x} data-ovs-sy={p.y} cx={p.x} cy={p.y} r={weight * 1.4} fill={VOID.textDim} />;
163
+ }))}
164
+ {unitSquare && (
165
+ <g>
166
+ <polygon data-ovs-ghost="" points={poly(ghost)} fill="none" stroke={VOID.textDim} strokeWidth={weight} strokeDasharray={`${weight * 2.5} ${weight * 2}`} strokeOpacity={0.7} />
167
+ <polygon data-ovs-unit-square="" data-ovs-orientation={flipped ? "flipped" : "preserved"} points={poly(sq)} fill={accent} fillOpacity={0.2} stroke={accent} strokeWidth={weight} strokeLinejoin="round" />
168
+ {sq.map((p, i) => <circle key={i} data-ovs-corner={i} data-ovs-sx={p.x} data-ovs-sy={p.y} cx={p.x} cy={p.y} r={weight * 1.4} fill={accent} />)}
169
+ <Arrow from={o} to={screen(1, 0)} color={accent} weight={weight} name="i" />
170
+ <Arrow from={o} to={screen(0, 1)} color={VOID.text} weight={weight} name="j" />
171
+ </g>
172
+ )}
173
+ <circle data-ovs-origin="" data-ovs-sx={o.x} data-ovs-sy={o.y} cx={o.x} cy={o.y} r={weight * 2} fill={VOID.text} />
174
+ {showMatrix && (
175
+ <g data-ovs-matrix="">
176
+ <path d={bracket(-1)} fill="none" stroke={VOID.textDim} strokeWidth={weight} />
177
+ <path d={bracket(1)} fill="none" stroke={VOID.textDim} strokeWidth={weight} />
178
+ {matrix.flatMap((row, r) => row.map((v, c) => (
179
+ <text key={`${r}${c}`} data-ovs-entry={`${r},${c}`} x={mc.x + (c - 0.5) * colW} y={mc.y + (r - 0.5) * rowH + entryPx * 0.35} textAnchor="middle" fontSize={entryPx} fill={VOID.text} fontFamily={displayStack()} fontWeight={500}>{fmt(v)}</text>
180
+ )))}
181
+ <text x={mc.x} y={mc.y - bh / 2 - entryPx * 0.5} textAnchor="middle" fontSize={Math.round(minSide * 0.026)} fill={VOID.textDim} fontFamily={bodyStack()}>{`det = ${fmt(matrix[0][0] * matrix[1][1] - matrix[0][1] * matrix[1][0])}`}</text>
182
+ </g>
183
+ )}
184
+ </svg>
185
+ </div>
186
+ );
187
+ };