cozyclay 2.0.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (256) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +17 -2
  3. package/THIRD_PARTY_NOTICES.md +8 -0
  4. package/bin/agent/agent-routes.mjs +211 -36
  5. package/bin/agent/agent-runner.mjs +98 -4
  6. package/bin/agent/codex-client.mjs +2 -1
  7. package/bin/agent/fal-contract.mjs +0 -17
  8. package/bin/agent/h3-preservation.mjs +6 -8
  9. package/bin/agent/provider-keys.mjs +1 -1
  10. package/bin/agent/providers.mjs +124 -3
  11. package/bin/agent/script-tool.mjs +74 -0
  12. package/bin/agent/session-store.mjs +5 -5
  13. package/bin/agent/studio-prompt.mjs +2 -1
  14. package/bin/agent/studio-rebase.mjs +314 -0
  15. package/bin/agent/studio-tools.mjs +21 -14
  16. package/bin/agent/supervisor.mjs +311 -0
  17. package/bin/agent/video-adapters.mjs +0 -7
  18. package/bin/codex-auth.mjs +4 -4
  19. package/bin/live/cli.mjs +2 -3
  20. package/bin/live/client.mjs +1 -1
  21. package/bin/live/studio.mjs +5 -2
  22. package/bin/mcp-runtime.mjs +2 -2
  23. package/bin/telemetry-state.mjs +3 -3
  24. package/dist/app/index.html +6 -6
  25. package/dist/assets/analytics-B41FmPYI.js +1 -0
  26. package/dist/assets/app-Do-Og5Pu.css +1 -0
  27. package/dist/assets/app-DwP-tl2x.js +4920 -0
  28. package/dist/assets/{demo-ByeYp-dY.js → demo-Dt0qZfS7.js} +1 -1
  29. package/dist/assets/{first-shot-handoff-DgT28ZiV.js → first-shot-handoff-BlT33CGf.js} +1 -1
  30. package/dist/assets/{landing-BaXkpFip.js → landing-BSesrCpL.js} +1 -1
  31. package/dist/assets/scene-asset-sync-BCWDJB1d.js +19 -0
  32. package/dist/assets/{shot-prompt-CbqWcWYi.css → scene-asset-sync-DjJ_cJ2R.css} +1 -1
  33. package/dist/assets/{ticket-BcmsnHn5.js → ticket-jqMhqL88.js} +1 -1
  34. package/dist/assets/workflow-Br-K7Srs.js +179 -0
  35. package/dist/cozyclay-package.json +1 -1
  36. package/dist/index.html +328 -222
  37. package/dist/sitemap.xml +1 -1
  38. package/dist/workflow/index.html +4 -4
  39. package/mcp/LIVE-PROTOCOL.md +23 -2
  40. package/mcp/README.md +37 -2
  41. package/mcp/ardy-prompts.mjs +1 -1
  42. package/mcp/image-file.mjs +132 -0
  43. package/mcp/live-hub.mjs +2 -2
  44. package/mcp/package.json +1 -0
  45. package/mcp/runtime/package-lock.json +7 -0
  46. package/mcp/runtime/package.json +1 -0
  47. package/mcp/server.mjs +0 -6
  48. package/mcp/tool-handlers.mjs +289 -87
  49. package/package.json +11 -2
  50. package/src/App.jsx +789 -263
  51. package/src/agent-image-client.js +94 -0
  52. package/src/analytics.js +47 -3
  53. package/src/app-stage.jsx +129 -2745
  54. package/src/ardy/auto-physics.js +6 -16
  55. package/src/ardy/collision-blockers.js +3 -7
  56. package/src/ardy/fix-collisions.js +5 -35
  57. package/src/ardy/ik-drag.js +86 -5
  58. package/src/ardy/ik-key-json.js +3 -1
  59. package/src/ardy/ik.js +15 -13
  60. package/src/ardy/motion-calibration.js +1 -1
  61. package/src/ardy/npz.js +0 -8
  62. package/src/ardy/physics-panel.jsx +3 -2
  63. package/src/ardy/physics-review.js +3 -2
  64. package/src/ardy/physics-support.js +1 -1
  65. package/src/ardy/physics-surface.js +1 -1
  66. package/src/ardy/platform-fit-panel.jsx +2 -1
  67. package/src/ardy/playback.js +1 -5
  68. package/src/ardy/range-pin.js +2 -2
  69. package/src/ardy/root-drop.js +0 -1
  70. package/src/ardy/timeline.css +184 -6
  71. package/src/ardy/timeline.jsx +113 -28
  72. package/src/ardy/to-cskel27.js +0 -2
  73. package/src/ardy/waypoints.js +0 -3
  74. package/src/ardy-bridge-contract.js +281 -0
  75. package/src/asset-pane.jsx +19 -9
  76. package/src/auto-color.js +1 -1
  77. package/src/burn-in.js +1 -1
  78. package/src/camera-block.js +1 -1
  79. package/src/camera-follow.js +75 -32
  80. package/src/camera-move.js +0 -6
  81. package/src/camera-tutorial.jsx +6 -7
  82. package/src/character-kind.js +79 -0
  83. package/src/command-bus.js +76 -17
  84. package/src/commands/cast.js +31 -7
  85. package/src/commands/elements/object.js +1 -1
  86. package/src/commands/elements/shot.js +1 -1
  87. package/src/commands/elements.js +1 -1
  88. package/src/commands/export.js +3 -3
  89. package/src/commands/motion.js +2 -2
  90. package/src/commands/objects.js +105 -10
  91. package/src/commands/project.js +15 -2
  92. package/src/commands/sculpt-images.js +53 -0
  93. package/src/commands/shot.js +88 -10
  94. package/src/commands/view.js +10 -4
  95. package/src/coplanar-depth.js +216 -0
  96. package/src/cuts.js +7 -6
  97. package/src/domains/cast.js +60 -43
  98. package/src/domains/motion/motion-intent-codec.js +50 -0
  99. package/src/domains/motion/motion-range-pin-rules.js +23 -0
  100. package/src/domains/motion/motion-video-capture.js +664 -0
  101. package/src/domains/motion.js +253 -742
  102. package/src/domains/objects.js +106 -5
  103. package/src/domains/scenes.js +79 -28
  104. package/src/domains/shots.js +33 -7
  105. package/src/error-boundary.jsx +1 -1
  106. package/src/execution-telemetry.js +5 -5
  107. package/src/facing-marks.js +14 -1
  108. package/src/first-success-guide.jsx +35 -4
  109. package/src/gizmo-claim.js +3 -1
  110. package/src/grid-floor.jsx +1 -4
  111. package/src/hierarchy-model.js +48 -35
  112. package/src/hierarchy-panel.css +1 -0
  113. package/src/hierarchy-panel.jsx +54 -102
  114. package/src/history.js +0 -2
  115. package/src/i2v-motion-client.js +1 -4
  116. package/src/ime.js +6 -0
  117. package/src/keyframe-pack.js +15 -6
  118. package/src/line-edit.js +4 -4
  119. package/src/live-control.js +6 -6
  120. package/src/matte-editor.js +2 -8
  121. package/src/matte.js +0 -19
  122. package/src/mesh-graph-clone.js +16 -2
  123. package/src/mesh-sniff.js +0 -1
  124. package/src/monotone-cubic.js +40 -0
  125. package/src/motion-store.js +4 -4
  126. package/src/motion-trail.js +5 -11
  127. package/src/multimodel-ingest.js +1 -3
  128. package/src/object-catalog.jsx +7 -5
  129. package/src/object-gizmo.jsx +45 -31
  130. package/src/object-path.js +310 -67
  131. package/src/object-travel.js +224 -0
  132. package/src/otio.js +8 -3
  133. package/src/panels/CharacterTransformPanel.jsx +24 -6
  134. package/src/panels/ObjectTransformPanel.jsx +4 -3
  135. package/src/panels/PoseImagePanel.jsx +205 -0
  136. package/src/panels/PromptBlocksPanel.jsx +3 -1
  137. package/src/panels/ProxyFigurePanel.jsx +56 -0
  138. package/src/panels/ReferenceImageField.jsx +1 -1
  139. package/src/panels/RigControlPanel.jsx +24 -3
  140. package/src/panels/RigPanel.jsx +2 -1
  141. package/src/panels/SculptPanel.jsx +405 -0
  142. package/src/panels/SubjectBox.jsx +1 -1
  143. package/src/panels/SubjectsPanel.jsx +0 -2
  144. package/src/panels/VideoCapturePanel.jsx +2 -1
  145. package/src/panels/pose-image.css +42 -0
  146. package/src/panels/pose.css +3 -0
  147. package/src/panels/sculpt.css +162 -0
  148. package/src/plan-diagram.js +184 -0
  149. package/src/planview.jsx +127 -80
  150. package/src/playground.js +3 -5
  151. package/src/pose-fade.js +77 -0
  152. package/src/pose-gizmo.css +23 -0
  153. package/src/pose-gizmo.js +96 -0
  154. package/src/posestudio.jsx +134 -83
  155. package/src/project-browser.css +53 -0
  156. package/src/project-browser.jsx +49 -4
  157. package/src/project.js +55 -24
  158. package/src/props.jsx +177 -36
  159. package/src/proxy-figure.jsx +69 -0
  160. package/src/range-pin-panel.jsx +0 -1
  161. package/src/receipt-findings.js +20 -0
  162. package/src/render-passes.js +0 -1
  163. package/src/resource-status.jsx +1 -1
  164. package/src/root-path.js +48 -0
  165. package/src/route-owner.js +52 -0
  166. package/src/scene-asset-cache.js +1 -2
  167. package/src/scene-assets.js +12 -4
  168. package/src/scene-fbx.js +1 -1
  169. package/src/scene-mesh-cache.js +0 -1
  170. package/src/scene-objects.js +319 -19
  171. package/src/scenes.js +58 -9
  172. package/src/sculpt-blueprint.js +65 -0
  173. package/src/sculpt-compare.js +384 -0
  174. package/src/sculpt-geometry.js +141 -0
  175. package/src/sculpt-mode.js +168 -0
  176. package/src/sculpt-recipe.js +385 -0
  177. package/src/sculpt-run.js +351 -0
  178. package/src/sculpt-stage.jsx +221 -0
  179. package/src/settings-menu.jsx +1 -1
  180. package/src/shell/BoardSlot.jsx +341 -0
  181. package/src/shell/BottomDock.jsx +58 -14
  182. package/src/shell/DetailsSlot.jsx +26 -6
  183. package/src/shell/MenuBar.jsx +78 -6
  184. package/src/shell/OutlinerSlot.jsx +3 -1
  185. package/src/shell/TopBar.jsx +17 -3
  186. package/src/shell/ViewportToolbar.jsx +18 -3
  187. package/src/shell/dock.css +239 -0
  188. package/src/shell/glass-regions.css +3 -2
  189. package/src/shell/log-store.js +4 -4
  190. package/src/shell/mode.css +20 -0
  191. package/src/shell/studio-shell-context.js +4 -1
  192. package/src/shot-authoring.js +69 -6
  193. package/src/stable-items.js +1 -1
  194. package/src/stage-attach-frames.js +169 -0
  195. package/src/stage-canvas-rigs.jsx +402 -0
  196. package/src/stage-capture.jsx +216 -0
  197. package/src/stage-characters.jsx +302 -0
  198. package/src/stage-editor-gizmos.jsx +1229 -0
  199. package/src/stage-labels-ko.js +204 -0
  200. package/src/stage-startup-defaults.js +182 -0
  201. package/src/starter-scenes.js +1 -1
  202. package/src/storyboard.js +24 -5
  203. package/src/studio-actions.js +42 -9
  204. package/src/studio-agent-commands.js +186 -23
  205. package/src/studio-agent-context.js +11 -9
  206. package/src/studio-agent-motion.js +2 -1
  207. package/src/studio-agent-protocol.js +56 -23
  208. package/src/studio-app-binding.js +52 -23
  209. package/src/studio-contact-sheet.js +3 -3
  210. package/src/studio-elements.js +11 -2
  211. package/src/studio-geometry-facts.js +114 -0
  212. package/src/styles.css +92 -9
  213. package/src/ui-scrub.js +4 -4
  214. package/src/ui.jsx +6 -4
  215. package/src/use-asset-shelf.js +60 -0
  216. package/src/use-camera-tutorial.js +50 -0
  217. package/src/use-embed-bridge.js +12 -0
  218. package/src/use-first-success-prompt.js +7 -0
  219. package/src/use-i2v-capture-modal.js +15 -0
  220. package/src/use-sculpt-mode.js +95 -0
  221. package/src/workflow/AgentPanel.jsx +106 -18
  222. package/src/workflow/WorkflowBuilder.jsx +1 -1
  223. package/src/workflow/agent-client.js +113 -45
  224. package/src/workflow/agent-panel.css +35 -0
  225. package/src/workflow/attachment-image.js +1 -1
  226. package/src/workflow/canvas-commands.js +3 -3
  227. package/src/workflow/cozy-scene-node.js +3 -3
  228. package/src/workflow/local-workflow.js +2 -2
  229. package/src/workflow/motion-input.js +3 -3
  230. package/src/workflow/node-schema.js +1 -1
  231. package/src/workflow/scene-asset-sync.js +2 -9
  232. package/src/workflow/shot-prompt-node.js +1 -1
  233. package/src/workflow/video-contract.js +1 -1
  234. package/src/workflow/workflow-resources.js +0 -8
  235. package/src/workflow-send.js +87 -0
  236. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  237. package/tools/check-eslint.mjs +57 -0
  238. package/tools/check-knip.mjs +60 -0
  239. package/tools/check-loc.mjs +54 -0
  240. package/tools/demo-worker/api-client.mjs +7 -13
  241. package/tools/demo-worker/generate.mjs +2 -4
  242. package/tools/eslint-baseline.json +23 -0
  243. package/tools/kimodo/effector-constraints.mjs +4 -4
  244. package/tools/kimodo/generate.mjs +1 -1
  245. package/tools/knip-baseline.json +331 -0
  246. package/tools/loc-baseline.json +126 -0
  247. package/tools/process-supervisor.mjs +1 -1
  248. package/tools/projflow/replay.mjs +4 -4
  249. package/tools/projflow/runner.mjs +3 -3
  250. package/tools/qa/studio-control-count.mjs +2 -0
  251. package/tools/run-tests.mjs +94 -7
  252. package/dist/assets/analytics-MVZOEW8O.js +0 -1
  253. package/dist/assets/app-BxBLC6GT.js +0 -4876
  254. package/dist/assets/app-CJdxvnrl.css +0 -1
  255. package/dist/assets/shot-prompt-hN0j_O-p.js +0 -17
  256. package/dist/assets/workflow-B5XZwVfe.js +0 -179
