@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
package/docs/API.md CHANGED
@@ -1014,6 +1014,55 @@ width }`). `useNumberLine().toScreen(value)` lets a Spark ride the line.
1014
1014
  Strokes are never thinner than `lineWeight(width, height)`, 3px at 1080 on the shorter side (the plot
1015
1015
  line is about 1.7 times that). None of these glow: the Spark is the one element that does.
1016
1016
 
1017
+ ### `Derivation`
1018
+
1019
+ ![A projectile's range derived in steps: three lines of algebra stacked, the newest in the accent and white, the two before it dimmed, with a reason under one step](img/api/stem-derivation.png)
1020
+
1021
+ A formula built line by line, each line spoken: the derive beat of a lesson. The newest line sits at the bottom;
1022
+ the one before it moves up and dims, up to a stack of three, so the viewer reads the algebra top to bottom. The
1023
+ lines share a left edge and sit on a baseline, as algebra on paper does, so between two lines the terms they
1024
+ share (matched by their TeX, top level terms) stay exactly where they are, and slide only if the terms before them
1025
+ changed width. Terms that are new fade in and rise in the accent and settle to white; terms that are gone fade out where
1026
+ they were. Term level, not per glyph. The range of a projectile, `R = u^2 sin(2 theta) / g`, in seven steps, is the sample scene.
1027
+
1028
+ | Prop | Type | Default | Notes |
1029
+ |---|---|---|---|
1030
+ | `steps` | `{ tex, spoken, because? }[]` | required | `tex` is the line. `spoken` is **required**, exactly as on `Equation`: it must appear in the beat's narration, in the order the steps are given, and `check_script` fails a step that does not. `because` is a short reason in plain words ("no horizontal force") shown under the step |
1031
+ | `beatId` | `string` | | Reveal each step at the word its `spoken` starts at, found in `useNarrationWords(beatId)` after the previous step's words |
1032
+ | `at` | `number[]` | | Explicit frames, one per step, which win over the narration |
1033
+ | `from`, `stagger` | `number` | `0`, `60` | When there is neither narration nor `at`: the first step's frame and the gap between steps |
1034
+ | `size` | `number` | 7% of the shorter side, smaller if the widest line would not fit | Font size in px |
1035
+ | `color`, `subject` | | the Void's warm white, `"math"` | `subject` picks the colour new terms arrive in |
1036
+ | `center` | `{ x, y }` | the horizontal centre, 60% down a tall frame | `y` is the baseline of the current line, `x` the middle of the block |
1037
+
1038
+ The stack is three lines: opacity 1, about a half, about a quarter; a fourth line is gone. `revealFrames`,
1039
+ `matchTerms` and `slotOpacity` are the pure functions behind it.
1040
+
1041
+ ### `LessonCaptions`
1042
+
1043
+ ![A lesson caption block above the bottom safe zone over a projectile path: five words in warm white with the word being said in the accent](img/api/stem-lessoncaptions.png)
1044
+
1045
+ Burned-in captions timed to the narration, in one line: `<LessonCaptions beatId="hook" text={vo} />`. The word
1046
+ timings (`<beat>.words.json`) carry bare words with no punctuation, so chunking on them runs one sentence into
1047
+ the next. The text comes from the beat's `vo`: split on spaces, and when that gives as many words as the timings it is
1048
+ shown with its punctuation, otherwise the timings' bare words are. A caption breaks at a sentence end, at a comma
1049
+ after at least three words, and at `maxWords`; a one word sentence is a caption on its own. The word being said is
1050
+ picked out in the subject's accent. A pure function of the frame.
1051
+
1052
+ | Prop | Type | Default | Notes |
1053
+ |---|---|---|---|
1054
+ | `beatId` | `string` | required | Read with `useNarrationWords(beatId)` |
1055
+ | `text` | `string` | required | The beat's `vo` |
1056
+ | `position` | `"bottom" \| "top"` | `"bottom"` | Above the bottom 22% or below the top 14% of a tall frame (8% either way on a wide one) |
1057
+ | `maxWords` | `number` | `6` | Most words in one caption |
1058
+ | `left`, `width` | `number` | 10% of the width, the rest | The block's box |
1059
+ | `subject`, `fontSize` | | `"math"`, 4.8% of the shorter side | |
1060
+
1061
+ With no timings at all (an engine that gives none) the words are spread evenly over the scene, and the block says so in
1062
+ `data-ovs-captions-timing="estimated"`. The block is tagged `data-ovs-captions`, which `validate_scenes` ignores when it
1063
+ looks for the first drawn element and for a static stretch. `captionChunks(words, maxWords)` and `captionInsets(width, height)`
1064
+ are the pure functions.
1065
+
1017
1066
  ### `FreeBody`
1018
1067
 
1019
1068
  ![A block on a slope at rest: weight, the normal force and friction as labelled arrows from its centre, with a 10 N scale bar](img/api/stem-freebody.png)
@@ -1053,8 +1102,250 @@ binary search and a sorting pass. Binary search over a sorted array is the sampl
1053
1102
  `useArrayViz()` returns `{ cellSize, left, top, cellCenter(i) }` so a Spark's path (`walk`) can be built from
1054
1103
  the cells' own positions.
1055
1104
 
1105
+ ### `Wave`
1106
+
1107
+ ![A sine wave on a dark background with a bracket one wavelength wide above two crests, an arrow from the rest line up to a crest for the amplitude, and a marker for the phase at the left edge.](img/api/stem-wave.png)
1108
+
1109
+ A travelling or standing sine wave with its wavelength, amplitude and phase drawn on it. Every number is exact: crests are exactly `wavelength` px apart, a crest is exactly `amplitude` px above the rest line, and a travelling crest moves exactly `speed` px per frame. The curve is a dense SVG path (24 or more samples per wavelength, with a sample on every crest and trough). In a standing wave the nodes (small rings on the rest line) never move and the antinodes swing up and down. `spark="crest"` rides the one Spark on a crest (an antinode, for a standing wave). A pure function of the frame; nothing is drawn before `at`.
1110
+
1111
+ The displacement is `amplitude * cos(k (x - x0 - speed t) + phase)` for a travelling wave and `amplitude * cos(k (x - x0) + phase) * cos(2 pi t / period)` for a standing one, with `k = 2 pi / wavelength` and `x0` the left edge of the box. Find the elements in a test by `data-ovs-wave` (with `data-ovs-crests`, `data-ovs-wavelength`, `data-ovs-amplitude`), `data-ovs-node`, `data-ovs-label` (`wavelength`, `amplitude`, `phase`) and `data-ovs-crest-spark`.
1112
+
1113
+ | Prop | Type | Default | Notes |
1114
+ | --- | --- | --- | --- |
1115
+ | `mode` | `"travelling" \| "standing"` | `"travelling"` | |
1116
+ | `wavelength` | number (px) | a third of the box width | Crest to crest. |
1117
+ | `amplitude` | number (px) | 8% of the shorter side | Rest line to crest. |
1118
+ | `speed` | number (px per frame) | one wavelength per 60 frames | Travelling only. A crest moves exactly this far each frame. |
1119
+ | `period` | number (frames) | 90 | Standing only. |
1120
+ | `phase` | number (radians) | 0 | The displacement at the left edge at time zero is `amplitude * cos(phase)`. |
1121
+ | `at` | number (frame) | 0 | The wave draws itself left to right over `WAVE_REVEAL_FRAMES` (24). |
1122
+ | `box` | `{ x, y, width, height }` | middle of the frame, inside the Reels safe zone | The rest line runs through the middle of the box. |
1123
+ | `labels` | boolean or `{ wavelength?, amplitude?, phase? }` | all on | Wavelength bracket (crest to crest), amplitude arrow (rest line to crest), phase marker. They fade in once the wave has drawn. |
1124
+ | `wavelengthText`, `amplitudeText` | string | `"λ"`, `"A"` | |
1125
+ | `spark` | `"crest"` | none | One Spark on a crest. On a travelling wave it rides a crest and, when that crest has gone a wavelength on, hands over to the next one. |
1126
+
1127
+ `waveField(input)` returns `{ k, displacement, crestX, antinodeX, nodeX, crestsIn }`, the maths without the drawing.
1128
+
1129
+ ### `Orbit`
1130
+
1131
+ ![A dark picture of a body on an elliptical orbit round a grey central disc, with a blue velocity arrow along the path and an ember force arrow pointing at the centre, and two scale bars in the corner.](img/api/stem-orbit.png)
1132
+
1133
+ A body on a circular or elliptical orbit with its velocity and force arrows drawn to scale, like `FreeBody`: each arrow is exactly `magnitude * scale` px from tail to tip, and a scale bar for each says how much that is. The body is the one Spark; the central mass is a plain disc at a focus of the ellipse.
1134
+
1135
+ What is exact: the position (Kepler's equation is solved, so the body is at periapsis at `t = 0` and every `period`, at apoapsis half a period later, at distances `a(1 - e)` and `a(1 + e)`); the force arrow always points at the central mass; the velocity arrow is tangent to the path (perpendicular to the radius on a circle); the magnitudes follow vis-viva for speed, `v sqrt(2a/r - 1)`, and the inverse square law for force, `F (a/r)^2`, where `v` and `F` are the speed and force at a distance of `semiMajor`. What is not: `v` and `F` are the caller's numbers in the caller's units, so nothing is checked against a real gravitational constant, and the arrow scale (px per unit) is independent of the scale of the drawn orbit. A pure function of the frame; nothing is drawn before `at`. Find the elements by `data-ovs-arrow` (`velocity`, `force`, with `data-ovs-magnitude` and `data-ovs-length`), `data-ovs-mass`, `data-ovs-body`, `data-ovs-orbit-path` and `data-ovs-scale`.
1136
+
1137
+ | Prop | Type | Default | Notes |
1138
+ | --- | --- | --- | --- |
1139
+ | `semiMajor` | number (px) | 34% of the shorter side | The radius, for a circle. |
1140
+ | `eccentricity` | number | 0 | 0 is a circle; capped at 0.95. |
1141
+ | `period` | number (frames) | required | One full orbit. |
1142
+ | `v`, `F` | number | none | Speed and force at distance `semiMajor`. On a circle they are the speed and force all the way round. |
1143
+ | `speedAt`, `forceAt` | `(r: px) => number` | vis-viva, inverse square | Override the physics. |
1144
+ | `scale` | `{ velocity, force }` | required | Pixels per unit of magnitude. |
1145
+ | `units` | `{ velocity?, force? }` | `"m/s"`, `"N"` | On the scale bars. |
1146
+ | `scaleValue` | `{ velocity?, force? }` | a round value near half the periapsis value | What each scale bar stands for. |
1147
+ | `centre` | `{ x, y }` | puts the whole ellipse in the middle of the frame | Where the central mass is (the focus). |
1148
+ | `periapsisAngle` | degrees | 0 | Direction of the closest point, counter-clockwise from the right. The body starts there. |
1149
+ | `clockwise` | boolean | false | |
1150
+ | `arrows` | boolean | true | `false` hides the arrows and the scale bars. |
1151
+ | `at`, `arrowsAt` | number (frame) | 0, `at + 12` | The arrows grow over `ORBIT_ARROW_FRAMES` (14). |
1152
+ | `massRadius`, `centreLabel` | number, string | 3.2% of the shorter side, none | |
1153
+
1154
+ `orbitState(shape, t)` returns `{ anomaly, r, offset, tangent, towardCentre }` for a body `t` frames after release.
1155
+
1156
+ ### `VectorField`
1157
+
1158
+ ![A point charge's electric field: amber arrows pointing straight out from the centre of a grid, longer near the charge, with twelve ember field lines running out to the edges](img/api/stem-vectorfield.png)
1159
+
1160
+ Arrows on a grid for a vector field `(x, y) => [fx, fy]` given in graph coordinates, with optional field
1161
+ lines, for a physics lesson on electric or gravitational fields. Inside an `<Axes>` it uses that mapping;
1162
+ on its own it takes a `box` and `xRange`/`yRange`. Each arrow starts at its grid point (cell centres) and is
1163
+ exactly `|f| * scale` px from tail to tip, with arrowheads like `FreeBody`'s. Nothing is capped or
1164
+ normalised unless `maxLength` or `normalize` is given, and then the arrows that are capped or normalised no
1165
+ longer show `|f|`, so say so in the narration. Arrows and lines point along `(fx, -fy)` in px whatever the
1166
+ axes' scales; give the box and ranges equal px per unit (a square box over square ranges) if the picture
1167
+ should look like the field does in the plane. With `fieldLines`, streamlines are traced from the seeds by
1168
+ integrating the field (RK4, fixed step, deterministic) both ways until they leave the box, run into a zero
1169
+ or a source, or close up, and are drawn on after the arrows. A point charge's radial field with its lines
1170
+ is the sample scene.
1171
+
1172
+ | Prop | Type | Default | Notes |
1173
+ |---|---|---|---|
1174
+ | `field` | `(x, y) => [fx, fy]` | required | Graph point to vector, y up. Return `[0, 0]` where undefined (a charge's own position) |
1175
+ | `grid` | `{ columns, rows? }` | 7 columns, rows to keep cells square | Arrows are at the cell centres, so a grid never sits on the origin or an edge |
1176
+ | `scale` | `number` | longest arrow is 80% of a cell | Pixels per unit of `|f|`. An arrow is exactly `\|f\| * scale` px |
1177
+ | `normalize` | `number` | off | Every arrow this many px long, direction only |
1178
+ | `maxLength` | `number` | off | Cap in px. Capped arrows are shorter than `\|f\| * scale` |
1179
+ | `fieldLines` | `boolean` | `false` | Trace and draw field lines |
1180
+ | `seeds` | `[x, y][]` | every other grid point | Graph points the lines start from, each traced both ways |
1181
+ | `box`, `xRange`, `yRange` | `Box`, `[n, n]` | frame-sized box, `[-1, 1]` | Used outside `<Axes>`. Inside one, the Axes' own are used |
1182
+ | `color`, `lineColor` | `string` | physics amber, ember | |
1183
+ | `at`, `lineDuration` | `number` | `0`, `45` | When the arrows start growing (20 frames, `FIELD_ARROW_FRAMES`), and how long the lines take to draw on after that |
1184
+
1185
+ Helpers: `vectorFieldGrid(geometry, props)` returns each arrow (`world`, `screen` tail, `length`, unit
1186
+ `dir`), and `traceFieldLine(geometry, field, seed, step)` one streamline as px points. Elements carry
1187
+ `data-ovs-vectorfield`, `data-ovs-arrow` (with `data-ovs-gx`, `data-ovs-gy`, the grid point) and
1188
+ `data-ovs-fieldline`.
1189
+
1190
+ ### `ComplexityCurve`
1191
+
1192
+ ![n, n log n and n squared drawn on shared axes in green, violet and red, named at their ends, with a dashed rule at n equals 12 and a dot with its value on each curve](img/api/stem-complexitycurve.png)
1193
+
1194
+ The growth of `n`, `n log n` and `n^2` plotted together on shared axes, with a marker for a chosen `n`: a
1195
+ dashed vertical rule, a dot on each curve and the value there. Built from `Axes` and `Plot`, so a curve
1196
+ that climbs past `yMax` is clipped at the top of the plot. **The log is base 2**: the `n log n` curve is
1197
+ `n * log2(n)` (drawn as 0 for n at or below 1). Each curve has its own accent (n green, n log n the cs
1198
+ violet, n squared red) and is named at its end. The curves are drawn on over `duration` frames, and the
1199
+ marker and names appear once the draw-on reaches them. The marker at n = 12 is the sample scene.
1200
+
1201
+ | Prop | Type | Default | Notes |
1202
+ |---|---|---|---|
1203
+ | `curves` | `("n" \| "nlogn" \| "n2")[]` | all three | Which to draw, in this order |
1204
+ | `n` | `number` | none | The marked input size. Dots sit at `Axes.toScreen(n, f(n))` |
1205
+ | `xMax` | `number` | `1.5 * n`, or 16 | Right end of the x axis (it starts at 0) |
1206
+ | `yMax` | `number` | `1.25 * ` the largest value at `n`, or the largest at `xMax` | Top of the y axis |
1207
+ | `at`, `duration` | `number` | `0`, `60` | Draw-on start frame and length |
1208
+ | `box` | `Box` | frame-sized | Where the plot sits |
1209
+ | `labels` | `boolean` | `true` | Curve names at their ends, the values at the dots and "n = 12" |
1210
+
1211
+ Also exports `complexityValue(kind, n)`, `COMPLEXITY_NAMES` and `COMPLEXITY_COLORS`. Elements carry
1212
+ `data-ovs-complexity`, `data-ovs-plot="point"`, `data-ovs-marker`, `data-ovs-marker-rule` and
1213
+ `data-ovs-curve-label`.
1214
+
1215
+ ### `MatrixGrid`
1216
+
1217
+ ![A two by two matrix drawn in brackets above a plane of points, with the unit square sheared and stretched into a parallelogram and arrows for the images of i-hat and j-hat.](img/api/stem-matrixgrid.png)
1218
+
1219
+ A 2x2 matrix acting on the plane. A grid of points (and the lines through them), the unit square and the images of i-hat and j-hat move from the identity to the matrix over `at` to `at + duration`. Every entry is interpolated linearly from the identity, so each frame shows the plane under `I + p (M - I)` and the last frame exactly `M`. The picture is honest: a point (x, y) is drawn at `scale` px per unit times `M * (x, y)`, the origin never moves, and the square is drawn flipped when the determinant is negative (`data-ovs-orientation`). The matrix is shown in brackets above the plane in portrait and beside it in landscape, with its determinant. A pure function of the frame; nothing glows.
1220
+
1221
+ | Prop | Type | Default | |
1222
+ |---|---|---|---|
1223
+ | `matrix` | `number[][]` | required | 2x2, rows first: `[[a, b], [c, d]]` |
1224
+ | `at` | `number` | `0` | Frame the picture appears and the transform starts |
1225
+ | `duration` | `number` | `45` | Frames from the identity to the matrix |
1226
+ | `transform` | `boolean` | `true` | `false` shows the plane already transformed |
1227
+ | `ease` | `(t) => number` | `E.cinematic` | Shapes the progress; `(t) => t` is linear |
1228
+ | `grid` | `{ extent?, spacing? }` | `{ 2, 1 }` | Reach and gap of the point grid, graph units |
1229
+ | `unitSquare` | `boolean` | `true` | Unit square, its image, and the i-hat and j-hat arrows |
1230
+ | `showMatrix` | `boolean` | `true` | Draw the matrix with its determinant |
1231
+ | `box` | `Box` | middle band (portrait), right side (landscape) | Where the plane is drawn; its centre is the origin |
1232
+ | `scale` | `number` | fits the grid and its image in the box | px per graph unit |
1233
+ | `subject` | `Subject` | `"math"` | Picks the accent |
1234
+
1235
+ Helpers: `matrixGridGeometry(matrix, { width, height, box?, scale?, extent? })` returns `{ box, origin, scale, toScreen }`; `interpolateMatrix(m, progress)` and `applyMatrix(m, x, y)` are the pure maths. DOM: `data-ovs-point="x,y"` (with `data-ovs-sx/sy`), `data-ovs-corner`, `data-ovs-basis="i|j"`, `data-ovs-unit-square`, `data-ovs-progress`.
1236
+
1237
+ ### `TreeViz`
1238
+
1239
+ ![A binary search tree of six violet-ringed numbered nodes joined by edges, with the in-order list of visited values under it.](img/api/stem-treeviz.png)
1240
+
1241
+ A binary search tree built one insert at a time, with traversals. The component builds the BST from `values` itself (smaller to the left, a repeated value skipped) and lays it out tidily from the frame size: x is the in-order rank, y the depth, so a left child is always left of its parent and a right child right of it, one level lower. Each node arrives with its edge. A Spark can walk from the root to the place of a value as it is inserted (the node appears when the Spark gets there). A traversal then lights the nodes in visit order, one every `hop` frames, and lists the visited values under the tree. The only glowing element is the Spark. A pure function of the frame.
1242
+
1243
+ | Prop | Type | Default | |
1244
+ |---|---|---|---|
1245
+ | `values` | `number[]` | required | Inserted in this order |
1246
+ | `at` | `number` | `0` | Frame the first insert starts |
1247
+ | `insertAt` | `number` | `24` | Frames per insert (a walked insert also takes its walk) |
1248
+ | `walkValue` | `number` | last value | The Spark walks to this value's place |
1249
+ | `spark` | `boolean` | `true` | `false` turns the walk off |
1250
+ | `walkHop`, `walkHold` | `number` | `14`, `8` | Frames per hop down, and resting on each node |
1251
+ | `traversal` | `"inorder" \| "preorder" \| "postorder" \| "level"` | none | Light nodes in this order and list them |
1252
+ | `traverseAt` | `number` | 20 frames after the last insert | Frame the first node lights |
1253
+ | `hop` | `number` | `18` | Frames between one node lighting and the next |
1254
+ | `highlight` | `number` | none | Ring the node with this value |
1255
+ | `subject` | `Subject` | `"cs"` | Picks the accent |
1256
+
1257
+ Helper: `treeLayout(values, { width, height, at?, insertAt?, walkValue?, spark?, walkHop?, walkHold?, traverseAt? })` returns `{ nodes: [{ value, order, parent, side, depth, x, y, appearFrame }], radius, orders: { inorder, preorder, postorder, level }, walkPoints, ... }`. DOM: `data-ovs-node`, `data-ovs-x/y`, `data-ovs-lit`, `data-ovs-edge="parent-child"`, `data-ovs-visited` with `data-ovs-visit-index`.
1258
+
1259
+ ### `GraphViz`
1260
+
1261
+ ![A graph of six violet-outlined nodes joined by lines, with three nodes filled blue as done, two filled violet as the frontier, one warm white where a single glowing Spark rests, and a legend naming each colour](img/api/stem-graphviz.png)
1262
+
1263
+ Nodes and edges for teaching graph search. Each node is coloured by its role at the current frame (dim: unvisited, violet: frontier, warm white: being visited, blue: done, ember: on the final path) and a legend names the roles. A `walk` hops one `Spark` from node to node (the only glowing element), using `walk` from `paths.ts`. Build `steps` from a real search with `bfsSteps`, not by hand.
1264
+
1265
+ | Prop | Type | Default | Meaning |
1266
+ |---|---|---|---|
1267
+ | `nodes` | `{ id, x?, y?, label? }[]` | required | `x`, `y` are fractions (0..1) of the layout box. Give all nodes both, or none: then they sit evenly on a circle that fits the box, from the top. `label` defaults to the id and is drawn inside the node. |
1268
+ | `edges` | `[from, to][]` | required | Lines run centre to centre. |
1269
+ | `directed` | `boolean` | `false` | Arrowheads; lines stop at the node edge. |
1270
+ | `steps` | `{ frame, visit?, frontier?, done?, path? }[]` | none | A snapshot from `frame` on: the latest step at or before the current frame gives every node's role. Nodes in none of its lists are unvisited. Edges between consecutive nodes of `path` are drawn in ember at twice the weight. |
1271
+ | `walk` | `string[]` | none | Node ids a single Spark hops along. The Spark rests on `walk[k]` during local frames `[k*(hop+hold)+hop, (k+1)*(hop+hold)]`, so a step at `walkAt + k*(hop+hold) + hop` lines the colour up with the Spark. |
1272
+ | `hop`, `hold` | `number` | `20`, `20` | Frames to move, and to rest on each node. |
1273
+ | `walkAt` | `number` | `at` | Frame the Spark starts. |
1274
+ | `at` | `number` | `0` | Frame the graph appears; nothing is drawn before it. |
1275
+ | `box` | `{ x, y, w, h }` | 80% of the width, from 17% down, 45% of the height | The layout box in px. |
1276
+ | `radius` | `number` | 4.5% of the shorter side | Node radius. Label text is 80% of it, the legend is 2.7% of the shorter side. |
1277
+ | `legend` | `boolean` | `true` | The role legend, below the box, wrapped to the box width. |
1278
+
1279
+ Helpers: `bfsOrder(adjacency, start)` (visiting order, neighbours in listed order), `shortestPath(adjacency, from, to)` (fewest edges, or `null`), `adjacencyFrom(edges, directed?, ids?)`, `bfsSteps(adjacency, start, { first, every, goal? })` (a step per visited node: that node visiting, the earlier ones done, the discovered ones frontier; with `goal`, a last step showing the shortest path), `graphLayout(nodes, width, height, { box, radius })` (centres in px). `GRAPH_ROLE_COLOR` holds the role fills.
1280
+
1056
1281
  ### The glow budget
1057
1282
 
1058
1283
  One glowing element at a time. Spark and Nova draw their glow through `Glow`, which tags its element
1059
1284
  with `data-ovs-glow` and `data-ovs-glow-level`; `countGlowing(root)` counts the elements above
1060
1285
  `GLOW_VISIBLE_LEVEL`. A Spark is one, a Nova's flash is one (the ring and particles do not glow).
1286
+
1287
+ ## Pixel narrator and depth kit: `@openvidstudio/styles`
1288
+
1289
+ Plain TypeScript modules that draw to a 2D canvas context, for canvas explainers (hand-written or built by `render_canvas_explainer`). No React, no WebGL, no dependencies. Everything is a pure function of its inputs and of `t` in seconds: the same `t` draws the same pixels. Randomness comes from `createRng(seed)` (a seeded mulberry32), never from the platform's global generator, and a test greps the package for it. `import { ... } from "@openvidstudio/styles"`.
1290
+
1291
+ ### Sprites and the narrator
1292
+
1293
+ `makeSprite(palette, frames, fps?)` builds a palette-indexed string-grid sprite: each frame is equal-width strings, each character a palette key, `.` is transparent, and a ragged row or a key missing from the palette throws. `drawSprite(ctx, sprite, x, y, scale, frame)` draws one frame with integer-scale `fillRect` and smoothing off, so a sprite pixel is exactly `scale` by `scale` device pixels (scale and position are rounded to whole pixels). Options: `tint` (a silhouette in one colour), `flipX`, `palette` (override colours by key). `frameAt(sprite, t)` is the frame that plays at `t`.
1294
+
1295
+ `drawNarrator(ctx, pose, x, y, scale, t, accent?)` draws the one recurring narrator, a 16x23 pixel character whose shirt takes the lesson's accent. `narratorSprite(pose, accent?)` and `narratorFrame(pose, t)` give the sprite and the frame at `t`. Poses: `idle`, `talk`, `point`, `think`, `celebrate`.
1296
+
1297
+ ![The pixel narrator standing still with arms at his sides, blinking, in physics amber beside a ruler prop, on a dark ground with a perspective grid](img/api/styles-narrator-idle.png)
1298
+
1299
+ `idle`: arms down, a blink every couple of seconds.
1300
+
1301
+ ![The pixel narrator mid-sentence with his mouth open, in maths blue beside a battery prop, on a dark ground with a perspective grid](img/api/styles-narrator-talk.png)
1302
+
1303
+ `talk`: the mouth cycles closed, half, open at 8 frames a second. Drive it only while the voice is speaking.
1304
+
1305
+ ![The pixel narrator in computer science violet with his right arm stretched out to point at a bridge section on the ground](img/api/styles-narrator-point.png)
1306
+
1307
+ `point`: the right arm reaches out (toward the right of the frame), a two-frame wobble. Place the thing pointed at to his right.
1308
+
1309
+ ![The pixel narrator in chemistry red with a hand at his chin and a trail of thought dots above his head, a ball on the ground beside him](img/api/styles-narrator-think.png)
1310
+
1311
+ `think`: a hand at the chin, and three dots that appear one after another above the head.
1312
+
1313
+ ![The pixel narrator in biology green leaping with both arms raised and sparkles around his head, a bat standing on the ground beside him](img/api/styles-narrator-celebrate.png)
1314
+
1315
+ `celebrate`: both arms up, a two-frame jump with sparkles. Use it on the payoff.
1316
+
1317
+ Props are sprites in a registry: `ball`, `ruler`, `battery`, `cable`, `bridge` (a truss section) and `bat`. `drawProp(ctx, name, x, y, scale, t?)` draws one, `getProp(name)` returns it, `propNames()` lists them. A lesson adds its own with `registerProp(name, makeSprite(...))`; a name that already exists is refused unless `overwrite` is passed, so a lesson cannot silently change a built-in.
1318
+
1319
+ ### Depth without WebGL
1320
+
1321
+ - `new Parallax({ width, height, seed?, layers?, horizon?, speed?, accent? })` holds three to five layers (fewer or more throws), drawn far to near. A layer is `{ kind: "stars" | "hills" | "towers" | "dots", color, depth (0 far, 1 near), blur?, count?, amplitude? }`. Each layer is rendered once into a wrapping tile, far layers pre-blurred (`blur`), so `parallax.draw(ctx, t, camera?)` is a few `drawImage` calls. Near layers scroll faster and zoom more with the camera. With no `layers`, `defaultLayers(accent)` gives four.
1322
+ - `dolly(t, { from?, to, start?, duration, ease? })` returns the camera `{ scale, x, y }` at `t`; pass it to `parallax.draw`, or to `applyCamera(ctx, camera, width, height)` for foreground drawing.
1323
+ - `drawFloorGrid(ctx, area, t, { horizon, vanishX?, color?, rows?, cols?, speed?, fog? })` draws a perspective floor: lines converge on the horizon and scroll toward the viewer with `t`; nothing is drawn above the horizon.
1324
+ - `drawFog(ctx, area, horizon, { color?, strength?, reach? })` is the depth fog: a band at the horizon that fades into the ground colour.
1325
+ - `drawShadow(ctx, cx, cy, rx, ry, alpha?, pixel?)` is a hard-edged pixel ellipse on the ground; `drawSpriteShadow(ctx, sprite, x, y, scale, frame?, opts?)` draws a sprite's offset silhouette (draw it before the sprite).
1326
+
1327
+ ![A violet night scene in four layers: faint stars, blurred far hills, a skyline of towers and nearer hills, with the narrator pointing from the ground in front of them](img/api/styles-parallax.png)
1328
+
1329
+ ![An amber perspective floor grid whose lines converge on a misty horizon, with a red ball standing on it under a glowing sky](img/api/styles-floorgrid.png)
1330
+
1331
+ A frame of the full scene (parallax, floor grid, fog, shadow, narrator, prop, caption) draws in about 12 ms at 720x1280 in headless Chromium, and the `styles-depth` test holds it under 30 ms.
1332
+
1333
+ ### Accents, font and sound
1334
+
1335
+ `ACCENTS` has one accent per subject: maths blue `#5CC8FF`, physics amber `#FFB347`, computer science violet `#B58CFF`, chemistry red `#FF4D5E`, biology green `#7CFFB2` (`Subject` is `"math" | "physics" | "cs" | "chemistry" | "biology"`). `VOID` (`#0B0D14`) is the dark ground the kit is drawn against.
1336
+
1337
+ The pixel font has two paths. `drawPixelText(ctx, text, x, y, scale, color)` uses a 5x7 bitmap font written for this package (uppercase, digits and basic punctuation) and draws it with `fillRect`; nothing is fetched or vendored, so it works offline, and it is Apache-2.0 like the package. For text that goes through the normal font stack (`fitText`, captions), `PIXEL_FONT` names Press Start 2P (SIL Open Font License 1.1) and its Google Fonts stylesheet URL; the package loads nothing itself, so a lesson that wants that face adds the link and text falls back to `monospace` without it.
1338
+
1339
+ `CHIP_SOUNDS` is a chip-style sound palette as plain data: `{ name, wave: "square" | "triangle" | "sawtooth" | "noise", gain, env: { attack, decay, sustain, release }, notes: [{ at, dur, note, slideTo? }] }` with `blip`, `confirm`, `wrong`, `think`, `point`, `celebrate`, `step` and `whoosh`. `noteToHz("A4")` is 440. `scheduleChip(audioContext, sound, when)` plays one on Web Audio (noise comes from the seeded generator, so it is the same samples every time); `chipDuration(sound)` gives its length, and `registerChipSound` adds a lesson's own. Because it is data, the same palette can be rendered offline into the narration mix.
1340
+
1341
+ ### Vertical layout: 720x1280
1342
+
1343
+ `LOGICAL_WIDTH` and `LOGICAL_HEIGHT` (720 and 1280, also `LOGICAL_SIZE`) are the logical canvas of a Reel. The helpers keep the platform's own interface off your content:
1344
+
1345
+ - `safeArea(width, height)` returns `{ x, y, w, h }`: the top 14% and the bottom 22% are clear, and 5% is kept at each side. At 720x1280 that is x 36, y 179, w 648, h 819 (the area ends at y 998). It is computed from proportions, so it is right for other sizes too. `insideSafeArea(width, height, rect)` tests a rectangle.
1346
+ - `fitText(ctx, text, maxWidth, maxLines, startSize, role?, family?, weight?)` returns the largest font size at which the text wraps into at most `maxLines` lines no wider than `maxWidth`, never below the role's minimum: `"headline"` 44, `"label"` 28, `"number"` 64 (`ROLE_MIN_SIZE`). A `startSize` below the minimum is raised to it. `fitTextLayout` takes the same arguments and returns `{ size, lines, fits }`: `fits` is false when even the minimum overflows, which means the text is too long and should be shortened, not shrunk.
1347
+ - `captionBand(width, height)` is the caption band: full safe width, resting on the bottom edge of the safe area, room for two lines. `drawCaption(ctx, text, width, height, style?)` fits the text at label size into it and draws it.
1348
+
1349
+ ![A vertical 720 by 1280 frame with the unsafe top and bottom bands shaded, a three-line headline, the narrator, a large key number with a label and a caption band, all inside a dashed safe-area outline](img/api/styles-vertical-layout.png)
1350
+
1351
+ Reframe-by-cropping is not offered. Cropping a landscape layout to vertical cuts it, so there is no helper that does it; lay the frame out vertically with the helpers above. The renderer's `width` and `height` arguments carry the vertical size through to an explainer, which draws at that size.
package/docs/NARRATION.md CHANGED
@@ -43,19 +43,19 @@ being too sparse, which is not the failure most people expect.
43
43
  a number instead of guessed and revised.
