@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
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useCurrentFrame, useVideoConfig } from "remotion";
|
|
3
|
+
import { E, tween } from "@openvidstudio/core";
|
|
4
|
+
import { SUBJECT_ACCENT } from "./designSystem";
|
|
5
|
+
import { lineWeight, useAxes, type AxesGeometry, type Box } from "./graph";
|
|
6
|
+
import type { Point } from "./paths";
|
|
7
|
+
|
|
8
|
+
export interface VectorFieldProps {
|
|
9
|
+
/** The field: a graph point to the vector there, `[fx, fy]`, y up. Return `[0, 0]` where it is undefined. */
|
|
10
|
+
field: (x: number, y: number) => [number, number];
|
|
11
|
+
/** Where the field is drawn, graph units. Used when the field is not inside an <Axes>. Default [-1, 1]. */
|
|
12
|
+
xRange?: [number, number];
|
|
13
|
+
yRange?: [number, number];
|
|
14
|
+
/** Where it is drawn, composition px, when not inside an <Axes> (inside one, the Axes' box is used). Default: a box sized to the frame. */
|
|
15
|
+
box?: Box;
|
|
16
|
+
/** Arrows per row and column. `rows` default: as many as keep the cells square. Default 7 columns. */
|
|
17
|
+
grid?: { columns: number; rows?: number };
|
|
18
|
+
/**
|
|
19
|
+
* Pixels per unit of field magnitude. An arrow is exactly `|f| * scale` px from tail to tip (no
|
|
20
|
+
* caps, no normalising, unless `normalize` or `maxLength` is given). Default: the longest arrow on the grid is 80% of a cell.
|
|
21
|
+
*/
|
|
22
|
+
scale?: number;
|
|
23
|
+
/** Draw every arrow this many px long, direction only. Arrow length then no longer says anything about |f|. */
|
|
24
|
+
normalize?: number;
|
|
25
|
+
/** Cap arrows at this many px. A capped arrow is shorter than `|f| * scale`. */
|
|
26
|
+
maxLength?: number;
|
|
27
|
+
/** Also trace field lines (streamlines) from the seeds, drawn on after the arrows. Default false. */
|
|
28
|
+
fieldLines?: boolean;
|
|
29
|
+
/** Where field lines start, in graph units. Each is traced both ways. Default: every other grid point. */
|
|
30
|
+
seeds?: [number, number][];
|
|
31
|
+
/** Colour of the arrows. Default the physics accent. */
|
|
32
|
+
color?: string;
|
|
33
|
+
/** Colour of the field lines. Default the physics ember. */
|
|
34
|
+
lineColor?: string;
|
|
35
|
+
/** Composition frame the arrows start to grow. Default 0. */
|
|
36
|
+
at?: number;
|
|
37
|
+
/** Frames the field lines take to draw on, after the arrows. Default 45. */
|
|
38
|
+
lineDuration?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Frames the arrows take to grow to full length. */
|
|
42
|
+
export const FIELD_ARROW_FRAMES = 20;
|
|
43
|
+
|
|
44
|
+
function defaultBox(width: number, height: number): Box {
|
|
45
|
+
const x = Math.round(width * 0.14);
|
|
46
|
+
return height > width
|
|
47
|
+
? { x, y: Math.round(height * 0.3), width: width - 2 * x, height: Math.round(width * 0.78) }
|
|
48
|
+
: { x, y: Math.round(height * 0.14), width: width - 2 * x, height: Math.round(height * 0.66) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function ownGeometry(box: Box, xRange: [number, number], yRange: [number, number], weight: number, accent: string): AxesGeometry {
|
|
52
|
+
const xs = xRange[1] - xRange[0];
|
|
53
|
+
const ys = yRange[1] - yRange[0];
|
|
54
|
+
return {
|
|
55
|
+
box,
|
|
56
|
+
xRange,
|
|
57
|
+
yRange,
|
|
58
|
+
weight,
|
|
59
|
+
accent,
|
|
60
|
+
toScreen: (x, y) => ({ x: box.x + ((x - xRange[0]) / xs) * box.width, y: box.y + box.height - ((y - yRange[0]) / ys) * box.height }),
|
|
61
|
+
toWorld: (px, py) => ({ x: xRange[0] + ((px - box.x) / box.width) * xs, y: yRange[0] + ((box.y + box.height - py) / box.height) * ys }),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface VectorFieldGrid {
|
|
66
|
+
/** Graph coordinates of the grid point. */
|
|
67
|
+
world: Point;
|
|
68
|
+
/** Where the arrow's tail is, composition px. */
|
|
69
|
+
screen: Point;
|
|
70
|
+
/** Arrow length at full growth, px. */
|
|
71
|
+
length: number;
|
|
72
|
+
/** Unit direction on screen (x right, y down). */
|
|
73
|
+
dir: Point;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The grid points, and the arrow drawn at each. Cell centres, so the grid never sits on an axis edge. */
|
|
77
|
+
export function vectorFieldGrid(g: AxesGeometry, props: Pick<VectorFieldProps, "field" | "grid" | "scale" | "normalize" | "maxLength">): { cells: VectorFieldGrid[]; scale: number } {
|
|
78
|
+
const columns = Math.max(1, Math.round(props.grid?.columns ?? 7));
|
|
79
|
+
const cellW = g.box.width / columns;
|
|
80
|
+
const rows = Math.max(1, Math.round(props.grid?.rows ?? g.box.height / cellW));
|
|
81
|
+
const cellH = g.box.height / rows;
|
|
82
|
+
const raw: { world: Point; screen: Point; mag: number; dir: Point }[] = [];
|
|
83
|
+
for (let r = 0; r < rows; r++) {
|
|
84
|
+
for (let c = 0; c < columns; c++) {
|
|
85
|
+
const screen = { x: g.box.x + (c + 0.5) * cellW, y: g.box.y + (r + 0.5) * cellH };
|
|
86
|
+
const world = g.toWorld(screen.x, screen.y);
|
|
87
|
+
const [fx, fy] = props.field(world.x, world.y);
|
|
88
|
+
const mag = Math.hypot(fx, fy);
|
|
89
|
+
raw.push({ world, screen, mag: Number.isFinite(mag) ? mag : 0, dir: mag > 0 && Number.isFinite(mag) ? { x: fx / mag, y: -fy / mag } : { x: 0, y: 0 } });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const biggest = Math.max(1e-12, ...raw.map((a) => a.mag));
|
|
93
|
+
const scale = props.scale ?? (0.8 * Math.min(cellW, cellH)) / biggest;
|
|
94
|
+
const cells = raw.map((a) => {
|
|
95
|
+
let length = props.normalize !== undefined ? (a.mag > 0 ? props.normalize : 0) : a.mag * scale;
|
|
96
|
+
if (props.maxLength !== undefined) length = Math.min(length, props.maxLength);
|
|
97
|
+
return { world: a.world, screen: a.screen, length, dir: a.dir };
|
|
98
|
+
});
|
|
99
|
+
return { cells, scale };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** One streamline through a seed (graph coordinates), as composition px points that run along the field. */
|
|
103
|
+
export function traceFieldLine(g: AxesGeometry, field: VectorFieldProps["field"], seed: [number, number], step: number, maxSteps = 500): Point[] {
|
|
104
|
+
const b = g.box;
|
|
105
|
+
const inside = (p: Point) => p.x >= b.x && p.x <= b.x + b.width && p.y >= b.y && p.y <= b.y + b.height;
|
|
106
|
+
const dirAt = (p: Point): Point | null => {
|
|
107
|
+
const w = g.toWorld(p.x, p.y);
|
|
108
|
+
const [fx, fy] = field(w.x, w.y);
|
|
109
|
+
const m = Math.hypot(fx, fy);
|
|
110
|
+
if (!(m > 1e-12) || !Number.isFinite(m)) return null;
|
|
111
|
+
return { x: fx / m, y: -fy / m };
|
|
112
|
+
};
|
|
113
|
+
const walk = (start: Point, sign: number): Point[] => {
|
|
114
|
+
const out: Point[] = [];
|
|
115
|
+
let p = start;
|
|
116
|
+
let prev: Point | null = null;
|
|
117
|
+
for (let i = 0; i < maxSteps; i++) {
|
|
118
|
+
const k1 = dirAt(p);
|
|
119
|
+
if (!k1) break;
|
|
120
|
+
const at = (q: Point, k: Point, h: number): Point => ({ x: q.x + sign * k.x * h, y: q.y + sign * k.y * h });
|
|
121
|
+
const k2 = dirAt(at(p, k1, step / 2));
|
|
122
|
+
const k3 = k2 && dirAt(at(p, k2, step / 2));
|
|
123
|
+
const k4 = k3 && dirAt(at(p, k3, step));
|
|
124
|
+
if (!k2 || !k3 || !k4) break;
|
|
125
|
+
const d = { x: (k1.x + 2 * k2.x + 2 * k3.x + k4.x) / 6, y: (k1.y + 2 * k2.y + 2 * k3.y + k4.y) / 6 };
|
|
126
|
+
// A turn of more than 90 degrees in one step means the line ran into a source, sink or zero.
|
|
127
|
+
if (prev && d.x * prev.x + d.y * prev.y < 0) break;
|
|
128
|
+
prev = d;
|
|
129
|
+
const next = { x: p.x + sign * d.x * step, y: p.y + sign * d.y * step };
|
|
130
|
+
if (!inside(next)) break;
|
|
131
|
+
// Stepping over a source or a sink would turn the line round: stop short of it.
|
|
132
|
+
const dn = dirAt(next);
|
|
133
|
+
if (dn && dn.x * d.x + dn.y * d.y < 0) break;
|
|
134
|
+
out.push(next);
|
|
135
|
+
p = next;
|
|
136
|
+
// A closed line (an orbit) stops when it comes back to where it began.
|
|
137
|
+
if (i > 10 && Math.hypot(p.x - start.x, p.y - start.y) < step * 0.75) break;
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
};
|
|
141
|
+
const s = g.toScreen(seed[0], seed[1]);
|
|
142
|
+
if (!inside(s)) return [];
|
|
143
|
+
const back = walk(s, -1).reverse();
|
|
144
|
+
const fwd = walk(s, 1);
|
|
145
|
+
return [...back, s, ...fwd];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const Arrow: React.FC<{ from: Point; dir: Point; length: number; color: string; weight: number; dataset: Record<string, string> }> = ({ from, dir, length, color, weight, dataset }) => {
|
|
149
|
+
if (length <= 0) return null;
|
|
150
|
+
const head = Math.min(weight * 5, length * 0.6);
|
|
151
|
+
const tip = { x: from.x + dir.x * length, y: from.y + dir.y * length };
|
|
152
|
+
const base = { x: from.x + dir.x * (length - head), y: from.y + dir.y * (length - head) };
|
|
153
|
+
const half = head * 0.55;
|
|
154
|
+
const left = { x: base.x - dir.y * half, y: base.y + dir.x * half };
|
|
155
|
+
const right = { x: base.x + dir.y * half, y: base.y - dir.x * half };
|
|
156
|
+
return (
|
|
157
|
+
<g {...dataset}>
|
|
158
|
+
<line x1={from.x} y1={from.y} x2={base.x} y2={base.y} stroke={color} strokeWidth={weight} />
|
|
159
|
+
<polygon points={`${tip.x},${tip.y} ${left.x},${left.y} ${right.x},${right.y}`} fill={color} />
|
|
160
|
+
</g>
|
|
161
|
+
);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Arrows on a grid for a vector field, the picture an electric or gravitational field lesson draws. Inside
|
|
166
|
+
* an <Axes> it uses that mapping; on its own it takes a `box` and ranges. Each arrow starts at its grid
|
|
167
|
+
* point and is exactly `|f| * scale` px long unless `normalize` or `maxLength` says otherwise. With
|
|
168
|
+
* `fieldLines`, streamlines are traced from the seeds by integrating the field (deterministic) and drawn
|
|
169
|
+
* on after the arrows. Arrows and lines follow the field's direction in px: (fx, -fy), whatever the axes' scales. A pure function of the frame.
|
|
170
|
+
*/
|
|
171
|
+
export const VectorField: React.FC<VectorFieldProps> = (props) => {
|
|
172
|
+
const { field, xRange = [-1, 1], yRange = [-1, 1], box, fieldLines = false, seeds, color, lineColor, at = 0, lineDuration = 45 } = props;
|
|
173
|
+
const frame = useCurrentFrame();
|
|
174
|
+
const { width, height } = useVideoConfig();
|
|
175
|
+
let inAxes: AxesGeometry | null = null;
|
|
176
|
+
try {
|
|
177
|
+
inAxes = useAxes();
|
|
178
|
+
} catch {
|
|
179
|
+
inAxes = null;
|
|
180
|
+
}
|
|
181
|
+
const weight = lineWeight(width, height);
|
|
182
|
+
const tone = SUBJECT_ACCENT.physics;
|
|
183
|
+
const g = inAxes ?? ownGeometry(box ?? defaultBox(width, height), xRange, yRange, weight, tone.accent);
|
|
184
|
+
const { cells } = vectorFieldGrid(g, props);
|
|
185
|
+
const grow = tween(frame, [at, at + FIELD_ARROW_FRAMES], [0, 1], E.cinematic);
|
|
186
|
+
const lineP = fieldLines ? tween(frame, [at + FIELD_ARROW_FRAMES, at + FIELD_ARROW_FRAMES + lineDuration], [0, 1], E.sine) : 0;
|
|
187
|
+
|
|
188
|
+
const columns = Math.max(1, Math.round(props.grid?.columns ?? 7));
|
|
189
|
+
const seedList: [number, number][] =
|
|
190
|
+
seeds ?? cells.filter((_, i) => Math.floor(i / columns) % 2 === 0 && (i % columns) % 2 === 0).map((c) => [c.world.x, c.world.y] as [number, number]);
|
|
191
|
+
const step = Math.max(2, Math.min(g.box.width, g.box.height) / 120);
|
|
192
|
+
const lines = fieldLines && lineP > 0 ? seedList.map((s) => traceFieldLine(g, field, s, step)).filter((l) => l.length >= 2) : [];
|
|
193
|
+
const partial = (pts: Point[]): Point[] => {
|
|
194
|
+
const m = lineP * (pts.length - 1);
|
|
195
|
+
const full = Math.floor(m);
|
|
196
|
+
const out = pts.slice(0, full + 1);
|
|
197
|
+
if (full < pts.length - 1 && m > full) {
|
|
198
|
+
const a = pts[full];
|
|
199
|
+
const b = pts[full + 1];
|
|
200
|
+
out.push({ x: a.x + (b.x - a.x) * (m - full), y: a.y + (b.y - a.y) * (m - full) });
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
};
|
|
204
|
+
const arrowColor = color ?? tone.accent;
|
|
205
|
+
const streamColor = lineColor ?? tone.accentAlt;
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<div style={{ position: "absolute", inset: 0, pointerEvents: "none" }} data-ovs-vectorfield="">
|
|
209
|
+
<svg width={width} height={height} style={{ position: "absolute", left: 0, top: 0 }}>
|
|
210
|
+
{lines.map((pts, i) => {
|
|
211
|
+
const shown = partial(pts);
|
|
212
|
+
return (
|
|
213
|
+
<path
|
|
214
|
+
key={i}
|
|
215
|
+
data-ovs-fieldline=""
|
|
216
|
+
d={shown.map((p, j) => `${j === 0 ? "M" : "L"}${p.x.toFixed(2)} ${p.y.toFixed(2)}`).join(" ")}
|
|
217
|
+
fill="none"
|
|
218
|
+
stroke={streamColor}
|
|
219
|
+
strokeWidth={weight * 1.2}
|
|
220
|
+
strokeLinecap="round"
|
|
221
|
+
strokeLinejoin="round"
|
|
222
|
+
/>
|
|
223
|
+
);
|
|
224
|
+
})}
|
|
225
|
+
{cells.map((c, i) => (
|
|
226
|
+
<Arrow key={i} from={c.screen} dir={c.dir} length={c.length * grow} color={arrowColor} weight={weight * 1.6} dataset={{ "data-ovs-arrow": "", "data-ovs-gx": String(c.world.x), "data-ovs-gy": String(c.world.y) }} />
|
|
227
|
+
))}
|
|
228
|
+
</svg>
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
};
|
|
@@ -0,0 +1,234 @@
|
|
|
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, type Box } from "./graph";
|
|
6
|
+
import { Spark } from "./Spark";
|
|
7
|
+
import type { Path, Point } from "./paths";
|
|
8
|
+
|
|
9
|
+
export interface WaveLabels {
|
|
10
|
+
/** A bracket exactly one wavelength wide, from one crest to the next. Default true. */
|
|
11
|
+
wavelength?: boolean;
|
|
12
|
+
/** An arrow from the rest line to a crest, exactly `amplitude` px long. Default true. */
|
|
13
|
+
amplitude?: boolean;
|
|
14
|
+
/** A marker at the left edge of the wave, with the phase written out. Default true. */
|
|
15
|
+
phase?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface WaveProps {
|
|
19
|
+
/** "travelling" moves along; "standing" has fixed nodes and antinodes that oscillate. Default "travelling". */
|
|
20
|
+
mode?: "travelling" | "standing";
|
|
21
|
+
/** Crest to crest, in px. Default a third of the box width. */
|
|
22
|
+
wavelength?: number;
|
|
23
|
+
/** Rest line to crest, in px. The drawn height of a crest is exactly this. Default 8% of the shorter side. */
|
|
24
|
+
amplitude?: number;
|
|
25
|
+
/** Travelling only: how far a crest moves per frame, in px. Exactly this. Default one wavelength per 60 frames. */
|
|
26
|
+
speed?: number;
|
|
27
|
+
/** Standing only: frames for an antinode to go up, down and back. Default 90. */
|
|
28
|
+
period?: number;
|
|
29
|
+
/** Phase in radians. The displacement at the left edge of the box, at time zero, is `amplitude * cos(phase)`. Default 0. */
|
|
30
|
+
phase?: number;
|
|
31
|
+
/** Composition frame the wave starts to draw. Default 0. */
|
|
32
|
+
at?: number;
|
|
33
|
+
/** Where the wave lives. The rest line runs through the middle of the box. Default: the middle of the frame, inside the Reels safe zone. */
|
|
34
|
+
box?: Box;
|
|
35
|
+
/** Which labels to draw. `false` draws none, `true` (the default) draws all of them. */
|
|
36
|
+
labels?: boolean | WaveLabels;
|
|
37
|
+
/** Words for the labels. Defaults "λ", "A". */
|
|
38
|
+
wavelengthText?: string;
|
|
39
|
+
amplitudeText?: string;
|
|
40
|
+
/** "crest" rides a Spark on a crest (travelling) or an antinode (standing). Default: none. */
|
|
41
|
+
spark?: "crest";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Frames the wave takes to draw itself from left to right. */
|
|
45
|
+
export const WAVE_REVEAL_FRAMES = 24;
|
|
46
|
+
const LABEL_FADE_FRAMES = 10;
|
|
47
|
+
|
|
48
|
+
export interface WaveFieldInput {
|
|
49
|
+
mode: "travelling" | "standing";
|
|
50
|
+
wavelength: number;
|
|
51
|
+
amplitude: number;
|
|
52
|
+
speed: number;
|
|
53
|
+
period: number;
|
|
54
|
+
phase: number;
|
|
55
|
+
/** Left edge of the wave, px. */
|
|
56
|
+
x0: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The maths of the wave, with no drawing. `t` is frames since the wave began. Displacement is upward positive.
|
|
61
|
+
* travelling: d = A cos(k (x - x0 - speed t) + phase), so a crest sits at x0 + speed t + (2 pi n - phase) / k
|
|
62
|
+
* standing: d = A cos(k (x - x0) + phase) cos(2 pi t / period)
|
|
63
|
+
*/
|
|
64
|
+
export function waveField(w: WaveFieldInput) {
|
|
65
|
+
const k = (2 * Math.PI) / w.wavelength;
|
|
66
|
+
const displacement = (x: number, t: number): number =>
|
|
67
|
+
w.mode === "travelling" ? w.amplitude * Math.cos(k * (x - w.x0 - w.speed * t) + w.phase) : w.amplitude * Math.cos(k * (x - w.x0) + w.phase) * Math.cos((2 * Math.PI * t) / w.period);
|
|
68
|
+
/** x of the crest (travelling) or the antinode that is at full upward stretch at t = 0 (standing), as a function of n. */
|
|
69
|
+
const crestX = (n: number, t: number): number => w.x0 + (w.mode === "travelling" ? w.speed * t : 0) + (2 * Math.PI * n - w.phase) / k;
|
|
70
|
+
/** Antinodes (maximum swing, either direction) at x0 + (n pi - phase) / k. Standing waves only. */
|
|
71
|
+
const antinodeX = (n: number): number => w.x0 + (Math.PI * n - w.phase) / k;
|
|
72
|
+
/** Nodes (never move) at x0 + ((n + 1/2) pi - phase) / k. Standing waves only. */
|
|
73
|
+
const nodeX = (n: number): number => w.x0 + ((n + 0.5) * Math.PI - w.phase) / k;
|
|
74
|
+
const crestsIn = (from: number, to: number, t: number): number[] => {
|
|
75
|
+
const out: number[] = [];
|
|
76
|
+
const n0 = Math.ceil((k * (from - w.x0 - (w.mode === "travelling" ? w.speed * t : 0)) + w.phase) / (2 * Math.PI) - 1e-9);
|
|
77
|
+
for (let n = n0; crestX(n, t) <= to + 1e-9; n++) out.push(crestX(n, t));
|
|
78
|
+
return out;
|
|
79
|
+
};
|
|
80
|
+
return { k, displacement, crestX, antinodeX, nodeX, crestsIn };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const fmtPhase = (phase: number): string => {
|
|
84
|
+
const v = Number((phase / Math.PI).toFixed(2));
|
|
85
|
+
return v === 0 ? "φ = 0" : `φ = ${v}π`;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
function useWaveParams(p: WaveProps) {
|
|
89
|
+
const { width, height } = useVideoConfig();
|
|
90
|
+
const S = Math.min(width, height);
|
|
91
|
+
const box = p.box ?? { x: Math.round(width * 0.08), y: Math.round(height * 0.38), width: Math.round(width * 0.84), height: Math.round(height * 0.24) };
|
|
92
|
+
const wavelength = p.wavelength ?? box.width / 3;
|
|
93
|
+
return {
|
|
94
|
+
width,
|
|
95
|
+
height,
|
|
96
|
+
S,
|
|
97
|
+
box,
|
|
98
|
+
input: {
|
|
99
|
+
mode: p.mode ?? "travelling",
|
|
100
|
+
wavelength,
|
|
101
|
+
amplitude: p.amplitude ?? Math.round(S * 0.08),
|
|
102
|
+
speed: p.speed ?? wavelength / 60,
|
|
103
|
+
period: p.period ?? 90,
|
|
104
|
+
phase: p.phase ?? 0,
|
|
105
|
+
x0: box.x,
|
|
106
|
+
} as WaveFieldInput,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Where the Spark rides at wave time t: a crest, kept inside the first wavelength or so of the box by wrapping. */
|
|
111
|
+
function sparkPoint(input: WaveFieldInput, box: Box, t: number): Point {
|
|
112
|
+
const f = waveField(input);
|
|
113
|
+
const base = box.y + box.height / 2;
|
|
114
|
+
if (input.mode === "standing") {
|
|
115
|
+
const xc = box.x + box.width / 2;
|
|
116
|
+
const n = Math.ceil((f.k * (xc - input.x0) + input.phase) / (2 * Math.PI));
|
|
117
|
+
return { x: f.crestX(n, 0), y: base - f.displacement(f.crestX(n, 0), t) };
|
|
118
|
+
}
|
|
119
|
+
const lo = input.x0 + input.wavelength * 0.25;
|
|
120
|
+
const raw = input.x0 + input.speed * t - input.phase / f.k;
|
|
121
|
+
const x = lo + (((raw - lo) % input.wavelength) + input.wavelength) % input.wavelength;
|
|
122
|
+
return { x, y: base - f.displacement(x, t) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* A sine wave, travelling or standing, with the wavelength, amplitude and phase drawn on it: the picture a
|
|
127
|
+
* waves lesson explains. Every number drawn is exact. Crests are exactly `wavelength` px apart, a crest is
|
|
128
|
+
* exactly `amplitude` px above the rest line, and a travelling crest moves exactly `speed` px per frame.
|
|
129
|
+
* The curve is sampled at 24 or more points per wavelength, with a sample on every crest and trough.
|
|
130
|
+
* With `spark="crest"` the one Spark rides a crest. A pure function of the frame.
|
|
131
|
+
*/
|
|
132
|
+
export const Wave: React.FC<WaveProps> = (props) => {
|
|
133
|
+
const frame = useCurrentFrame();
|
|
134
|
+
const { width, height, S, box, input } = useWaveParams(props);
|
|
135
|
+
const at = props.at ?? 0;
|
|
136
|
+
const weight = lineWeight(width, height);
|
|
137
|
+
const labelPx = Math.round(S * 0.03);
|
|
138
|
+
const accent = SUBJECT_ACCENT.physics;
|
|
139
|
+
const t = frame - at;
|
|
140
|
+
if (t < 0) return null;
|
|
141
|
+
const field = waveField(input);
|
|
142
|
+
const base = box.y + box.height / 2;
|
|
143
|
+
const x1 = box.x + box.width;
|
|
144
|
+
const reveal = tween(frame, [at, at + WAVE_REVEAL_FRAMES], [0, 1], E.cinematic);
|
|
145
|
+
const xEnd = box.x + box.width * reveal;
|
|
146
|
+
const labelOpacity = tween(frame, [at + WAVE_REVEAL_FRAMES, at + WAVE_REVEAL_FRAMES + LABEL_FADE_FRAMES], [0, 1], E.cinematic);
|
|
147
|
+
const want = props.labels === undefined || props.labels === true ? { wavelength: true, amplitude: true, phase: true } : props.labels === false ? { wavelength: false, amplitude: false, phase: false } : { wavelength: props.labels.wavelength ?? true, amplitude: props.labels.amplitude ?? true, phase: props.labels.phase ?? true };
|
|
148
|
+
|
|
149
|
+
// Sample positions: a regular lattice that has a point on every extremum, plus both ends.
|
|
150
|
+
const perWave = Math.max(24, 2 * Math.ceil(input.wavelength / 4));
|
|
151
|
+
const step = input.wavelength / perWave;
|
|
152
|
+
const origin = field.crestX(0, input.mode === "travelling" ? t : 0);
|
|
153
|
+
const first = Math.ceil((box.x - origin) / step - 1e-9);
|
|
154
|
+
const xs: number[] = [box.x];
|
|
155
|
+
for (let i = first; origin + i * step < xEnd - 1e-9; i++) if (origin + i * step > box.x + 1e-9) xs.push(origin + i * step);
|
|
156
|
+
if (xEnd > box.x) xs.push(xEnd);
|
|
157
|
+
const pts = xs.map((x) => ({ x, y: base - field.displacement(x, t) }));
|
|
158
|
+
const d = pts.map((q, i) => `${i === 0 ? "M" : "L"}${q.x.toFixed(3)} ${q.y.toFixed(3)}`).join(" ");
|
|
159
|
+
|
|
160
|
+
const crests = input.mode === "travelling" ? field.crestsIn(box.x, x1, t) : [];
|
|
161
|
+
const nodes: number[] = [];
|
|
162
|
+
const antinodes: number[] = [];
|
|
163
|
+
if (input.mode === "standing") {
|
|
164
|
+
for (let n = -200; n <= 200; n++) {
|
|
165
|
+
const nx = field.nodeX(n);
|
|
166
|
+
if (nx >= box.x - 1e-9 && nx <= xEnd + 1e-9) nodes.push(nx);
|
|
167
|
+
const ax = field.antinodeX(n);
|
|
168
|
+
if (ax >= box.x - 1e-9 && ax <= xEnd + 1e-9) antinodes.push(ax);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The crest the labels hang on: the first one at or after a quarter wavelength in, one wavelength wide bracket.
|
|
173
|
+
const anchor = sparkPoint(input, box, t).x;
|
|
174
|
+
const bracketY = base - input.amplitude - S * 0.05;
|
|
175
|
+
const showLabels = labelOpacity > 0;
|
|
176
|
+
const sparkPath: Path = (local) => sparkPoint(input, box, local + WAVE_REVEAL_FRAMES);
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<div style={{ position: "absolute", inset: 0, pointerEvents: "none" }} data-ovs-wave-root="">
|
|
180
|
+
<svg width={width} height={height} style={{ position: "absolute", left: 0, top: 0 }}>
|
|
181
|
+
<line data-ovs-baseline="" x1={box.x} y1={base} x2={x1} y2={base} stroke={VOID.textDim} strokeWidth={weight} strokeDasharray={`${weight * 2.5} ${weight * 2}`} />
|
|
182
|
+
<path
|
|
183
|
+
data-ovs-wave={input.mode}
|
|
184
|
+
data-ovs-wavelength={input.wavelength}
|
|
185
|
+
data-ovs-amplitude={input.amplitude}
|
|
186
|
+
data-ovs-speed={input.speed}
|
|
187
|
+
data-ovs-baseline-y={base}
|
|
188
|
+
data-ovs-crests={crests.map((c) => c.toFixed(3)).join(",")}
|
|
189
|
+
d={d}
|
|
190
|
+
fill="none"
|
|
191
|
+
stroke={accent.accent}
|
|
192
|
+
strokeWidth={weight * 1.6}
|
|
193
|
+
strokeLinejoin="round"
|
|
194
|
+
strokeLinecap="round"
|
|
195
|
+
/>
|
|
196
|
+
{nodes.map((nx, i) => (
|
|
197
|
+
<circle key={`n${i}`} data-ovs-node={nx.toFixed(3)} cx={nx} cy={base} r={weight * 2} fill={VOID.base} stroke={VOID.text} strokeWidth={weight} />
|
|
198
|
+
))}
|
|
199
|
+
{showLabels && (
|
|
200
|
+
<g opacity={labelOpacity}>
|
|
201
|
+
{want.wavelength && (
|
|
202
|
+
<g data-ovs-label="wavelength" data-ovs-x1={anchor.toFixed(3)} data-ovs-x2={(anchor + input.wavelength).toFixed(3)}>
|
|
203
|
+
<line x1={anchor} y1={bracketY} x2={anchor + input.wavelength} y2={bracketY} stroke={accent.accentAlt} strokeWidth={weight} />
|
|
204
|
+
<line x1={anchor} y1={bracketY - weight * 3} x2={anchor} y2={bracketY + weight * 3} stroke={accent.accentAlt} strokeWidth={weight} />
|
|
205
|
+
<line x1={anchor + input.wavelength} y1={bracketY - weight * 3} x2={anchor + input.wavelength} y2={bracketY + weight * 3} stroke={accent.accentAlt} strokeWidth={weight} />
|
|
206
|
+
<text x={anchor + input.wavelength / 2} y={bracketY - labelPx * 0.5} textAnchor="middle" fontSize={labelPx} fill={accent.accentAlt} fontFamily={displayStack()} fontWeight={700}>{props.wavelengthText ?? "λ"}</text>
|
|
207
|
+
</g>
|
|
208
|
+
)}
|
|
209
|
+
{want.amplitude && (
|
|
210
|
+
<g data-ovs-label="amplitude" data-ovs-length={input.amplitude}>
|
|
211
|
+
<line data-ovs-amplitude-line="" x1={anchor} y1={base} x2={anchor} y2={base - input.amplitude} stroke={VOID.text} strokeWidth={weight} />
|
|
212
|
+
<polygon points={`${anchor},${base - input.amplitude} ${anchor - weight * 2.2},${base - input.amplitude + weight * 4.5} ${anchor + weight * 2.2},${base - input.amplitude + weight * 4.5}`} fill={VOID.text} />
|
|
213
|
+
<text x={anchor + weight * 4} y={base - input.amplitude / 2 + labelPx * 0.35} fontSize={labelPx} fill={VOID.text} fontFamily={displayStack()} fontWeight={700}>{props.amplitudeText ?? "A"}</text>
|
|
214
|
+
</g>
|
|
215
|
+
)}
|
|
216
|
+
{want.phase && (
|
|
217
|
+
<g data-ovs-label="phase" data-ovs-phase={input.phase}>
|
|
218
|
+
<line x1={box.x} y1={base} x2={box.x} y2={base - field.displacement(box.x, t)} stroke={VOID.textDim} strokeWidth={weight} />
|
|
219
|
+
<circle cx={box.x} cy={base - field.displacement(box.x, t)} r={weight * 2.2} fill={VOID.textDim} />
|
|
220
|
+
<text x={box.x} y={base + input.amplitude + labelPx * 1.6} fontSize={labelPx} fill={VOID.textDim} fontFamily={bodyStack()}>{fmtPhase(input.phase)}</text>
|
|
221
|
+
</g>
|
|
222
|
+
)}
|
|
223
|
+
</g>
|
|
224
|
+
)}
|
|
225
|
+
{props.spark === "crest" && (() => {
|
|
226
|
+
const sp = sparkPoint(input, box, t);
|
|
227
|
+
return reveal >= 1 ? <circle data-ovs-crest-spark="" data-ovs-x={sp.x.toFixed(3)} data-ovs-y={sp.y.toFixed(3)} cx={sp.x} cy={sp.y} r={1} fill="none" /> : null;
|
|
228
|
+
})()}
|
|
229
|
+
{antinodes.length > 0 && <g data-ovs-antinodes={antinodes.map((a) => a.toFixed(3)).join(",")} />}
|
|
230
|
+
</svg>
|
|
231
|
+
{props.spark === "crest" && <Spark path={sparkPath} at={at + WAVE_REVEAL_FRAMES} trail={10} subject="physics" />}
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
};
|
|
@@ -38,3 +38,24 @@ export { FreeBody, ARROW_GROW_FRAMES } from "./FreeBody";
|
|
|
38
38
|
export type { FreeBodyProps, Force } from "./FreeBody";
|
|
39
39
|
export { ArrayViz, useArrayViz } from "./ArrayViz";
|
|
40
40
|
export type { ArrayVizProps, ArrayPointer, ArrayGeometry } from "./ArrayViz";
|
|
41
|
+
|
|
42
|
+
export { LessonCaptions, captionChunks, captionInsets } from "./LessonCaptions";
|
|
43
|
+
export type { LessonCaptionsProps } from "./LessonCaptions";
|
|
44
|
+
|
|
45
|
+
export { Derivation, revealFrames, matchTerms, slotOpacity, DERIVATION_MOVE_FRAMES, DERIVATION_TERM_STAGGER, DERIVATION_STACK } from "./Derivation";
|
|
46
|
+
export type { DerivationProps, DerivationStep } from "./Derivation";
|
|
47
|
+
|
|
48
|
+
export { Wave, waveField, WAVE_REVEAL_FRAMES } from "./Wave";
|
|
49
|
+
export type { WaveProps, WaveLabels, WaveFieldInput } from "./Wave";
|
|
50
|
+
export { Orbit, orbitState, eccentricAnomaly, visViva, inverseSquare, ORBIT_ARROW_FRAMES } from "./Orbit";
|
|
51
|
+
export type { OrbitProps, OrbitShape, OrbitState } from "./Orbit";
|
|
52
|
+
export { VectorField, vectorFieldGrid, traceFieldLine, FIELD_ARROW_FRAMES } from "./VectorField";
|
|
53
|
+
export type { VectorFieldProps, VectorFieldGrid } from "./VectorField";
|
|
54
|
+
export { ComplexityCurve, complexityValue, COMPLEXITY_NAMES, COMPLEXITY_COLORS } from "./ComplexityCurve";
|
|
55
|
+
export type { ComplexityCurveProps, ComplexityKind } from "./ComplexityCurve";
|
|
56
|
+
export { MatrixGrid, matrixGridGeometry, interpolateMatrix, applyMatrix, defaultMatrixBox } from "./MatrixGrid";
|
|
57
|
+
export type { MatrixGridProps, MatrixGridGeometry, Matrix2 } from "./MatrixGrid";
|
|
58
|
+
export { TreeViz, treeLayout } from "./TreeViz";
|
|
59
|
+
export type { TreeVizProps, TreeLayout, TreeNodeLayout, Traversal } from "./TreeViz";
|
|
60
|
+
export { GraphViz, GRAPH_ROLES, GRAPH_ROLE_COLOR, adjacencyFrom, bfsOrder, bfsSteps, shortestPath, graphLayout } from "./GraphViz";
|
|
61
|
+
export type { GraphVizProps, GraphNode, GraphStep, GraphRole, GraphLayout, Adjacency } from "./GraphViz";
|
|
@@ -9,20 +9,20 @@
|
|
|
9
9
|
"dependencies": {
|
|
10
10
|
"@openvidstudio/core": "file:./openvidstudio-core",
|
|
11
11
|
"@openvidstudio/stem": "file:./openvidstudio-stem",
|
|
12
|
-
"@remotion/cli": "4.0.
|
|
13
|
-
"@remotion/google-fonts": "4.0.
|
|
14
|
-
"@remotion/motion-blur": "4.0.
|
|
15
|
-
"@remotion/shapes": "4.0.
|
|
16
|
-
"@remotion/tailwind-v4": "4.0.
|
|
17
|
-
"@remotion/transitions": "4.0.
|
|
18
|
-
"react": "19.3.
|
|
19
|
-
"react-dom": "19.3.
|
|
20
|
-
"remotion": "4.0.
|
|
12
|
+
"@remotion/cli": "4.0.536",
|
|
13
|
+
"@remotion/google-fonts": "4.0.536",
|
|
14
|
+
"@remotion/motion-blur": "4.0.536",
|
|
15
|
+
"@remotion/shapes": "4.0.536",
|
|
16
|
+
"@remotion/tailwind-v4": "4.0.536",
|
|
17
|
+
"@remotion/transitions": "4.0.536",
|
|
18
|
+
"react": "19.3.1",
|
|
19
|
+
"react-dom": "19.3.1",
|
|
20
|
+
"remotion": "4.0.536",
|
|
21
21
|
"tailwindcss": "4.3.3"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"@types/react": "19.3.0",
|
|
25
|
-
"@types/web": "0.0.
|
|
25
|
+
"@types/web": "0.0.359",
|
|
26
26
|
"typescript": "7.0.2"
|
|
27
27
|
},
|
|
28
28
|
"scripts": {
|