cozyclay 3.2.0 → 3.2.1

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 (185) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/INSTALL_FOR_AGENTS.md +4 -4
  3. package/README.md +4 -2
  4. package/bin/agent/agent-failure-codes.mjs +34 -8
  5. package/bin/agent/agent-routes.mjs +6 -6
  6. package/bin/agent/agent-runner.mjs +16 -13
  7. package/bin/agent/agent-stream.mjs +26 -3
  8. package/bin/agent/main-prompt.mjs +1 -1
  9. package/bin/agent/studio-prompt.mjs +2 -2
  10. package/bin/agent/supervisor-followup.mjs +34 -0
  11. package/bin/agent/supervisor.mjs +6 -6
  12. package/bin/cozyclay.mjs +23 -60
  13. package/bin/star-prompt.mjs +15 -14
  14. package/bin/studio-routes.mjs +6 -3
  15. package/bin/telemetry-state.mjs +5 -7
  16. package/bin/update-check.mjs +61 -7
  17. package/bin/update-install.mjs +16 -3
  18. package/bin/update-restart.mjs +95 -0
  19. package/bin/update-routes.mjs +25 -3
  20. package/dist/app/index.html +13 -7
  21. package/dist/assets/TaskBoard-tgO0MNKL.js +1 -0
  22. package/dist/assets/analytics-CaTUuLPM.js +1 -0
  23. package/dist/assets/app-CnDkB5WO.css +1 -0
  24. package/dist/assets/app-DJt06Oyb.js +5144 -0
  25. package/dist/assets/{compile-worker-DRWRa9pr.js → compile-worker-D1pl6G7x.js} +5 -5
  26. package/dist/assets/landing-2Fy7DU41.js +1 -0
  27. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
  28. package/dist/assets/motion3-compile-BxaM0Xwl.js +1 -0
  29. package/dist/assets/scene-asset-sync-CLGNbEBq.js +13 -0
  30. package/dist/assets/task-board-UVGTZLRF.js +15 -0
  31. package/dist/assets/workflow-CSiBlX1s.js +179 -0
  32. package/dist/cozyclay-package.json +1 -1
  33. package/dist/index.html +4 -1
  34. package/dist/privacy/index.html +9 -6
  35. package/dist/sitemap.xml +2 -2
  36. package/dist/workflow/index.html +5 -4
  37. package/mcp/README.md +1 -1
  38. package/mcp/live-hub.mjs +4 -4
  39. package/mcp/tool-handlers.mjs +14 -16
  40. package/package.json +2 -1
  41. package/src/App.jsx +37 -70
  42. package/src/analytics-controls.js +25 -3
  43. package/src/analytics-strict.js +16 -4
  44. package/src/analytics.js +32 -26
  45. package/src/app-version.js +13 -0
  46. package/src/asset-drop.js +39 -0
  47. package/src/asset-pane.css +34 -0
  48. package/src/asset-pane.jsx +4 -35
  49. package/src/bus-tasks.js +1 -1
  50. package/src/character-kind.js +8 -6
  51. package/src/click-friction.js +5 -4
  52. package/src/command-bus-telemetry.js +8 -7
  53. package/src/command-bus.js +2 -16
  54. package/src/commands/cast.js +2 -1
  55. package/src/commands/motion3.js +3 -1
  56. package/src/commands/objects.js +2 -37
  57. package/src/commands/project.js +3 -3
  58. package/src/commands/sculpt-compare.js +53 -0
  59. package/src/content-tile.jsx +36 -0
  60. package/src/domains/cast.js +7 -6
  61. package/src/domains/motion.js +6 -5
  62. package/src/domains/scenes.js +41 -31
  63. package/src/execution-telemetry.js +7 -3
  64. package/src/export-menu-telemetry.js +26 -0
  65. package/src/finger-handles.jsx +128 -0
  66. package/src/friction-hint.js +82 -0
  67. package/src/live-control.js +2 -1
  68. package/src/locale-zh/panels.js +20 -0
  69. package/src/locale-zh/shell.js +4 -0
  70. package/src/locale-zh/workspace.js +6 -0
  71. package/src/motion/find-bone.js +13 -0
  72. package/src/motion/fingers.js +170 -0
  73. package/src/motion/fingertip-pick.js +21 -0
  74. package/src/motion/ik-exposure.js +48 -0
  75. package/src/motion/ik.js +4 -11
  76. package/src/motion3/compiler/head-lead.js +4 -2
  77. package/src/motion3/hybrid-hint.js +19 -0
  78. package/src/motion3/load.js +19 -12
  79. package/src/motion3/qa/hints-contact.js +12 -0
  80. package/src/motion3/qa/patch.js +4 -1
  81. package/src/motion3/qa/verdict.js +7 -2
  82. package/src/motion3/repair.js +28 -8
  83. package/src/motion3/skills/catch.js +2 -1
  84. package/src/panels/FingerPanel.jsx +38 -0
  85. package/src/panels/RigControlPanel.jsx +9 -1
  86. package/src/panels/SculptPanel.jsx +12 -12
  87. package/src/panels/SculptPartTree.jsx +46 -0
  88. package/src/panels/SubjectsPanel.jsx +1 -1
  89. package/src/panels/sculpt.css +11 -0
  90. package/src/pose-fade.js +7 -3
  91. package/src/posestudio.jsx +14 -27
  92. package/src/project-browser.css +6 -0
  93. package/src/project-browser.jsx +9 -18
  94. package/src/project-start-choices.jsx +45 -0
  95. package/src/project.js +16 -12
  96. package/src/proxy-figure-shape.js +11 -0
  97. package/src/proxy-figure.jsx +25 -18
  98. package/src/scene-assets.js +17 -13
  99. package/src/scenes.js +22 -11
  100. package/src/sculpt-blueprint.js +56 -0
  101. package/src/sculpt-mode.js +5 -0
  102. package/src/sculpt-part-edit.js +115 -0
  103. package/src/sculpt-part-gizmo.jsx +159 -0
  104. package/src/sculpt-part-outline.jsx +72 -0
  105. package/src/sculpt-pick.js +53 -0
  106. package/src/sculpt-run-text.js +51 -0
  107. package/src/sculpt-run.js +10 -46
  108. package/src/sculpt-shelf-file.js +92 -0
  109. package/src/sculpt-shelf-tiles.jsx +69 -0
  110. package/src/sculpt-shelf.js +108 -0
  111. package/src/sculpt-stage.jsx +39 -2
  112. package/src/sculpt-thumb.js +72 -0
  113. package/src/sculpt-views.js +75 -0
  114. package/src/semantic-edit.js +0 -87
  115. package/src/settings-menu.jsx +2 -2
  116. package/src/shell/DetailsSlot.jsx +5 -6
  117. package/src/shell/LibrarySlot.jsx +8 -10
  118. package/src/shell/MenuBar.jsx +6 -6
  119. package/src/shell/PreferencesDialog.jsx +2 -3
  120. package/src/shell/ViewportToolbar.jsx +15 -18
  121. package/src/shell/add-character-entries.js +13 -0
  122. package/src/shell/pose-controls.js +32 -0
  123. package/src/shell/preference-pages.jsx +2 -11
  124. package/src/shell/storyboard-caption.js +21 -0
  125. package/src/shell/studio-shell-context.js +1 -0
  126. package/src/shortcut-friction.js +2 -1
  127. package/src/site/landing-analytics.js +28 -0
  128. package/src/studio-actions.js +3 -2
  129. package/src/studio-agent-protocol.js +4 -4
  130. package/src/studio-app-binding.js +4 -3
  131. package/src/update/UpdateChip.jsx +21 -2
  132. package/src/update/UpdateDialog.jsx +26 -6
  133. package/src/update/update-client.js +123 -17
  134. package/src/update/update-telemetry.js +15 -3
  135. package/src/use-sculpt-mode.js +62 -8
  136. package/src/workflow/agent-failure-codes.js +28 -9
  137. package/src/workflow/agent-turn-telemetry.js +1 -1
  138. package/src/workflow/task-board.js +2 -2
  139. package/tools/dev/page-shell.py +1 -1
  140. package/tools/dev/pages/greybox-to-video.html +21 -2
  141. package/tools/dev/pages/previs-software.html +3 -3
  142. package/tools/dev/pages/privacy.html +9 -6
  143. package/tools/dev/pages/seedance-camera-control.html +2 -2
  144. package/tools/loc-baseline.json +10 -10
  145. package/tools/run-tests.mjs +45 -69
  146. package/tools/select-tests.mjs +119 -0
  147. package/tools/test-durations.json +455 -0
  148. package/tools/test-graph.mjs +181 -0
  149. package/tools/test-shards.mjs +78 -0
  150. package/tools/tutorial/README.md +74 -0
  151. package/tools/tutorial/assemble.mjs +43 -0
  152. package/tools/tutorial/captions.mjs +43 -0
  153. package/tools/tutorial/cdp.mjs +55 -0
  154. package/tools/tutorial/cursor.mjs +159 -0
  155. package/tools/tutorial/episode.mjs +173 -0
  156. package/tools/tutorial/episodes/beginner-guide/00-install.mjs +42 -0
  157. package/tools/tutorial/episodes/beginner-guide/01-start.mjs +50 -0
  158. package/tools/tutorial/episodes/beginner-guide/02-layout.mjs +67 -0
  159. package/tools/tutorial/episodes/beginner-guide/03-stage.mjs +58 -0
  160. package/tools/tutorial/episodes/beginner-guide/04-motion.mjs +98 -0
  161. package/tools/tutorial/episodes/beginner-guide/05-camera.mjs +47 -0
  162. package/tools/tutorial/episodes/beginner-guide/06-export.mjs +59 -0
  163. package/tools/tutorial/episodes/beginner-guide/steps.mjs +32 -0
  164. package/tools/tutorial/episodes/beginner-guide.mjs +17 -0
  165. package/tools/tutorial/episodes/cast.mjs +12 -0
  166. package/tools/tutorial/episodes/first-video.mjs +121 -0
  167. package/tools/tutorial/episodes/stub-tour.mjs +97 -0
  168. package/tools/tutorial/launch.mjs +63 -0
  169. package/tools/tutorial/mux.mjs +61 -0
  170. package/tools/tutorial/overlay.mjs +142 -0
  171. package/tools/tutorial/record.mjs +156 -0
  172. package/tools/tutorial/scenes/terminal.html +63 -0
  173. package/tools/tutorial/steps.mjs +111 -0
  174. package/tools/tutorial/terminal.mjs +43 -0
  175. package/tools/tutorial/voice.mjs +49 -0
  176. package/tools/update-test-durations.mjs +42 -0
  177. package/tools/verify-package-tarball.mjs +18 -12
  178. package/dist/assets/TaskBoard-BUjjuvm5.js +0 -1
  179. package/dist/assets/app-Dige7q3X.css +0 -1
  180. package/dist/assets/app-DxoI941o.js +0 -5144
  181. package/dist/assets/motion3-compile-B4SX4TGx.js +0 -1
  182. package/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
  183. package/dist/assets/scene-asset-sync-LeI3eSnO.js +0 -13
  184. package/dist/assets/task-board-5PKn7ZaA.js +0 -15
  185. package/dist/assets/workflow-Dg9jCKOq.js +0 -179