44
44
 
45
45
  The word budget is an estimate. A real voice reads plain prose at about 3 words per
46
- second and number-heavy lines nearer 2.2, so after `generate_narration` read each clip's
47
- `spokenSeconds` and size the beat from that (SUPERNOVA.md, "Fit the beats to the voice").
48
- Then regenerate with `overwrite: true`.
46
+ second and number-heavy lines nearer 2.2, so after `generate_narration` run `fit_beats`: it
47
+ sizes each beat from the clips' measured `spokenSeconds` (plus a half second pad, clamped into the pace window) and
48
+ lists any line that cannot fit. Use `dryRun: true` to see the lengths first. Then regenerate with `overwrite: true`.
49
49
 
50
50
  ## Engines
51
51
 
52
52
  | Engine | Quality | Speed | Runs locally |
53
53
  |---|---|---|---|
54
- | `edge-tts` (default, 7.3 or newer: 7.2.8 got a 403 from Microsoft in October 2026, `pip install -U edge-tts` fixes it) | good | about a minute for five minutes of speech | no, sends text to Microsoft |
54
+ | `edge-tts` (default; `preflight` synthesizes one word to check it, because Microsoft refused 7.2.8 with a 403 in October 2026 and `pip install -U edge-tts` fixes it) | good | about a minute for five minutes of speech | no, sends text to Microsoft |
55
55
  | `say` (macOS) | fair | instant | yes |