@@ -0,0 +1,281 @@
1
+ import { ko, isKo } from "./locale.js";
2
+ import { LINE_EDIT_TRACK_IDS, PINNED_CURVE_ENDS } from "./line-edit.js";
3
+ import { FK_TRACKS, IK_TRACKS, MID_TRACKS } from "./ardy/ik.js";
4
+
5
+ // Two clocks, one boundary. The app timeline runs the production 24 fps —
6
+ // the rate the reference footage and the recorded export are counted in.
7
+ // ARDY Core generates on its trained 20 fps clock and that cannot move, so
8
+ // takes are RETIMED inbound (bridge → app, retimeMotion) and frame numbers
9
+ // are CONVERTED outbound (app → bridge, toArdyFrame). Nothing between the
10
+ // boundaries may mix the clocks.
11
+ export const TIMELINE_FPS = 24;
12
+ // The motion bridge uses the Studio's fixed 24 fps production clock.
13
+ export const ARDY_FPS = 24;
14
+ export const toArdyFrame = (frame) => Math.round((frame * ARDY_FPS) / TIMELINE_FPS);
15
+
16
+ // Outbound converters: timeline-frame entries → strictly-ascending bridge
17
+ // frames. Rounding can land two timeline frames on one bridge frame; the
18
+ // first wins — the bridge refuses non-ascending lists outright.
19
+ /** Where a placed pose lands, in TIMELINE frames, for a clip of `clipFrames`. */
20
+ export function posePlacementFrame(placement, clipFrames, playheadFrame) {
21
+ const last = Math.max(0, clipFrames - 1);
22
+ if (placement === "end") return last;
23
+ if (placement === "middle") return Math.round(last / 2);
24
+ if (placement === "playhead") return Math.max(0, Math.min(last, playheadFrame));
25
+ return 0;
26
+ }
27
+
28
+ export function toArdyFrameEntries(entries) {
29
+ const out = [];
30
+ for (const entry of entries) {
31
+ const frame = toArdyFrame(entry.frame);
32
+ if (out.length && frame <= out[out.length - 1].frame) continue;
33
+ out.push({ ...entry, frame });
34
+ }
35
+ return out;
36
+ }
37
+
38
+ export function toArdySegments(segments) {
39
+ const out = [];
40
+ for (const segment of segments) {
41
+ const startFrame = toArdyFrame(segment.startFrame);
42
+ const endFrame = toArdyFrame(segment.endFrame);
43
+ if (endFrame <= startFrame) continue; // a zero-length rounding remnant covers nothing
44
+ out.push({ ...segment, startFrame, endFrame });
45
+ }
46
+ return out;
47
+ }
48
+
49
+ export const DEFAULT_DURATION_S = 15; // pre-motion timeline duration; shown as duration × TIMELINE_FPS frames
50
+ export const DEFAULT_PLAYBACK_SPEED = 1;
51
+ export const BRIDGE_RECHECK_MS = 3000;
52
+ export const ARDY_PROMPT_HORIZON_FRAMES = 2 * TIMELINE_FPS; // core model horizon: 2 seconds, counted on the timeline clock
53
+ export const MAX_WAYPOINTS = 32; // ARDY bridge contract: a root path holds 2..32 distinct waypoint frames
54
+ export const ARDY_PROMPT_MAX = 500; // bridge contract: prompt must be non-empty, capped at 500 chars
55
+ export const ARDY_DURATION_MIN = 1; // the UI works in whole seconds; the bridge floor is 0.15 s
56
+ export const ARDY_DURATION_MAX = 1200; // bridge contract: duration capped at 1200 s
57
+ export const ARDY_SEED_MAX = 2 ** 31 - 1; // bridge contract: optional seed, integer in 0..2**31-1
58
+ // Scheduled inpainting (contract C3): how hard a regeneration holds onto the
59
+ // take that is already loaded. 0 turns preserving OFF entirely; 0.5 is the
60
+ // paper's recommended setting. The app ships this RAW number and nothing else:
61
+ // the strength -> denoising-schedule mapping (sigma_s = round(1000 * strength),
62
+ // sigma_e = min(50, sigma_s), diffusion time 0..1000) lives on the box side, and
63
+ // duplicating it here is exactly how the two would silently drift apart.
64
+ export const ARDY_PRESERVE_DEFAULT = 0.5;
65
+ export const DEFAULT_PROMPT_CLIPS = [];
66
+
67
+ /** The prompt-block schedule: every authored block in frame order, with the gaps
68
+ * between them filled by the base prompt so the bridge always receives one
69
+ * contiguous 0..clipFrames sequence. Blocks shorter than 3 frames are dropped —
70
+ * they cannot carry a generation.
71
+ * Pure and shared on purpose: the request assembly decides which blocks count as
72
+ * "edited", and the preserve panel has to name the SAME blocks. Re-deriving them
73
+ * approximately beside the slider is how the UI starts promising a locality the
74
+ * wire does not carry. */
75
+ export function buildPromptSchedule(sourcePromptClips, clipFrames, prompt) {
76
+ const segments = [];
77
+ let cursor = 0;
78
+ for (const clip of sourcePromptClips) {
79
+ const startFrame = Math.max(cursor, Math.min(clipFrames, clip.startFrame));
80
+ const endFrame = Math.max(startFrame, Math.min(clipFrames, clip.endFrame));
81
+ if (startFrame > cursor) segments.push({ startFrame: cursor, endFrame: startFrame, prompt });
82
+ if (endFrame - startFrame >= 3) segments.push({ startFrame, endFrame, prompt: clip.text.trim() || prompt });
83
+ cursor = Math.max(cursor, endFrame);
84
+ if (cursor >= clipFrames) break;
85
+ }
86
+ if (cursor < clipFrames) segments.push({ startFrame: cursor, endFrame: clipFrames, prompt });
87
+ if (segments.length === 0) segments.push({ startFrame: 0, endFrame: clipFrames, prompt });
88
+ return segments;
89
+ }
90
+
91
+ /** The DISTINCT ik track ids keyed inside the half-open timeline span
92
+ * [startFrame, endFrame). `ikState.keys` is Map(frame -> Map(trackId -> pose)),
93
+ * so a frame with several dragged effectors contributes all of them. */
94
+ export function ikTracksInRange(ikState, ikFrames, startFrame, endFrame) {
95
+ const tracks = new Set();
96
+ for (const frame of ikFrames) {
97
+ if (frame < startFrame || frame >= endFrame) continue;
98
+ for (const trackId of ikState?.keys?.get(frame)?.keys() ?? []) tracks.add(trackId);
99
+ }
100
+ return [...tracks];
101
+ }
102
+
103
+ /* Which limb a keyed IK track frees once the preserve mask is grouped
104
+ (contract C3v2). The single source of truth for track -> mask group is
105
+ TRACK_GROUPS in tools/kimodo/preserve-mask.mjs; nothing under tools/ may be
106
+ imported into src/, so the rule is transcribed here — as USER-FACING LABELS
107
+ only, never as a second copy of the mapping the wire depends on:
108
+ leftHand, leftElbow -> leftArm rightHand, rightElbow -> rightArm
109
+ leftFoot, leftKnee -> leftLeg rightFoot, rightKnee -> rightLeg
110
+ head, neck -> head spine, chest, leftShoulder,
111
+ hips -> torso AND root rightShoulder -> torso
112
+ The track ids are the same namespace on both sides (src/ardy/ik.js owns
113
+ them), so the app ships whatever ids it has and the mask builder maps them.
114
+ Torso and pelvis tracks are worded as the whole body: the pelvis carries the
115
+ global transform as well as a bone, and naming a limb beside it would promise
116
+ a locality the mask does not have. */
117
+ export const PRESERVE_TRACK_LIMBS = {
118
+ leftHand: "leftArm",
119
+ leftElbow: "leftArm",
120
+ rightHand: "rightArm",
121
+ rightElbow: "rightArm",
122
+ leftFoot: "leftLeg",
123
+ leftKnee: "leftLeg",
124
+ rightFoot: "rightLeg",
125
+ rightKnee: "rightLeg",
126
+ head: "head",
127
+ neck: "head",
128
+ spine: "body",
129
+ chest: "body",
130
+ leftShoulder: "body",
131
+ rightShoulder: "body",
132
+ hips: "body",
133
+ };
134
+ // A fixed reading order, so the same edit always reads back the same way.
135
+ export const PRESERVE_LIMB_ORDER = ["head", "leftArm", "rightArm", "leftLeg", "rightLeg"];
136
+ export const preserveLimbLabel = (limb) => ({
137
+ head: ko("head", "머리"),
138
+ leftArm: ko("left arm", "왼팔"),
139
+ rightArm: ko("right arm", "오른팔"),
140
+ leftLeg: ko("left leg", "왼발"),
141
+ rightLeg: ko("right leg", "오른발"),
142
+ }[limb]);
143
+
144
+ /** One muted line naming what a grouped preserve run will regenerate, or "" when
145
+ * no track can be attributed — then the request carries no `tracks` either and
146
+ * the panel's existing whole-take wording is already the truth. */
147
+ export function preserveTracksSummary(tracks) {
148
+ const limbs = new Set();
149
+ for (const trackId of tracks) {
150
+ const limb = PRESERVE_TRACK_LIMBS[trackId];
151
+ if (limb) limbs.add(limb);
152
+ }
153
+ if (limbs.size === 0) return "";
154
+ if (limbs.has("body")) return ko("regenerates the whole body", "몸 전체를 다시 생성");
155
+ const names = PRESERVE_LIMB_ORDER.filter((limb) => limbs.has(limb)).map(preserveLimbLabel);
156
+ return isKo ? `${names.join("·")}만 다시 생성` : `regenerates ${names.join(" · ")} only`;
157
+ }
158
+
159
+ /* --------------------------- line editing (C6) ---------------------------
160
+ * "Grab the joint's motion path and pull it; the joint follows the pulled path
161
+ * exactly" (ProjFlow). The arithmetic lives in ./line-edit.js; what stays here
162
+ * is the panel's own vocabulary and the one thing App owns that the pure module
163
+ * cannot — the mapping from a track id to a HUMAN LABEL, which is read straight
164
+ * out of ik.js's three tables so the picker can never drift from the pose
165
+ * studio's own naming. An id in LINE_EDIT_TRACK_IDS with no entry in those
166
+ * tables is a bug, and falling back to the raw id makes it visible instead of
167
+ * silent. */
168
+ export const LINE_EDIT_TRACK_OPTIONS = LINE_EDIT_TRACK_IDS.map((id) => ({
169
+ value: id,
170
+ label: [...IK_TRACKS, ...MID_TRACKS, ...FK_TRACKS].find((track) => track.id === id)?.label ?? id,
171
+ }));
172
+ /** The same human label, by id — what a version-strip chip says a refinement
173
+ * touched ("Refine · Left Hand"). */
174
+ export const lineTrackLabel = (id) => LINE_EDIT_TRACK_OPTIONS.find((option) => option.value === id)?.label ?? id;
175
+ /** The default edited track: a wrist is the joint the paper's own demo draws
176
+ * with, and the one whose screen trajectory a user can actually see. */
177
+ export const LINE_EDIT_DEFAULT_TRACK = "leftHand";
178
+ /** Draw a grab handle every Nth frame. Fewer would leave the curve looking
179
+ * un-grabbable in the gaps; more turns a 200-frame range into a solid bead of
180
+ * dots. The hit test tolerates CURVE_GRAB_RADIUS_PX regardless, so the handles
181
+ * are an affordance rather than the actual geometry. */
182
+ export const LINE_CURVE_MARKER_STRIDE = 4;
183
+ /** The shortest curve with anything draggable in it: both ends are hard-pinned
184
+ * to the original trajectory, so a range needs more than 2 x PINNED_CURVE_ENDS
185
+ * frames before the middle can move at all. */
186
+ export const MIN_CURVE_POINTS = PINNED_CURVE_ENDS * 2 + 1;
187
+ /** How long a released drag has to sit still before it costs a preview. Long
188
+ * enough that pull-pull-pull is one request instead of three, short enough that
189
+ * the loop still feels like it answers the gesture — a preview round trip is
190
+ * ~1 s on the warm service, so 150 ms is noise against it. */
191
+ export const LINE_PREVIEW_DEBOUNCE_MS = 150;
192
+ /** How often the line-edit capability is re-probed while the bridge is up and
193
+ * the answer is still no.
194
+ *
195
+ * The bridge answers `capabilities.lineEdit` from a LAZY ssh probe of the
196
+ * ProjFlow box behind a 5 s cache, so the very first /ardy/health of a session
197
+ * can land on a cold cache and come back false — a box that is perfectly fine
198
+ * two seconds later. Asking once per bridge-up transition therefore made the
199
+ * whole mode dead until a reload (first boot broken, second boot fine, which is
200
+ * exactly how it was reported). A capability is an ADVERTISEMENT, so it is
201
+ * allowed to arrive late; what is not allowed is to assume it. 4 s sits just
202
+ * under the bridge's own 5 s TTL, so a retry can actually see a new answer
203
+ * rather than re-reading the same cached false. */
204
+ export const LINE_CAPABILITY_RETRY_MS = 4000;
205
+ /** Why a validateLineEdit code refused, said once, in the user's language.
206
+ * Keyed by the pure module's stable codes so the copy and the check can never
207
+ * disagree about which field was wrong. */
208
+ export const LINE_EDIT_REFUSALS = {
209
+ sourceMotion: ["The current take has no bridge source — generate it once before editing a path", "현재 테이크에 브리지 원본이 없어요 — 궤적을 편집하기 전에 한 번 생성하세요"],
210
+ track: ["Pick a joint to edit first", "먼저 편집할 관절을 고르세요"],
211
+ frameRange: ["The frame range must sit inside the clip and span at least 2 frames", "프레임 구간은 클립 안에 있어야 하고 최소 2프레임이어야 해요"],
212
+ points: ["Pull a longer stretch of the path — a single point is not a path", "궤적을 좀 더 길게 잡아당겨 주세요 — 점 하나는 경로가 아니에요"],
213
+ camera: ["The camera could not be captured — nudge the view and try again", "카메라를 캡처하지 못했어요 — 뷰를 조금 움직인 뒤 다시 시도하세요"],
214
+ prompt: ["The motion prompt is not usable for a line edit", "모션 프롬프트를 라인 편집에 쓸 수 없어요"],
215
+ pins: ["The pinned moments could not be sent — try clearing them and pinning again", "찍은 순간을 보낼 수 없어요 — 지우고 다시 찍어 보세요"],
216
+ shape: ["The line edit could not be assembled", "라인 편집을 구성하지 못했어요"],
217
+ };
218
+ /** Why curveToPoints2d refused. Same shape, different stage: these are about
219
+ * the CURVE, before there is a payload to validate. */
220
+ export const LINE_CURVE_REFUSALS = {
221
+ empty: ["This joint is not visible over the chosen frames — frame it in view first", "선택한 구간에서 이 관절이 보이지 않아요 — 먼저 화면 안에 들어오도록 잡아 주세요"],
222
+ offscreen: ["The pulled path leaves the frame — keep it inside the dashed border", "잡아당긴 궤적이 화면을 벗어났어요 — 점선 테두리 안에 두세요"],
223
+ };
224
+
225
+ // Named ingest failures, in both locales. A reason the user cannot act on is
226
+ // not a message: each line says what was wrong with THIS source.
227
+ export const MULTIMODEL_REASONS = {
228
+ "url-empty": ["Enter a video URL first", "영상 URL을 먼저 입력하세요"],
229
+ "url-protocol-relative": ["Use a full https:// address", "https:// 로 시작하는 전체 주소를 쓰세요"],
230
+ "url-scheme-unsupported": ["Only http(s) or a local /path is accepted", "http(s) 또는 로컬 /경로만 사용할 수 있어요"],
231
+ "url-platform-page": [
232
+ "YouTube/Vimeo pages are players, not video files, and block browser downloads. Start the dev bridge to use the URL directly, or download the clip and use Choose video.",
233
+ "유튜브·비메오 주소는 영상 파일이 아니라 플레이어 페이지이고 브라우저 다운로드를 막습니다. 개발 브리지를 켜면 URL을 그대로 쓸 수 있고, 아니면 영상을 내려받아 '영상 선택'을 쓰세요.",
234
+ ],
235
+ "url-malformed": ["That address could not be parsed", "주소를 해석할 수 없어요"],
236
+ "fetch-failed": ["The host refused a browser download (CORS or offline)", "호스트가 브라우저 다운로드를 거부했어요(CORS 또는 오프라인)"],
237
+ "not-a-video": ["That address did not return a video (check the path)", "그 주소는 영상을 반환하지 않았어요(경로를 확인하세요)"],
238
+ "decode-failed": ["This browser cannot decode that video", "이 브라우저가 디코딩할 수 없는 영상이에요"],
239
+ "duration-unreadable": ["The clip length could not be read", "클립 길이를 읽지 못했어요"],
240
+ "dimensions-unreadable": ["The frame size could not be read", "프레임 크기를 읽지 못했어요"],
241
+ "probe-timeout": ["The video never reported metadata", "영상이 메타데이터를 보내지 않았어요"],
242
+ "fetch-unavailable": ["This browser cannot download files", "이 브라우저는 파일을 내려받을 수 없어요"],
243
+ "footage-load-timeout": ["The clip never became seekable", "클립이 탐색 가능한 상태가 되지 않았어요"],
244
+ "seek-timeout": ["Seeking inside the clip stalled", "클립 내부 탐색이 멈췄어요"],
245
+ "pose-runtime-unavailable": ["The pose engine is missing from this build", "이 빌드에 포즈 엔진이 없어요"],
246
+ "pose-model-download-failed": ["The pose model could not be downloaded (offline?)", "포즈 모델을 내려받지 못했어요(오프라인인가요?)"],
247
+ "pose-engine-download-failed": ["The pose engine could not be downloaded (offline?)", "포즈 엔진을 내려받지 못했어요(오프라인인가요?)"],
248
+ "pose-engine-init-failed": ["The pose engine failed to start on this device", "이 기기에서 포즈 엔진을 시작하지 못했어요"],
249
+ "rest-unavailable": ["The rig rest data (/ardy/cskel27-rest.json) could not be loaded", "리그 레스트 데이터(/ardy/cskel27-rest.json)를 불러오지 못했어요"],
250
+ "no-person-found": ["No person was detected in the footage", "영상에서 사람을 감지하지 못했어요"],
251
+ "no-person-in-photo": ["No person was detected in that photograph", "사진에서 사람을 감지하지 못했어요"],
252
+ "image-load-timeout": ["The photograph never finished decoding", "사진 디코딩이 끝나지 않았어요"],
253
+ "pose-partly-occluded": ["Shoulders and hips must both be visible in the photograph", "사진에 어깨와 골반이 모두 보여야 해요"],
254
+ "no-usable-pose": ["A person was seen, but no stable pose could be fitted", "사람은 보였지만 안정적인 포즈를 만들지 못했어요"],
255
+ "rig-not-loaded": ["Subject 1's rig is not loaded yet", "인물 1 리그가 아직 로드되지 않았어요"],
256
+ "bridge-unreachable": ["The dev bridge did not answer", "개발 브리지가 응답하지 않아요"],
257
+ "bridge-footage-incomplete": ["The bridge stream ended without footage", "브리지 전송이 영상 없이 끝났어요"],
258
+ "footage-url-invalid": ["That address could not be used for a bridge download", "이 주소는 브리지 다운로드에 쓸 수 없어요"],
259
+ "footage-probe-failed": ["The bridge could not read that page's video info", "브리지가 그 페이지의 영상 정보를 읽지 못했어요"],
260
+ "footage-live-unsupported": ["Live streams have no fixed length and cannot be ingested", "라이브 스트림은 길이가 없어 인제스트할 수 없어요"],
261
+ "footage-too-long": ["That video is over 15 minutes — trim or pick a shorter one", "15분이 넘는 영상이에요 — 잘라내거나 더 짧은 걸 골라 주세요"],
262
+ "footage-download-failed": ["The bridge could not download that video", "브리지가 영상을 내려받지 못했어요"],
263
+ "footage-normalize-failed": ["The bridge could not convert that video for extraction", "브리지가 영상을 추출용으로 변환하지 못했어요"],
264
+ "footage-timeout": ["The bridge download took too long and was stopped", "브리지 다운로드가 너무 오래 걸려 중단됐어요"],
265
+ "bridge-extract-incomplete": ["The bridge stream ended without a take", "브리지 전송이 테이크 없이 끝났어요"],
266
+ "extract-host-missing": ["The bridge has no GPU box configured (CCLAY_EXTRACT_HOST)", "브리지에 GPU 박스가 설정돼 있지 않아요(CCLAY_EXTRACT_HOST)"],
267
+ "extract-bridge-required": ["GVHMR extraction requires the local GPU bridge", "GVHMR 추출에는 로컬 GPU 브리지가 필요해요"],
268
+ "extract-backend-unsupported": ["This bridge is not configured for GVHMR", "이 브리지가 GVHMR로 설정되지 않았어요"],
269
+ "extract-upload-failed": ["The footage could not be copied to the GPU box", "영상을 GPU 박스로 복사하지 못했어요"],
270
+ "extract-upload-too-large": ["That clip is too large to upload for extraction (300 MB cap)", "추출 업로드 한도(300MB)를 넘는 영상이에요"],
271
+ "extract-upload-empty": ["No video bytes arrived at the bridge", "브리지에 영상 데이터가 도착하지 않았어요"],
272
+ "extract-footage-unknown": ["The bridge no longer holds that download — re-ingest the URL", "브리지에 그 다운로드가 더 이상 없어요 — URL을 다시 넣어 주세요"],
273
+ "extract-run-failed": ["GVHMR failed on the GPU box (see the bridge log)", "GPU 박스에서 GVHMR 실행이 실패했어요(브리지 로그 확인)"],
274
+ "extract-no-person": ["The GPU box tracked no person in that footage", "GPU 박스가 영상에서 사람을 추적하지 못했어요"],
275
+ "extract-convert-failed": ["The extracted take could not be converted for the timeline", "추출된 테이크를 타임라인용으로 변환하지 못했어요"],
276
+ "extract-timeout": ["GPU extraction took too long and was stopped", "GPU 추출이 너무 오래 걸려 중단됐어요"],
277
+ };
278
+
279
+ // Extraction samples and bakes straight onto the production clock — an
280
+ // extracted take never touches ARDY, so it never needs retiming either.
281
+ export const MULTIMODEL_SAMPLE_FPS = TIMELINE_FPS;
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
2
2
  import { ko } from "./locale.js";
