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.
- package/CHANGELOG.md +87 -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 +16 -13
- package/bin/agent/agent-stream.mjs +26 -3
- package/bin/agent/main-prompt.mjs +1 -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-CnDkB5WO.css +1 -0
- package/dist/assets/app-DJt06Oyb.js +5144 -0
- package/dist/assets/{compile-worker-DRWRa9pr.js → compile-worker-D1pl6G7x.js} +5 -5
- 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 +2 -1
- package/src/App.jsx +37 -70
- 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/motion3.js +3 -1
- package/src/commands/objects.js +2 -37
- 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 +6 -5
- 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/find-bone.js +13 -0
- package/src/motion/fingers.js +170 -0
- package/src/motion/fingertip-pick.js +21 -0
- package/src/motion/ik-exposure.js +48 -0
- package/src/motion/ik.js +4 -11
- package/src/motion3/compiler/head-lead.js +4 -2
- package/src/motion3/hybrid-hint.js +19 -0
- package/src/motion3/load.js +19 -12
- package/src/motion3/qa/hints-contact.js +12 -0
- package/src/motion3/qa/patch.js +4 -1
- package/src/motion3/qa/verdict.js +7 -2
- package/src/motion3/repair.js +28 -8
- 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/pose-fade.js +7 -3
- package/src/posestudio.jsx +14 -27
- 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 +45 -69
- 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-BUjjuvm5.js +0 -1
- package/dist/assets/app-Dige7q3X.css +0 -1
- package/dist/assets/app-DxoI941o.js +0 -5144
- package/dist/assets/motion3-compile-B4SX4TGx.js +0 -1
- package/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
- package/dist/assets/scene-asset-sync-LeI3eSnO.js +0 -13
- package/dist/assets/task-board-5PKn7ZaA.js +0 -15
- package/dist/assets/workflow-Dg9jCKOq.js +0 -179
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Episode 1, "First video in 5 minutes": start screen to a keyframe-pack
|
|
2
|
+
// export, following the measured beginner path. The in-app first-success
|
|
3
|
+
// guide stays on screen and advances as each of its steps is done. The walk
|
|
4
|
+
// take is compiled in the page because the recording has no signed-in agent;
|
|
5
|
+
// the narration says so.
|
|
6
|
+
import { skeletonFor } from "../../../src/motion3/rigs/index.js";
|
|
7
|
+
import { castFirstCharacter } from "./cast.mjs";
|
|
8
|
+
|
|
9
|
+
const skeleton = skeletonFor("y-bot-tpose");
|
|
10
|
+
const rest = (bone) => skeleton.restWorldPos[skeleton.index[bone]];
|
|
11
|
+
const ankle = rest("LeftFoot")[1];
|
|
12
|
+
const WALK = {
|
|
13
|
+
schema: "cozyclay.motion-spec.v1",
|
|
14
|
+
fps: 24,
|
|
15
|
+
frames: 240,
|
|
16
|
+
keys: [{ f: 0, set: { root: [0, rest("Hips")[1] - 0.01, 0], yaw: 0, foot_l: [0.09, ankle, -0.03], foot_r: [-0.09, ankle, 0.03], fyaw_l: 0, fyaw_r: 0, armw_r: 0 } }],
|
|
17
|
+
generators: [{ type: "walk", f0: 6, steps: 8, heading: [0, 0] }],
|
|
18
|
+
};
|
|
19
|
+
const GUIDE_NEXT = ".v2-first-success-guide-next";
|
|
20
|
+
|
|
21
|
+
export default [
|
|
22
|
+
{
|
|
23
|
+
id: "install",
|
|
24
|
+
narration: {
|
|
25
|
+
ko: "터미널에서 npx cozyclay를 실행하면 브라우저에 스튜디오가 열립니다.",
|
|
26
|
+
en: "Run npx cozyclay in a terminal and the studio opens in your browser.",
|
|
27
|
+
},
|
|
28
|
+
speech: { ko: "터미널에서 엔피엑스 코지클레이를 실행하면 브라우저에 스튜디오가 열립니다.", en: "Run N P X cozyclay in a terminal and the studio opens in your browser." },
|
|
29
|
+
wait: ".v2-start-screen",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: "create",
|
|
33
|
+
narration: {
|
|
34
|
+
ko: "\"새 프로젝트\"에 이름을 적고 \"생성\"을 누르세요. 빈 무대에서 시작합니다.",
|
|
35
|
+
en: "Pick \"New project\", name it, and press \"Create\". You start from an empty stage.",
|
|
36
|
+
},
|
|
37
|
+
type: { into: "[data-testid=start-project-name]", text: "my_first_video" },
|
|
38
|
+
},
|
|
39
|
+
{ id: "create-click", click: "[data-testid=start-create]", until: ".timeline" },
|
|
40
|
+
// The character rig takes about a second to load after Create.
|
|
41
|
+
{ id: "rig-load", hold: 1500 },
|
|
42
|
+
...castFirstCharacter("cast", {
|
|
43
|
+
ko: "장면 보기의 \"추가\" 메뉴에서 Y Bot을 골라 캐릭터를 놓으세요.",
|
|
44
|
+
en: "Open Add in the Scene view and pick Y Bot to place a character.",
|
|
45
|
+
}),
|
|
46
|
+
{
|
|
47
|
+
id: "select",
|
|
48
|
+
narration: { ko: "아웃라이너에서 \"캐릭터 1\"을 고르세요.", en: "Select \"Character 1\" in the Outliner." },
|
|
49
|
+
click: "[data-node-id=characterA] .hierarchy-row",
|
|
50
|
+
},
|
|
51
|
+
{ id: "guide-select", click: GUIDE_NEXT },
|
|
52
|
+
{
|
|
53
|
+
id: "move",
|
|
54
|
+
narration: { ko: "장면 보기에서 바닥의 핸들을 끌어 옮기세요.", en: "Drag the handle on the floor in the Scene view to place it." },
|
|
55
|
+
drag: { from: { js: "window.__characterGizmoHandles?.().find((handle) => handle.axis === 'plane:xz')" }, to: { dx: -120, dy: 45 } },
|
|
56
|
+
},
|
|
57
|
+
{ id: "guide-move", click: GUIDE_NEXT },
|
|
58
|
+
{
|
|
59
|
+
id: "agent",
|
|
60
|
+
narration: {
|
|
61
|
+
ko: "Cmd/Ctrl+B로 에이전트 패널을 엽니다. 여기서 \"Sign in with ChatGPT\"로 로그인하면 동작을 말로 요청할 수 있어요.",
|
|
62
|
+
en: "Press Cmd/Ctrl+B to open the Agent panel. Sign in here, then describe the motion you want.",
|
|
63
|
+
},
|
|
64
|
+
speech: { ko: "커맨드 또는 컨트롤 B로 에이전트 패널을 엽니다. 여기서 \"Sign in with ChatGPT\"로 로그인하면 동작을 말로 요청할 수 있어요.", en: "Press Command or Control B to open the Agent panel. Sign in here, then describe the motion you want." },
|
|
65
|
+
key: "Mod+B",
|
|
66
|
+
until: "[data-agent-card=signed-out]",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: "motion",
|
|
70
|
+
narration: {
|
|
71
|
+
ko: "로그인한 뒤 요청하면 테이크가 \"바디\" 레인에 올라옵니다. 여기서는 완성된 걷기 테이크를 바로 불러왔어요.",
|
|
72
|
+
en: "Once you are signed in, the take lands on the Body lane. Here we load a finished walk take.",
|
|
73
|
+
},
|
|
74
|
+
key: "Mod+B",
|
|
75
|
+
},
|
|
76
|
+
// The walk starts where the "move" step left the character: the studio's
|
|
77
|
+
// own character state offsets the spec's root and feet on the floor.
|
|
78
|
+
{
|
|
79
|
+
id: "motion-compile",
|
|
80
|
+
eval: `(() => {
|
|
81
|
+
const character = window.__cozyclay.characters[0], { x, z } = character;
|
|
82
|
+
const spec = ${JSON.stringify(WALK)};
|
|
83
|
+
const set = spec.keys[0].set;
|
|
84
|
+
for (const name of ["root", "foot_l", "foot_r"]) set[name] = [set[name][0] + x, set[name][1], set[name][2] + z];
|
|
85
|
+
return window.__cozyclay.runStudioAction("motion.compile", { characterId: character.id, spec });
|
|
86
|
+
})()`,
|
|
87
|
+
},
|
|
88
|
+
{ id: "guide-ask", click: GUIDE_NEXT },
|
|
89
|
+
{
|
|
90
|
+
id: "play",
|
|
91
|
+
narration: {
|
|
92
|
+
ko: "Space를 누르면 재생되고, 타임라인을 문질러 확인할 수도 있어요. 재생 헤드가 움직이면 첫 결과입니다.",
|
|
93
|
+
en: "Press Space to play, or scrub the timeline. Seeing the playhead move is your first visible result.",
|
|
94
|
+
},
|
|
95
|
+
key: "Space",
|
|
96
|
+
// The play button gains "on" while playing; its aria-label is translated.
|
|
97
|
+
until: ".tl-btn.play.on",
|
|
98
|
+
},
|
|
99
|
+
// The walk carries the character out of the viewport by frame ~100; pausing
|
|
100
|
+
// near frame 36 keeps it in view for the shot and export steps.
|
|
101
|
+
{ id: "playing", hold: 1500 },
|
|
102
|
+
{ id: "pause", key: "Space" },
|
|
103
|
+
{ id: "guide-complete", click: GUIDE_NEXT },
|
|
104
|
+
{ id: "guide-close", click: GUIDE_NEXT },
|
|
105
|
+
{
|
|
106
|
+
id: "shot",
|
|
107
|
+
narration: { ko: "카메라 컷 레인의 \"+\"를 누르면 짧은 샷이 추가됩니다.", en: "Press \"+\" on the Camera Cuts lane to add a short shot." },
|
|
108
|
+
click: ".tl-track-add.cut",
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
id: "export",
|
|
112
|
+
narration: {
|
|
113
|
+
ko: "파일, 내보내기에서 \"키프레임 팩 (zip)\"을 고르거나 Cmd/Ctrl+E를 누르세요. 첫 프레임, 마지막 프레임, 참고 클립, 프롬프트가 한 zip에 담깁니다.",
|
|
114
|
+
en: "Choose File, Export, \"Keyframe pack (zip)\", or press Cmd/Ctrl+E. First frame, last frame, reference clip and prompt land in one zip.",
|
|
115
|
+
},
|
|
116
|
+
speech: { ko: "파일, 내보내기에서 키프레임 팩 집을 고르거나 커맨드 또는 컨트롤 E를 누르세요. 첫 프레임, 마지막 프레임, 참고 클립, 프롬프트가 한 집 파일에 담깁니다.", en: "Choose File, Export, Keyframe pack zip, or press Command or Control E. First frame, last frame, reference clip and prompt land in one zip." },
|
|
117
|
+
key: "Mod+E",
|
|
118
|
+
},
|
|
119
|
+
// The last sentence finishes with the export toast still on screen.
|
|
120
|
+
{ id: "export-toast", hold: 1000 },
|
|
121
|
+
];
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Stub tour: proves the point-at-features format (cards, the install terminal,
|
|
2
|
+
// spotlights with callouts, a menu path) on a short path. The terminal lines
|
|
3
|
+
// are placeholders until the measured install output replaces them. Fixed
|
|
4
|
+
// holds keep it under a minute; a real episode lets reading time decide.
|
|
5
|
+
const OUTLINER = ".hierarchy-pane.v2-outliner";
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
title: { ko: "CozyClay 둘러보기", en: "A tour of CozyClay" },
|
|
9
|
+
subtitle: { ko: "설치부터 편집기까지", en: "From install to the editor" },
|
|
10
|
+
firstChapter: 0,
|
|
11
|
+
chapters: [
|
|
12
|
+
{ id: "intro", steps: [{ id: "opening", card: "opening", hold: 2500 }] },
|
|
13
|
+
{
|
|
14
|
+
id: "install",
|
|
15
|
+
title: { ko: "설치", en: "Install" },
|
|
16
|
+
steps: [
|
|
17
|
+
{ id: "install-card", hold: 2000, card: { title: { ko: "설치", en: "Install" }, subtitle: { ko: "터미널에서 한 줄이면 됩니다", en: "One line in a terminal" } } },
|
|
18
|
+
{
|
|
19
|
+
id: "install-run",
|
|
20
|
+
narration: { ko: "터미널에서 npx cozyclay를 실행하세요.", en: "Run npx cozyclay in a terminal." },
|
|
21
|
+
terminal: { type: "npx cozyclay" },
|
|
22
|
+
},
|
|
23
|
+
{ id: "install-output", terminal: { print: ["[placeholder] installing CozyClay", "[placeholder] CozyClay is running at http://127.0.0.1:5180/app/", "[placeholder] opening your browser"] } },
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: "start",
|
|
28
|
+
title: { ko: "시작 화면", en: "Start screen" },
|
|
29
|
+
steps: [
|
|
30
|
+
{ id: "open-studio", studio: true },
|
|
31
|
+
{ id: "start-card", hold: 2000, card: { title: { ko: "시작 화면", en: "Start screen" }, subtitle: { ko: "프로젝트를 만드는 곳", en: "Where a project begins" } } },
|
|
32
|
+
{
|
|
33
|
+
id: "spot-animation",
|
|
34
|
+
narration: { ko: "먼저 프로젝트 모드를 고릅니다.", en: "First, pick the project mode." },
|
|
35
|
+
spotlight: "[data-testid=start-previs-mode] [data-previs-mode=animation]",
|
|
36
|
+
hold: 2600,
|
|
37
|
+
label: { ko: "애니메이션", en: "Animation" },
|
|
38
|
+
note: { ko: "움직임을 블로킹하고 샷을 찍는 모드", en: "Block continuous motion and shoot shots" },
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: "spot-create",
|
|
42
|
+
spotlight: "[data-testid=start-create]",
|
|
43
|
+
hold: 2600,
|
|
44
|
+
label: { ko: "생성", en: "Create" },
|
|
45
|
+
note: { ko: "빈 무대로 새 프로젝트를 엽니다", en: "Opens a new project on an empty stage" },
|
|
46
|
+
},
|
|
47
|
+
{ id: "spot-clear-start", spotlight: null },
|
|
48
|
+
{ id: "create", click: "[data-testid=start-create]", until: ".timeline" },
|
|
49
|
+
{ id: "rig-load", hold: 1500 },
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: "editor",
|
|
54
|
+
title: { ko: "편집기", en: "Editor" },
|
|
55
|
+
steps: [
|
|
56
|
+
{ id: "editor-card", hold: 2000, card: { title: { ko: "편집기", en: "Editor" }, subtitle: { ko: "장면, 모드, 타임라인", en: "Scene, modes and timeline" } } },
|
|
57
|
+
{
|
|
58
|
+
id: "spot-outliner",
|
|
59
|
+
narration: { ko: "편집기의 세 부분을 차례로 봅니다.", en: "Three parts of the editor, one at a time." },
|
|
60
|
+
spotlight: OUTLINER,
|
|
61
|
+
label: { ko: "아웃라이너", en: "Outliner" },
|
|
62
|
+
note: { ko: "장면에 있는 카메라, 조명, 캐릭터 목록", en: "Every camera, light and character in the scene" },
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: "spot-modes",
|
|
66
|
+
spotlight: ".vp-mode-keys",
|
|
67
|
+
hold: 2600,
|
|
68
|
+
label: { ko: "작업 모드", en: "Workflow modes" },
|
|
69
|
+
note: { ko: "배치, 포즈, 카메라, 모션, 조각을 오갑니다", en: "Switch between Stage, Pose, Camera, Motion and Sculpt" },
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: "spot-body",
|
|
73
|
+
spotlight: "[data-lane-label=Body]",
|
|
74
|
+
hold: 2600,
|
|
75
|
+
label: { ko: "바디 레인", en: "Body lane" },
|
|
76
|
+
note: { ko: "캐릭터의 동작 테이크가 올라오는 곳", en: "Where the character's motion takes land" },
|
|
77
|
+
},
|
|
78
|
+
{ id: "spot-clear-editor", spotlight: null },
|
|
79
|
+
{
|
|
80
|
+
id: "open-export",
|
|
81
|
+
narration: { ko: "내보내기는 파일 메뉴에 있습니다.", en: "Exports live in the File menu." },
|
|
82
|
+
menu: { open: "[data-testid=menu-file]", then: "[data-testid=topbar-export]" },
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: "spot-keyframe-pack",
|
|
86
|
+
spotlight: "[data-testid=export-keyframe-pack]",
|
|
87
|
+
hold: 2600,
|
|
88
|
+
label: { ko: "키프레임 팩 (zip)", en: "Keyframe pack (zip)" },
|
|
89
|
+
note: { ko: "첫 프레임, 마지막 프레임, 참고 클립, 프롬프트", en: "First frame, last frame, reference clip and prompt" },
|
|
90
|
+
},
|
|
91
|
+
{ id: "spot-clear-export", spotlight: null },
|
|
92
|
+
{ id: "close-menu", click: "[data-testid=menu-file]" },
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
{ id: "outro", steps: [{ id: "closing", card: "closing", hold: 3000 }] },
|
|
96
|
+
],
|
|
97
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `npm run tutorial:record -- <episode> [flags]`: starts a dev server with a
|
|
3
|
+
// throwaway config dir (so the Agent panel shows the signed-out screen, not
|
|
4
|
+
// this machine's sign-in), runs record.mjs inside tools/qa-browser.mjs, and
|
|
5
|
+
// tears both down. Ports come from TUTORIAL_DEV_PORT, CDP_PORT,
|
|
6
|
+
// COZYCLAY_LIVE_PORT and COZYCLAY_OAUTH_PORT, defaulting to 5594-5597 so a
|
|
7
|
+
// recording never touches a Studio on 5180/5184. dev-full, not vite alone:
|
|
8
|
+
// without the agent server the panel reports a missing server instead of the
|
|
9
|
+
// sign-in card.
|
|
10
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { findFfmpeg } from "./assemble.mjs";
|
|
14
|
+
import { installSignalCleanup, spawnOwned, terminateOwned, waitForExit } from "../process-supervisor.mjs";
|
|
15
|
+
|
|
16
|
+
const devPort = Number(process.env.TUTORIAL_DEV_PORT || 5594);
|
|
17
|
+
const cdpPort = String(process.env.CDP_PORT || 5595);
|
|
18
|
+
const livePort = String(process.env.COZYCLAY_LIVE_PORT || 5596);
|
|
19
|
+
const oauthPort = String(process.env.COZYCLAY_OAUTH_PORT || 5597);
|
|
20
|
+
const url = `http://127.0.0.1:${devPort}/app/`;
|
|
21
|
+
const reachable = () => fetch(url).then((response) => response.ok, () => false);
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
findFfmpeg();
|
|
25
|
+
} catch (error) {
|
|
26
|
+
console.error(error.message);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
if (await reachable()) {
|
|
30
|
+
console.error(`port ${devPort} already serves a page; stop it or set TUTORIAL_DEV_PORT`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const configDir = mkdtempSync(join(tmpdir(), "cozyclay-tutorial-config-"));
|
|
35
|
+
const children = [];
|
|
36
|
+
const removeSignalCleanup = installSignalCleanup(() => children, () => rmSync(configDir, { recursive: true, force: true }));
|
|
37
|
+
// No provider key or endpoint from this shell may reach the recording's agent sidecar.
|
|
38
|
+
const devEnv = Object.fromEntries(Object.entries(process.env).filter(([name]) => !/(_API_KEY|_AUTH_TOKEN|_BASE_URL)$/.test(name) && name !== "COZYCLAY_CODEX_AUTH_FILE"));
|
|
39
|
+
try {
|
|
40
|
+
const dev = spawnOwned(process.execPath, ["tools/dev-full.mjs", "--host", "127.0.0.1", "--port", String(devPort)], {
|
|
41
|
+
env: { ...devEnv, COZYCLAY_CONFIG_DIR: configDir, XDG_CONFIG_HOME: configDir, COZYCLAY_LIVE_PORT: livePort, COZYCLAY_OAUTH_PORT: oauthPort },
|
|
42
|
+
stdio: ["ignore", "ignore", "inherit"],
|
|
43
|
+
});
|
|
44
|
+
children.push(dev);
|
|
45
|
+
const deadline = Date.now() + 60000;
|
|
46
|
+
while (!(await reachable())) {
|
|
47
|
+
if (dev.exitCode !== null) throw new Error("the dev server exited before it served the Studio");
|
|
48
|
+
if (Date.now() > deadline) throw new Error(`the dev server did not answer on ${url} within 60s`);
|
|
49
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
50
|
+
}
|
|
51
|
+
const recorder = spawnOwned(process.execPath, ["tools/qa-browser.mjs", "--", process.execPath, "tools/tutorial/record.mjs", ...process.argv.slice(2)], {
|
|
52
|
+
env: { ...process.env, QA_URL: url, CDP_PORT: cdpPort, QA_STARTUP_CHOOSER: "1" },
|
|
53
|
+
});
|
|
54
|
+
children.push(recorder);
|
|
55
|
+
process.exitCode = (await waitForExit(recorder)).code ?? 1;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.error(error.message);
|
|
58
|
+
process.exitCode = 1;
|
|
59
|
+
} finally {
|
|
60
|
+
removeSignalCleanup();
|
|
61
|
+
await Promise.allSettled(children.map((child) => terminateOwned(child)));
|
|
62
|
+
rmSync(configDir, { recursive: true, force: true });
|
|
63
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Final assembly for the tutorial recorder: the silent capture gets a caption
|
|
2
|
+
// band under the frame (so a caption never covers the UI the step points at),
|
|
3
|
+
// one caption PNG per cue in that band, and the voice clips laid on the cue
|
|
4
|
+
// windows. This ffmpeg has neither libass nor freetype, so captions are images
|
|
5
|
+
// and the graph is built here. Pure, so the test checks it without ffmpeg.
|
|
6
|
+
|
|
7
|
+
const seconds = (ms) => (ms / 1000).toFixed(3);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* ffmpeg argv for `output`. `cues` are contiguous [{ id, startMs, endMs }];
|
|
11
|
+
* `clips` maps a cue id to { path, audioS } (may be empty: captions only);
|
|
12
|
+
* `captions` holds one PNG per cue, `bandHeight` px tall.
|
|
13
|
+
*/
|
|
14
|
+
export function muxArgs({ base, output, cues, clips, captions, endMs, bandHeight }) {
|
|
15
|
+
for (const cue of cues) {
|
|
16
|
+
const clip = clips.get(cue.id);
|
|
17
|
+
if (clip && clip.audioS * 1000 > cue.endMs - cue.startMs) {
|
|
18
|
+
throw new Error(`the voice clip for ${cue.id} runs ${clip.audioS.toFixed(2)} s, longer than its ${seconds(cue.endMs - cue.startMs)} s cue; record again so the hold follows the clip`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const voiced = clips.size > 0;
|
|
22
|
+
const inputs = ["-i", base];
|
|
23
|
+
// Each audio segment runs from its cue start to the next cue start (the first from 0, the last to the end).
|
|
24
|
+
const segments = cues.map((cue, index) => ({ cue, fromMs: index === 0 ? 0 : cue.startMs, toMs: index + 1 < cues.length ? cues[index + 1].startMs : endMs }));
|
|
25
|
+
const filters = [];
|
|
26
|
+
if (voiced) {
|
|
27
|
+
segments.forEach(({ cue, fromMs, toMs }, index) => {
|
|
28
|
+
const length = seconds(toMs - fromMs);
|
|
29
|
+
const clip = clips.get(cue.id);
|
|
30
|
+
inputs.push(...(clip ? ["-i", clip.path] : ["-f", "lavfi", "-t", length, "-i", "anullsrc=r=48000:cl=mono"]));
|
|
31
|
+
const delay = index === 0 && cue.startMs > 0 ? `,adelay=delays=${Math.round(cue.startMs)}:all=1` : "";
|
|
32
|
+
filters.push(`[${index + 1}:a]aresample=48000,aformat=channel_layouts=mono${delay},apad=whole_dur=${length},atrim=end=${length}[a${index}]`);
|
|
33
|
+
});
|
|
34
|
+
filters.push(`${segments.map((_, index) => `[a${index}]`).join("")}concat=n=${segments.length}:v=0:a=1[aout]`);
|
|
35
|
+
}
|
|
36
|
+
const firstCaption = voiced ? cues.length + 1 : 1;
|
|
37
|
+
for (const caption of captions) inputs.push("-i", caption);
|
|
38
|
+
filters.push(`[0:v]pad=iw:ih+${bandHeight}:0:0:color=0x111111[v]`);
|
|
39
|
+
cues.forEach((cue, index) => {
|
|
40
|
+
filters.push(`[${index ? `v${index - 1}` : "v"}][${firstCaption + index}:v]overlay=0:H-h:enable='between(t,${seconds(cue.startMs)},${seconds(cue.endMs)})'[v${index}]`);
|
|
41
|
+
});
|
|
42
|
+
return [
|
|
43
|
+
"-hide_banner", "-loglevel", "error", "-y", ...inputs,
|
|
44
|
+
"-filter_complex", filters.join(";"),
|
|
45
|
+
"-map", cues.length ? `[v${cues.length - 1}]` : "[v]", ...(voiced ? ["-map", "[aout]", "-c:a", "aac", "-b:a", "128k"] : []),
|
|
46
|
+
"-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p",
|
|
47
|
+
"-t", seconds(endMs), "-movflags", "+faststart", output,
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Caption band height for a frame height: about 13%, even, so two lines fit. */
|
|
52
|
+
export const bandHeightFor = (height) => Math.round((height * 0.13) / 2) * 2;
|
|
53
|
+
|
|
54
|
+
/** ffmpeg argv cutting [startMs, endMs) of the finished episode into one chapter file, re-encoded for exact cuts. */
|
|
55
|
+
export function chapterCutArgs({ input, output, startMs, endMs, audio }) {
|
|
56
|
+
return [
|
|
57
|
+
"-hide_banner", "-loglevel", "error", "-y", "-ss", seconds(startMs), "-t", seconds(endMs - startMs), "-i", input,
|
|
58
|
+
"-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p",
|
|
59
|
+
...(audio ? ["-c:a", "aac", "-b:a", "128k"] : ["-an"]), "-movflags", "+faststart", output,
|
|
60
|
+
];
|
|
61
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Teaching overlays for the tutorial recorder, injected into every page the
|
|
2
|
+
// episode visits: a spotlight (the page dimmed around a cut-out with an accent
|
|
3
|
+
// ring, a callout naming the feature beside it) and full-frame chapter cards.
|
|
4
|
+
// pointer-events: none throughout, so the real input still reaches the app.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Where a callout box goes beside a target rect: right, left, below, above,
|
|
8
|
+
* the first side where it fits; else the roomiest side, clamped into the
|
|
9
|
+
* viewport. Self-contained, because it is also serialized into the page.
|
|
10
|
+
*/
|
|
11
|
+
export function calloutPlacement(target, box, viewport, gap = 16, margin = 12) {
|
|
12
|
+
const fits = {
|
|
13
|
+
right: target.x + target.width + gap + box.width <= viewport.width - margin,
|
|
14
|
+
left: target.x - gap - box.width >= margin,
|
|
15
|
+
below: target.y + target.height + gap + box.height <= viewport.height - margin,
|
|
16
|
+
above: target.y - gap - box.height >= margin,
|
|
17
|
+
};
|
|
18
|
+
const room = { right: viewport.width - target.x - target.width, left: target.x, below: viewport.height - target.y - target.height, above: target.y };
|
|
19
|
+
const sides = ["right", "left", "below", "above"];
|
|
20
|
+
const side = sides.find((name) => fits[name]) ?? sides.reduce((best, name) => (room[name] > room[best] ? name : best));
|
|
21
|
+
const middleX = target.x + target.width / 2 - box.width / 2;
|
|
22
|
+
const middleY = target.y + target.height / 2 - box.height / 2;
|
|
23
|
+
const x = side === "right" ? target.x + target.width + gap : side === "left" ? target.x - gap - box.width : middleX;
|
|
24
|
+
const y = side === "below" ? target.y + target.height + gap : side === "above" ? target.y - gap - box.height : middleY;
|
|
25
|
+
const clamp = (value, low, high) => Math.min(Math.max(value, low), Math.max(low, high));
|
|
26
|
+
return { side, x: Math.round(clamp(x, margin, viewport.width - margin - box.width)), y: Math.round(clamp(y, margin, viewport.height - margin - box.height)) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolves a target in the page: a CSS selector (first visible match),
|
|
31
|
+
* { css, text } (first visible match containing the text), { js } (an
|
|
32
|
+
* expression giving an element or a { x, y, width, height } rect), or an
|
|
33
|
+
* array (the union of its targets). Returns { el, rect } or null.
|
|
34
|
+
* Self-contained, because it is serialized into the page.
|
|
35
|
+
*/
|
|
36
|
+
function findTarget(spec) {
|
|
37
|
+
if (Array.isArray(spec)) {
|
|
38
|
+
const rects = spec.map(findTarget).filter(Boolean).map((found) => found.rect);
|
|
39
|
+
if (!rects.length) return null;
|
|
40
|
+
const left = Math.min(...rects.map((rect) => rect.x)), top = Math.min(...rects.map((rect) => rect.y));
|
|
41
|
+
const right = Math.max(...rects.map((rect) => rect.x + rect.width)), bottom = Math.max(...rects.map((rect) => rect.y + rect.height));
|
|
42
|
+
return { el: null, rect: { x: left, y: top, width: right - left, height: bottom - top } };
|
|
43
|
+
}
|
|
44
|
+
const box = (node) => { const rect = node.getBoundingClientRect(); return rect.width > 0 && rect.height > 0 ? { el: node, rect: { x: rect.left, y: rect.top, width: rect.width, height: rect.height } } : null; };
|
|
45
|
+
if (spec && typeof spec === "object" && spec.js) {
|
|
46
|
+
const value = (0, eval)(spec.js);
|
|
47
|
+
if (!value) return null;
|
|
48
|
+
return value instanceof Element ? box(value) : { el: null, rect: { x: value.x, y: value.y, width: value.width, height: value.height } };
|
|
49
|
+
}
|
|
50
|
+
const { css, text } = typeof spec === "string" ? { css: spec } : spec;
|
|
51
|
+
for (const node of document.querySelectorAll(css)) {
|
|
52
|
+
if (text && !node.textContent.includes(text)) continue;
|
|
53
|
+
const found = box(node);
|
|
54
|
+
if (found) return found;
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const FONT = `"Apple SD Gothic Neo","Noto Sans CJK KR","Noto Sans KR","Malgun Gothic",system-ui,sans-serif`;
|
|
60
|
+
|
|
61
|
+
export const TEACHING_OVERLAY = `(() => {
|
|
62
|
+
if (window.__tutorialOverlay) return;
|
|
63
|
+
const place = ${calloutPlacement.toString()};
|
|
64
|
+
const find = ${findTarget.toString()};
|
|
65
|
+
window.__tutorialFind = find;
|
|
66
|
+
const PAD = 8;
|
|
67
|
+
const css = document.createElement("style");
|
|
68
|
+
css.textContent = \`
|
|
69
|
+
.tt-root{position:fixed;inset:0;pointer-events:none;z-index:2147483646;font-family:${FONT.replace(/"/g, "'")}}
|
|
70
|
+
.tt-hole{position:absolute;border-radius:10px;border:2px solid #3b82f6;box-shadow:0 0 0 4px rgba(59,130,246,.28),0 0 0 9999px rgba(8,10,14,.62);opacity:0;transition:left .35s ease,top .35s ease,width .35s ease,height .35s ease,opacity .3s ease}
|
|
71
|
+
.tt-callout{position:absolute;max-width:640px;padding:12px 16px;border-radius:10px;background:#fff;color:#111827;box-shadow:0 10px 30px rgba(0,0,0,.35);opacity:0;transition:opacity .3s ease}
|
|
72
|
+
.tt-callout b{display:block;font-size:19px;line-height:1.3;margin-bottom:3px}
|
|
73
|
+
.tt-callout span{display:block;font-size:15px;line-height:1.35;color:#374151;white-space:nowrap}
|
|
74
|
+
.tt-card{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;background:rgba(10,12,18,.9);color:#fff;opacity:0;transition:opacity .35s ease;text-align:center}
|
|
75
|
+
.tt-card small{font-size:20px;letter-spacing:.08em;color:#93c5fd;font-weight:600}
|
|
76
|
+
.tt-card h1{margin:0;font-size:60px;line-height:1.2;font-weight:700;word-break:keep-all}
|
|
77
|
+
.tt-card p{margin:0;font-size:26px;color:#cbd5e1;word-break:keep-all}
|
|
78
|
+
.tt-card ol{margin:10px 0 0;padding:0;list-style:none;font-size:26px;line-height:1.7;color:#e5e7eb;text-align:left}
|
|
79
|
+
.tt-card ol li b{color:#93c5fd;margin-right:14px}
|
|
80
|
+
.tt-carded .tt-pointer-host{opacity:0}\`;
|
|
81
|
+
const root = document.createElement("div");
|
|
82
|
+
root.className = "tt-root";
|
|
83
|
+
const hole = document.createElement("div");
|
|
84
|
+
hole.className = "tt-hole";
|
|
85
|
+
root.append(hole);
|
|
86
|
+
let target = null, callout = null, card = null;
|
|
87
|
+
const mount = () => { document.documentElement.append(css, root); };
|
|
88
|
+
if (document.documentElement) mount(); else document.addEventListener("DOMContentLoaded", mount);
|
|
89
|
+
const follow = () => {
|
|
90
|
+
const found = target ? find(target) : null;
|
|
91
|
+
if (found) {
|
|
92
|
+
const box = found.rect;
|
|
93
|
+
const rect = { x: box.x - PAD, y: box.y - PAD, width: box.width + PAD * 2, height: box.height + PAD * 2 };
|
|
94
|
+
Object.assign(hole.style, { left: rect.x + "px", top: rect.y + "px", width: rect.width + "px", height: rect.height + "px" });
|
|
95
|
+
if (callout) {
|
|
96
|
+
const spot = place(rect, { width: callout.offsetWidth, height: callout.offsetHeight }, { width: innerWidth, height: innerHeight });
|
|
97
|
+
callout.style.left = spot.x + "px"; callout.style.top = spot.y + "px";
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
requestAnimationFrame(follow);
|
|
101
|
+
};
|
|
102
|
+
requestAnimationFrame(follow);
|
|
103
|
+
const fadeOut = (node) => { if (!node) return; node.style.opacity = "0"; setTimeout(() => node.remove(), 350); };
|
|
104
|
+
const text = (tag, value) => { const node = document.createElement(tag); node.textContent = value; return node; };
|
|
105
|
+
window.__tutorialOverlay = {
|
|
106
|
+
spotlight(spec, label, note) {
|
|
107
|
+
const found = find(spec);
|
|
108
|
+
if (!found) return false;
|
|
109
|
+
found.el?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
110
|
+
target = spec;
|
|
111
|
+
fadeOut(callout);
|
|
112
|
+
callout = null;
|
|
113
|
+
if (label) {
|
|
114
|
+
callout = document.createElement("div");
|
|
115
|
+
callout.className = "tt-callout";
|
|
116
|
+
callout.append(text("b", label));
|
|
117
|
+
if (note) callout.append(text("span", note));
|
|
118
|
+
root.append(callout);
|
|
119
|
+
}
|
|
120
|
+
requestAnimationFrame(() => { hole.style.opacity = "1"; if (callout) callout.style.opacity = "1"; });
|
|
121
|
+
return true;
|
|
122
|
+
},
|
|
123
|
+
clear() { hole.style.opacity = "0"; fadeOut(callout); callout = null; setTimeout(() => { if (!callout) target = null; }, 350); },
|
|
124
|
+
card({ eyebrow, title, subtitle, items }) {
|
|
125
|
+
fadeOut(card);
|
|
126
|
+
card = document.createElement("div");
|
|
127
|
+
card.className = "tt-card";
|
|
128
|
+
if (eyebrow) card.append(text("small", eyebrow));
|
|
129
|
+
card.append(text("h1", title));
|
|
130
|
+
if (subtitle) card.append(text("p", subtitle));
|
|
131
|
+
if (items?.titles.length) {
|
|
132
|
+
const list = document.createElement("ol");
|
|
133
|
+
items.titles.forEach((item, index) => { const row = document.createElement("li"); if (items.numbered) row.append(text("b", String(index + 1).padStart(2, "0"))); row.append(item); list.append(row); });
|
|
134
|
+
card.append(list);
|
|
135
|
+
}
|
|
136
|
+
root.append(card);
|
|
137
|
+
document.documentElement.classList.add("tt-carded");
|
|
138
|
+
requestAnimationFrame(() => { card.style.opacity = "1"; });
|
|
139
|
+
},
|
|
140
|
+
hideCard() { fadeOut(card); card = null; document.documentElement.classList.remove("tt-carded"); },
|
|
141
|
+
};
|
|
142
|
+
})()`;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Records one tutorial episode: optional voice clips first (voice.mjs), then
|
|
3
|
+
// one continuous capture over CDP (steps.mjs runs each step, the page carries
|
|
4
|
+
// the cursor and teaching overlays), then captions in a band under the frame
|
|
5
|
+
// and the mux (mux.mjs). A chaptered episode is also cut into one file per
|
|
6
|
+
// chapter. Run it through tools/tutorial/launch.mjs (npm run tutorial:record
|
|
7
|
+
// -- <episode>) or inside tools/qa-browser.mjs with QA_URL pointing at a dev
|
|
8
|
+
// server. See tools/tutorial/README.md.
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join, resolve } from "node:path";
|
|
13
|
+
import { pathToFileURL } from "node:url";
|
|
14
|
+
import { encode, findFfmpeg } from "./assemble.mjs";
|
|
15
|
+
import { renderCaptions } from "./captions.mjs";
|
|
16
|
+
import { connectPage } from "./cdp.mjs";
|
|
17
|
+
import { createInput } from "./cursor.mjs";
|
|
18
|
+
import { DEFAULT_WPS, actionOf, chapterFileName, normalizeEpisode, stepHoldMs, toSrt, validateEpisode } from "./episode.mjs";
|
|
19
|
+
import { bandHeightFor, chapterCutArgs, muxArgs } from "./mux.mjs";
|
|
20
|
+
import { TEACHING_OVERLAY } from "./overlay.mjs";
|
|
21
|
+
import { localeScript, openStudio, runStep } from "./steps.mjs";
|
|
22
|
+
import { TERMINAL_URL } from "./terminal.mjs";
|
|
23
|
+
import { prepareVoice } from "./voice.mjs";
|
|
24
|
+
|
|
25
|
+
const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, Math.max(0, ms)));
|
|
26
|
+
|
|
27
|
+
function parseArgs(argv) {
|
|
28
|
+
const options = { episode: null, locale: "en", size: "1600x1000", "wps-ko": DEFAULT_WPS.ko, "wps-en": DEFAULT_WPS.en, voice: "off", fps: 30, out: "dist/tutorial" };
|
|
29
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
30
|
+
const flag = argv[index];
|
|
31
|
+
if (flag.startsWith("--")) options[flag.slice(2)] = argv[(index += 1)];
|
|
32
|
+
else options.episode = flag;
|
|
33
|
+
}
|
|
34
|
+
const [width, height] = String(options.size).split("x").map(Number);
|
|
35
|
+
if (!options.episode) throw new Error("usage: record.mjs <episode> [--locale en|ko] [--size 1600x1000|1920x1080] [--voice off|say] [--wps-ko 1.6] [--wps-en 2.5] [--fps 30] [--out dist/tutorial]");
|
|
36
|
+
if (!["en", "ko"].includes(options.locale)) throw new Error(`--locale must be en or ko (got ${options.locale})`);
|
|
37
|
+
if (!(width > 0 && height > 0)) throw new Error(`--size must look like 1600x1000 (got ${options.size})`);
|
|
38
|
+
if (!["off", "say"].includes(options.voice)) throw new Error(`--voice must be off or say (got ${options.voice})`);
|
|
39
|
+
return { ...options, width, height, wps: { ko: Number(options["wps-ko"]), en: Number(options["wps-en"]) }, fps: Number(options.fps) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function loadEpisode(name) {
|
|
43
|
+
const base = existsSync(name) ? resolve(name) : resolve(import.meta.dirname, "episodes", name);
|
|
44
|
+
const file = [base, `${base}.mjs`, `${base}.json`].find((candidate) => existsSync(candidate) && statSync(candidate).isFile());
|
|
45
|
+
if (!file) throw new Error(`no episode ${name} (looked in tools/tutorial/episodes/)`);
|
|
46
|
+
const episode = file.endsWith(".json") ? JSON.parse(readFileSync(file, "utf8")) : (await import(pathToFileURL(file).href)).default;
|
|
47
|
+
const errors = validateEpisode(episode);
|
|
48
|
+
if (errors.length) throw new Error(`episode ${name} is invalid:\n ${errors.join("\n ")}`);
|
|
49
|
+
return normalizeEpisode(episode);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const run = (ffmpeg, args, what) => {
|
|
53
|
+
const result = spawnSync(ffmpeg, args, { encoding: "utf8" });
|
|
54
|
+
if (result.status !== 0) throw new Error(`ffmpeg ${what} failed (exit ${result.status}): ${result.stderr.trim()}`);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const options = parseArgs(process.argv.slice(2));
|
|
58
|
+
const ffmpeg = findFfmpeg();
|
|
59
|
+
console.log(`ffmpeg: ${ffmpeg.path} (${ffmpeg.version})`);
|
|
60
|
+
const episode = await loadEpisode(options.episode);
|
|
61
|
+
const steps = episode.chapters.flatMap((chapter) => chapter.steps);
|
|
62
|
+
const episodeName = options.episode.replace(/^.*\//, "").replace(/\.(mjs|json)$/, "");
|
|
63
|
+
const outDir = episode.chaptered ? join(options.out, episodeName) : options.out;
|
|
64
|
+
const fullPath = episode.chaptered ? join(outDir, `${episodeName}.${options.locale}.mp4`) : join(outDir, `${options.locale === "en" ? episodeName : `${episodeName}-${options.locale}`}.mp4`);
|
|
65
|
+
const frameDir = mkdtempSync(join(tmpdir(), "cozyclay-tutorial-"));
|
|
66
|
+
// Voice first: each cue's hold comes from its clip, so the clips exist before the capture.
|
|
67
|
+
const clips = options.voice === "say" ? prepareVoice({ steps, language: options.locale, voiceDir: resolve(import.meta.dirname, "voice", episodeName), workDir: frameDir, ffprobe: ffmpeg.path.replace(/ffmpeg$/, "ffprobe") }) : new Map();
|
|
68
|
+
console.log(clips.size ? `voice (${options.locale}): ${clips.size} clips` : `voice (${options.locale}): off, captions only`);
|
|
69
|
+
const url = process.env.QA_URL || "http://127.0.0.1:5594/app/";
|
|
70
|
+
const cdp = await connectPage(Number(process.env.CDP_PORT || 9222));
|
|
71
|
+
try {
|
|
72
|
+
// An export step's download lands in the scratch dir, never in the user's Downloads.
|
|
73
|
+
await cdp.send("Browser.setDownloadBehavior", { behavior: "allow", downloadPath: join(frameDir, "downloads") });
|
|
74
|
+
await cdp.send("Emulation.setDeviceMetricsOverride", { width: options.width, height: options.height, deviceScaleFactor: 1, mobile: false });
|
|
75
|
+
await cdp.send("Page.enable");
|
|
76
|
+
const input = createInput(cdp, { mac: (await cdp.evaluate("navigator.platform")).startsWith("Mac") });
|
|
77
|
+
await input.install(TEACHING_OVERLAY, localeScript(new URL(url).origin, options.locale));
|
|
78
|
+
// An episode that opens the Studio itself starts on its first scene; otherwise on the chooser.
|
|
79
|
+
const firstScene = steps.map(actionOf).find((action) => action === "terminal" || action === "studio");
|
|
80
|
+
if (firstScene === "terminal") {
|
|
81
|
+
const loaded = cdp.once("Page.loadEventFired");
|
|
82
|
+
await cdp.send("Page.navigate", { url: TERMINAL_URL });
|
|
83
|
+
await loaded;
|
|
84
|
+
} else if (!firstScene) await openStudio(cdp, url);
|
|
85
|
+
|
|
86
|
+
const frames = [];
|
|
87
|
+
let started = 0;
|
|
88
|
+
cdp.on("Page.screencastFrame", ({ data, sessionId }) => {
|
|
89
|
+
void cdp.send("Page.screencastFrameAck", { sessionId }).catch(() => {});
|
|
90
|
+
const at = Date.now();
|
|
91
|
+
if (!started) started = at;
|
|
92
|
+
const file = `${String(frames.length).padStart(6, "0")}.jpg`;
|
|
93
|
+
writeFileSync(join(frameDir, file), Buffer.from(data, "base64"));
|
|
94
|
+
frames.push({ file, ms: at - started });
|
|
95
|
+
});
|
|
96
|
+
const firstFrame = cdp.once("Page.screencastFrame", 10000);
|
|
97
|
+
await cdp.send("Page.startScreencast", { format: "jpeg", quality: 90, maxWidth: options.width, maxHeight: options.height, everyNthFrame: 1 });
|
|
98
|
+
await firstFrame;
|
|
99
|
+
const clock = () => Date.now() - started;
|
|
100
|
+
await sleep(500);
|
|
101
|
+
|
|
102
|
+
// A narrated step opens a cue; the silent steps after it run inside that cue,
|
|
103
|
+
// and the cue closes once the narration has had its time, or at a chapter end.
|
|
104
|
+
const cues = [];
|
|
105
|
+
const marks = [];
|
|
106
|
+
const timeline = [];
|
|
107
|
+
let open = null;
|
|
108
|
+
const closeCue = async () => {
|
|
109
|
+
if (!open) return;
|
|
110
|
+
await sleep(open.startMs + stepHoldMs(clock() - open.startMs, open.narration, options.wps, clips.get(open.id)?.audioS ?? null, options.locale) - clock());
|
|
111
|
+
cues.push({ ...open, endMs: clock() });
|
|
112
|
+
open = null;
|
|
113
|
+
};
|
|
114
|
+
for (const chapter of episode.chapters) {
|
|
115
|
+
await closeCue();
|
|
116
|
+
marks.push({ chapter, startMs: marks.length ? clock() : 0 });
|
|
117
|
+
for (const step of chapter.steps) {
|
|
118
|
+
// A card is a new scene: the caption before it ends where it starts.
|
|
119
|
+
if (step.narration || step.card) {
|
|
120
|
+
await closeCue();
|
|
121
|
+
if (step.narration) open = { id: step.id, startMs: clock(), narration: step.narration };
|
|
122
|
+
}
|
|
123
|
+
const stepStart = clock();
|
|
124
|
+
await runStep({ cdp, input, wps: options.wps, locale: options.locale, url, episode, chapter }, step);
|
|
125
|
+
timeline.push({ chapter: chapter.id, id: step.id, action: actionOf(step), startMs: stepStart, endMs: clock() });
|
|
126
|
+
}
|
|
127
|
+
console.log(`chapter ${chapter.id}: ${((clock() - marks.at(-1).startMs) / 1000).toFixed(1)}s`);
|
|
128
|
+
}
|
|
129
|
+
await closeCue();
|
|
130
|
+
await sleep(800);
|
|
131
|
+
const endMs = clock();
|
|
132
|
+
await cdp.send("Page.stopScreencast");
|
|
133
|
+
if (frames.length === 0) throw new Error("the screencast produced no frames");
|
|
134
|
+
|
|
135
|
+
mkdirSync(outDir, { recursive: true });
|
|
136
|
+
const base = join(frameDir, "capture.mp4");
|
|
137
|
+
console.log(`ffmpeg command: ${await encode({ ffmpeg: ffmpeg.path, dir: frameDir, frames, endMs, fps: options.fps, output: base })}`);
|
|
138
|
+
const bandHeight = bandHeightFor(options.height);
|
|
139
|
+
const captions = await renderCaptions(cdp, { texts: cues.map((cue) => cue.narration[options.locale]), width: options.width, height: bandHeight, dir: frameDir, language: options.locale });
|
|
140
|
+
run(ffmpeg.path, muxArgs({ base, output: fullPath, cues, clips, captions, endMs, bandHeight }), "mux");
|
|
141
|
+
console.log(`ffmpeg mux: ${clips.size ? "h264 + aac" : "h264, no audio"}, ${captions.length} captions, ${options.width}x${options.height + bandHeight}`);
|
|
142
|
+
const srtBase = fullPath.replace(/\.mp4$/, "");
|
|
143
|
+
for (const language of episode.chaptered ? [options.locale] : ["ko", "en"]) writeFileSync(`${srtBase}${episode.chaptered ? "" : `.${language}`}.srt`, toSrt(cues, language));
|
|
144
|
+
// When each step ran, for QA frames (one per spotlight) and for timing a re-record.
|
|
145
|
+
writeFileSync(`${srtBase}.steps.json`, `${JSON.stringify(timeline, null, "\t")}\n`);
|
|
146
|
+
console.log(`wrote ${fullPath} (${(endMs / 1000).toFixed(1)}s, ${frames.length} frames)`);
|
|
147
|
+
if (episode.chaptered) marks.forEach((mark, index) => {
|
|
148
|
+
const endAt = index + 1 < marks.length ? marks[index + 1].startMs : endMs;
|
|
149
|
+
const output = join(outDir, chapterFileName(episode.firstChapter + index, mark.chapter.id, options.locale));
|
|
150
|
+
run(ffmpeg.path, chapterCutArgs({ input: fullPath, output, startMs: mark.startMs, endMs: endAt, audio: clips.size > 0 }), `cut ${mark.chapter.id}`);
|
|
151
|
+
console.log(`wrote ${output} (${((endAt - mark.startMs) / 1000).toFixed(1)}s)`);
|
|
152
|
+
});
|
|
153
|
+
} finally {
|
|
154
|
+
cdp.close();
|
|
155
|
+
rmSync(frameDir, { recursive: true, force: true });
|
|
156
|
+
}
|