56
56
  | `espeak` | poor | instant | yes |
57
57
 
58
- `edge-tts` needs `pip install edge-tts`.
58
+ `edge-tts` needs `pip install edge-tts`. When a line comes back with "no usable audio", the warning ends with the last error line edge-tts printed.
59
59
 
60
60
  **If the text must not leave the machine**, use `say` on macOS, or wire up a local
61
61
  neural model. A local model on CPU is slow: one measured at a real time factor of
@@ -102,6 +102,13 @@ const at = narration.frameOfWord(3); // scene-local frame the fourth w
102
102
  `wordAt` gives the word being said, or in a pause the last one said (`speaking` is then false), and `null` before
103
103
  the first word. `available` is false for a beat with no timings.
104
104
 
105
+ ## Narration over a canvas explainer
106
+
107
+ `render_canvas_explainer` with `videoName` mixes the clips `generate_narration` wrote into a canvas explainer's video: each
108
+ clip starts at its beat's start, the score is rendered with the sound kit's `voiceover: true` mode and ducked at least 6 dB
109
+ under the voice, and the beats' text is burned in as captions that break at sentence ends. Run `fit_beats` first so the beat
110
+ times follow the voice. See "Canvas explainers" in PIPELINE.md.
111
+
105
112
  ## Silence is silent