3
- import { CHARACTER_MODEL_IDS } from "./scenes.js";
3
+ import { CHARACTER_MODEL_IDS, PROXY_FIGURE_MODEL } from "./scenes.js";
4
4
  import { OBJECT_LIBRARY } from "./scene-objects.js";
5
5
  import { displayObjectLabel } from "./object-catalog.jsx";
6
6
  import { assetAspect, isMeshAssetId, isSupportedMeshType } from "./scene-assets.js";
@@ -10,14 +10,25 @@ import ResourceStatus from "./resource-status.jsx";
10
10
  import { logStore } from "./shell/log-store.js";
11
11
  import "./asset-pane.css";
12
12
 
13
- /** Casting assets offered in the Content browser's Characters folder. `id` doubles as the FBX
14
- * file stem and the ARDY wire rig name (see scenes.js). */
15
- export const CHARACTER_ASSETS = CHARACTER_MODEL_IDS.map((id) => ({
16
- id,
17
- label: id === "y-bot-tpose" ? "Y Bot" : "X Bot",
18
- }));
13
+ /** Casting assets offered in the Content browser's Characters folder. A rig's `id` doubles as
14
+ * the FBX file stem and the ARDY wire rig name (see scenes.js); the capsule figure has no FBX. */
15
+ export const CHARACTER_ASSETS = [
16
+ ...CHARACTER_MODEL_IDS.map((id) => ({
17
+ id,
18
+ label: id === "y-bot-tpose" ? "Y Bot" : "X Bot",
19
+ })),
20
+ { id: PROXY_FIGURE_MODEL, label: ko("Capsule figure", "캡슐 인물") },
21
+ ];
19
22
 
