cozyclay 3.1.6 → 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.
- package/CHANGELOG.md +145 -0
- package/INSTALL_FOR_AGENTS.md +4 -4
- package/README.md +4 -2
- package/bin/agent/agent-failure-codes.mjs +34 -8
- package/bin/agent/agent-routes.mjs +6 -6
- package/bin/agent/agent-runner.mjs +25 -17
- package/bin/agent/agent-stream.mjs +26 -3
- package/bin/agent/main-prompt.mjs +1 -1
- package/bin/agent/openai-compatible-provider.mjs +24 -1
- package/bin/agent/studio-prompt.mjs +2 -2
- package/bin/agent/supervisor-followup.mjs +34 -0
- package/bin/agent/supervisor.mjs +6 -6
- package/bin/cozyclay.mjs +23 -60
- package/bin/star-prompt.mjs +15 -14
- package/bin/studio-routes.mjs +6 -3
- package/bin/telemetry-state.mjs +5 -7
- package/bin/update-check.mjs +61 -7
- package/bin/update-install.mjs +16 -3
- package/bin/update-restart.mjs +95 -0
- package/bin/update-routes.mjs +25 -3
- package/dist/app/index.html +13 -7
- package/dist/assets/TaskBoard-tgO0MNKL.js +1 -0
- package/dist/assets/analytics-CaTUuLPM.js +1 -0
- package/dist/assets/{app-CEP7jzXB.css → app-CnDkB5WO.css} +1 -1
- package/dist/assets/app-DJt06Oyb.js +5144 -0
- package/dist/assets/compile-worker-D1pl6G7x.js +6 -0
- package/dist/assets/landing-2Fy7DU41.js +1 -0
- package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
- package/dist/assets/motion3-compile-BxaM0Xwl.js +1 -0
- package/dist/assets/scene-asset-sync-CLGNbEBq.js +13 -0
- package/dist/assets/task-board-UVGTZLRF.js +15 -0
- package/dist/assets/workflow-CSiBlX1s.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +4 -1
- package/dist/privacy/index.html +9 -6
- package/dist/sitemap.xml +2 -2
- package/dist/workflow/index.html +5 -4
- package/mcp/README.md +1 -1
- package/mcp/live-hub.mjs +4 -4
- package/mcp/tool-handlers.mjs +14 -16
- package/package.json +3 -1
- package/src/App.jsx +42 -75
- package/src/analytics-controls.js +25 -3
- package/src/analytics-strict.js +16 -4
- package/src/analytics.js +32 -26
- package/src/app-version.js +13 -0
- package/src/asset-drop.js +39 -0
- package/src/asset-pane.css +34 -0
- package/src/asset-pane.jsx +4 -35
- package/src/bus-tasks.js +1 -1
- package/src/character-kind.js +8 -6
- package/src/click-friction.js +5 -4
- package/src/command-bus-telemetry.js +8 -7
- package/src/command-bus.js +2 -16
- package/src/commands/cast.js +2 -1
- package/src/commands/index.js +2 -1
- package/src/commands/motion3.js +3 -1
- package/src/commands/objects.js +2 -37
- package/src/commands/physics.js +21 -0
- package/src/commands/project.js +3 -3
- package/src/commands/sculpt-compare.js +53 -0
- package/src/content-tile.jsx +36 -0
- package/src/domains/cast.js +7 -6
- package/src/domains/motion.js +13 -14
- package/src/domains/scenes.js +41 -31
- package/src/execution-telemetry.js +7 -3
- package/src/export-menu-telemetry.js +26 -0
- package/src/finger-handles.jsx +128 -0
- package/src/friction-hint.js +82 -0
- package/src/live-control.js +2 -1
- package/src/locale-zh/panels.js +20 -0
- package/src/locale-zh/shell.js +4 -0
- package/src/locale-zh/workspace.js +6 -0
- package/src/motion/center-of-mass.js +5 -23
- package/src/motion/contact-project.js +143 -0
- package/src/motion/find-bone.js +13 -0
- package/src/motion/fingers.js +170 -0
- package/src/motion/fingertip-pick.js +21 -0
- package/src/motion/fix-collisions.js +1 -1
- package/src/motion/ik-drag.js +93 -1
- package/src/motion/ik-exposure.js +48 -0
- package/src/motion/ik.js +4 -11
- package/src/motion/timeline.css +12 -0
- package/src/motion/timeline.jsx +2 -2
- package/src/motion3/compile-job.js +3 -2
- package/src/motion3/compiler/compile.js +3 -3
- package/src/motion3/compiler/head-lead.js +4 -2
- package/src/motion3/compiler/spring.js +95 -0
- package/src/motion3/compiler/validate-objects.js +18 -2
- package/src/motion3/compiler/validate-parts.js +1 -0
- package/src/motion3/compiler/vocab.js +1 -1
- package/src/motion3/fix/index.js +8 -4
- package/src/motion3/fix/person.js +110 -0
- package/src/motion3/free-motion.js +49 -11
- package/src/motion3/hybrid-hint.js +19 -0
- package/src/motion3/load.js +19 -12
- package/src/motion3/object-pose.js +44 -2
- package/src/motion3/people.js +16 -1
- package/src/motion3/physics-frames.js +72 -0
- package/src/motion3/physics-trail.js +45 -0
- package/src/motion3/physics-view.js +79 -0
- package/src/motion3/pipeline.js +5 -1
- package/src/motion3/qa/ballistic.js +67 -0
- package/src/motion3/qa/com.js +140 -0
- package/src/motion3/qa/ground.js +10 -3
- package/src/motion3/qa/hints-contact.js +21 -7
- package/src/motion3/qa/hints-motion.js +38 -1
- package/src/motion3/qa/key-checks.js +5 -4
- package/src/motion3/qa/patch.js +32 -4
- package/src/motion3/qa/people-checks.js +1 -1
- package/src/motion3/qa/spring-pivot.js +23 -0
- package/src/motion3/qa/style.js +13 -30
- package/src/motion3/qa/verdict.js +7 -2
- package/src/motion3/repair.js +28 -8
- package/src/motion3/schema/fields.js +4 -2
- package/src/motion3/schema/topics.js +15 -11
- package/src/motion3/schema/types.js +13 -9
- package/src/motion3/skills/catch.js +2 -1
- package/src/panels/FingerPanel.jsx +38 -0
- package/src/panels/RigControlPanel.jsx +9 -1
- package/src/panels/SculptPanel.jsx +12 -12
- package/src/panels/SculptPartTree.jsx +46 -0
- package/src/panels/SubjectsPanel.jsx +1 -1
- package/src/panels/sculpt.css +11 -0
- package/src/physics-overlay-settings.js +41 -0
- package/src/physics-overlay.js +152 -0
- package/src/physics-overlay.jsx +54 -0
- package/src/physics-toggle.jsx +20 -0
- package/src/pose-fade.js +7 -3
- package/src/posestudio.jsx +15 -28
- package/src/project-browser.css +6 -0
- package/src/project-browser.jsx +9 -18
- package/src/project-start-choices.jsx +45 -0
- package/src/project.js +16 -12
- package/src/proxy-figure-shape.js +11 -0
- package/src/proxy-figure.jsx +25 -18
- package/src/scene-assets.js +17 -13
- package/src/scenes.js +22 -11
- package/src/sculpt-blueprint.js +56 -0
- package/src/sculpt-mode.js +5 -0
- package/src/sculpt-part-edit.js +115 -0
- package/src/sculpt-part-gizmo.jsx +159 -0
- package/src/sculpt-part-outline.jsx +72 -0
- package/src/sculpt-pick.js +53 -0
- package/src/sculpt-run-text.js +51 -0
- package/src/sculpt-run.js +10 -46
- package/src/sculpt-shelf-file.js +92 -0
- package/src/sculpt-shelf-tiles.jsx +69 -0
- package/src/sculpt-shelf.js +108 -0
- package/src/sculpt-stage.jsx +39 -2
- package/src/sculpt-thumb.js +72 -0
- package/src/sculpt-views.js +75 -0
- package/src/semantic-edit.js +0 -87
- package/src/settings-menu.jsx +2 -2
- package/src/shell/DetailsSlot.jsx +5 -6
- package/src/shell/LibrarySlot.jsx +8 -10
- package/src/shell/MenuBar.jsx +6 -6
- package/src/shell/PreferencesDialog.jsx +2 -3
- package/src/shell/ViewportToolbar.jsx +15 -18
- package/src/shell/add-character-entries.js +13 -0
- package/src/shell/pose-controls.js +32 -0
- package/src/shell/preference-pages.jsx +2 -11
- package/src/shell/storyboard-caption.js +21 -0
- package/src/shell/studio-shell-context.js +1 -0
- package/src/shortcut-friction.js +2 -1
- package/src/site/landing-analytics.js +28 -0
- package/src/studio-actions.js +3 -2
- package/src/studio-agent-protocol.js +4 -4
- package/src/studio-app-binding.js +4 -3
- package/src/update/UpdateChip.jsx +21 -2
- package/src/update/UpdateDialog.jsx +26 -6
- package/src/update/update-client.js +123 -17
- package/src/update/update-telemetry.js +15 -3
- package/src/use-sculpt-mode.js +62 -8
- package/src/workflow/agent-failure-codes.js +28 -9
- package/src/workflow/agent-turn-telemetry.js +1 -1
- package/src/workflow/task-board.js +2 -2
- package/tools/dev/page-shell.py +1 -1
- package/tools/dev/pages/greybox-to-video.html +21 -2
- package/tools/dev/pages/previs-software.html +3 -3
- package/tools/dev/pages/privacy.html +9 -6
- package/tools/dev/pages/seedance-camera-control.html +2 -2
- package/tools/loc-baseline.json +10 -10
- package/tools/run-tests.mjs +53 -84
- package/tools/select-tests.mjs +119 -0
- package/tools/test-durations.json +455 -0
- package/tools/test-graph.mjs +181 -0
- package/tools/test-shards.mjs +78 -0
- package/tools/tutorial/README.md +74 -0
- package/tools/tutorial/assemble.mjs +43 -0
- package/tools/tutorial/captions.mjs +43 -0
- package/tools/tutorial/cdp.mjs +55 -0
- package/tools/tutorial/cursor.mjs +159 -0
- package/tools/tutorial/episode.mjs +173 -0
- package/tools/tutorial/episodes/beginner-guide/00-install.mjs +42 -0
- package/tools/tutorial/episodes/beginner-guide/01-start.mjs +50 -0
- package/tools/tutorial/episodes/beginner-guide/02-layout.mjs +67 -0
- package/tools/tutorial/episodes/beginner-guide/03-stage.mjs +58 -0
- package/tools/tutorial/episodes/beginner-guide/04-motion.mjs +98 -0
- package/tools/tutorial/episodes/beginner-guide/05-camera.mjs +47 -0
- package/tools/tutorial/episodes/beginner-guide/06-export.mjs +59 -0
- package/tools/tutorial/episodes/beginner-guide/steps.mjs +32 -0
- package/tools/tutorial/episodes/beginner-guide.mjs +17 -0
- package/tools/tutorial/episodes/cast.mjs +12 -0
- package/tools/tutorial/episodes/first-video.mjs +121 -0
- package/tools/tutorial/episodes/stub-tour.mjs +97 -0
- package/tools/tutorial/launch.mjs +63 -0
- package/tools/tutorial/mux.mjs +61 -0
- package/tools/tutorial/overlay.mjs +142 -0
- package/tools/tutorial/record.mjs +156 -0
- package/tools/tutorial/scenes/terminal.html +63 -0
- package/tools/tutorial/steps.mjs +111 -0
- package/tools/tutorial/terminal.mjs +43 -0
- package/tools/tutorial/voice.mjs +49 -0
- package/tools/update-test-durations.mjs +42 -0
- package/tools/verify-package-tarball.mjs +18 -12
- package/dist/assets/TaskBoard-DwXhsJXz.js +0 -1
- package/dist/assets/app-BLS1Zaru.js +0 -5144
- package/dist/assets/compile-worker-or7djI2v.js +0 -6
- package/dist/assets/motion3-compile-Du7UXrWn.js +0 -1
- package/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
- package/dist/assets/scene-asset-sync-t45pWCyq.js +0 -13
- package/dist/assets/task-board-CkLZGPkv.js +0 -15
- package/dist/assets/workflow-DJwOUzqJ.js +0 -179
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Test → file dependency graph for change-based test selection (#632).
|
|
2
|
+
// Each test's set is a superset of what it loads: static imports, literal
|
|
3
|
+
// dynamic imports, literal spawned scripts, and every string literal in a
|
|
4
|
+
// reached file that names an existing repository file or directory (which
|
|
5
|
+
// covers readFileSync(new URL("../src/x.js", import.meta.url)) and
|
|
6
|
+
// join(root, "src/x.js")). A test-side file whose import() or spawned script
|
|
7
|
+
// path cannot be read as a literal marks the test unresolved: it always runs.
|
|
8
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
9
|
+
import { dirname, join, normalize, relative } from "node:path";
|
|
10
|
+
|
|
11
|
+
const CODE = /\.(?:m?js|jsx|cjs)$/;
|
|
12
|
+
const EXTENSIONS = ["", ".js", ".jsx", ".mjs", ".cjs", "/index.js", "/index.jsx", "/index.mjs"];
|
|
13
|
+
const STATIC_IMPORT = /\b(?:import|export)\b[^;"'`]*?\bfrom\s*(["'])([^"'\n]+)\1|\bimport\s*(["'])([^"'\n]+)\3/g;
|
|
14
|
+
const DYNAMIC_IMPORT = /\bimport\(\s*([^)]*)\)/g;
|
|
15
|
+
const SPAWN = /\b(?:spawn|spawnSync|execFile|execFileSync|fork)\(\s*process\.execPath\s*,\s*\[([^\]]{0,300})/g;
|
|
16
|
+
// File reads and URLs: a path built at run time hides what the test reads, like a non-literal import().
|
|
17
|
+
// The argument is a lookahead, so readFileSync(new URL(...)) still yields its inner new URL( match.
|
|
18
|
+
const READ = /\b(?:readFileSync|readFile|createReadStream|new URL)\((?=([^\n]{0,300}))/g;
|
|
19
|
+
const JOINED = /\b(?:join|resolve)\(\s*[\w.$]+\s*,((?:\s*["'][^"'$\n]+["']\s*,?)+)\)/g;
|
|
20
|
+
const PATH_LITERAL = /(["'`])((?:\.{0,2}\/)?[\w@.-]+(?:\/[\w@.-]*)*)\1/g;
|
|
21
|
+
const STRING = /^\s*(["'`])([^"'`$]*)\1\s*$/;
|
|
22
|
+
|
|
23
|
+
// Repository-relative, forward slashes; null for anything outside the tree or in node_modules.
|
|
24
|
+
function repoPath(root, absolute) {
|
|
25
|
+
const path = relative(root, absolute).replaceAll("\\", "/");
|
|
26
|
+
return path && !path.startsWith("..") && !path.split("/").includes("node_modules") ? path : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function resolveFile(root, from, specifier) {
|
|
30
|
+
if (!/^(?:\.{1,2}\/|\/)/.test(specifier)) return null; // bare package or node: builtin
|
|
31
|
+
const base = specifier.startsWith("/") ? join(root, specifier) : join(root, dirname(from), specifier);
|
|
32
|
+
for (const extension of EXTENSIONS) {
|
|
33
|
+
const candidate = normalize(base.split("?")[0] + extension);
|
|
34
|
+
if (existsSync(candidate) && statSync(candidate).isFile()) return repoPath(root, candidate);
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// A literal like "src/motion3/x.js", "../src/" or "./fixtures/a.json": the
|
|
40
|
+
// existing file or directory it names, relative to the file or to the root.
|
|
41
|
+
function resolveLiteral(root, from, literal) {
|
|
42
|
+
if (!literal.includes("/") && !/\.\w+$/.test(literal)) return null;
|
|
43
|
+
const bases = literal.startsWith("/") ? [join(root, literal)] : [join(root, dirname(from), literal), join(root, literal)];
|
|
44
|
+
for (const base of bases) {
|
|
45
|
+
const path = existsSync(base) ? repoPath(root, base) : null;
|
|
46
|
+
if (!path || path === "." ) continue;
|
|
47
|
+
if (statSync(base).isFile()) return { file: path };
|
|
48
|
+
if (path.includes("/") || literal.endsWith("/")) return { directory: `${path}/` };
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// An argument is literal when it is a string, or an expression or one-level
|
|
54
|
+
// `const name = ...` binding whose path part is a string without `${`.
|
|
55
|
+
function firstElement(list) {
|
|
56
|
+
let depth = 0;
|
|
57
|
+
for (let index = 0; index < list.length; index += 1) {
|
|
58
|
+
if ("([{".includes(list[index])) depth += 1;
|
|
59
|
+
else if (")]}".includes(list[index])) depth -= 1;
|
|
60
|
+
if (depth < 0 || (depth === 0 && list[index] === ",")) return list.slice(0, index);
|
|
61
|
+
}
|
|
62
|
+
return list;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function literalArgument(source, argument, bound = false) {
|
|
66
|
+
const text = argument.trim();
|
|
67
|
+
if (!text || STRING.test(text) || /^["']-/.test(text) || text.startsWith("`data:") || text === "fileURLToPath(import.meta.url)") return true;
|
|
68
|
+
if (/^`[\w./-]+[?#]/.test(text)) return true; // a cache-busting query on a literal path
|
|
69
|
+
if (text.includes("${") || text.includes("+")) return false;
|
|
70
|
+
if (/["'][^"'$]+["']/.test(text)) return true;
|
|
71
|
+
const name = /^([A-Za-z_$][\w$]*)(?:\.href|\.pathname)?$/.exec(text)?.[1];
|
|
72
|
+
if (!name || name === "import") return false;
|
|
73
|
+
const binding = new RegExp(`\\b(?:const|let|var)\\s+${name.replace("$", "\\$")}\\s*(?:=|of)\\s*([^;\\n]+)`).exec(source);
|
|
74
|
+
return Boolean(binding && !bound && literalArgument(source, binding[1], true));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function scanFile(root, file) {
|
|
78
|
+
const source = readFileSync(join(root, file), "utf8");
|
|
79
|
+
const imports = new Set();
|
|
80
|
+
const files = new Set();
|
|
81
|
+
const directories = new Set();
|
|
82
|
+
let unresolved = null;
|
|
83
|
+
for (const match of source.matchAll(STATIC_IMPORT)) imports.add(match[2] ?? match[4]);
|
|
84
|
+
for (const [, argument] of source.matchAll(DYNAMIC_IMPORT)) {
|
|
85
|
+
const literal = STRING.exec(argument)?.[2] ?? /^\s*`([\w./-]+)[?#]/.exec(argument)?.[1];
|
|
86
|
+
if (literal) imports.add(literal);
|
|
87
|
+
else if (!literalArgument(source, argument)) unresolved ??= `import(${argument.trim().slice(0, 60)})`;
|
|
88
|
+
}
|
|
89
|
+
for (const [, list] of source.matchAll(SPAWN)) {
|
|
90
|
+
const argument = firstElement(list);
|
|
91
|
+
if (!literalArgument(source, argument)) unresolved ??= `spawn(process.execPath, [${argument.trim().slice(0, 60)}])`;
|
|
92
|
+
}
|
|
93
|
+
for (const match of source.matchAll(READ)) {
|
|
94
|
+
const [call, list] = match;
|
|
95
|
+
let argument = firstElement(list);
|
|
96
|
+
// readFileSync(new URL(...)) is judged by its own new URL( match; join(root, x) by its last part.
|
|
97
|
+
if (/^\s*new URL\(/.test(argument) || literalArgument(source, argument)) continue;
|
|
98
|
+
argument = /^\s*(?:join|resolve)\(\s*[\w.$]+\s*,\s*([A-Za-z_$][\w$]*)\s*\)\s*$/.exec(argument)?.[1] ?? argument;
|
|
99
|
+
// A bare parameter of the enclosing arrow or function (`paths.map(path => readFileSync(new URL(path, ...)))`):
|
|
100
|
+
// its values are the call sites' strings, which the literal scan below already records.
|
|
101
|
+
const name = /^\s*([A-Za-z_$][\w$]*)\s*$/.exec(argument)?.[1]?.replace("$", "\\$");
|
|
102
|
+
const context = source.slice(0, match.index).split("\n").slice(-4).join("\n");
|
|
103
|
+
if (name && new RegExp(`(?:\\b${name}\\s*=>|\\(([^()]*,\\s*)?${name}\\s*(?:,[^()]*)?\\)\\s*(?:=>|\\{))`).test(context)) continue;
|
|
104
|
+
// `../src/panels/${name}.jsx`, inline or in the `const` it names: the fixed directory before the first ${.
|
|
105
|
+
const bound = name && new RegExp(`\\bconst\\s+${name}\\s*=\\s*(\`[^\`\\n]*\`)`).exec(source.slice(0, match.index).split("\n").slice(-6).join("\n"))?.[1];
|
|
106
|
+
const prefix = /`([^`$]*\/)[^`/$]*\$\{/.exec(bound ?? argument)?.[1];
|
|
107
|
+
const directory = prefix && resolveLiteral(root, file, prefix)?.directory;
|
|
108
|
+
if (directory) directories.add(directory);
|
|
109
|
+
else unresolved ??= `${call.slice(0, call.indexOf("("))}(${argument.trim().slice(0, 60)})`;
|
|
110
|
+
}
|
|
111
|
+
for (const specifier of imports) {
|
|
112
|
+
const resolved = resolveFile(root, file, specifier);
|
|
113
|
+
if (resolved) files.add(resolved);
|
|
114
|
+
}
|
|
115
|
+
for (const [, , literal] of source.matchAll(PATH_LITERAL)) {
|
|
116
|
+
const resolved = resolveLiteral(root, file, literal);
|
|
117
|
+
if (resolved?.file) files.add(resolved.file);
|
|
118
|
+
else if (resolved?.directory) directories.add(resolved.directory);
|
|
119
|
+
}
|
|
120
|
+
for (const [, segments] of source.matchAll(JOINED)) {
|
|
121
|
+
const literal = [...segments.matchAll(/["']([^"']+)["']/g)].map((match) => match[1]).join("/");
|
|
122
|
+
const resolved = resolveLiteral(root, file, literal.includes("/") ? literal : `./${literal}`);
|
|
123
|
+
if (resolved?.file) files.add(resolved.file);
|
|
124
|
+
else if (resolved?.directory) directories.add(resolved.directory);
|
|
125
|
+
}
|
|
126
|
+
files.delete(file);
|
|
127
|
+
return { files, directories, unresolved };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const isTestSide = (file) => file.startsWith("test/") || /^mcp\/verify[^/]*\.mjs$/.test(file);
|
|
131
|
+
|
|
132
|
+
// Per test: every reachable file, every directory a reached file names, and
|
|
133
|
+
// the first non-literal load found on the test side (null when none).
|
|
134
|
+
export function buildGraph(root, tests) {
|
|
135
|
+
const scans = new Map();
|
|
136
|
+
const scan = (file) => {
|
|
137
|
+
if (!scans.has(file)) scans.set(file, CODE.test(file) ? scanFile(root, file) : { files: new Set(), directories: new Set(), unresolved: null });
|
|
138
|
+
return scans.get(file);
|
|
139
|
+
};
|
|
140
|
+
const graph = new Map();
|
|
141
|
+
for (const test of tests) {
|
|
142
|
+
const files = new Set([test]);
|
|
143
|
+
const directories = new Set();
|
|
144
|
+
let unresolved = null;
|
|
145
|
+
for (const file of files) {
|
|
146
|
+
const result = scan(file);
|
|
147
|
+
for (const next of result.files) files.add(next);
|
|
148
|
+
for (const directory of result.directories) directories.add(directory);
|
|
149
|
+
if (isTestSide(file)) unresolved ??= result.unresolved && `${file}: ${result.unresolved}`;
|
|
150
|
+
}
|
|
151
|
+
graph.set(test, { files, directories, unresolved });
|
|
152
|
+
}
|
|
153
|
+
return graph;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// File → tests that reach it, plus the directory entries for a prefix match.
|
|
157
|
+
export function reverseIndex(graph) {
|
|
158
|
+
const byFile = new Map();
|
|
159
|
+
const byDirectory = new Map();
|
|
160
|
+
for (const [test, { files, directories }] of graph) {
|
|
161
|
+
for (const file of files) byFile.set(file, [...(byFile.get(file) ?? []), test]);
|
|
162
|
+
for (const directory of directories) byDirectory.set(directory, [...(byDirectory.get(directory) ?? []), test]);
|
|
163
|
+
}
|
|
164
|
+
return (file) => {
|
|
165
|
+
const tests = new Set(byFile.get(file) ?? []);
|
|
166
|
+
for (const [directory, readers] of byDirectory) if (file.startsWith(directory)) for (const test of readers) tests.add(test);
|
|
167
|
+
return tests;
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// The same `verify*.mjs` sweep tools/run-tests.mjs uses, to detect a test the
|
|
172
|
+
// manifest does not know about.
|
|
173
|
+
export function sweepVerifyFiles(root, directory) {
|
|
174
|
+
const absolute = join(root, directory);
|
|
175
|
+
if (!existsSync(absolute)) return [];
|
|
176
|
+
return readdirSync(absolute, { withFileTypes: true }).flatMap((entry) => {
|
|
177
|
+
const path = `${directory}/${entry.name}`;
|
|
178
|
+
if (entry.isDirectory()) return entry.name === "node_modules" ? [] : sweepVerifyFiles(root, path);
|
|
179
|
+
return entry.isFile() && /^verify(-.*)?\.mjs$/.test(entry.name) ? [path] : [];
|
|
180
|
+
});
|
|
181
|
+
}
|
|
@@ -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) => ({ "&": "&", "<": "<", ">": ">" })[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
|
+
}
|