106
113
 
107
114
  `stitch_composition` skips a missing `public/audio/vo/<beatId>.mp3` with no error and
package/docs/PIPELINE.md CHANGED
@@ -454,6 +454,52 @@ Each is checked at both sizes in `vertical.test.mjs`.
454
454
  Anything not on this list has not been checked at 1080x1920. Measure it with `validate_scenes`
455
455
  (`render: true`) and a still before trusting it in a vertical video.
456
456
 
457
+ ## Canvas explainers
458
+
459
+ A second visual path next to Remotion scenes: one self-contained HTML file with a `<canvas>` (chalkboard, whiteboard,
460
+ flat vector, a pixel narrator) that keeps three promises:
461
+
462
+ | Promise | Meaning |
463
+ |---|---|
464
+ | `window.seek(t)` | Draws the frame at time `t`, in seconds, as a pure function of `t` |
465
+ | `window.DURATION` | Its length in seconds |
466
+ | `window.scoreSound(kit)` | Its Web Audio score, rendered offline through `window.SOUND.renderOffline(scoreSound, seconds, sampleRate, { voiceover })` |
467
+
468
+ `render_canvas_explainer` loads the file in headless Chromium, waits for `document.fonts.ready`, walks
469
+ `round(DURATION * fps)` frames (1280x720 at 30 fps; pass `width: 720, height: 1280` for a Reel, both even), pipes the
470
+ canvas PNGs into ffmpeg (H.264, yuv420p), renders the score, and muxes the two. A file that does not expose the
471
+ promises is refused with the missing ones named. The logic is `dist/canvasExplainer.js`, which also runs standalone:
472
+ `node canvasExplainer.js --html explainer.html --out out.mp4 --width 720 --height 1280`. `async: true` returns a
473
+ `jobId` for `job_status`, as `render_video` does. The result carries SHA-256 hashes of the first, middle and last
474
+ frame: render twice and compare them to check that frames are a function of `t`. A file that calls `Math.random` is
475
+ warned about.
476
+
477
+ **Voice-over.** Pass `videoName` and the project's `beats.json` (after `generate_narration` and `fit_beats`) drives the
478
+ render: before the first draw the renderer sets `window.BEATS`, an array of `{ id, start, end, text }` in seconds, and
479
+ `window.DURATION` becomes the last beat's end (an explainer reads `BEATS` instead of constants). The narration clips
480
+ `public/audio/vo/<beatId>.mp3` are mixed in at each beat's start, to the sample, over the score rendered with
481
+ `{ voiceover: true }`, and the score is ducked `audio.duckDb` (default 9, never under 6) while a clip plays, eased down over
482
+ 40 ms and back over 250 ms. `narration: "none"` renders silent narration with the full-mix sound. `check_script` and
483
+ `validate_beats` read the same `beats.json`, so the script gates apply unchanged.
484
+
485
+ **Captions.** Burned in from each beat's text, in chunks that break at sentence ends (and at a comma after three words
486
+ and at six words), timed from `<beatId>.words.json` when it exists and by word length across the clip when it does not,
487
+ inside the safe areas (above the bottom 22% and below the top 14% of a tall canvas, 8% either way on a wide one). Done
488
+ through `window.CAPTIONS.draw(ctx, t, opts)`, a helper the renderer injects: an explainer calls it at the end of
489
+ `seek(t)` to caption in its own font and colours (`opts.font`, `color`, `background`, `position`), and the renderer calls it
490
+ itself for one that does not. Chosen over an ASS subtitle pass in ffmpeg because that needs libass and system fonts, cannot match a
491
+ canvas's pixel font, and draws outside the explainer's own frame; the helper keeps captions inside the pure-function-of-`t`
492
+ promise and costs an explainer one line.
493
+
494
+ **Checks.** `validate_canvas_explainer` (also a pre-flight inside the renderer, which refuses to render on an error;
495
+ `validate: false` skips it) loads the file and samples frames through `seek`: a frame is the same drawn twice (error); text is at
496
+ least 28px for labels, 44px for headlines and 64px for key numbers at 720 on the shorter side, scaled to the canvas, found by
497
+ intercepting `fillText` and `strokeText` (warning below, error below half; the shorter side rather than the width, so a
498
+ 720x1280 Reel keeps the full minimums); no text in the top 14% or bottom 22% of a tall canvas except captions (warning); the arc
499
+ through the code `validate_scenes` uses (first drawn element after 15 s a warning, after 25 s an error; 8 s of identical
500
+ frames a warning); the sound scheduled into an offline context, with the most source nodes playing at once (warning above 3)
501
+ and the peak level (warning at clipping); and `Math.random` in the source. Findings have `validate_scenes`' shape.
502
+
457
503
  ## Run records and facts