@@ -0,0 +1,78 @@
1
+ // Duration-balanced test shards (#632). tools/test-durations.json holds the
2
+ // per-file wall-clock seconds of a full run; `node tools/update-test-durations.mjs`
3
+ // refreshes it from the artifacts of the latest pages.yml run on main.
4
+ import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
5
+
6
+ export const DURATIONS_PATH = new URL("./test-durations.json", import.meta.url);
7
+
8
+ export function readDurations(path = DURATIONS_PATH) {
9
+ return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : {};
10
+ }
11
+
12
+ // Sorted keys, seconds to one decimal: the format of tools/test-durations.json.
13
+ export function writeDurations(path, durations) {
14
+ const entries = Object.entries(durations).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
15
+ writeFileSync(path, `${JSON.stringify(Object.fromEntries(entries.map(([file, seconds]) => [file, Math.round(seconds * 10) / 10])), null, "\t")}\n`);
16
+ }
17
+
18
+ function median(values) {
19
+ const sorted = values.filter(Number.isFinite).sort((a, b) => a - b);
20
+ if (sorted.length === 0) return 1;
21
+ const middle = Math.floor(sorted.length / 2);
22
+ return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
23
+ }
24
+
25
+ // A file with no recorded time counts as the median of every recorded time,
26
+ // so a batch of new suites spreads over the shards instead of piling up.
27
+ export function estimate(files, durations) {
28
+ const fallback = median(Object.values(durations));
29
+ return new Map(files.map((file) => [file, Number.isFinite(durations[file]) ? durations[file] : fallback]));
30
+ }
31
+
32
+ // Longest first, ties by path, so the tail of a run is never one slow suite.
33
+ export function slowestFirst(files, durations) {
34
+ const seconds = estimate(files, durations);
35
+ return [...files].sort((a, b) => seconds.get(b) - seconds.get(a) || (a < b ? -1 : a > b ? 1 : 0));
36
+ }
37
+
38
+ // LPT: each file, longest first, goes to the shard with the smallest predicted
39
+ // sum (lowest index on a tie). Every CI shard computes the same split from the
40
+ // same inputs, so the shards never need to agree at run time.
41
+ export function assignShards(files, durations, total) {
42
+ const seconds = estimate(files, durations);
43
+ const shards = Array.from({ length: total }, () => ({ files: [], predicted: 0 }));
44
+ for (const file of slowestFirst(files, durations)) {
45
+ const lightest = shards.reduce((best, shard) => (shard.predicted < best.predicted ? shard : best));
46
+ lightest.files.push(file);
47
+ lightest.predicted += seconds.get(file);
48
+ }
49
+ return shards;
50
+ }
51
+
52
+ // tools/select-tests.mjs output: null (run everything) unless mode is "selected".
53
+ export function readSelection(path) {
54
+ if (!path) return null;
55
+ const { mode, files } = JSON.parse(readFileSync(path, "utf8"));
56
+ if (mode !== "selected") return null;
57
+ if (!Array.isArray(files)) throw new Error(`${path}: a selected run needs a files array`);
58
+ return new Set(files);
59
+ }
60
+
61
+ export function parseShard(value = "") {
62
+ const match = /^(\d+)\/(\d+)$/.exec(value);
63
+ const current = Number(match?.[1]);
64
+ const total = Number(match?.[2]);
65
+ if (!match || current < 1 || total < 1 || current > total) {
66
+ throw new Error(`--shard must be INDEX/TOTAL with 1 <= INDEX <= TOTAL, got ${value}`);
67
+ }
68
+ return { current, total };
69
+ }
70
+
71
+ // Runs once the suite ends, pass or fail: writes --durations-out and, on a CI
72
+ // runner, one line of the job's step summary.
73
+ export function reportDurations(timings, { out, selected, predicted }) {
74
+ const actual = [...timings.values()].reduce((sum, seconds) => sum + seconds, 0);
75
+ if (out) writeDurations(out, Object.fromEntries(timings));
76
+ const summary = process.env.GITHUB_STEP_SUMMARY;
77
+ if (summary) appendFileSync(summary, `selected ${selected}, predicted ${predicted.toFixed(1)} s, actual ${actual.toFixed(1)} s\n`);
78
+ }
@@ -0,0 +1,74 @@
1
+ # Tutorial recorder
2
+
3
+ Turns an episode script into a tutorial video that points at each feature: it drives the Studio (and an install terminal) over CDP, dims the page around the feature it names in a callout, shows chapter cards, screencasts it with a visible cursor, and burns captions in the `--locale` language into a band under the frame. No voice unless asked.
4
+
5
+ ```
6
+ episode (flat steps | chapters) -> record.mjs -> steps.mjs (input, overlay.mjs, terminal.mjs, Studio)
7
+ -> assemble.mjs (capture) -> captions.mjs (PNG per cue) -> mux.mjs (band + captions [+ voice]) -> mp4, chapter cuts
8
+ ```
9
+
10
+ | Episode shape | Output |
11
+ | --- | --- |
12
+ | flat array of steps | `dist/tutorial/<episode>.mp4` (`<episode>-ko.mp4`), `.ko.srt`, `.en.srt` |
13
+ | `{ title, subtitle, chapters: [{ id, title, steps }] }` | `dist/tutorial/<episode>/<episode>.<locale>.mp4`, `<nn>-<chapter>.<locale>.mp4` per chapter, `<episode>.<locale>.srt` |
14
+
15
+ ## Run
16
+
17
+ ```sh
18
+ npm run tutorial:record -- stub-tour --locale ko
19
+ npm run tutorial:record -- first-video --size 1920x1080
20
+ ```
21
+
22
+ `launch.mjs` starts `tools/dev-full.mjs` (the Agent panel needs the agent server) with an empty config dir and no provider keys or endpoints from your shell, so the panel shows the "Connect a model" card on any machine. It runs `record.mjs` inside `tools/qa-browser.mjs` and stops both. Ports: `TUTORIAL_DEV_PORT` (5594), `CDP_PORT` (5595), `COZYCLAY_LIVE_PORT` (5596), `COZYCLAY_OAUTH_PORT` (5597).
23
+
24
+ Against a dev server you already run:
25
+
26
+ ```sh
27
+ QA_URL=http://127.0.0.1:5594/app/ CDP_PORT=5595 node tools/qa-browser.mjs -- node tools/tutorial/record.mjs first-video
28
+ ```
29
+
30
+ Opening the Studio clears the origin's storage first (`Storage.clearDataForOrigin`), so it always shows the chooser with the name field. Downloads from an export step go to a scratch dir. One continuous capture is cut into the chapter files, so chapters join without a seam.
31
+
32
+ Flags: `--locale en|ko` (UI and caption language), `--size 1600x1000|1920x1080`, `--voice off|say` (default off), `--wps-ko 1.6` and `--wps-en 2.5` (reading speed), `--fps 30`, `--out dist/tutorial`. `npm run build` empties `dist/`.
33
+
34
+ ## Captions and optional voice
35
+
36
+ A cue without a clip lasts max(its actions, 1.2 s + its words at the video language's reading speed). With `--voice say`, voice runs before recording, because each cue then lasts its clip plus 0.4 s. Per narrated step, in order:
37
+
38
+ 1. Your recording: `tools/tutorial/voice/<episode>/<step-id>.<ko|en>.<wav|m4a|mp3|aiff>` (ignored by git). Use the srt as the script.
39
+ 2. macOS `say` (ko Yuna, en Samantha). A step's `speech: { ko, en }` changes what is read, not the caption.
40
+ 3. Neither: the cue is silent and holds for its word count at `--wps-ko`/`--wps-en`.
41
+
42
+ Captions are PNGs drawn by the recording Chrome and laid with ffmpeg `overlay` in a band added under the frame (13% of its height), so they never cover the UI. ffmpeg needs `libx264` and `aac`; libass is not required. Korean captions need a Korean font (macOS has one; on Linux install `fonts-noto-cjk`). The mux stops if a clip is longer than its cue.
43
+
44
+ Requires ffmpeg on `PATH` or in `FFMPEG`; the recorder stops with one line if it is missing.
45
+
46
+ ## Episodes
47
+
48
+ One file per episode in `episodes/`: a `.mjs` default export or a `.json` array of steps.
49
+
50
+ ```js
51
+ { id: "select", narration: { en: "...", ko: "..." }, click: "[data-node-id=characterA]", until: ".selected" }
52
+ ```
53
+
54
+ Each step has exactly one action:
55
+
56
+ | Action | Value |
57
+ | --- | --- |
58
+ | `click` | selector, or a list clicked in order |
59
+ | `type` | text for the focused element, or `{ into: selector, text }` (clicks the field and replaces its value) |
60
+ | `key` | chord such as `Mod+B` or `Space` (`Mod` is Cmd on macOS, Ctrl elsewhere); a focused button is blurred first |
61
+ | `drag` | `{ from: selector or { js: expression giving { x, y } }, to: selector or { dx, dy } }` |
62
+ | `wait` | selector, or milliseconds |
63
+ | `hold` | milliseconds |
64
+ | `eval` | page expression, awaited; a result with `ok: false` stops the run |
65
+
66
+ | `spotlight` | selector, with `label: { ko, en }` and optional `note`; dims the page around the element (rect read at that moment, so open menus work) and names it; `null` clears |
67
+ | `menu` | `{ open, then }`: click `open`, then click `then`, leaving the menu open for a spotlight |
68
+ | `card` | `"opening"` (episode title), `"closing"` (lists the titled chapters) or `{ title, subtitle }` (numbered by its chapter) |
69
+ | `terminal` | `{ typed, lines }`: `scenes/terminal.html` types the command and prints the lines |
70
+ | `studio` | `true`: open the Studio at its startup chooser (an episode without it starts there) |
71
+
72
+ A spotlight holds for 1.2 s + its label and note at reading speed, a card for at least 2 s, a terminal for 1.5 s after its last line; `hold` beside them overrides that.
73
+
74
+ `until` (optional) waits for a selector after the action. A step without narration runs inside the cue of the narrated step before it; a chapter end closes the cue. The srt entry and caption span exactly the cue. `test/verify-tutorial-recorder.mjs` validates every rule above.
@@ -0,0 +1,43 @@
1
+ // ffmpeg side of the tutorial recorder: find the binary (an external
2
+ // requirement, never an npm dependency) and turn timestamped frames into a
3
+ // constant-rate mp4.
4
+ import { spawn, spawnSync } from "node:child_process";
5
+ import { readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { once } from "node:events";
8
+
9
+ const CANDIDATES = [process.env.FFMPEG, "ffmpeg", "/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"].filter(Boolean);
10
+
11
+ /** { path, version } of the first ffmpeg that runs; throws one actionable line when none does. */
12
+ export function findFfmpeg() {
13
+ for (const path of CANDIDATES) {
14
+ const probe = spawnSync(path, ["-version"], { encoding: "utf8" });
15
+ if (probe.status === 0) return { path, version: probe.stdout.split("\n")[0] };
16
+ }
17
+ throw new Error("ffmpeg not found: install it (brew install ffmpeg, apt install ffmpeg) or set FFMPEG=/path/to/ffmpeg");
18
+ }
19
+
20
+ /**
21
+ * Encode `frames` ([{ file, ms }] in `dir`, ms from the start of the recording,
22
+ * sorted) into `output` at a constant `fps`. The screencast only sends a frame
23
+ * when the page changes, so output frame k repeats the newest capture at
24
+ * k / fps, and the video lasts exactly `endMs` — the clock the srt cues use.
25
+ */
26
+ export async function encode({ ffmpeg, dir, frames, endMs, fps, output }) {
27
+ const args = ["-hide_banner", "-loglevel", "error", "-y", "-f", "image2pipe", "-c:v", "mjpeg", "-framerate", String(fps), "-i", "-", "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p", "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-movflags", "+faststart", output];
28
+ const child = spawn(ffmpeg, args, { stdio: ["pipe", "ignore", "pipe"] });
29
+ let stderr = "";
30
+ child.stderr.on("data", (chunk) => { stderr += chunk; });
31
+ const exited = once(child, "close");
32
+ let source = 0;
33
+ let cached = { index: -1, data: null };
34
+ for (let index = 0; index < Math.round((endMs / 1000) * fps); index += 1) {
35
+ while (source + 1 < frames.length && frames[source + 1].ms <= (index * 1000) / fps) source += 1;
36
+ if (cached.index !== source) cached = { index: source, data: readFileSync(join(dir, frames[source].file)) };
37
+ if (!child.stdin.write(cached.data)) await once(child.stdin, "drain");
38
+ }
39
+ child.stdin.end();
40
+ const [code] = await exited;
41
+ if (code !== 0) throw new Error(`ffmpeg failed (exit ${code}): ${stderr.trim()}`);
42
+ return `${ffmpeg} ${args.join(" ")}`;
43
+ }
@@ -0,0 +1,43 @@
1
+ // Caption PNGs for the tutorial recorder, drawn by the same headless Chrome
2
+ // that recorded the episode: one transparent image per cue, as wide as the
3
+ // video and as tall as the caption band under it (tools/tutorial/mux.mjs).
4
+ import { writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+
7
+ const FONT = `"Apple SD Gothic Neo","Noto Sans CJK KR","Noto Sans KR","Malgun Gothic",sans-serif`;
8
+ const escape = (text) => text.replace(/[&<>]/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[character]);
9
+
10
+ function captionHtml(text, width, height) {
11
+ const size = Math.round(width * 0.024);
12
+ return `<!doctype html><meta charset="utf-8"><style>
13
+ html,body{margin:0;background:transparent}
14
+ body{width:${width}px;height:${height}px;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.62)}
15
+ p{margin:0 ${Math.round(width * 0.06)}px;font:600 ${size}px/1.35 ${FONT};color:#fff;text-align:center;word-break:keep-all;overflow-wrap:normal}
16
+ </style><p>${escape(text)}</p>`;
17
+ }
18
+
19
+ /** One PNG per text in `dir`, rendered through the open CDP page; returns their paths. */
20
+ export async function renderCaptions(cdp, { texts, width, height, dir, language }) {
21
+ const loaded = cdp.once("Page.loadEventFired");
22
+ await cdp.send("Page.navigate", { url: "about:blank" });
23
+ await loaded;
24
+ await cdp.send("Emulation.setDefaultBackgroundColorOverride", { color: { r: 0, g: 0, b: 0, a: 0 } });
25
+ const paths = [];
26
+ for (const [index, text] of texts.entries()) {
27
+ const html = captionHtml(text, width, height);
28
+ // Korean wraps at spaces only (keep-all); a line that still overflows the
29
+ // width, or text taller than the band, gets a smaller font, never a broken word.
30
+ await cdp.evaluate(`(async () => {
31
+ document.open(); document.write(${JSON.stringify(html)}); document.close();
32
+ await document.fonts.ready;
33
+ const text = document.querySelector("p");
34
+ let size = parseFloat(getComputedStyle(text).fontSize);
35
+ while (size > 16 && (text.scrollWidth > text.clientWidth || text.scrollHeight > ${height})) text.style.fontSize = (size -= 2) + "px";
36
+ })()`);
37
+ const shot = await cdp.send("Page.captureScreenshot", { format: "png", clip: { x: 0, y: 0, width, height, scale: 1 } });
38
+ const path = join(dir, `caption-${String(index).padStart(2, "0")}.${language}.png`);
39
+ writeFileSync(path, Buffer.from(shot.data, "base64"));
40
+ paths.push(path);
41
+ }
42
+ return paths;
43
+ }
@@ -0,0 +1,55 @@
1
+ // Raw-WebSocket CDP client for the tutorial recorder: the same pattern the
2
+ // browser suites use (test/verify-first-success-guide-browser.mjs), packaged
3
+ // so the recorder can also listen for screencast events.
4
+
5
+ export async function connectPage(port) {
6
+ const targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json();
7
+ const page = targets.find((target) => target.type === "page" && target.webSocketDebuggerUrl);
8
+ if (!page) throw new Error("no page target on the QA browser");
9
+ const ws = new WebSocket(page.webSocketDebuggerUrl);
10
+ await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
11
+ let nextId = 1;
12
+ const pending = new Map();
13
+ const listeners = new Map();
14
+ ws.onmessage = (event) => {
15
+ const message = JSON.parse(event.data);
16
+ if (message.method) for (const listener of listeners.get(message.method) ?? []) listener(message.params);
17
+ if (!message.id || !pending.has(message.id)) return;
18
+ const { resolve, reject } = pending.get(message.id);
19
+ pending.delete(message.id);
20
+ if (message.error) reject(new Error(JSON.stringify(message.error)));
21
+ else resolve(message.result);
22
+ };
23
+ const send = (method, params = {}) => new Promise((resolve, reject) => {
24
+ const id = nextId++;
25
+ pending.set(id, { resolve, reject });
26
+ ws.send(JSON.stringify({ id, method, params }));
27
+ });
28
+ const on = (method, listener) => {
29
+ if (!listeners.has(method)) listeners.set(method, new Set());
30
+ listeners.get(method).add(listener);
31
+ return () => listeners.get(method).delete(listener);
32
+ };
33
+ const once = (method, timeoutMs = 30000) => new Promise((resolve, reject) => {
34
+ const timer = setTimeout(() => { off(); reject(new Error(`timed out waiting for ${method}`)); }, timeoutMs);
35
+ const off = on(method, (params) => { clearTimeout(timer); off(); resolve(params); });
36
+ });
37
+ const evaluate = async (expression) => {
38
+ const result = await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true });
39
+ if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || "evaluate failed");
40
+ return result.result.value;
41
+ };
42
+ /** Resolve true once `condition` (a page expression) holds, false on timeout. */
43
+ const waitFor = (condition, timeoutMs = 15000) => evaluate(`new Promise((resolve) => {
44
+ const started = performance.now();
45
+ const tick = () => {
46
+ let value = false;
47
+ try { value = Boolean(${condition}); } catch {}
48
+ if (value) resolve(true);
49
+ else if (performance.now() - started > ${timeoutMs}) resolve(false);
50
+ else setTimeout(tick, 50);
51
+ };
52
+ tick();
53
+ })`);
54
+ return { send, on, once, evaluate, waitFor, close: () => ws.close() };
55
+ }
@@ -0,0 +1,159 @@
1
+ // Visible input for the tutorial recorder. CDP frames carry no OS cursor, so
2
+ // the page gets a DOM pointer (pointer-events: none) that glides to each
3
+ // target before the real Input.dispatch* event lands there, a ripple on every
4
+ // press, and a keycap badge for each shortcut.
5
+
6
+ const OVERLAY = `(() => {
7
+ if (window.__tutorialCursor) return;
8
+ const root = document.createElement("div");
9
+ root.className = "tt-pointer-host";
10
+ root.style.cssText = "position:fixed;inset:0;pointer-events:none;z-index:2147483647;transition:opacity .3s";
11
+ const pointer = document.createElement("div");
12
+ pointer.style.cssText = "position:absolute;left:0;top:0;width:28px;height:28px;filter:drop-shadow(0 1px 2px rgba(0,0,0,.35))";
13
+ pointer.innerHTML = '<svg viewBox="0 0 28 28" width="28" height="28"><path d="M3 2 L3 22 L8.5 17 L12.5 25.5 L16 24 L12 15.8 L19.5 15.5 Z" fill="#fff" stroke="#111" stroke-width="1.6" stroke-linejoin="round"/></svg>';
14
+ const keycap = document.createElement("div");
15
+ keycap.style.cssText = "position:absolute;left:50%;bottom:72px;transform:translateX(-50%);padding:10px 18px;border-radius:10px;background:rgba(20,20,24,.86);color:#fff;font:600 22px/1 system-ui,sans-serif;letter-spacing:.04em;opacity:0;transition:opacity .18s";
16
+ root.append(pointer, keycap);
17
+ const mount = () => document.documentElement.append(root);
18
+ if (document.documentElement) mount(); else document.addEventListener("DOMContentLoaded", mount);
19
+ let x = innerWidth / 2, y = innerHeight / 2, keycapTimer = 0;
20
+ const place = () => { pointer.style.transform = "translate(" + (x - 3) + "px," + (y - 2) + "px)"; };
21
+ place();
22
+ window.__tutorialCursor = {
23
+ moveTo(toX, toY, ms) {
24
+ const fromX = x, fromY = y, started = performance.now();
25
+ return new Promise((resolve) => {
26
+ const frame = (now) => {
27
+ const t = Math.min(1, (now - started) / Math.max(1, ms));
28
+ const eased = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
29
+ x = fromX + (toX - fromX) * eased; y = fromY + (toY - fromY) * eased; place();
30
+ if (t < 1) requestAnimationFrame(frame); else resolve();
31
+ };
32
+ requestAnimationFrame(frame);
33
+ });
34
+ },
35
+ jump(toX, toY) { x = toX; y = toY; place(); },
36
+ ripple() {
37
+ const ring = document.createElement("div");
38
+ ring.style.cssText = "position:absolute;left:" + (x - 18) + "px;top:" + (y - 18) + "px;width:36px;height:36px;border-radius:50%;border:3px solid rgba(37,99,235,.9);background:rgba(37,99,235,.18)";
39
+ root.insertBefore(ring, pointer);
40
+ ring.animate([{ transform: "scale(.35)", opacity: 1 }, { transform: "scale(1.5)", opacity: 0 }], { duration: 520, easing: "ease-out" }).finished.then(() => ring.remove());
41
+ },
42
+ keycap(label) {
43
+ keycap.textContent = label; keycap.style.opacity = "1";
44
+ clearTimeout(keycapTimer); keycapTimer = setTimeout(() => { keycap.style.opacity = "0"; }, 1400);
45
+ },
46
+ };
47
+ })()`;
48
+
49
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
50
+ const MODIFIER_BITS = { Alt: 1, Control: 2, Meta: 4, Shift: 8 };
51
+ const NAMED_KEYS = { Space: [" ", "Space", 32], Enter: ["Enter", "Enter", 13], Escape: ["Escape", "Escape", 27], Tab: ["Tab", "Tab", 9], Backspace: ["Backspace", "Backspace", 8], ArrowLeft: ["ArrowLeft", "ArrowLeft", 37], ArrowUp: ["ArrowUp", "ArrowUp", 38], ArrowRight: ["ArrowRight", "ArrowRight", 39], ArrowDown: ["ArrowDown", "ArrowDown", 40] };
52
+ const MAC_GLYPHS = { Meta: "⌘", Control: "⌃", Alt: "⌥", Shift: "⇧" };
53
+
54
+ /** Input helpers on one CDP page; `mac` picks Cmd for "Mod" and the keycap glyphs. */
55
+ export function createInput(cdp, { mac }) {
56
+ let at = null;
57
+ const mouse = (type, x, y, extra = {}) => cdp.send("Input.dispatchMouseEvent", { type, x, y, button: "left", ...extra });
58
+ const glide = async (x, y) => {
59
+ const distance = at ? Math.hypot(x - at.x, y - at.y) : 600;
60
+ const ms = Math.round(Math.min(1100, Math.max(350, distance * 1.1)));
61
+ await cdp.evaluate(`window.__tutorialCursor.moveTo(${x}, ${y}, ${ms})`);
62
+ await mouse("mouseMoved", x, y, { button: "none" });
63
+ at = { x, y };
64
+ };
65
+ /** Center of a target (window.__tutorialFind, overlay.mjs), scrolled into view; null when it never shows. */
66
+ const locate = async (spec, timeoutMs = 10000) => {
67
+ const query = JSON.stringify(spec);
68
+ if (!(await cdp.waitFor(`!!window.__tutorialFind(${query})`, timeoutMs))) return null;
69
+ return cdp.evaluate(`(() => { window.__tutorialFind(${query}).el?.scrollIntoView({ block: "nearest", inline: "nearest" }); const box = window.__tutorialFind(${query}).rect; return { x: Math.round(box.x + box.width / 2), y: Math.round(box.y + box.height / 2) }; })()`);
70
+ };
71
+ const need = async (spec) => {
72
+ const point = await locate(spec);
73
+ if (!point) throw new Error(`no visible element for ${JSON.stringify(spec)}`);
74
+ return point;
75
+ };
76
+ /** A drag end: a target, or { js } evaluating to a viewport point such as a gizmo handle. */
77
+ const pointOf = async (source) => {
78
+ if (!source?.js) return need(source);
79
+ const point = await cdp.evaluate(`(async () => (${source.js}))()`);
80
+ if (!Number.isFinite(point?.x) || !Number.isFinite(point?.y)) throw new Error(`${source.js} gave no { x, y }`);
81
+ return { x: Math.round(point.x), y: Math.round(point.y) };
82
+ };
83
+ const press = async (point) => {
84
+ await glide(point.x, point.y);
85
+ await sleep(180);
86
+ await cdp.evaluate("window.__tutorialCursor.ripple()");
87
+ await mouse("mousePressed", point.x, point.y, { clickCount: 1, buttons: 1 });
88
+ await mouse("mouseReleased", point.x, point.y, { clickCount: 1 });
89
+ await sleep(250);
90
+ };
91
+ return {
92
+ /** The pointer and the teaching overlay on this page and every page the episode opens next. */
93
+ async install(...extra) {
94
+ for (const source of [OVERLAY, ...extra]) {
95
+ await cdp.send("Page.addScriptToEvaluateOnNewDocument", { source });
96
+ await cdp.evaluate(source);
97
+ }
98
+ },
99
+ locate,
100
+ click: async (selector) => press(await need(selector)),
101
+ /** Glide to the element and rest there without pressing. */
102
+ async hover(selector) {
103
+ const point = await need(selector);
104
+ await glide(point.x, point.y);
105
+ },
106
+ async drag(from, to) {
107
+ const start = await pointOf(from);
108
+ const end = Number.isFinite(to?.dx) ? { x: start.x + to.dx, y: start.y + to.dy } : await pointOf(to);
109
+ await glide(start.x, start.y);
110
+ await cdp.evaluate("window.__tutorialCursor.ripple()");
111
+ await mouse("mousePressed", start.x, start.y, { clickCount: 1, buttons: 1 });
112
+ const steps = 45;
113
+ for (let index = 1; index <= steps; index += 1) {
114
+ const t = index / steps;
115
+ const eased = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
116
+ const x = Math.round(start.x + (end.x - start.x) * eased);
117
+ const y = Math.round(start.y + (end.y - start.y) * eased);
118
+ await cdp.evaluate(`window.__tutorialCursor.jump(${x}, ${y})`);
119
+ await mouse("mouseMoved", x, y, { buttons: 1 });
120
+ await sleep(25);
121
+ }
122
+ await mouse("mouseReleased", end.x, end.y, { clickCount: 1 });
123
+ at = end;
124
+ },
125
+ async type(value) {
126
+ const { into, text } = typeof value === "string" ? { text: value } : value;
127
+ // The field the click found: every target form, as need() resolves it, not a bare selector.
128
+ const field = `window.__tutorialFind(${JSON.stringify(into)})?.el`;
129
+ if (into) {
130
+ await press(await need(into));
131
+ await cdp.evaluate(`${field}?.select?.()`);
132
+ }
133
+ for (const character of text) {
134
+ await cdp.send("Input.insertText", { text: character });
135
+ await sleep(70);
136
+ }
137
+ // A React-controlled input can drop inserted text; the native setter plus an input event cannot be missed.
138
+ if (into) await cdp.evaluate(`(() => {
139
+ const input = ${field};
140
+ if (!input || input.value === ${JSON.stringify(text)}) return;
141
+ Object.getOwnPropertyDescriptor(Object.getPrototypeOf(input), "value").set.call(input, ${JSON.stringify(text)});
142
+ input.dispatchEvent(new Event("input", { bubbles: true }));
143
+ })()`);
144
+ },
145
+ async key(chord) {
146
+ const parts = chord.split("+").map((part) => (part === "Mod" ? (mac ? "Meta" : "Control") : part === "Ctrl" ? "Control" : part === "Cmd" ? "Meta" : part));
147
+ const name = parts.pop();
148
+ const modifiers = parts.reduce((bits, part) => bits | (MODIFIER_BITS[part] ?? 0), 0);
149
+ const [key, code, keyCode] = NAMED_KEYS[name] ?? (/^\d$/.test(name) ? [name, `Digit${name}`, name.charCodeAt(0)] : [name.toLowerCase(), `Key${name.toUpperCase()}`, name.toUpperCase().charCodeAt(0)]);
150
+ const label = mac ? parts.map((part) => MAC_GLYPHS[part]).join("") + name : [...parts.map((part) => (part === "Control" ? "Ctrl" : part)), name].join("+");
151
+ // A focused button swallows Space and Cmd/Ctrl+B (src/motion/timeline.jsx), so the chord goes to the page.
152
+ await cdp.evaluate(`window.__tutorialCursor.keycap(${JSON.stringify(label)}); if (document.activeElement?.tagName === "BUTTON") document.activeElement.blur();`);
153
+ await sleep(350);
154
+ const event = { key, code, windowsVirtualKeyCode: keyCode, modifiers };
155
+ await cdp.send("Input.dispatchKeyEvent", { type: "keyDown", ...event, ...(key.length === 1 && !(modifiers & 6) ? { text: key } : {}) });
156
+ await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", ...event });
157
+ },
158
+ };
159
+ }
@@ -0,0 +1,173 @@
1
+ // Episode schema, step timing and the srt writer for the tutorial recorder.
2
+ // Pure functions, so test/verify-tutorial-recorder.mjs checks them without a
3
+ // browser.
4
+ //
5
+ // An episode is a flat array of steps, or { title, subtitle?, chapters: [{ id, title?, steps }] }.
6
+ // A target is "<css>" (first visible match), { css, text } (first visible match containing
7
+ // text), { js } (an expression giving an element or rect; a point for drag), an array (the
8
+ // union, spotlight only), or { ko, en } holding one target per UI language.
9
+ // A step is { id, narration?: { ko, en }, <one action>, until? }:
10
+ // click: target or [target, ...] eased cursor move, then a real click on each
11
+ // type: "text" | { into: "<css>", text } typed into the focused element, or
12
+ // into `into` after clicking it and selecting its value
13
+ // key: "Mod+B" | "Space" | ... one chord; Mod is Cmd on macOS, Ctrl elsewhere
14
+ // drag: { from, to } from: "<css>" or { js: "<expression giving { x, y }>" };
15
+ // to: "<css>" or { dx, dy } from the start
16
+ // wait: "<css>" | ms until the element shows, or a pause
17
+ // hold: ms keep the frame still (alone), or how long a
18
+ // spotlight, card or terminal stays (beside it)
19
+ // eval: "<js>" run in the page and awaited (setup the viewer does not see)
20
+ // spotlight: target | null, label?: { ko, en }, note?: { ko, en }, glide?: false
21
+ // dim the page around the target, name it in a callout; null clears;
22
+ // glide: false skips the cursor move (a toast that lives 2 s)
23
+ // menu: { open: target, then: target } click open, then click then (File > Export)
24
+ // card: "opening" | "closing" | { title: { ko, en }, subtitle?: { ko, en }, chapters?: true }
25
+ // chapters: true lists the titled chapters under it
26
+ // terminal: { print?: [...], type?: "npx cozyclay", fresh? } the install scene: print lines,
27
+ // then type on the last line and press Enter; continues the
28
+ // page an earlier terminal step left, unless fresh
29
+ // studio: true open the Studio at its startup chooser
30
+ // `until` is a selector the step waits for after its action. A step without
31
+ // narration joins the cue of the narrated step before it. `speech: { ko?, en? }`
32
+ // replaces what the voice reads (tools/tutorial/voice.mjs), not the caption.
33
+
34
+ const ACTIONS = ["click", "type", "key", "drag", "wait", "eval", "spotlight", "menu", "card", "terminal", "studio"];
35
+ const HELD = ["spotlight", "card", "terminal"];
36
+ const LANGUAGES = ["ko", "en"];
37
+ const isText = (value) => typeof value === "string" && value.trim().length > 0;
38
+ const isMs = (value) => Number.isFinite(value) && value >= 0;
39
+ const isPair = (value) => LANGUAGES.every((language) => isText(value?.[language]));
40
+ const isLocalized = (value) => value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).sort().join() === "en,ko";
41
+
42
+ /** A target as the episode may write it (see the header). */
43
+ export function isTarget(value, { union = false } = {}) {
44
+ if (isText(value)) return true;
45
+ if (Array.isArray(value)) return union && value.length > 0 && value.every((item) => isTarget(item));
46
+ if (isLocalized(value)) return LANGUAGES.every((language) => isTarget(value[language], { union }));
47
+ if (isText(value?.js)) return true;
48
+ return isText(value?.css) && (value.text === undefined || isText(value.text) || (isLocalized(value.text) && isPair(value.text)));
49
+ }
50
+
51
+ /** The target for one UI language: { ko, en } maps and localized texts resolved. */
52
+ export function localizeTarget(value, language) {
53
+ if (Array.isArray(value)) return value.map((item) => localizeTarget(item, language));
54
+ if (isLocalized(value)) return localizeTarget(value[language], language);
55
+ if (value && typeof value === "object" && isLocalized(value.text)) return { ...value, text: value.text[language] };
56
+ return value;
57
+ }
58
+
59
+ function actionError(name, value, step) {
60
+ if (name === "click") return (isTarget(value) || (Array.isArray(value) && value.length > 0 && value.every((item) => isTarget(item)))) ? null : "click needs a target or a non-empty target list";
61
+ if (name === "type") return isText(value) || (isTarget(value?.into) && isText(value?.text)) ? null : "type needs text, or { into, text }";
62
+ if (name === "key") return isText(value) ? null : "key needs a chord such as Mod+B";
63
+ if (name === "drag") return isTarget(value?.from) && (isTarget(value?.to) || (Number.isFinite(value?.to?.dx) && Number.isFinite(value?.to?.dy))) ? null : "drag needs from (target) and to (target or { dx, dy })";
64
+ if (name === "eval") return isText(value) ? null : "eval needs a page expression";
65
+ if (name === "wait") return isTarget(value) || isMs(value) ? null : "wait needs a target or milliseconds";
66
+ if (name === "spotlight") return value === null || (isTarget(value, { union: true }) && (step.label === undefined || isPair(step.label)) && (step.note === undefined || (isPair(step.note) && step.label))) ? null : "spotlight needs a target (label { ko, en } and note optional), or null";
67
+ if (name === "menu") return isTarget(value?.open) && isTarget(value?.then) ? null : "menu needs { open, then } targets";
68
+ if (name === "card") return value === "opening" || value === "closing" || (isPair(value?.title) && (value.subtitle === undefined || isPair(value.subtitle)) && (value.chapters === undefined || value.chapters === true)) ? null : "card needs \"opening\", \"closing\" or { title: { ko, en }, subtitle?, chapters? }";
69
+ if (name === "terminal") return value && typeof value === "object" && (value.type === undefined || isText(value.type)) && (value.print === undefined || (Array.isArray(value.print) && value.print.every((line) => typeof line === "string"))) && Object.keys(value).every((key) => ["print", "type", "fresh"].includes(key)) ? null : "terminal needs { print?: [lines], type?: text, fresh? }";
70
+ if (name === "studio") return value === true ? null : "studio must be true";
71
+ return isMs(value) ? null : "hold needs milliseconds";
72
+ }
73
+
74
+ /** The action a step performs; `hold` only when nothing else is there. */
75
+ export const actionOf = (step) => ACTIONS.find((name) => Object.hasOwn(step, name)) ?? (Object.hasOwn(step, "hold") ? "hold" : undefined);
76
+
77
+ /**
78
+ * { title, subtitle, chaptered, firstChapter, chapters: [{ id, title, steps }] } from either
79
+ * episode shape. `firstChapter` is the number the first chapter carries (default 1).
80
+ */
81
+ export function normalizeEpisode(episode) {
82
+ if (Array.isArray(episode)) return { title: null, subtitle: null, chaptered: false, firstChapter: 1, chapters: [{ id: "main", title: null, steps: episode }] };
83
+ return { title: episode?.title ?? null, subtitle: episode?.subtitle ?? null, chaptered: true, firstChapter: episode?.firstChapter ?? 1, chapters: episode?.chapters ?? [] };
84
+ }
85
+
86
+ function stepErrors(step, label) {
87
+ const errors = [];
88
+ if (step && Object.hasOwn(step, "narration") && !isPair(step.narration)) errors.push(`${label}: narration needs ko and en`);
89
+ const actions = ACTIONS.filter((name) => Object.hasOwn(step ?? {}, name));
90
+ const action = actionOf(step ?? {});
91
+ if (actions.length > 1 || !action) errors.push(`${label}: needs exactly one action of ${[...ACTIONS, "hold"].join(", ")} (found ${actions.length})`);
92
+ else {
93
+ const problem = actionError(action, step[action], step);
94
+ if (problem) errors.push(`${label}: ${problem}`);
95
+ if (action !== "hold" && Object.hasOwn(step, "hold") && !(HELD.includes(action) && isMs(step.hold))) errors.push(`${label}: hold only times a spotlight, card or terminal`);
96
+ }
97
+ if (step && Object.hasOwn(step, "until") && !isTarget(step.until)) errors.push(`${label}: until must be a target`);
98
+ if (step && Object.hasOwn(step, "speech")) {
99
+ if (!step.narration) errors.push(`${label}: speech needs narration`);
100
+ for (const [language, text] of Object.entries(step.speech ?? {})) if (!LANGUAGES.includes(language) || !isText(text)) errors.push(`${label}: speech.${language} must be ko or en text`);
101
+ }
102
+ const allowed = ["id", "narration", "speech", "until", "hold", "label", "note", "glide", ...ACTIONS];
103
+ const unknown = Object.keys(step ?? {}).filter((key) => !allowed.includes(key) || (["label", "note", "glide"].includes(key) && action !== "spotlight") || (key === "glide" && step.glide !== false));
104
+ if (unknown.length) errors.push(`${label}: unknown field ${unknown.join(", ")}`);
105
+ return errors;
106
+ }
107
+
108
+ /** Every problem in an episode, as "<where>: <problem>" lines; empty when valid. */
109
+ export function validateEpisode(episode) {
110
+ const { title, chaptered, chapters } = normalizeEpisode(episode);
111
+ if (chaptered && (!Array.isArray(chapters) || chapters.length === 0)) return ["an episode object needs a non-empty chapters array"];
112
+ const errors = Number.isInteger(normalizeEpisode(episode).firstChapter) && normalizeEpisode(episode).firstChapter >= 0 ? [] : ["firstChapter must be a whole number"];
113
+ const seen = new Set();
114
+ const chapterIds = new Set();
115
+ for (const chapter of chapters) {
116
+ if (chaptered && !/^[a-z0-9-]+$/.test(chapter?.id ?? "")) errors.push(`chapter ${chapter?.id}: id must be lowercase letters, digits and dashes`);
117
+ else if (chapterIds.has(chapter.id)) errors.push(`chapter ${chapter.id}: duplicate id`);
118
+ chapterIds.add(chapter?.id);
119
+ if (chapter?.title !== undefined && chapter?.title !== null && !isPair(chapter.title)) errors.push(`chapter ${chapter.id}: title needs ko and en`);
120
+ if (!Array.isArray(chapter?.steps) || chapter.steps.length === 0) {
121
+ errors.push(chaptered ? `chapter ${chapter?.id}: needs steps` : "an episode is a non-empty array of steps");
122
+ continue;
123
+ }
124
+ chapter.steps.forEach((step, index) => {
125
+ const label = `step ${step?.id ?? `#${index + 1}`}`;
126
+ if (!isText(step?.id)) errors.push(`${label}: id must be a non-empty string`);
127
+ else if (seen.has(step.id)) errors.push(`${label}: duplicate id`);
128
+ seen.add(step?.id);
129
+ errors.push(...stepErrors(step, label));
130
+ if (step?.card === "opening" && !isPair(title)) errors.push(`${label}: an opening card needs the episode title { ko, en }`);
131
+ });
132
+ }
133
+ return errors;
134
+ }
135
+
136
+ /** Reading speed with no voice: Korean `say` measures 1.7-1.9 words/s, so 1.6 leaves room. */
137
+ export const DEFAULT_WPS = { ko: 1.6, en: 2.5 };
138
+
139
+ /**
140
+ * 1.2 s to take in the new screen, then the words at that language's speed:
141
+ * the video's own language, or the slower of the two when none is given.
142
+ */
143
+ export function readingMs(text, wps, language = null) {
144
+ const words = (value) => value.trim().split(/\s+/).filter(Boolean).length;
145
+ return 1200 + Math.max(...(language ? [language] : LANGUAGES).map((name) => words(text[name]) / wps[name])) * 1000;
146
+ }
147
+
148
+ /** The ko/en texts joined, for reading time of a callout or card. */
149
+ export const joinTexts = (...pairs) => Object.fromEntries(LANGUAGES.map((language) => [language, pairs.filter(Boolean).map((pair) => pair[language]).join(" ")]));
150
+
151
+ /**
152
+ * How long a cue stays on screen: its clip plus a 0.4 s breath when there is
153
+ * voice, the reading estimate otherwise, and never less than its actions.
154
+ * The epsilon keeps float noise (4.66 * 1000 = 4660.000000000001) from adding a millisecond.
155
+ */
156
+ export const stepHoldMs = (actionMs, narration, wps, audioS = null, language = null) => Math.max(actionMs, audioS === null ? readingMs(narration, wps, language) : Math.ceil(audioS * 1000 - 1e-6) + 400);
157
+
158
+ /** dist/tutorial/<episode>/<nn>-<id>.<locale>.mp4 for the chapter numbered `number`. */
159
+ export const chapterFileName = (number, id, locale) => `${String(number).padStart(2, "0")}-${id}.${locale}.mp4`;
160
+
161
+ export function srtTime(ms) {
162
+ const total = Math.max(0, Math.round(ms));
163
+ const pad = (value, width = 2) => String(value).padStart(width, "0");
164
+ const hours = Math.floor(total / 3600000);
165
+ const minutes = Math.floor(total / 60000) % 60;
166
+ const seconds = Math.floor(total / 1000) % 60;
167
+ return `${pad(hours)}:${pad(minutes)}:${pad(seconds)},${pad(total % 1000, 3)}`;
168
+ }
169
+
170
+ /** One srt track from [{ startMs, endMs, narration }] in the given language. */
171
+ export function toSrt(cues, language) {
172
+ return cues.map((cue, index) => `${index + 1}\n${srtTime(cue.startMs)} --> ${srtTime(cue.endMs)}\n${cue.narration[language]}\n`).join("\n");
173
+ }