@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.
- package/dist/canvasExplainer.d.ts +198 -0
- package/dist/canvasExplainer.js +661 -0
- package/dist/canvasValidate.d.ts +76 -0
- package/dist/canvasValidate.js +263 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +2 -0
- package/dist/install.d.ts +63 -0
- package/dist/install.js +225 -0
- package/dist/lessonArc.d.ts +68 -0
- package/dist/lessonArc.js +188 -0
- package/dist/server.d.ts +1 -1
- package/dist/server.js +1 -1
- package/dist/stdio.js +12 -4
- package/dist/tools/checkScript.d.ts +31 -1
- package/dist/tools/checkScript.js +183 -6
- package/dist/tools/fitBeats.d.ts +69 -0
- package/dist/tools/fitBeats.js +229 -0
- package/dist/tools/generateNarration.d.ts +19 -0
- package/dist/tools/generateNarration.js +48 -5
- package/dist/tools/index.js +6 -0
- package/dist/tools/initProject.js +6 -1
- package/dist/tools/preflight.js +20 -1
- package/dist/tools/renderCanvasExplainer.d.ts +2 -0
- package/dist/tools/renderCanvasExplainer.js +56 -0
- package/dist/tools/renderVideo.d.ts +3 -0
- package/dist/tools/renderVideo.js +13 -4
- package/dist/tools/validateBeats.d.ts +2 -0
- package/dist/tools/validateBeats.js +5 -5
- package/dist/tools/validateCanvasExplainer.d.ts +2 -0
- package/dist/tools/validateCanvasExplainer.js +35 -0
- package/dist/tools/validateScenes.d.ts +6 -0
- package/dist/tools/validateScenes.js +27 -6
- package/dist/tools/validateScenesRender.d.ts +8 -0
- package/dist/tools/validateScenesRender.js +31 -0
- package/dist/tools/writeDescription.d.ts +6 -0
- package/dist/tools/writeDescription.js +64 -2
- package/docs/API.md +291 -0
- package/docs/NARRATION.md +12 -5
- package/docs/PIPELINE.md +46 -0
- package/docs/SCRIPT.md +63 -3
- package/docs/SUPERNOVA.md +53 -15
- package/docs/TOOLS.md +4 -1
- package/package.json +4 -4
- package/templates/default/openvidstudio-core/package.json +9 -9
- package/templates/default/openvidstudio-stem/package.json +5 -5
- package/templates/default/openvidstudio-stem/src/ComplexityCurve.tsx +108 -0
- package/templates/default/openvidstudio-stem/src/Derivation.tsx +334 -0
- package/templates/default/openvidstudio-stem/src/Equation.tsx +3 -3
- package/templates/default/openvidstudio-stem/src/GraphViz.tsx +249 -0
- package/templates/default/openvidstudio-stem/src/LessonCaptions.tsx +135 -0
- package/templates/default/openvidstudio-stem/src/MatrixGrid.tsx +187 -0
- package/templates/default/openvidstudio-stem/src/Orbit.tsx +241 -0
- package/templates/default/openvidstudio-stem/src/TreeViz.tsx +241 -0
- package/templates/default/openvidstudio-stem/src/VectorField.tsx +231 -0
- package/templates/default/openvidstudio-stem/src/Wave.tsx +234 -0
- package/templates/default/openvidstudio-stem/src/index.ts +21 -0
- package/templates/default/package.json +10 -10
package/docs/TOOLS.md
CHANGED
|
@@ -4,7 +4,7 @@ GENERATED by `node scripts/generate-tool-docs.mjs` from the tools the server act
|
|
|
4
4
|
Do not edit by hand: CI fails if this file is out of date. Each tool's own description (what it
|
|
5
5
|
checks, what it returns, when to use it) is what an MCP client shows an agent; this page is the map.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
44 tools, 45 with the Higgsfield tier enabled.
|
|
8
8
|
|
|
9
9
|
| Tool | What it does |
|
|
10
10
|
| --- | --- |
|
|
@@ -25,6 +25,7 @@ checks, what it returns, when to use it) is what an MCP client shows an agent; t
|
|
|
25
25
|
| `extract_brand` | Read a repo's own palette, fonts and logo, and theme the video with them. |
|
|
26
26
|
| `finalize_video` | Final assembly: loudness target, poster frame, size-capped share copy. |
|
|
27
27
|
| `find_music_bed` | Search public-domain music catalogs for a background track. |
|
|
28
|
+
| `fit_beats` | Size the beats from the measured voice. |
|
|
28
29
|
| `generate_narration` | Generate narration audio for every beat. |
|
|
29
30
|
| `import_higgsfield_clip` | Import an already-generated Higgsfield clip into this project's asset convention. Only registered when `hasHiggsfield` is true in `openvidstudio.config.json`. |
|
|
30
31
|
| `import_music_bed` | Download a CC0 track and shape it into a music bed. |
|
|
@@ -40,11 +41,13 @@ checks, what it returns, when to use it) is what an MCP client shows an agent; t
|
|
|
40
41
|
| `qc_extract_frames` | Extract QC frames for every beat. |
|
|
41
42
|
| `reformat_vertical` | Reformat a finished video to 9:16 using the manifest. |
|
|
42
43
|
| `release_diff` | Two refs in, a what's-new clip's manifest out. |
|
|
44
|
+
| `render_canvas_explainer` | Render an HTML canvas explainer to a video. |
|
|
43
45
|
| `render_stills` | Render representative frames without a full video encode. |
|
|
44
46
|
| `render_video` | Render a composition with the Remotion CLI. |
|
|
45
47
|
| `scaffold_scene` | Scaffold a working scene for one beat. |
|
|
46
48
|
| `stitch_composition` | Stitch a video's beats into a composition. |
|
|
47
49
|
| `validate_beats` | Validate a drafted beats.json. |
|
|
50
|
+
| `validate_canvas_explainer` | Check a canvas explainer for problems that render without erroring. |
|
|
48
51
|
| `validate_scenes` | Check scenes for problems that render without erroring. |
|
|
49
52
|
| `visual_regression` | Diff this render's beats against the last accepted one. |
|
|
50
53
|
| `write_beats_file` | Write a validated beats.json to disk. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openvidstudio/mcp-server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "MCP server for openvidstudio: plan, capture, narrate, score, render and finalize a video of your real product, driven by an AI coding agent.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"engines": {
|
|
@@ -20,12 +20,12 @@
|
|
|
20
20
|
],
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
23
|
-
"@remotion/bundler": "4.0.
|
|
24
|
-
"@remotion/renderer": "4.0.
|
|
23
|
+
"@remotion/bundler": "4.0.536",
|
|
24
|
+
"@remotion/renderer": "4.0.536",
|
|
25
25
|
"playwright": "^1.64.0",
|
|
26
26
|
"sharp": "^0.35.5",
|
|
27
27
|
"zod": "^4.6.6",
|
|
28
|
-
"@openvidstudio/capture": "1.
|
|
28
|
+
"@openvidstudio/capture": "1.4.0"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"@types/node": "^26.6.5",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openvidstudio/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Camera rig, shot moves, transitions, motion, sound, look pass and scene components shared by every openvidstudio video project.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"engines": {
|
|
@@ -15,19 +15,19 @@
|
|
|
15
15
|
"public"
|
|
16
16
|
],
|
|
17
17
|
"peerDependencies": {
|
|
18
|
-
"@remotion/motion-blur": "4.0.
|
|
19
|
-
"react": "19.3.
|
|
20
|
-
"react-dom": "19.3.
|
|
21
|
-
"remotion": "4.0.
|
|
18
|
+
"@remotion/motion-blur": "4.0.536",
|
|
19
|
+
"react": "19.3.1",
|
|
20
|
+
"react-dom": "19.3.1",
|
|
21
|
+
"remotion": "4.0.536"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"@fontsource/inter": "^5.3.0",
|
|
25
25
|
"@fontsource/jetbrains-mono": "^5.3.0",
|
|
26
|
-
"@remotion/motion-blur": "4.0.
|
|
26
|
+
"@remotion/motion-blur": "4.0.536",
|
|
27
27
|
"@types/react": "19.3.0",
|
|
28
|
-
"react": "19.3.
|
|
29
|
-
"react-dom": "19.3.
|
|
30
|
-
"remotion": "4.0.
|
|
28
|
+
"react": "19.3.1",
|
|
29
|
+
"react-dom": "19.3.1",
|
|
30
|
+
"remotion": "4.0.536",
|
|
31
31
|
"typescript": "5.9.3"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openvidstudio/stem",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Supernova mode: the Void + Spark design system and the STEM lesson primitives for openvidstudio.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"engines": {
|
|
@@ -14,10 +14,10 @@
|
|
|
14
14
|
"public"
|
|
15
15
|
],
|
|
16
16
|
"peerDependencies": {
|
|
17
|
-
"@openvidstudio/core": "^1.
|
|
18
|
-
"react": "19.3.
|
|
19
|
-
"react-dom": "19.3.
|
|
20
|
-
"remotion": "4.0.
|
|
17
|
+
"@openvidstudio/core": "^1.4.0",
|
|
18
|
+
"react": "19.3.1",
|
|
19
|
+
"react-dom": "19.3.1",
|
|
20
|
+
"remotion": "4.0.536"
|
|
21
21
|
},
|
|
22
22
|
"publishConfig": {
|
|
23
23
|
"access": "public"
|
|
@@ -0,0 +1,108 @@
|
|
|
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 { Axes, Plot, useAxes, type Box } from "./graph";
|
|
6
|
+
|
|
7
|
+
export type ComplexityKind = "n" | "nlogn" | "n2";
|
|
8
|
+
|
|
9
|
+
export interface ComplexityCurveProps {
|
|
10
|
+
/** Which curves to draw, in this order. Default all three: "n", "nlogn", "n2". */
|
|
11
|
+
curves?: ComplexityKind[];
|
|
12
|
+
/** The input size to mark: a vertical rule, a dot on each curve and its value. Left out, no marker. */
|
|
13
|
+
n?: number;
|
|
14
|
+
/** The right end of the x axis. Default 1.5 x `n`, or 16 without a marker. */
|
|
15
|
+
xMax?: number;
|
|
16
|
+
/** The top of the y axis. Default 1.25 x the largest curve's value at `n` (so the marker is in view and the steep curve leaves the top), or the largest value at `xMax` without a marker. */
|
|
17
|
+
yMax?: number;
|
|
18
|
+
/** Composition frame the draw-on starts. Default 0. */
|
|
19
|
+
at?: number;
|
|
20
|
+
/** Frames the curves take to draw on. Default 60. */
|
|
21
|
+
duration?: number;
|
|
22
|
+
/** Where the plot sits, composition px. Default: a box sized to the frame. */
|
|
23
|
+
box?: Box;
|
|
24
|
+
/** Name each curve at its end and write the values at the marker. Default true. */
|
|
25
|
+
labels?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Operation counts. `log` is base 2. `n log n` is drawn as 0 for n at or below 1, where log2 n is not positive. */
|
|
29
|
+
export function complexityValue(kind: ComplexityKind, n: number): number {
|
|
30
|
+
if (kind === "n") return n;
|
|
31
|
+
if (kind === "n2") return n * n;
|
|
32
|
+
return n > 1 ? n * Math.log2(n) : 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const COMPLEXITY_NAMES: Record<ComplexityKind, string> = { n: "n", nlogn: "n log n", n2: "n²" };
|
|
36
|
+
|
|
37
|
+
/** One accent per curve: green for n, the cs violet for n log n, red for n squared. */
|
|
38
|
+
export const COMPLEXITY_COLORS: Record<ComplexityKind, string> = { n: SUBJECT_ACCENT.biology.accent, nlogn: SUBJECT_ACCENT.cs.accent, n2: SUBJECT_ACCENT.chemistry.accent };
|
|
39
|
+
|
|
40
|
+
const fmtValue = (v: number) => String(Number(v.toFixed(v < 10 ? 2 : 1)));
|
|
41
|
+
|
|
42
|
+
const Decorations: React.FC<{ kinds: ComplexityKind[]; n?: number; at: number; duration: number; labels: boolean }> = ({ kinds, n, at, duration, labels }) => {
|
|
43
|
+
const g = useAxes();
|
|
44
|
+
const frame = useCurrentFrame();
|
|
45
|
+
const { width, height } = useVideoConfig();
|
|
46
|
+
const p = tween(frame, [at, at + duration], [0, 1], E.sine);
|
|
47
|
+
const font = Math.round(Math.min(width, height) * 0.03);
|
|
48
|
+
const [x0, x1] = g.xRange;
|
|
49
|
+
const [, yTop] = g.yRange;
|
|
50
|
+
const lw = g.weight;
|
|
51
|
+
const reach = x0 + (x1 - x0) * p;
|
|
52
|
+
const done = p >= 0.999;
|
|
53
|
+
return (
|
|
54
|
+
<svg width={width} height={height} style={{ position: "absolute", left: 0, top: 0 }}>
|
|
55
|
+
{labels &&
|
|
56
|
+
done &&
|
|
57
|
+
kinds.map((k) => {
|
|
58
|
+
// Where the curve ends inside the box: at the right edge, or where it leaves through the top.
|
|
59
|
+
let xe = x1;
|
|
60
|
+
if (complexityValue(k, x1) > yTop) {
|
|
61
|
+
let lo = x0;
|
|
62
|
+
let hi = x1;
|
|
63
|
+
for (let i = 0; i < 40; i++) {
|
|
64
|
+
const mid = (lo + hi) / 2;
|
|
65
|
+
if (complexityValue(k, mid) > yTop) hi = mid;
|
|
66
|
+
else lo = mid;
|
|
67
|
+
}
|
|
68
|
+
xe = lo;
|
|
69
|
+
}
|
|
70
|
+
const s = g.toScreen(xe, Math.min(complexityValue(k, xe), yTop));
|
|
71
|
+
const top = s.y <= g.box.y + 1;
|
|
72
|
+
return (
|
|
73
|
+
<text key={k} data-ovs-curve-label={k} x={s.x - font * 0.4} y={top ? s.y + font * 1.2 : s.y - font * 0.6} textAnchor="end" fontSize={font} fontWeight={700} fill={COMPLEXITY_COLORS[k]} fontFamily={displayStack()}>
|
|
74
|
+
{COMPLEXITY_NAMES[k]}
|
|
75
|
+
</text>
|
|
76
|
+
);
|
|
77
|
+
})}
|
|
78
|
+
{n !== undefined && reach >= n && (
|
|
79
|
+
<g data-ovs-marker="">
|
|
80
|
+
<line data-ovs-marker-rule="" x1={g.toScreen(n, 0).x} x2={g.toScreen(n, 0).x} y1={g.box.y} y2={g.toScreen(n, 0).y} stroke={VOID.text} strokeWidth={lw} strokeDasharray={`${lw * 3} ${lw * 2.5}`} />
|
|
81
|
+
{labels && (
|
|
82
|
+
<text x={g.toScreen(n, 0).x + font * 0.4} y={g.box.y + font} fontSize={font} fill={VOID.text} fontFamily={bodyStack()}>{`n = ${fmtValue(n)}`}</text>
|
|
83
|
+
)}
|
|
84
|
+
</g>
|
|
85
|
+
)}
|
|
86
|
+
</svg>
|
|
87
|
+
);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* n, n log n and n squared drawn together on shared axes, with an optional marker at a chosen n: a rule, a
|
|
92
|
+
* dot on each curve and the value there. Built from `Axes` and `Plot`. The log is base 2. Curves that climb
|
|
93
|
+
* past `yMax` leave through the top of the plot (clipped). A pure function of the frame.
|
|
94
|
+
*/
|
|
95
|
+
export const ComplexityCurve: React.FC<ComplexityCurveProps> = ({ curves = ["n", "nlogn", "n2"], n, xMax, yMax, at = 0, duration = 60, box, labels = true }) => {
|
|
96
|
+
const right = xMax ?? (n !== undefined ? n * 1.5 : 16);
|
|
97
|
+
const top = yMax ?? Math.max(...curves.map((k) => complexityValue(k, n !== undefined ? n : right))) * (n !== undefined ? 1.25 : 1);
|
|
98
|
+
return (
|
|
99
|
+
<div data-ovs-complexity="" style={{ position: "absolute", inset: 0, pointerEvents: "none" }}>
|
|
100
|
+
<Axes box={box} xRange={[0, right]} yRange={[0, top]} xLabel="n (input size)" yLabel="operations" subject="cs">
|
|
101
|
+
{curves.map((k) => (
|
|
102
|
+
<Plot key={k} fn={(x) => complexityValue(k, x)} at={at} duration={duration} color={COMPLEXITY_COLORS[k]} points={n !== undefined ? [{ x: n, label: labels ? fmtValue(complexityValue(k, n)) : undefined }] : []} />
|
|
103
|
+
))}
|
|
104
|
+
<Decorations kinds={curves} n={n} at={at} duration={duration} labels={labels} />
|
|
105
|
+
</Axes>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
};
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { cancelRender, continueRender, delayRender, useCurrentFrame, useVideoConfig } from "remotion";
|
|
3
|
+
import { E, tween, useNarrationWords } from "@openvidstudio/core";
|
|
4
|
+
import { SUBJECT_ACCENT, VOID, bodyStack, type Subject } from "./designSystem";
|
|
5
|
+
import { EQUATION_TERM_FRAMES, parse, termsOf } from "./Equation";
|
|
6
|
+
import { cachedSvg, texToSvg } from "./mathjax";
|
|
7
|
+
import type { Point } from "./paths";
|
|
8
|
+
|
|
9
|
+
export interface DerivationStep {
|
|
10
|
+
/** The line, as TeX. */
|
|
11
|
+
tex: string;
|
|
12
|
+
/**
|
|
13
|
+
* The line as it is said, in plain words. Required, exactly as on `Equation`: it must appear in the beat's
|
|
14
|
+
* narration, in the order the steps are given, and `check_script` checks that.
|
|
15
|
+
*/
|
|
16
|
+
spoken: string;
|
|
17
|
+
/** A short reason in plain words, shown with the step ("no horizontal force"). */
|
|
18
|
+
because?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DerivationProps {
|
|
22
|
+
steps: DerivationStep[];
|
|
23
|
+
/** The beat these steps are narrated in. Each step is revealed at the word its `spoken` starts at (`useNarrationWords`). */
|
|
24
|
+
beatId?: string;
|
|
25
|
+
/** Explicit frames each step is revealed at, which win over the narration. A step with no entry falls back to `from` and `stagger`. */
|
|
26
|
+
at?: number[];
|
|
27
|
+
/** Frame the first step is revealed when there is neither narration nor `at`. Default 0. */
|
|
28
|
+
from?: number;
|
|
29
|
+
/** Frames between steps when there is neither narration nor `at`. Default 60. */
|
|
30
|
+
stagger?: number;
|
|
31
|
+
/** Font size in px. Default 7% of the shorter side, made smaller if the widest line would not fit. */
|
|
32
|
+
size?: number;
|
|
33
|
+
/** Defaults to the Void's warm white. */
|
|
34
|
+
color?: string;
|
|
35
|
+
/** Where the baseline of the current (newest) line sits, and the middle of the block. Default: the horizontal centre, 60% down a tall frame and 64% down a wide one. */
|
|
36
|
+
center?: Point;
|
|
37
|
+
/** Picks the colour a new term arrives in. Default "math". */
|
|
38
|
+
subject?: Subject;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Frames the previous line takes to move up and dim, and the carried terms to settle into their new places. */
|
|
42
|
+
export const DERIVATION_MOVE_FRAMES = 14;
|
|
43
|
+
/** Frames between one new term and the next. */
|
|
44
|
+
export const DERIVATION_TERM_STAGGER = 4;
|
|
45
|
+
/** The most lines on screen at once: the current one and the two before it. */
|
|
46
|
+
export const DERIVATION_STACK = 3;
|
|
47
|
+
|
|
48
|
+
/** Brightness of a line `slot` places above the current one (0 is the current line). Zero from the third slot up, so three show. */
|
|
49
|
+
export function slotOpacity(slot: number): number {
|
|
50
|
+
const knots: [number, number][] = [[0, 1], [1, 0.5], [2, 0.26], [3, 0]];
|
|
51
|
+
if (slot <= 0) return 1;
|
|
52
|
+
for (let i = 1; i < knots.length; i++) {
|
|
53
|
+
if (slot <= knots[i][0]) {
|
|
54
|
+
const [s0, a0] = knots[i - 1];
|
|
55
|
+
const [s1, a1] = knots[i];
|
|
56
|
+
return a0 + (a1 - a0) * ((slot - s0) / (s1 - s0));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const spokenTokens = (text: string) => text.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim().split(/\s+/).filter(Boolean);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Pure: the frame each step is revealed at. A step's `spoken` is looked for in the narration's words, in order,
|
|
66
|
+
* after the previous step's; the first word it starts at is its frame. Where it is not found (or there are no
|
|
67
|
+
* timings) the step takes an explicit `at[i]`, else `from + i * stagger`. Each step is at least one frame after the one before.
|
|
68
|
+
*/
|
|
69
|
+
export function revealFrames(
|
|
70
|
+
steps: { spoken: string }[],
|
|
71
|
+
opts: { at?: number[]; from?: number; stagger?: number; words?: { word: string }[]; frameOfWord?: (index: number) => number | null },
|
|
72
|
+
): number[] {
|
|
73
|
+
const from = opts.from ?? 0;
|
|
74
|
+
const stagger = opts.stagger ?? 60;
|
|
75
|
+
const heard = (opts.words ?? []).map((w) => spokenTokens(w.word).join(" "));
|
|
76
|
+
let cursor = 0;
|
|
77
|
+
const out: number[] = [];
|
|
78
|
+
steps.forEach((step, i) => {
|
|
79
|
+
let frame: number | null = null;
|
|
80
|
+
const explicit = opts.at?.[i];
|
|
81
|
+
if (explicit === undefined && opts.frameOfWord && heard.length > 0) {
|
|
82
|
+
const tokens = spokenTokens(step.spoken);
|
|
83
|
+
for (let p = cursor; tokens.length > 0 && p + tokens.length <= heard.length; p++) {
|
|
84
|
+
if (tokens.every((t, k) => heard[p + k] === t)) {
|
|
85
|
+
frame = opts.frameOfWord(p);
|
|
86
|
+
cursor = p + tokens.length;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
let r = explicit ?? frame ?? from + i * stagger;
|
|
92
|
+
if (i > 0) r = Math.max(r, out[i - 1] + 1);
|
|
93
|
+
out.push(r);
|
|
94
|
+
});
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface TermInfo {
|
|
99
|
+
el: Element;
|
|
100
|
+
latex: string;
|
|
101
|
+
x: number;
|
|
102
|
+
y: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface LineModel {
|
|
106
|
+
/** The viewBox, in MathJax's units (1000 to the em): its left and top, its width and height. */
|
|
107
|
+
minX: number;
|
|
108
|
+
minY: number;
|
|
109
|
+
width: number;
|
|
110
|
+
height: number;
|
|
111
|
+
terms: TermInfo[];
|
|
112
|
+
structured: boolean;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function termInfo(el: Element): TermInfo {
|
|
116
|
+
const m = /translate\(\s*(-?[\d.eE+-]+)[\s,]+(-?[\d.eE+-]+)\s*\)/.exec(el.getAttribute("transform") ?? "");
|
|
117
|
+
return { el, latex: el.getAttribute("data-latex") ?? el.innerHTML, x: m ? Number(m[1]) : 0, y: m ? Number(m[2]) : 0 };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function modelOf(markup: string): LineModel {
|
|
121
|
+
const { svg } = parse(markup);
|
|
122
|
+
const [minX, minY, width, height] = (svg.getAttribute("viewBox") ?? "0 0 1000 1000").split(/[\s,]+/).map(Number);
|
|
123
|
+
const found = termsOf(svg);
|
|
124
|
+
return { minX, minY, width, height, terms: found ? found.kids.map(termInfo) : [], structured: found !== null };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Pure: which term of the new line is the same term as one of the old line, by longest common subsequence of their TeX. */
|
|
128
|
+
export function matchTerms(before: string[], after: string[]): Map<number, number> {
|
|
129
|
+
const n = before.length;
|
|
130
|
+
const m = after.length;
|
|
131
|
+
const len: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
|
|
132
|
+
for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) len[i][j] = before[i] === after[j] ? len[i + 1][j + 1] + 1 : Math.max(len[i + 1][j], len[i][j + 1]);
|
|
133
|
+
const pairs = new Map<number, number>();
|
|
134
|
+
let i = 0;
|
|
135
|
+
let j = 0;
|
|
136
|
+
while (i < n && j < m) {
|
|
137
|
+
if (before[i] === after[j]) {
|
|
138
|
+
pairs.set(j, i);
|
|
139
|
+
i++;
|
|
140
|
+
j++;
|
|
141
|
+
} else if (len[i + 1][j] >= len[i][j + 1]) i++;
|
|
142
|
+
else j++;
|
|
143
|
+
}
|
|
144
|
+
return pairs;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const hex = (h: string): [number, number, number] => [1, 3, 5].map((i) => Number.parseInt(h.slice(i, i + 2), 16)) as [number, number, number];
|
|
148
|
+
const mixHex = (a: string, b: string, k: number): string => {
|
|
149
|
+
const x = hex(a);
|
|
150
|
+
const y = hex(b);
|
|
151
|
+
return `rgb(${x.map((v, i) => Math.round(v + (y[i] - v) * k)).join(",")})`;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A formula built line by line, each line spoken. The newest line sits at the bottom; the one before it moves up
|
|
156
|
+
* and dims, up to a stack of three, so the viewer reads the algebra top to bottom. The lines share a left edge, as
|
|
157
|
+
* algebra on paper does, and sit on a baseline, so between two lines the terms they share stay where they are (or
|
|
158
|
+
* slide to their new place if the terms before them changed width), the terms that are new fade in in the accent and
|
|
159
|
+
* settle to white, and the terms that are gone fade out. Term level, not per glyph.
|
|
160
|
+
* `because` puts a plain-words reason under a step. A pure function of the frame, sized from the composition.
|
|
161
|
+
*/
|
|
162
|
+
export const Derivation: React.FC<DerivationProps> = ({ steps, beatId, at, from, stagger, size, color, center, subject = "math" }) => {
|
|
163
|
+
steps.forEach((s, i) => {
|
|
164
|
+
if (typeof s.spoken !== "string" || s.spoken.trim() === "") {
|
|
165
|
+
throw new Error(`Derivation: step ${i + 1} has no \`spoken\`. Every step needs the line as it is said ("y equals u sine theta t"), so the narration and the formula can be checked against each other.`);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
const frame = useCurrentFrame();
|
|
169
|
+
const { width, height } = useVideoConfig();
|
|
170
|
+
const narration = useNarrationWords(beatId ?? "");
|
|
171
|
+
const [markups, setMarkups] = useState<(string | undefined)[]>(() => steps.map((s) => cachedSvg(s.tex)));
|
|
172
|
+
const [handle] = useState(() => delayRender("Derivation: typesetting the steps"));
|
|
173
|
+
const key = steps.map((s) => s.tex).join("\n");
|
|
174
|
+
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
if (markups.length === steps.length && markups.every((m) => m !== undefined)) {
|
|
177
|
+
continueRender(handle);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
let alive = true;
|
|
181
|
+
Promise.all(steps.map((s) => texToSvg(s.tex))).then(
|
|
182
|
+
(all) => {
|
|
183
|
+
if (alive) setMarkups(all);
|
|
184
|
+
},
|
|
185
|
+
(err) => cancelRender(err),
|
|
186
|
+
);
|
|
187
|
+
return () => {
|
|
188
|
+
alive = false;
|
|
189
|
+
};
|
|
190
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
191
|
+
}, [key, markups, handle]);
|
|
192
|
+
|
|
193
|
+
const models = useMemo(() => (markups.length === steps.length && markups.every((m) => m !== undefined) ? markups.map((m) => modelOf(m as string)) : null), [markups, steps.length]);
|
|
194
|
+
const matches = useMemo(() => (models ? models.map((model, i) => (i === 0 ? new Map<number, number>() : matchTerms(models[i - 1].terms.map((t) => t.latex), model.terms.map((t) => t.latex)))) : []), [models]);
|
|
195
|
+
if (!models) return null;
|
|
196
|
+
|
|
197
|
+
const reveals = revealFrames(steps, { at, from, stagger, words: narration.available ? narration.words : undefined, frameOfWord: narration.frameOfWord });
|
|
198
|
+
const widest = Math.max(...models.map((m) => m.width)) / 1000;
|
|
199
|
+
const tallest = Math.max(...models.map((m) => m.height)) / 1000;
|
|
200
|
+
const px = size ?? Math.min(Math.round(Math.min(width, height) * 0.07), Math.floor((width * 0.84) / widest));
|
|
201
|
+
const c = center ?? { x: width / 2, y: Math.round(height * (height > width ? 0.6 : 0.64)) };
|
|
202
|
+
const slotH = Math.round(px * (tallest + 0.9));
|
|
203
|
+
const accent = SUBJECT_ACCENT[subject].accent;
|
|
204
|
+
const ink = color ?? VOID.text;
|
|
205
|
+
const inkHex = /^#[0-9a-f]{6}$/i.test(ink) ? ink : VOID.text;
|
|
206
|
+
const becauseGap = px * 0.1;
|
|
207
|
+
const blockLeft = c.x - (widest * px) / 2;
|
|
208
|
+
|
|
209
|
+
// How far each line has moved up: the sum of the moves of every later line that has arrived.
|
|
210
|
+
const moved = (i: number) => steps.reduce((sum, _, j) => (j > i ? sum + tween(frame, [reveals[j], reveals[j] + DERIVATION_MOVE_FRAMES], [0, 1], E.cinematic) : sum), 0);
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<div data-ovs-derivation="" data-steps={steps.length} style={{ position: "absolute", inset: 0, pointerEvents: "none" }}>
|
|
214
|
+
{steps.map((step, i) => {
|
|
215
|
+
if (frame < reveals[i]) return null;
|
|
216
|
+
const slot = moved(i);
|
|
217
|
+
const lineAlpha = slotOpacity(slot);
|
|
218
|
+
if (lineAlpha <= 0.002) return null;
|
|
219
|
+
const model = models[i];
|
|
220
|
+
const svgMarkup = markups[i] as string;
|
|
221
|
+
const y = c.y - slot * slotH;
|
|
222
|
+
const morph = i === 0 ? 1 : tween(frame, [reveals[i], reveals[i] + DERIVATION_MOVE_FRAMES], [0, 1], E.cinematic);
|
|
223
|
+
|
|
224
|
+
const root = parse(svgMarkup).svg;
|
|
225
|
+
// 1000 units to the em exactly, so the line's size does not depend on the font's x-height, and no
|
|
226
|
+
// baseline shift: the line is placed by its baseline below.
|
|
227
|
+
root.setAttribute("width", `${model.width / 1000}em`);
|
|
228
|
+
root.setAttribute("height", `${model.height / 1000}em`);
|
|
229
|
+
root.setAttribute("style", "display:block");
|
|
230
|
+
const found = termsOf(root);
|
|
231
|
+
let html = root.outerHTML;
|
|
232
|
+
if (found && model.structured) {
|
|
233
|
+
const doc = root.ownerDocument;
|
|
234
|
+
const wrap = (term: Element, state: string, latex: string, alpha: number, fill: string | null, rise: number) => {
|
|
235
|
+
const g = doc.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
236
|
+
g.setAttribute("data-ovs-term", state);
|
|
237
|
+
g.setAttribute("data-ovs-latex", latex);
|
|
238
|
+
// Fill and stroke opacity, not `opacity`, as Equation does: the markup for a frame is always identical.
|
|
239
|
+
g.setAttribute("fill-opacity", (alpha * lineAlpha).toFixed(4));
|
|
240
|
+
g.setAttribute("stroke-opacity", (alpha * lineAlpha).toFixed(4));
|
|
241
|
+
if (fill) g.setAttribute("fill", fill);
|
|
242
|
+
if (rise !== 0) g.setAttribute("transform", `translate(0,${rise.toFixed(2)})`);
|
|
243
|
+
found.parent.replaceChild(g, term);
|
|
244
|
+
g.appendChild(term);
|
|
245
|
+
return g;
|
|
246
|
+
};
|
|
247
|
+
let arrivals = 0;
|
|
248
|
+
const previous = i > 0 ? models[i - 1] : null;
|
|
249
|
+
model.terms.forEach((info, k) => {
|
|
250
|
+
const term = found.kids[k];
|
|
251
|
+
const oldIndex = matches[i].get(k);
|
|
252
|
+
if (previous && oldIndex !== undefined) {
|
|
253
|
+
// Carried: it is the same term as in the line before, so it stays where it was, sliding only if the terms before it changed width.
|
|
254
|
+
const from0 = previous.terms[oldIndex].x - previous.minX;
|
|
255
|
+
const to = info.x - model.minX;
|
|
256
|
+
const x = from0 + (to - from0) * morph + model.minX;
|
|
257
|
+
term.setAttribute("transform", `translate(${x.toFixed(2)},${info.y})`);
|
|
258
|
+
wrap(term, "carried", info.latex, 1, null, 0);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const start = previous ? reveals[i] + 4 + arrivals++ * DERIVATION_TERM_STAGGER : reveals[i] + k * 7;
|
|
262
|
+
const p = tween(frame, [start, start + EQUATION_TERM_FRAMES], [0, 1], E.cinematic);
|
|
263
|
+
// The root group is flipped, so a negative y is down: a term rises from a quarter em below.
|
|
264
|
+
const settle = previous ? tween(frame, [start + EQUATION_TERM_FRAMES, start + EQUATION_TERM_FRAMES + 26], [0, 1], E.cinematic) : 1;
|
|
265
|
+
wrap(term, "new", info.latex, p, previous ? mixHex(accent, inkHex, settle) : null, -(1 - p) * 250);
|
|
266
|
+
});
|
|
267
|
+
if (previous && morph < 1) {
|
|
268
|
+
// Gone: the terms of the line before that this one does not have, fading out where they were.
|
|
269
|
+
const kept = new Set(matches[i].values());
|
|
270
|
+
previous.terms.forEach((old, k) => {
|
|
271
|
+
if (kept.has(k)) return;
|
|
272
|
+
const ghost = doc.importNode(old.el, true) as Element;
|
|
273
|
+
const x = old.x - previous.minX + model.minX;
|
|
274
|
+
ghost.setAttribute("transform", `translate(${x.toFixed(2)},${old.y})`);
|
|
275
|
+
const g = doc.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
276
|
+
g.setAttribute("data-ovs-term", "removed");
|
|
277
|
+
g.setAttribute("data-ovs-latex", old.latex);
|
|
278
|
+
g.setAttribute("fill-opacity", ((1 - morph) * lineAlpha).toFixed(4));
|
|
279
|
+
g.setAttribute("stroke-opacity", ((1 - morph) * lineAlpha).toFixed(4));
|
|
280
|
+
g.appendChild(ghost);
|
|
281
|
+
found.parent.appendChild(g);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
html = root.outerHTML;
|
|
285
|
+
} else {
|
|
286
|
+
// An unrecognised structure: show the line whole rather than not at all.
|
|
287
|
+
root.setAttribute("fill-opacity", lineAlpha.toFixed(4));
|
|
288
|
+
html = root.outerHTML;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const descentEm = (model.minY + model.height) / 1000;
|
|
292
|
+
return (
|
|
293
|
+
<React.Fragment key={i}>
|
|
294
|
+
<div
|
|
295
|
+
data-ovs-step={i}
|
|
296
|
+
data-ovs-slot={slot.toFixed(3)}
|
|
297
|
+
data-ovs-line-opacity={lineAlpha.toFixed(3)}
|
|
298
|
+
data-spoken={step.spoken}
|
|
299
|
+
data-tex={step.tex}
|
|
300
|
+
style={{
|
|
301
|
+
position: "absolute",
|
|
302
|
+
left: blockLeft,
|
|
303
|
+
top: y + (model.minY / 1000) * px,
|
|
304
|
+
fontSize: px,
|
|
305
|
+
color: ink,
|
|
306
|
+
lineHeight: 0,
|
|
307
|
+
}}
|
|
308
|
+
dangerouslySetInnerHTML={{ __html: html }}
|
|
309
|
+
/>
|
|
310
|
+
{step.because && (
|
|
311
|
+
<div
|
|
312
|
+
data-ovs-because={i}
|
|
313
|
+
style={{
|
|
314
|
+
position: "absolute",
|
|
315
|
+
left: 0,
|
|
316
|
+
width,
|
|
317
|
+
top: y + descentEm * px + becauseGap,
|
|
318
|
+
textAlign: "center",
|
|
319
|
+
fontFamily: bodyStack(),
|
|
320
|
+
fontSize: Math.round(px * 0.34),
|
|
321
|
+
lineHeight: 1.2,
|
|
322
|
+
color: VOID.textDim,
|
|
323
|
+
opacity: lineAlpha * tween(frame, [reveals[i] + 6, reveals[i] + 6 + DERIVATION_MOVE_FRAMES], [0, 1], E.cinematic),
|
|
324
|
+
}}
|
|
325
|
+
>
|
|
326
|
+
{step.because}
|
|
327
|
+
</div>
|
|
328
|
+
)}
|
|
329
|
+
</React.Fragment>
|
|
330
|
+
);
|
|
331
|
+
})}
|
|
332
|
+
</div>
|
|
333
|
+
);
|
|
334
|
+
};
|
|
@@ -29,7 +29,7 @@ export interface EquationProps {
|
|
|
29
29
|
/** The frames a single term takes to arrive. */
|
|
30
30
|
export const EQUATION_TERM_FRAMES = 10;
|
|
31
31
|
|
|
32
|
-
interface Parsed {
|
|
32
|
+
export interface Parsed {
|
|
33
33
|
svg: SVGSVGElement;
|
|
34
34
|
}
|
|
35
35
|
|
|
@@ -38,7 +38,7 @@ interface Parsed {
|
|
|
38
38
|
* MathJax's output contains; an XML parser rejects it when the TeX has a control character in it
|
|
39
39
|
* (MathJax keeps the TeX in a data-latex attribute, and a plain JavaScript string "\frac" holds a form feed).
|
|
40
40
|
*/
|
|
41
|
-
function parse(markup: string): Parsed {
|
|
41
|
+
export function parse(markup: string): Parsed {
|
|
42
42
|
const doc = new DOMParser().parseFromString(markup, "text/html");
|
|
43
43
|
const svg = doc.body.querySelector("svg");
|
|
44
44
|
if (!svg) throw new Error("Equation: the typeset markup has no <svg> in it.");
|
|
@@ -46,7 +46,7 @@ function parse(markup: string): Parsed {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
/** The top-level terms of a typeset equation: the children of the root mrow, left to right. */
|
|
49
|
-
function termsOf(root: Element): { parent: Element; kids: Element[] } | null {
|
|
49
|
+
export function termsOf(root: Element): { parent: Element; kids: Element[] } | null {
|
|
50
50
|
const math = root.querySelector('g[data-mml-node="math"]');
|
|
51
51
|
if (!math) return null;
|
|
52
52
|
const parent = math.children.length === 1 && math.children[0].getAttribute("data-mml-node") === "mrow" ? math.children[0] : math;
|