458
504
 
459
505
  A beat's text says what was intended. A run record says what actually happened. Each tool
package/docs/SCRIPT.md CHANGED
@@ -96,6 +96,8 @@ applies: 2.3 to 2.9 words per second, checked by `validate_beats`). `check_scrip
96
96
 
97
97
  A lesson explains **one concept in up to two minutes**, and every lesson has the same shape so a viewer
98
98
  knows what is coming. A series (a channel, a course) keeps that shape constant; only the content changes.
99
+ There are two shapes, chosen by the concept card's `arc`: `"seven-beat"` (the default, below) and `"story"`
100
+ ([the story arc](#the-story-arc)). A card without `arc` is a seven-beat lesson and is checked exactly as before.
99
101
 
100
102
  Start from a **concept card**, `src/videos/<name>/concept.json`:
101
103
 
@@ -127,14 +129,71 @@ the others. The times are for a 120 second lesson; shrink them in proportion for
127
129
  | 1 | Hook | A question or a surprising fact a beginner can feel, in the first sentence. Then say where the lesson sits: the `referenceSpoken` | 10 s |
128
130
  | 2 | Picture | The diagram starts here, before any formula. Say what the viewer is looking at in everyday words, and define each new term at first use | 20 s |
129
131
  | 3 | Set up | Name the quantities with their symbols and units, and state the assumptions ("no air resistance", "level ground"). Label them on the diagram | 15 s |
130
- | 4 | Derive | Build the formula line by line from things the viewer has already accepted. One `Equation` per line, each with its `spoken` form, and say why each step is allowed | 35 s |
132
+ | 4 | Derive | Build the formula line by line from things the viewer has already accepted. One `Derivation` with a step per line, each with its `spoken` form (and a `because`), and say why each step is allowed | 35 s |
131
133
  | 5 | Use it | One worked example with the real numbers and their units | 20 s |
132
134
  | 6 | Aha | The result that generalises or surprises, and where it stops being true (the assumption that breaks) | 10 s |
133
135
  | 7 | Close | A one-line recap, then the check question. End on the question and let the last frame hold | 10 s |
134
136
 
135
137
  About 270 to 330 words fit (2.3 to 2.9 words per second on plain prose; maths-heavy lines are read slower,
136
138
  nearer 2.2). The viewer should never wait more than about 8 seconds without something on screen changing,
137
- and the diagram's first element is on screen inside the first 15 seconds.
139
+ and the diagram's first element is on screen inside the first 15 seconds. `validate_scenes` with `render: true` checks both
140
+ from the frames it samples: `diagram-late` (the first frame with anything drawn beyond the background and the captions:
141
+ a warning after 15 s, an error after 25 s) and `static-stretch` (frames that do not change, captions aside, for more
142
+ than 8 s: a warning). The thresholds are the options `diagramWarnSeconds`, `diagramErrorSeconds` and `staticSeconds`.
143
+ A caption box marked `data-ovs-captions` (`LessonCaptions` does it) is the part ignored; otherwise the bottom 22 percent.
144
+
145
+ ### The story arc
146
+
147
+ `"arc": "story"` is the story-first lesson. It is not the seven-beat arc: it opens on a real situation told as a
148
+ problem, and it ends on a practice question the viewer answers in the comments. Six beats, in this order:
149
+
150
+ | # | Beat | What it does |
151
+ |---|---|---|
152
+ | 1 | Situation | A real scene told as a problem (the `situation`), in the first beat |
153
+ | 2 | The idea | The narrator (`character`) explains the idea in everyday words, terms defined at first use |
154
+ | 3 | The formulas | The key formulas stated on screen, each said as its `spoken` form, in the card's order |
155
+ | 4 | Apply | The formulas applied to the story's own numbers |
156
+ | 5 | Watch out | One short "watch out": the common mistake or the assumption that breaks |
157
+ | 6 | Practice | The original practice question, with a prompt to answer in the comments. End on the question |
158
+
159
+ The extra card fields, all optional and only read for the story arc:
160
+
161
+ ```json
162
+ {
163
+ "goal": "find what a jar of money grows to with compound interest",
164
+ "terms": ["interest"],
165
+ "example": "1000 rupees at five percent for three years",
166
+ "arc": "story",
167
+ "situation": "Maya saves money in a jar at the market",
168
+ "character": "Pixel",
169
+ "formulas": [
170
+ { "tex": "A = P(1+r)^n", "spoken": "A equals P times one plus r to the power n", "source": "IB data booklet" }
171
+ ],
172
+ "practiceQuestion": "How much is in a jar after three years at five percent?",
173
+ "answer": "1157.63"
174
+ }
175
+ ```
176
+
177
+ `formulas` are listed in the order they are stated: `tex` is what is on screen, `spoken` how it is said, `source`
178
+ where it comes from. The pacing rules, sentence length, reading level, terms, `maxSeconds` and `reference` apply to
179
+ both arcs. The story arc does not count beats: the six are the shape, not a limit, and a beat may take more than one
180
+ scene. Its checks, in `check_script`, replace the seven-beat arc's closing-question check and the hook's "question or a
181
+ number" warning (the "in this video" error stays):
182
+
183
+ - `story-situation` (error): the first beat shares at least two content words with `situation` (all of them if the
184
+ situation has fewer). A warning if the card has no `situation`.
185
+ - `story-formula` (error): each `formulas[].spoken` is in the narration, and in the order given. It is matched like an
186
+ `Equation`'s `spoken` string, ignoring case and punctuation; the formulas may fall in different beats.
187
+ - `story-practice` (error): the last beat ends with the `practiceQuestion` (it need not be a question mark). A warning
188
+ if the card has no `practiceQuestion`.
189
+ - `story-comment` (error): the last beat contains a prompt that matches `/comment/i`.
190
+ - `story-subscribe` (warning): a beat says "like and subscribe".
191
+ - `story-answer` (warning): the `answer` is in the narration. It is withheld until the pinned comment.
192
+
193
+ For a story lesson `write_description` puts the practice question and "Drop your answer in the comments." in the
194
+ description and writes the answer to `output/<name>-ANSWER.md`, the pinned comment (a line of working comes from an
195
+ optional card field `answerWorking`). The config's `disclaimers` array is appended, each line once, at the end of
196
+ every lesson description. See [SUPERNOVA.md](SUPERNOVA.md#the-description).
138
197
 
139
198
  ### Rules
140
199
 
@@ -176,7 +235,8 @@ question or does not hold a number (warning) or opens "in this video" (error), p
176
235
  whole lesson running over `maxSeconds` (error), and a card `reference` that the first beat does not say
177
236
  (error; a warning when `referenceSpoken` is missing, because then it cannot be checked). It counts and
178
237
  matches; it cannot tell you the explanation is good or the derivation is correct, so listen to it, and
179
- check the maths as above.
238
+ check the maths as above. For `"arc": "story"` it adds the six `story-*` checks listed under [the story
239
+ arc](#the-story-arc) and leaves out the closing-question check.
180
240
 
181
241
  ## No em dashes
182
242
 
package/docs/SUPERNOVA.md CHANGED
@@ -61,6 +61,9 @@ NARRATION.md):
61
61
 
62
62
  This is a worked example of the format, not a lesson that has been built and listened to.
63
63
 
64
+ A card can also set `"arc": "story"` for the story-first lesson, with `situation`, `character`, `formulas`,
65
+ `practiceQuestion` and `answer`; see [SCRIPT.md](SCRIPT.md#the-story-arc).
66
+
64
67
  ## Design: Void + Spark
65
68
 
66
69
  The idea: **light is meaning.** Everything is dark and flat except the one thing the viewer must look at.
@@ -92,6 +95,8 @@ All in `@openvidstudio/stem`, documented with stills in [API.md](API.md#supernov
92
95
  condenses from particles.
93
96
  - `Axes`, `Plot`, `NumberLine`: graphs, tangents, shaded area, intervals.
94
97
  - `Equation`: sans-serif maths revealed term by term, with a required `spoken` form that must match the narration.
98
+ - `Derivation`: the derive beat. A formula built line by line, each step with its `spoken` form and an optional `because`; the line before moves up and dims (a stack of three), terms that carry over stay put, new ones arrive in the accent, gone ones fade. Each step is revealed at the word it is spoken at, so `check_script` also reads its `spoken` strings and checks they are in the narration in order.
99
+ - `LessonCaptions`: word-timed captions that break at sentence ends, in the Reels safe zone.
95
100
  - `FreeBody`: forces as arrows whose length is exactly magnitude times the scale shown.
96
101
  - `ArrayViz`: cells, named pointers, a lit range and a dimmed one, a Spark-driven pointer.
97
102
  - `useNarrationWords(beatId)` (in core): when each word of the narration is said, so the Spark can point at it.
@@ -104,7 +109,7 @@ One fixed voice for the handle, so a viewer knows it by ear and lessons can be m
104
109
 
105
110
  | | |
106
111
  |---|---|
107
- | Engine | `edge-tts` 7.3 or newer (fast, and it gives word timings). It sends the script text to Microsoft, which is fine for public lesson scripts. Version 7.2.8 was refused with a 403 in October 2026: `pip install -U edge-tts` fixes it |
112
+ | Engine | `edge-tts` (fast, and it gives word timings). It sends the script text to Microsoft, which is fine for public lesson scripts. `preflight` speaks one word to check it works: Microsoft refused 7.2.8 with a 403 in October 2026, and `pip install -U edge-tts` fixes it |
108
113
  | Voice | `en-US-AndrewNeural` |
109
114
  | Rate | the engine's default, `+0%`. Pace comes from the script (2.3 to 2.9 words per second); the stretch is clamped to 0.9 to 1.12 so it never sounds processed |
110
115
  | Final pass | `omnivoice` gives better control but runs about 40 times slower than real time on a CPU: use it for a last pass, never to iterate |
@@ -115,24 +120,44 @@ owner, made once and for every lesson: pass the same `voice` to `generate_narrat
115
120
  ### Fit the beats to the voice