20
23
  function CharacterPreview({ model }) {
24
+ if (model === PROXY_FIGURE_MODEL) {
25
+ return (
26
+ <svg className="asset-card-preview" viewBox="0 0 48 48" aria-hidden="true" data-preview="proxy-figure">
27
+ <circle cx="24" cy="8.5" r="5.5" />
28
+ <rect x="16" y="16" width="16" height="26" rx="8" />
29
+ </svg>
30
+ );
31
+ }
21
32
  const yBot = model === "y-bot-tpose";
22
33
  return (
23
34
  <svg className="asset-card-preview" viewBox="0 0 48 48" aria-hidden="true">
@@ -392,7 +403,7 @@ function EmptyNote({ children }) {
392
403
  return <p className="content-empty">{children}</p>;
393
404
  }
394
405
 
395
- function FolderGrid({ folder, query, ...props }) {
406
+ export function FolderGrid({ folder, query, ...props }) {
396
407
  const { imageAssetIds, meshAssetIds, shots = [], takeVersions = [], poses = [], onShotOpen, onTakeOpen, selectedKey, onSelect } = props;
397
408
  let tiles = [];
398
409
  let empty = null;
@@ -653,7 +664,6 @@ export default function AssetPane({
653
664
  </button>
654
665
  </nav>
655
666
  <div className="content-main">
656
- {/* Only a project that carries resources has totals worth a line. */}
657
667
  {resourceManifest?.items?.length ? <ResourceStatus manifest={resourceManifest} compact /> : null}
658
668
  {manageStorage ? (
659
669
  <StorageManager unusedAssetIds={unusedAssetIds} usedAssetIds={usedAssetIds} usageCounts={usageCounts} graphSignature={graphSignature} trashCount={trashCount} onDelete={onDeleteUnusedAsset} onUndo={onUndoDelete} deletingAssetId={deletingAssetId} />
package/src/auto-color.js CHANGED
@@ -20,7 +20,7 @@ export function hashObjectId(id) {
20
20
  hash ^= id.charCodeAt(index);
21
21
  hash = Math.imul(hash, 16777619) >>> 0;
22
22
  }
23
- return hash >>> 0;
23
+ return hash;
24
24
  }
25
25
 
26
26
  const GOLDEN_RATIO_CONJUGATE = 0.61803398875;
package/src/burn-in.js CHANGED
@@ -51,7 +51,7 @@ export function compositeStamp(pixels, width, height, stamp, { marginX = 0, marg
51
51
  }
52
52
 
53
53
  /** Rasterize one stamp line into RGBA bytes (browser-only: Canvas 2D). */
54
- export function renderStampBitmap(text, { fontPx = 18, padX = 10, padY = 6, maxWidth = Infinity } = {}) {
54
+ function renderStampBitmap(text, { fontPx = 18, padX = 10, padY = 6, maxWidth = Infinity } = {}) {
55
55
  const probe = new OffscreenCanvas(1, 1).getContext("2d");
56
56
  probe.font = `600 ${fontPx}px ui-monospace, Menlo, monospace`;
57
57
  const textW = Math.ceil(probe.measureText(text).width);
@@ -5,7 +5,7 @@ import { createTiming, timingIsFlat } from "./speed-envelope.js";
5
5
 
6
6
  export const CAMERA_MODES = Object.freeze(["keys", "follow", "rail"]);
7
7
 
8
- export const CAMERA_FOLLOW_DEFAULTS = Object.freeze({
8
+ const CAMERA_FOLLOW_DEFAULTS = Object.freeze({
9
9
  distance: 3,
10
10
  height: 1.6,
11
11
  response: 0.7,
@@ -1,4 +1,5 @@
1
1
  import { timingProgress } from "./speed-envelope.js";
2
+ import { monotoneCubicAt } from "./monotone-cubic.js";
2
3
 
3
4
  /**
4
5
  * Follow camera: a real crew in three pieces of math.
@@ -200,7 +201,7 @@ export function buildFollowTrack(subject, fps, params = {}) {
200
201
  const p = { ...FOLLOW_DEFAULTS, ...params };
201
202
  if (!subject || subject.length === 0) return [];
202
203
  const dt = followFixedTimestep(fps);
203
- const dirs = travelDirections(subject, fps, p.initialDir ?? null, p.dirBlend);
204
+ const dirs = travelDirections(subject, fps, p.initialDir, p.dirBlend);
204
205
  const offsets = dirs.map((dir) => rotateDirection({ x: -dir.x, z: -dir.z }, p.orbitOffsetDeg));
205
206
  const vels = smoothedVelocities(subject, fps);
206
207
  const omega = omegaFor(p.response);
@@ -269,8 +270,6 @@ export function buildFollowTrack(subject, fps, params = {}) {
269
270
  return track;
270
271
  }
271
272
 
272
- /* ----------------------------------------------------------- the crane --- */
273
-
274
273
  /** Accepts the canonical { points } crane or the legacy { start, end } pair. */
275
274
  function cranePoints(value) {
276
275
  if (!value || typeof value !== "object") return null;
@@ -290,34 +289,9 @@ function cranePoints(value) {
290
289
  export function craneHeightAt(craneHeight, progress) {
291
290
  const points = cranePoints(craneHeight);
292
291
  if (!points) return NaN;
293
- const clamped = Math.max(0, Math.min(1, progress));
294
- if (clamped <= points[0].t) return points[0].height;
295
- if (clamped >= points[points.length - 1].t) return points[points.length - 1].height;
296
- let i = 0;
297
- while (i < points.length - 2 && clamped > points[i + 1].t) i += 1;
298
- const p0 = points[i];
299
- const p1 = points[i + 1];
300
- const h = p1.t - p0.t;
301
- if (h < 1e-9) return p1.height;
302
- const secant = (a, b) => (b.height - a.height) / Math.max(b.t - a.t, 1e-9);
303
- const d = secant(p0, p1);
304
- // harmonic mean of neighbouring secants; zero across a local extremum
305
- const mono = (sa, sb) => (sa * sb <= 0 ? 0 : (2 * sa * sb) / (sa + sb));
306
- const m0 = i > 0 ? mono(secant(points[i - 1], p0), d) : d;
307
- const m1 = i < points.length - 2 ? mono(d, secant(p1, points[i + 2])) : d;
308
- const u = (clamped - p0.t) / h;
309
- const u2 = u * u;
310
- const u3 = u2 * u;
311
- return (
312
- (2 * u3 - 3 * u2 + 1) * p0.height +
313
- (u3 - 2 * u2 + u) * h * m0 +
314
- (-2 * u3 + 3 * u2) * p1.height +
315
- (u3 - u2) * h * m1
316
- );
292
+ return monotoneCubicAt(points, Math.max(0, Math.min(1, progress)), (point) => point.height);
317
293
  }
318
294
 
319
- /* ------------------------------------------------------------ the rail --- */
320
-
321
295
  /**
322
296
  * Ramer–Douglas–Peucker: an authored stroke is hundreds of jittery pointer
323
297
  * samples; the rail wants the handful of points that carry its shape.
@@ -463,7 +437,7 @@ export function buildRailFollowTrack(subject, fps, rail, params = {}) {
463
437
  const crane = cranePoints(p.craneHeight) ? p.craneHeight : null;
464
438
  const craneTargetAt = (arc) => {
465
439
  if (!crane) return p.height;
466
- return craneHeightAt(crane, rail.length < 1e-9 ? 1 : arc / rail.length);
440
+ return craneHeightAt(crane, arc / rail.length);
467
441
  };
468
442
 
469
443
  const distanceErrorAt = (s, subj) => {
@@ -512,13 +486,13 @@ export function buildRailFollowTrack(subject, fps, rail, params = {}) {
512
486
  // area is the rail length, and a cut pins "at this frame, be at
513
487
  // this point of the rail". Null timing is the identity — the
514
488
  // constant-speed schedule this line always was.
515
- const authoredS = rail.length * timingProgress(p.dollyTiming ?? null, progress);
489
+ const authoredS = rail.length * timingProgress(p.dollyTiming, progress);
516
490
  const distanceS = bestSNear(authoredS, subject[f], p.searchWindow, 0);
517
491
  // Distance influence fades to zero at both authored endpoints, so
518
492
  // it refines the middle of the move without shifting its marks.
519
493
  const correctionEnvelope = Math.sin(Math.PI * progress) ** 2;
520
494
  const correction = (distanceS - authoredS) * clamp(p.distanceInfluence, 0, 1) * correctionEnvelope;
521
- const ledS = rail.length * timingProgress(p.dollyTiming ?? null, Math.min(1, progress + leadFraction));
495
+ const ledS = rail.length * timingProgress(p.dollyTiming, Math.min(1, progress + leadFraction));
522
496
  const sTarget = Math.max(s, Math.min(rail.length, ledS + correction));
523
497
  [s, sVel] = cappedSpringStep(s, sVel, sTarget, omega, dt, p.maxDollySpeed);
524
498
  s = Math.max(0, Math.min(s, rail.length));
@@ -535,3 +509,72 @@ export function buildRailFollowTrack(subject, fps, rail, params = {}) {
535
509
  }
536
510
  return track;
537
511
  }
512
+
513
+ /**
514
+ * Prepare an x/z rail bend for one crane mark: a copy of the drawn control
515
+ * points (with one inserted at the mark's base when none is close enough to
516
+ * anchor the bend) plus a per-point weight — a cosine bump centred on the
517
+ * anchor that fades to zero by the neighbouring crane marks, so dragging one
518
+ * mark can never tow the marks beside it. For an interior mark both rail
519
+ * ends are pinned outright. `maxControls` stops the seeding at a schema
520
+ * ceiling (the nearest point anchors the bend instead).
521
+ */
522
+ export function prepareRailBend(controlPoints, base, marks, markIndex, { maxControls = Infinity } = {}) {
523
+ const controls = (controlPoints ?? []).map((entry) => ({ ...entry }));
524
+ if (controls.length < 2) return null;
525
+ // nearest control point / segment to the mark's base
526
+ let nearestIdx = 0;
527
+ let nearestD = Infinity;
528
+ for (let i = 0; i < controls.length; i += 1) {
529
+ const d = Math.hypot(controls[i].x - base.x, controls[i].z - base.z);
530
+ if (d < nearestD) {
531
+ nearestD = d;
532
+ nearestIdx = i;
533
+ }
534
+ }
535
+ const interior = markIndex > 0 && markIndex < marks.length - 1;
536
+ let anchorIdx = nearestIdx;
537
+ if (interior && nearestD > 0.25 && controls.length < maxControls) {
538
+ // no control point near the mark: bend needs a vertex to pull, so one
539
+ // is seeded at the base, on the segment the base projects onto
540
+ let segmentIdx = 0;
541
+ let segmentD = Infinity;
542
+ for (let i = 0; i < controls.length - 1; i += 1) {
543
+ const ax = controls[i].x;
544
+ const az = controls[i].z;
545
+ const bx = controls[i + 1].x;
546
+ const bz = controls[i + 1].z;
547
+ const lenSq = Math.max((bx - ax) ** 2 + (bz - az) ** 2, 1e-9);
548
+ const u = Math.max(0, Math.min(1, ((base.x - ax) * (bx - ax) + (base.z - az) * (bz - az)) / lenSq));
549
+ const d = Math.hypot(ax + (bx - ax) * u - base.x, az + (bz - az) * u - base.z);
550
+ if (d < segmentD) {
551
+ segmentD = d;
552
+ segmentIdx = i;
553
+ }
554
+ }
555
+ // An object route's points carry a height; the seed takes the one the
556
+ // curve has there. (The camera rail's points have none.)
557
+ const seed = { x: base.x, z: base.z };
558
+ if (base.y !== undefined) seed.y = base.y;
559
+ controls.splice(segmentIdx + 1, 0, seed);
560
+ anchorIdx = segmentIdx + 1;
561
+ }
562
+ // normalized arc parameter of each control point along the control polygon
563
+ const ts = [0];
564
+ for (let i = 1; i < controls.length; i += 1) {
565
+ ts.push(ts[i - 1] + Math.hypot(controls[i].x - controls[i - 1].x, controls[i].z - controls[i - 1].z));
566
+ }
567
+ const total = Math.max(ts[ts.length - 1], 1e-9);
568
+ for (let i = 0; i < ts.length; i += 1) ts[i] /= total;
569
+ // the bump reaches exactly to the neighbouring crane marks
570
+ const t0 = ts[anchorIdx];
571
+ const previous = markIndex > 0 ? marks[markIndex - 1].t : null;
572
+ const next = markIndex < marks.length - 1 ? marks[markIndex + 1].t : null;
573
+ const radius = Math.max(0.15, Math.min(previous != null ? t0 - previous : 1, next != null ? next - t0 : 1));
574
+ const weights = ts.map((t, i) => {
575
+ if (interior && (i === 0 || i === ts.length - 1)) return 0;
576
+ const d = Math.abs(t - t0);
577
+ return d >= radius ? 0 : 0.5 * (1 + Math.cos((Math.PI * d) / radius));
578
+ });
579
+ return { startControls: controls, weights, t0, radius };
580
+ }
@@ -30,8 +30,6 @@ export function cameraPresetFraming(id, subject = {}, filmback = {}) {
30
30
  return { id, pos, yaw, pitch, fovDeg, focalMm: preset.focalMm };
31
31
  }
32
32
 
33
- /* ------------------------------------------------------------ framing --- */
34
-
35
33
  /**
36
34
  * Snapshot the shot camera as a plain framing record.
37
35
  * yaw/pitch follow the YXZ free-look convention in controls.jsx.
@@ -151,8 +149,6 @@ export function interpolateFraming(a, b, anchor, t, ease = easeInOut, filmback =
151
149
  };
152
150
  }
153
151
 
154
- /* ----------------------------------------------------------- naming ---- */
155
-
156
152
  const DEG = 180 / Math.PI;
157
153
 
158
154
  // Below these deltas the camera is, cinematically speaking, not doing that.
@@ -288,8 +284,6 @@ export function moveSlate(move) {
288
284
  return `${end[0]} → ${end[1]} · ${move.label.toUpperCase()}`;
289
285
  }
290
286
 
291
- /* ------------------------------------------------- multi-key sequences --- */
292
-
293
287
  /**
294
288
  * Sample an N-key camera move at a frame. Keys are sorted frame-unique
295
289
  * records `{ frame, framing }`. Before the first key and after the last the
@@ -98,8 +98,6 @@ export const CAMERA_TUTORIAL_STEPS = [
98
98
  const NAV_KINDS = new Set(["fly", "walk", "dolly", "orbit"]);
99
99
  const SIGNAL_KINDS = new Set(["shot", "rail"]);
100
100
 
101
- /* ------------------------------------------------------------- beacons --- */
102
-
103
101
  // Where each of the last three steps actually happens (#211). The card names
104
102
  // the gesture; these pin a numbered circle and a caption onto the control
105
103
  // itself, so "click + Add shot" stops being a treasure hunt across two panes.
@@ -249,8 +247,6 @@ export function TutorialBeacon({ kind, step, role, selector, label, requires = n
249
247
  );
250
248
  }
251
249
 
252
- /* -------------------------------------------------------- gesture cues --- */
253
-
254
250
  // Steps 1–4 have no control to point at: the gesture IS the interface. The cue
255
251
  // draws the mouse and the keys the step wants, animated the way the hand is
256
252
  // supposed to move, and sits centre-bottom of the viewport where the eye
@@ -330,7 +326,7 @@ export function GestureCue({ kind, walked, leaving = false, onLeft }) {
330
326
  * Top-View inset, and never takes the pointer except on its own close button
331
327
  * — every step is completed by working the studio underneath it.
332
328
  */
333
- export function CameraTutorial({ previewing = false, shotCount = 0, railReady = false, analytics, onStepChange, onClose, onComplete, handoff = null, shotId = null, onOpenExport, onContinue }) {
329
+ export function CameraTutorial({ previsMode, previewing = false, shotCount = 0, railReady = false, analytics, onStepChange, onClose, onComplete, handoff = null, shotId = null, onOpenExport, onContinue }) {
334
330
  const [done, setDone] = useState(() => new Set());
335
331
  const [walked, setWalked] = useState(() => new Set());
336
332
  const [cue, setCue] = useState(null);
@@ -429,6 +425,9 @@ export function CameraTutorial({ previewing = false, shotCount = 0, railReady =
429
425
  });
430
426
  }, [currentKind]);
431
427
 
428
+ // Storyboard projects never show the camera tutorial (its steps are shot/rail/play).
429
+ if (previsMode === "storyboard") return null;
430
+
432
431
  return (
433
432
  <>
434
433
  <aside
@@ -510,8 +509,8 @@ export function CameraTutorial({ previewing = false, shotCount = 0, railReady =
510
509
  role={beacon.role}
511
510
  selector={beacon.selector}
512
511
  label={beacon.label}
513
- requires={beacon.requires ?? null}
514
- absent={beacon.absent ?? null}
512
+ requires={beacon.requires}
513
+ absent={beacon.absent}
515
514
  />
516
515
  ))}
517
516
  </>