116
121
 
117
122
  The voice reads plain prose at about 3 words per second and number-heavy lines at about 2.2, so a word count
118
- alone mis-sizes a beat. Write the beats with provisional lengths, run `generate_narration`, read each clip's
119
- `spokenSeconds`, and set each beat to about the spoken length plus half a second. `validate_beats` still wants
120
- 2.3 to 2.9 words per second on the beat length, so a very short line ("Now the honest part.") needs a beat of about
121
- 1.7 seconds, and a long maths line needs more words or a longer beat. Rewrite the beats and run `generate_narration`
122
- again with `overwrite: true`. NARRATION.md has the clamp.
123
+ alone mis-sizes a beat. Write the beats with provisional lengths, run `generate_narration`, then run `fit_beats`.
124
+ It sets each beat to the measured spoken length plus half a second (`padSeconds`), clamps it into the 2.3 to 2.9
125
+ words per second window `validate_beats` wants, rounds to whole frames, moves the later beats' starts, and never
126
+ touches `vo`. A very short line ("Now the honest part.") ends up near 1.7 seconds. A beat it cannot fit, because the
127
+ voice needs more than the window allows, is listed in `unfittable` with the words to cut or the frames to lengthen it to.
128
+ Pass `dryRun: true` to see the lengths first, then run `generate_narration` again with `overwrite: true`.
129
+ NARRATION.md has the clamp.
123
130
 
124
131
  ## Captions
125
132
 
126
- Burn captions in for silent autoplay. The word timings carry bare words with no punctuation, so take the text from
127
- the beat's `vo`: split it on spaces (it matches the timings one to one) and break the caption at sentence ends, so a
128
- caption never runs one sentence into the next. Keep them inside the safe zone below.
133
+ Burn captions in for silent autoplay with `LessonCaptions`, one line in a scene:
129
134
 
130
135
  ```tsx
131
- const tokens = vo.split(/\s+/).filter(Boolean); // the beat's narration text
132
- const shown = tokens.length === narr.words.length ? tokens : narr.words.map((w) => w.word);
133
- const breakAfter = (i, size) => /[.?!:]$/.test(shown[i]) || (/[,;]$/.test(shown[i]) && size >= 3) || size >= 6;
136
+ <LessonCaptions beatId="hook" text={vo} /> // vo is the beat's narration text
134
137
  ```
135
138
 
139
+ The word timings carry bare words with no punctuation, so the component takes the text from the beat's `vo` (split on
140
+ spaces, it matches the timings one to one) and breaks a caption at a sentence end, at a comma after at least three
141
+ words, and at `maxWords` (default 6), so a caption never runs one sentence into the next. The word being said is
142
+ picked out in the subject's accent. If the text and the timings differ in length it falls back to the timings' bare
143
+ words, and with no timings at all (an engine that gives none) it spreads the words evenly over the scene and says so
144
+ in `data-ovs-captions-timing="estimated"`. It stays out of the Reels safe zones by default (above the bottom 22
145
+ percent of a tall frame, below the top 14), works at 1920x1080, and its box is tagged `data-ovs-captions`, which
146
+ `validate_scenes` ignores when it looks for a diagram or a static stretch. See API.md for the props.
147
+
148
+ ## Canvas explainers
149
+
150
+ A lesson can also be an HTML canvas explainer (hand-drawn chalkboard, whiteboard, flat vector, a pixel-character look) turned
151
+ into a video by `render_canvas_explainer`, with the same script gates: `beats.json` after `fit_beats` drives the beat times,
152
+ the narration clips are mixed over the score, and the text is captioned. `validate_canvas_explainer` is its `validate_scenes`.
153
+ See "Canvas explainers" in PIPELINE.md.
154
+
155
+ Canvas explainers in a pixel style have a ready kit: `@openvidstudio/styles` has the recurring pixel narrator (idle, talk,
156
+ point, think, celebrate), a small prop set, parallax, a perspective floor grid, fog and shadows, the accent per subject, a
157
+ chip sound palette, and vertical 720x1280 layout helpers (`safeArea`, `fitText` with the role minimums, a caption band).
158
+ It is plain 2D canvas and a pure function of `t`. Cropping a landscape layout to vertical is not offered: lay the lesson
159
+ out vertically. See "Pixel narrator and depth kit" in [API.md](API.md#pixel-narrator-and-depth-kit-openvidstudiostyles).
160
+
136
161
  ## Reels safe zones
137
162
 
138
163
  Instagram draws its own interface over a reel, so keep key content (text, the part of a diagram being explained) out of the
@@ -145,9 +170,22 @@ from Meta's percentages for Reels ads). The bottom does not: sources give anywhe
145
170
  out of the bottom 35%. Meta publishes percentages, not pixels, and the interface changes, so look at a real upload before
146
171
  trusting any of these. Backgrounds can run to the edge; key content stays inside.
147
172
 
173
+ ## The description
174
+
175
+ `write_description` writes `output/<name>-DESCRIPTION.md` for a lesson as for any video (chapters from the beats,
176
+ credits, and one line saying the video is a constructed explanation, not footage). Two things come from the lesson's
177
+ own files. If `concept.json` has a `practiceQuestion` and an `answer`, the description gets a "Practice question"
178
+ section with the question and the line "Drop your answer in the comments.", and the answer is written to
179
+ `output/<name>-ANSWER.md` instead, never into the description: the answer, and one line of working if the card has an
180
+ optional `answerWorking`. Paste that file as the pinned comment. And the `disclaimers` array in
181
+ `openvidstudio.config.json` (for example `["Constructed animation, not footage.", "Not affiliated with or endorsed by
182
+ any exam board."]`) is appended, each line once, at the end of every lesson description. A project with none of these
183
+ gets the same description as before. See [SCRIPT.md](SCRIPT.md#the-story-arc).
184
+
148
185
  ## Checks
149
186
 
150
187
  `validate_beats` (pace), `check_script` (the script, the two-minute budget and the reference), `validate_scenes` with
151
- `render: true` (framing, numbers, glow), a contact sheet, then listen. `finalize_video` must report no `audio-tick`. See
152
- the skill for the order. `render_video` asks for a brand lock and a lesson has none, so pass `skipBrandLock: true`.
153
- `validate_scenes` warns "fewer than two camera keyframes" on lesson scenes, which are flat on purpose: ignore it.
188
+ `render: true` (framing, numbers, glow, and the arc: `diagram-late` and `static-stretch`), a contact sheet, then listen. `finalize_video` must report no `audio-tick`. See
189
+ the skill for the order. `render_video` skips its brand-lock gate on a Supernova project, because a lesson has no brand to
190
+ extract, and says so in `brandLockSkipReason`.
191
+ In a lesson `validate_scenes` leaves out the camera checks (keyframes, a camera that never moves): lesson scenes are flat on purpose.