cozyclay 3.0.3 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (246) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/INSTALL_FOR_AGENTS.md +146 -0
  3. package/README.md +74 -192
  4. package/THIRD_PARTY_NOTICES.md +0 -21
  5. package/bin/agent/agent-routes.mjs +29 -62
  6. package/bin/agent/agent-runner.mjs +5 -3
  7. package/bin/agent/main-agent.mjs +70 -0
  8. package/bin/agent/main-prompt.mjs +17 -0
  9. package/bin/agent/plan-tool.mjs +114 -0
  10. package/bin/agent/script-tool.mjs +2 -0
  11. package/bin/agent/sculpt-sketch.mjs +190 -0
  12. package/bin/agent/session-store.mjs +4 -3
  13. package/bin/agent/sketch-code.mjs +151 -0
  14. package/bin/agent/sketch-sandbox.mjs +102 -0
  15. package/bin/agent/studio-prompt.mjs +1 -1
  16. package/bin/agent/studio-task-routes.mjs +185 -0
  17. package/bin/agent/studio-tasks.mjs +156 -0
  18. package/bin/agent/studio-tools.mjs +3 -2
  19. package/bin/agent/studio-turn-transaction.mjs +76 -0
  20. package/bin/agent/turn-frames.mjs +4 -0
  21. package/bin/cozyclay.mjs +5 -7
  22. package/dist/app/index.html +7 -6
  23. package/dist/assets/TaskBoard-ngdqiu7u.js +1 -0
  24. package/dist/assets/app-BlwOpest.css +1 -0
  25. package/dist/assets/app-DmR7jBkz.js +5144 -0
  26. package/dist/assets/compile-worker-D0pKioa0.js +5 -0
  27. package/dist/assets/landing-y3EqDgSZ.js +1 -0
  28. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
  29. package/dist/assets/motion3-compile-BB3_1-EB.js +1 -0
  30. package/dist/assets/scene-asset-sync-CBx7XRti.css +1 -0
  31. package/dist/assets/scene-asset-sync-Cgdtr1Yw.js +13 -0
  32. package/dist/assets/task-board-ErQvfoaz.js +14 -0
  33. package/dist/assets/workflow-zQM-EKW1.js +179 -0
  34. package/dist/assets/x-bot-tpose-body-BusyR3eg.json +1 -0
  35. package/dist/assets/y-bot-tpose-body-Dy1VKChZ.json +1 -0
  36. package/dist/cozyclay-package.json +1 -1
  37. package/dist/greybox-to-video/index.html +2 -3
  38. package/dist/index.html +7 -57
  39. package/dist/llms.txt +2 -1
  40. package/dist/previs-software/index.html +2 -2
  41. package/dist/privacy/index.html +3 -7
  42. package/dist/seedance-camera-control/index.html +2 -2
  43. package/dist/sitemap.xml +5 -5
  44. package/dist/sw.js +5 -1
  45. package/dist/thumbs/blank-stage-dark.webp +0 -0
  46. package/dist/thumbs/blank-stage-light.webp +0 -0
  47. package/dist/workflow/index.html +5 -4
  48. package/mcp/mesh-file.mjs +3 -1
  49. package/mcp/server.mjs +3 -0
  50. package/mcp/tool-handlers.mjs +2 -2
  51. package/package.json +5 -3
  52. package/src/App.jsx +51 -282
  53. package/src/analytics.js +3 -148
  54. package/src/app-stage.jsx +1 -1
  55. package/src/asset-pane.jsx +5 -10
  56. package/src/auto-flat.js +10 -0
  57. package/src/bus-tasks.js +11 -5
  58. package/src/command-bus.js +1 -1
  59. package/src/commands/elements.js +4 -3
  60. package/src/commands/motion3-compile.js +51 -27
  61. package/src/commands/motion3.js +3 -3
  62. package/src/commands/objects.js +1 -1
  63. package/src/commands/project.js +1 -1
  64. package/src/commands/shot.js +16 -4
  65. package/src/controls.jsx +0 -11
  66. package/src/cuts.js +11 -0
  67. package/src/domains/cast.js +3 -2
  68. package/src/domains/motion.js +3 -10
  69. package/src/domains/playback.js +32 -0
  70. package/src/domains/scenes.js +3 -22
  71. package/src/domains/shots.js +5 -5
  72. package/src/dualview.jsx +11 -15
  73. package/src/hierarchy-panel.css +18 -4
  74. package/src/hierarchy-panel.jsx +12 -11
  75. package/src/kits/held.js +3 -2
  76. package/src/light-blockers.js +2 -2
  77. package/src/main.jsx +5 -0
  78. package/src/material-look.js +189 -0
  79. package/src/material-presets.js +78 -0
  80. package/src/material-textures.js +157 -0
  81. package/src/motion/ground.js +5 -14
  82. package/src/motion/playback-frame-store.js +79 -0
  83. package/src/motion/playback-probe.js +16 -0
  84. package/src/motion/playback-subject.js +7 -0
  85. package/src/motion/timeline.css +43 -5
  86. package/src/motion/timeline.jsx +4 -3
  87. package/src/motion3/body.js +37 -5
  88. package/src/motion3/carried.js +11 -3
  89. package/src/motion3/compile-job.js +1 -1
  90. package/src/motion3/compiler/compile.js +7 -1
  91. package/src/motion3/compiler/finger-close.js +137 -0
  92. package/src/motion3/compiler/limbs.js +18 -3
  93. package/src/motion3/compiler/pose.js +6 -0
  94. package/src/motion3/compiler/validate-objects.js +17 -2
  95. package/src/motion3/compiler/validate-parts.js +2 -1
  96. package/src/motion3/compiler/vocab.js +4 -4
  97. package/src/motion3/describe.js +2 -2
  98. package/src/motion3/fix/index.js +5 -3
  99. package/src/motion3/flight.js +38 -0
  100. package/src/motion3/free-motion.js +10 -7
  101. package/src/motion3/load.js +125 -0
  102. package/src/motion3/mass.js +23 -0
  103. package/src/motion3/object-keys.js +11 -6
  104. package/src/motion3/object-pose.js +11 -3
  105. package/src/motion3/pipeline.js +3 -0
  106. package/src/motion3/qa/explain.js +3 -1
  107. package/src/motion3/qa/grasp-checks.js +123 -0
  108. package/src/motion3/qa/hints-grasp.js +71 -0
  109. package/src/motion3/qa/index.js +3 -1
  110. package/src/motion3/qa/object-checks.js +7 -2
  111. package/src/motion3/qa/patch.js +2 -1
  112. package/src/motion3/qa/spec-checks.js +10 -1
  113. package/src/motion3/schema/fields.js +3 -3
  114. package/src/motion3/schema/topics.js +9 -8
  115. package/src/motion3/schema/types.js +4 -1
  116. package/src/motion3/seat.js +84 -0
  117. package/src/motion3/settle.js +11 -6
  118. package/src/motion3/shapes.js +70 -0
  119. package/src/motion3/skills/catch.js +71 -0
  120. package/src/motion3/skills/index.js +10 -3
  121. package/src/motion3/skills/info.js +13 -0
  122. package/src/motion3/skills/throw.js +72 -0
  123. package/src/object-catalog.jsx +0 -4
  124. package/src/object-gizmo.jsx +2 -1
  125. package/src/object-look.js +9 -5
  126. package/src/object-path.js +1 -1
  127. package/src/panels/ProjectPanel.jsx +42 -21
  128. package/src/panels/details.css +15 -4
  129. package/src/popover.js +37 -0
  130. package/src/posestudio.jsx +15 -10
  131. package/src/primitive-look.js +38 -0
  132. package/src/project-browser-art.jsx +19 -0
  133. package/src/project-browser.css +74 -121
  134. package/src/project-browser.jsx +36 -60
  135. package/src/props.jsx +24 -35
  136. package/src/render-passes.js +7 -1
  137. package/src/retired-kinds.js +37 -0
  138. package/src/scene-objects.js +5 -9
  139. package/src/scenes.js +4 -5
  140. package/src/sculpt-colours.js +65 -0
  141. package/src/sculpt-compare.js +13 -50
  142. package/src/sculpt-csg.js +112 -0
  143. package/src/sculpt-curve.js +123 -0
  144. package/src/sculpt-cutters.js +43 -0
  145. package/src/sculpt-detail.js +207 -0
  146. package/src/sculpt-geometry-loft-convex.js +100 -0
  147. package/src/sculpt-geometry-shapes.js +250 -0
  148. package/src/sculpt-geometry.js +33 -15
  149. package/src/sculpt-loft-rings.js +225 -0
  150. package/src/sculpt-noise.js +65 -0
  151. package/src/sculpt-part-fields.js +27 -0
  152. package/src/sculpt-recipe-read.js +65 -0
  153. package/src/sculpt-recipe.js +33 -75
  154. package/src/sculpt-run.js +1 -1
  155. package/src/sculpt-scatter-items.js +183 -0
  156. package/src/sculpt-scatter-shape.js +57 -0
  157. package/src/sculpt-scatter.js +124 -0
  158. package/src/sculpt-shapes-curved.js +211 -0
  159. package/src/sculpt-shapes-loft-convex.js +218 -0
  160. package/src/sculpt-shapes.js +138 -0
  161. package/src/sculpt-sketch-state.js +76 -0
  162. package/src/sculpt-sketch.jsx +124 -0
  163. package/src/sculpt-surface-fields.js +65 -0
  164. package/src/settings-menu.jsx +1 -20
  165. package/src/shell/BottomDock.jsx +9 -9
  166. package/src/shell/MenuBar.jsx +1 -9
  167. package/src/shell/PreferencesDialog.jsx +3 -4
  168. package/src/shell/ViewportToolbar.jsx +3 -3
  169. package/src/shell/add-camera-shot.js +11 -0
  170. package/src/shell/glass-regions.css +3 -2
  171. package/src/shell/glass.css +0 -5
  172. package/src/shell/lens-scrub-math.js +51 -0
  173. package/src/shell/lens-scrub.css +73 -0
  174. package/src/shell/lens-scrub.jsx +98 -0
  175. package/src/shell/mode.css +6 -5
  176. package/src/shell/preferences.css +1 -32
  177. package/src/shell/shell.css +18 -1
  178. package/src/shell/studio-shell-context.js +5 -0
  179. package/src/shot-frame.js +26 -0
  180. package/src/stage-canvas-rigs.jsx +7 -8
  181. package/src/stage-characters.jsx +26 -0
  182. package/src/stage-gizmo.js +43 -0
  183. package/src/stage-labels-ko.js +0 -6
  184. package/src/stage-startup-defaults.js +3 -9
  185. package/src/starter-scenes.js +5 -11
  186. package/src/studio-agent-commands.js +2 -2
  187. package/src/studio-agent-protocol.js +2 -7
  188. package/src/studio-app-binding.js +1 -1
  189. package/src/studio-elements.js +6 -2
  190. package/src/styles/themes.css +13 -0
  191. package/src/styles.css +6 -674
  192. package/src/text/text-object.jsx +1 -1
  193. package/src/ui.jsx +8 -16
  194. package/src/use-embed-bridge.js +3 -6
  195. package/src/use-render-activity.js +3 -1
  196. package/src/view-ghosts.jsx +5 -3
  197. package/src/viewport-visible-rect.js +37 -0
  198. package/src/workflow/AgentPanel.jsx +79 -46
  199. package/src/workflow/AgentPlanCard.jsx +41 -0
  200. package/src/workflow/AgentProgressCard.jsx +1 -1
  201. package/src/workflow/TaskBoard.jsx +102 -0
  202. package/src/workflow/TaskInline.jsx +114 -0
  203. package/src/workflow/agent-client.js +25 -36
  204. package/src/workflow/agent-panel.css +14 -1
  205. package/src/workflow/agent-plan.css +93 -0
  206. package/src/workflow/agent-plan.js +59 -0
  207. package/src/workflow/attachment-image.js +10 -3
  208. package/src/workflow/attachment-video.js +91 -0
  209. package/src/workflow/task-board.css +98 -0
  210. package/src/workflow/task-board.js +169 -0
  211. package/tools/check-eslint.mjs +2 -1
  212. package/tools/check-knip.mjs +2 -1
  213. package/tools/dev/pages/greybox-to-video.html +2 -3
  214. package/tools/dev/pages/previs-software.html +2 -2
  215. package/tools/dev/pages/privacy.html +3 -7
  216. package/tools/dev/pages/seedance-camera-control.html +2 -2
  217. package/tools/dev-full.mjs +11 -2
  218. package/tools/eslint-baseline.json +6 -6
  219. package/tools/knip-baseline.json +0 -11
  220. package/tools/loc-baseline.json +30 -31
  221. package/tools/metrics-snapshot.mjs +43 -5
  222. package/tools/render-start-thumbs.mjs +175 -0
  223. package/tools/run-tests.mjs +10 -17
  224. package/tools/sitemap.mjs +1 -1
  225. package/dist/assets/analytics-BibyE5ar.js +0 -6
  226. package/dist/assets/app-DhRJ-PBz.js +0 -5088
  227. package/dist/assets/app-f-fkV5uP.css +0 -1
  228. package/dist/assets/compile-worker-DqiA4AaN.js +0 -5
  229. package/dist/assets/first-shot-handoff-Qx7SKl38.js +0 -1
  230. package/dist/assets/landing-DYg87MDY.js +0 -1
  231. package/dist/assets/scene-asset-sync-DBYQBR0T.js +0 -20
  232. package/dist/assets/scene-asset-sync-_S8NQl5B.css +0 -1
  233. package/dist/assets/workflow-BSA22Ofr.js +0 -179
  234. package/dist/scenes/city-block.cclayproject +0 -1
  235. package/src/burn-in.js +0 -91
  236. package/src/camera-tutorial.jsx +0 -520
  237. package/src/facing-marks.js +0 -88
  238. package/src/first-shot-handoff.js +0 -44
  239. package/src/panels/motion.css +0 -197
  240. package/src/set-pieces.jsx +0 -183
  241. package/src/tutorial-analytics.js +0 -86
  242. package/src/tutorial-walk.js +0 -17
  243. package/src/use-camera-tutorial.js +0 -50
  244. package/src/use-semantic-state.js +0 -12
  245. package/tools/metrics/archive-traffic.mjs +0 -138
  246. package/tools/perf/ik-camera-drag.mjs +0 -138
@@ -1,12 +1,13 @@
1
1
  import { useEffect, useLayoutEffect, useRef, useState } from "react";
2
- import { useStudioShell } from "./studio-shell-context.js";
2
+ import { useStudioPlaybackFrame, useStudioShell } from "./studio-shell-context.js";
3
3
  import { logStore } from "./log-store.js";
4
4
  import "./dock.css";
5
5
  import LibrarySlot from "./LibrarySlot.jsx";
6
6
  import BoardSlot from "./BoardSlot.jsx";
7
+ import LensScrub from "./lens-scrub.jsx";
7
8
  import { ko } from "../locale.js";
8
9
  import Timeline, { timelineTake } from "../motion/timeline.jsx";
9
- import { DEFAULT_PLAYBACK_SPEED, SHOT_ASPECT_PRESETS, sceneObjectNameDisplayKo } from "../app-stage.jsx";
10
+ import { DEFAULT_PLAYBACK_SPEED, sceneObjectNameDisplayKo } from "../app-stage.jsx";
10
11
 
11
12
  import { trackFeature } from "../analytics.js";
12
13
  import { defaultRailRange } from "../camera-rail-schedule.js";
@@ -67,11 +68,11 @@ function dockTabsFor(previsMode) {
67
68
  * switching keeps the Sequencer's scroll and zoom and the Library's folder
68
69
  * and search. Any stored tab the mode does not offer opens its first tab. */
69
70
  export default function BottomDock({ tab: requestedTab = "animation", onTabChange, embedded = false }) {
70
- const { previsMode } = useStudioShell();
71
+ const { previsMode } = useStudioShell(), liveFrame = useStudioPlaybackFrame();
71
72
  const tabs = embedded ? dockTabsFor(null) : dockTabsFor(previsMode);
72
73
  const tab = tabs.some((entry) => entry.key === requestedTab) ? requestedTab : tabs[0].key;
73
74
  const {
74
- tlFrame, motion, waypointMode, craneSelectedIndex,
75
+ motion, waypointMode, craneSelectedIndex,
75
76
  isCameraSelection, addActiveCranePoint, deleteSelectedCranePoint, setCraneSelectedIndex, tlFrameCount,
76
77
  tlFps, characters, activeCharIndex, ghostLayers, pathSpeed,
77
78
  tlPlaying, workflowMode, waypoints, pendingWaypointFrame,
@@ -205,7 +206,7 @@ export default function BottomDock({ tab: requestedTab = "animation", onTabChang
205
206
  {!embedded && previsMode === "storyboard" && <BoardSlot active={tab === "board"} />}
206
207
  <div className="bottom-timeline" hidden={tab !== "animation"}>
207
208
  <Timeline
208
- frame={tlFrame}
209
+ frame={liveFrame}
209
210
  craneSelectedIndex={craneSelectedIndex}
210
211
  cameraSelected={isCameraSelection}
211
212
  onCranePointAdd={addActiveCranePoint}
@@ -390,11 +391,10 @@ const CARD_RATIOS = [
390
391
 
391
392
  /** The shot camera's card at the dock's right end. The camera itself is drawn
392
393
  * into the stage canvas under `.vp-shot-preview`, which sits over the card's
393
- * frame slot (see glass.css); the card holds its title, ratio and look-through. */
394
+ * frame slot (see glass.css); the card holds its title, lens, ratio and look-through. */
394
395
  function ShotCard() {
395
- const { activeShot, shot, shotAspectKey, runStudioAction, enterShotLook, exitShotLook, lookThroughShot, tlFps, embedMode } = useStudioShell();
396
+ const { activeShot, shot, fovDeg, shotsDomain, shotAspectKey, runStudioAction, enterShotLook, exitShotLook, lookThroughShot, tlFps, embedMode } = useStudioShell();
396
397
  if (embedMode) return null;
397
- const ratio = SHOT_ASPECT_PRESETS[shotAspectKey]?.label?.replace(/:1$/, "") ?? shotAspectKey;
398
398
  const frames = activeShot && Number.isFinite(activeShot.startFrame) && Number.isFinite(activeShot.endFrame)
399
399
  ? `${activeShot.startFrame}–${activeShot.endFrame} · ${((activeShot.endFrame - activeShot.startFrame + 1) / (tlFps || 24)).toFixed(1)}s`
400
400
  : null;
@@ -403,7 +403,7 @@ function ShotCard() {
403
403
  <header className="dock-shot-head">
404
404
  <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2 5.75A1.5 1.5 0 0 1 3.5 4.25h6A1.5 1.5 0 0 1 11 5.75v4.5a1.5 1.5 0 0 1-1.5 1.5h-6A1.5 1.5 0 0 1 2 10.25z M11 7l3-1.5v5L11 9" /></svg>
405
405
  <span className="dock-shot-title">{ko("Shot Camera", "샷 카메라")}</span>
406
- <span className="dock-shot-meta">{shot.focalMm} mm · {ratio}</span>
406
+ <LensScrub fovDeg={fovDeg} sensorHeightMm={shot?.usedSensorHeightMm} shotsDomain={shotsDomain} disabled={!shot} />
407
407
  </header>
408
408
  {/* While the main view looks through this camera the card has nothing
409
409
  of its own to show (the preview pass is off): it says where the
@@ -196,7 +196,7 @@ export default function MenuBar({ preferences }) {
196
196
  ? panelCount ? "" : noPanels
197
197
  : shots.length ? "" : ko("Add a shot first — a pack describes one cut", "샷을 먼저 추가하세요 — 팩은 컷 하나를 설명합니다");
198
198
 
199
- // The camera tutorial's handoff opens Export directly; its flyout lives in File.
199
+ // Export's flyout lives in File, so an open Export menu opens File.
200
200
  useEffect(() => {
201
201
  if (exportMenuOpen) setOpen("file");
202
202
  }, [exportMenuOpen]);
@@ -534,14 +534,6 @@ export default function MenuBar({ preferences }) {
534
534
  ),
535
535
  help: (
536
536
  <>
537
- <button
538
- type="button"
539
- role="menuitem"
540
- className="menubar-item"
541
- onClick={item(() => window.dispatchEvent(new CustomEvent("cozyclay:camera-tutorial", { detail: { open: true, source: "help" } })))}
542
- >
543
- <span className="menubar-item-label">{ko("Tutorial", "튜토리얼")}</span>
544
- </button>
545
537
  <button type="button" role="menuitem" className="menubar-item" onClick={item(() => setDialog("shortcuts"))}>
546
538
  <span className="menubar-item-label">{ko("Keyboard shortcuts", "단축키")}</span>
547
539
  </button>
@@ -114,7 +114,7 @@ function ThemeControls() {
114
114
  }
115
115
 
116
116
  function preferenceSection(section, props) {
117
- const { autoColor, setAutoColor, analyticsOptOut, setAnalyticsOptOutState, gridView, setGridView, cameraTutorial } = props;
117
+ const { autoColor, setAutoColor, analyticsOptOut, setAnalyticsOptOutState, gridView, setGridView } = props;
118
118
  const languageControls = (
119
119
  <div className="v2-preferences__language-controls">
120
120
  {LANGUAGES.map((language) => (
@@ -158,7 +158,6 @@ function preferenceSection(section, props) {
158
158
  />
159
159
  </PreferenceRow>
160
160
  </PreferenceSection>
161
- {cameraTutorial && <div className="v2-preferences__help">{cameraTutorial}</div>}
162
161
  </>
163
162
  );
164
163
  case "Viewport":
@@ -181,7 +180,7 @@ function preferenceSection(section, props) {
181
180
  }
182
181
  }
183
182
 
184
- export default function PreferencesDialog({ open, onClose, initialSection = "Appearance", cameraTutorial }) {
183
+ export default function PreferencesDialog({ open, onClose, initialSection = "Appearance" }) {
185
184
  const dialogRef = useRef(null);
186
185
  const searchRef = useRef(null);
187
186
  const closeRef = useRef(onClose);
@@ -233,7 +232,7 @@ export default function PreferencesDialog({ open, onClose, initialSection = "App
233
232
  }, [open, initialSection]);
234
233
 
235
234
  if (!open || typeof document === "undefined") return null;
236
- const currentProps = { autoColor, setAutoColor, analyticsOptOut, setAnalyticsOptOutState, gridView, setGridView, cameraTutorial };
235
+ const currentProps = { autoColor, setAutoColor, analyticsOptOut, setAnalyticsOptOutState, gridView, setGridView };
237
236
 
238
237
  return createPortal(
239
238
  <div className="v2-preferences" data-testid="preferences-dialog-backdrop" onPointerDown={(event) => { if (event.target === event.currentTarget) closeRef.current?.(); }}>
@@ -11,7 +11,7 @@ import { elementByPath } from "../studio-elements.js";
11
11
  import { saveAutoColor } from "../auto-color.js";
12
12
  import { trackFeature } from "../analytics.js";
13
13
  import { GUIDE_LABELS, nextGuideMode } from "../shot-guides.js";
14
- import GhostsMenuGroup from "./GhostsMenuGroup.jsx";
14
+ import GhostsMenuGroup from "./GhostsMenuGroup.jsx"; import { addCameraShot } from "./add-camera-shot.js";
15
15
  import "./viewport.css";
16
16
 
17
17
  // G1: the single mode+tool pill. Keys 1-5 and W/E/R are bound in App's
@@ -122,7 +122,7 @@ export default function ViewportToolbar() {
122
122
  autoColor, setAutoColor, isCharacterSelection, partColoursChoice, embedMode,
123
123
  agentCollapsed, addSceneObject, spawnCharacter, characters, flySpeed,
124
124
  ikEditTool, setIkEditTool, motion, setRangePinPartPick,
125
- previsMode, sculptView, requestSculptView,
125
+ previsMode, sculptView, requestSculptView, appContext, setToast,
126
126
  } = useStudioShell();
127
127
  const menu = useOverlayMenu();
128
128
 
@@ -292,7 +292,7 @@ export default function ViewportToolbar() {
292
292
  role="menuitem"
293
293
  className="add-object-item"
294
294
  data-add="camera"
295
- onClick={() => { runStudioAction("shot.create"); menu.close(); }}
295
+ onClick={() => { addCameraShot({ appContext, runStudioAction, setToast }); menu.close(); }}
296
296
  >
297
297
  <span className="add-object-swatch vp-add-swatch-camera" aria-hidden="true" />
298
298
  <span>{ko("Camera", "카메라")}</span>
@@ -0,0 +1,11 @@
1
+ // Add > Camera names the cut it made (ViewportToolbar). A refusal is already toasted by runStudioAction.
2
+ import { ko } from "../locale.js";
3
+
4
+ export function addCameraShot({ appContext, runStudioAction, setToast }) {
5
+ const shots = appContext.storeDomain("shot"), before = shots.read(), receipt = runStudioAction("shot.create");
6
+ const made = shots.read().find((row) => row.id === receipt?.affectedIds.find((id) => !before.some((old) => old.id === id)));
7
+ if (!made) return;
8
+ setToast(made.kind === "still"
9
+ ? ko(`${made.name} added — a storyboard panel at frame ${made.startFrame}`, `${made.name} 추가됨 — ${made.startFrame}프레임의 스토리보드 패널`)
10
+ : ko(`${made.name} added — a camera cut at frame ${made.startFrame}`, `${made.name} 추가됨 — ${made.startFrame}프레임에서 카메라 컷`));
11
+ }
@@ -800,11 +800,12 @@
800
800
 
801
801
  /* ----------------------------------------------- Dock: Animation | Assets */
802
802
  /* The switch sits at the head's left end, where the Sequencer's transport
803
- and the Library's Content | Log row start after it. */
803
+ and the Library's Content | Log row start after it. It paints above the
804
+ timeline (z-index 6, timeline.css), which would otherwise take its clicks. */
804
805
  .app[data-shell="glass"]:not([data-embed-mode]) .v2-dock { --dock-tabs-w: 176px; }
805
806
  .app[data-shell="glass"]:not([data-embed-mode]) .dock-tabs {
806
807
  position: absolute;
807
- z-index: 3;
808
+ z-index: 7;
808
809
  top: 9px;
809
810
  left: 16px;
810
811
  box-sizing: border-box;
@@ -326,11 +326,6 @@
326
326
  font-size: 11.5px;
327
327
  font-weight: 400;
328
328
  }
329
- .app[data-shell="glass"]:not([data-embed-mode]) .studio-project-head .project-menu {
330
- top: calc(100% + 6px);
331
- left: -6px;
332
- z-index: 40;
333
- }
334
329
 
335
330
  /* --------------------------------------------- viewport overlay rails */
336
331
  /* Overlays fill the open stage between the columns: the mode switch rides
@@ -0,0 +1,51 @@
1
+ // The Shot Camera card's focal-length scrub (BottomDock ShotCard). The lens is
2
+ // stored as a vertical FOV (shotsDomain.changeLens takes degrees); the card
3
+ // speaks millimetres on the current filmback, the shot.js lens relation.
4
+ import { PRIME_SET } from "../shot.js";
5
+
6
+ // Editor lens range: shotsDomain.changeLens refuses FOVs outside 14-90°.
7
+ const FOV_MIN = 14, FOV_MAX = 90;
8
+ // px of horizontal drag per millimetre, and the Shift / Alt multipliers.
9
+ const PX_PER_MM = 4, FINE = 0.1, COARSE = 4;
10
+
11
+ const round1 = (value) => Math.round(value * 10) / 10;
12
+
13
+ export function fovDegToMm(fovDeg, sensorHeightMm) {
14
+ return sensorHeightMm / (2 * Math.tan((fovDeg * Math.PI) / 360));
15
+ }
16
+
17
+ export function mmToFovDeg(mm, sensorHeightMm) {
18
+ return (2 * Math.atan(sensorHeightMm / (2 * mm)) * 180) / Math.PI;
19
+ }
20
+
21
+ /** The millimetre range the editor FOV range allows on this filmback. */
22
+ export function lensRange(sensorHeightMm) {
23
+ return { min: round1(fovDegToMm(FOV_MAX, sensorHeightMm)) + 0.1, max: round1(fovDegToMm(FOV_MIN, sensorHeightMm)) - 0.1 };
24
+ }
25
+
26
+ export function clampMm(mm, range) {
27
+ return round1(Math.min(range.max, Math.max(range.min, mm)));
28
+ }
29
+
30
+ /** The lens a horizontal drag of dx px from startMm lands on. */
31
+ export function dragMm(startMm, dx, { fine = false, coarse = false } = {}, range) {
32
+ const scale = fine ? FINE : coarse ? COARSE : 1;
33
+ return clampMm(startMm + (dx / PX_PER_MM) * scale, range);
34
+ }
35
+
36
+ /** A click steps to the next real prime in range, wrapping to the widest. */
37
+ export function nextPrime(mm, range) {
38
+ const primes = PRIME_SET.filter((prime) => prime >= range.min && prime <= range.max);
39
+ return primes.find((prime) => prime > mm + 0.05) ?? primes[0];
40
+ }
41
+
42
+ /** The left arrow steps back to the previous real prime, wrapping to the longest. */
43
+ export function previousPrime(mm, range) {
44
+ const primes = PRIME_SET.filter((prime) => prime >= range.min && prime <= range.max);
45
+ return [...primes].reverse().find((prime) => prime < mm - 0.05) ?? primes.at(-1);
46
+ }
47
+
48
+ export function formatMm(mm) {
49
+ const value = round1(mm);
50
+ return `${Number.isInteger(value) ? value : value.toFixed(1)} mm`;
51
+ }
@@ -0,0 +1,73 @@
1
+ /* The Shot Camera card's focal-length scrub (lens-scrub.jsx): a small field, the mm value over a track with a
2
+ thumb, so it reads as a control at rest; the step arrows appear just outside it on hover, taking no width. */
3
+ .dock-lens-scrub {
4
+ position: relative;
5
+ flex: none;
6
+ display: inline-flex;
7
+ align-items: center;
8
+ gap: 0;
9
+ padding: 1px;
10
+ border-radius: 6px;
11
+ background: var(--chip-fill, rgba(127, 127, 127, .1));
12
+ color: var(--text-1, currentColor);
13
+ font: 500 11px var(--sans);
14
+ font-variant-numeric: tabular-nums;
15
+ cursor: ew-resize;
16
+ user-select: none;
17
+ touch-action: none;
18
+ }
19
+ .dock-lens-body { display: flex; flex-direction: column; align-items: stretch; gap: 2px; padding: 2px 4px; }
20
+ .dock-lens-value { line-height: 1; white-space: nowrap; text-align: center; }
21
+ .dock-lens-track {
22
+ position: relative;
23
+ width: 38px;
24
+ height: 2px;
25
+ border-radius: 1px;
26
+ background: var(--hairline, rgba(127, 127, 127, .3));
27
+ }
28
+ .dock-lens-fill {
29
+ position: absolute;
30
+ inset: 0 auto 0 0;
31
+ width: var(--lens-at, 0%);
32
+ border-radius: inherit;
33
+ background: var(--text-3, currentColor);
34
+ }
35
+ .dock-lens-thumb {
36
+ position: absolute;
37
+ top: 50%;
38
+ left: var(--lens-at, 0%);
39
+ width: 5px;
40
+ height: 5px;
41
+ border-radius: 50%;
42
+ background: var(--text-2, currentColor);
43
+ box-shadow: 0 0 0 1px var(--surface-raised, #fff);
44
+ transform: translate(-50%, -50%);
45
+ }
46
+ .dock-lens-step {
47
+ position: absolute;
48
+ top: 50%;
49
+ display: grid;
50
+ place-items: center;
51
+ width: 12px;
52
+ height: 18px;
53
+ border-radius: 3px;
54
+ color: var(--text-3, currentColor);
55
+ cursor: pointer;
56
+ opacity: 0;
57
+ transform: translateY(-50%);
58
+ transition: opacity .12s;
59
+ }
60
+ .dock-lens-step[data-step="back"] { right: 100%; }
61
+ .dock-lens-step[data-step="next"] { left: 100%; }
62
+ .dock-lens-scrub:is(:hover, :focus-visible, .dragging) .dock-lens-step { opacity: 1; }
63
+ .dock-lens-step:hover { color: var(--select); background: var(--hairline, rgba(127, 127, 127, .2)); }
64
+ .dock-lens-scrub:hover,
65
+ .dock-lens-scrub.dragging { color: var(--select); }
66
+ .dock-lens-scrub:hover .dock-lens-fill,
67
+ .dock-lens-scrub.dragging .dock-lens-fill,
68
+ .dock-lens-scrub:hover .dock-lens-thumb,
69
+ .dock-lens-scrub.dragging .dock-lens-thumb { background: var(--select); }
70
+ .dock-lens-scrub.dragging .dock-lens-thumb { transform: translate(-50%, -50%) scale(1.3); }
71
+ .dock-lens-scrub:focus-visible { outline: 2px solid var(--select); outline-offset: 1px; }
72
+ .dock-lens-scrub[aria-disabled="true"] { cursor: default; opacity: .45; background: none; box-shadow: none; color: var(--label, var(--text-2)); }
73
+ .dock-lens-scrub[aria-disabled="true"] :is(.dock-lens-fill, .dock-lens-thumb, .dock-lens-step) { visibility: hidden; }
@@ -0,0 +1,98 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { FiChevronLeft, FiChevronRight } from "react-icons/fi";
3
+ import { ko } from "../locale.js";
4
+ import { PRESETS } from "../app-stage.jsx";
5
+ import { clampMm, dragMm, fovDegToMm, formatMm, lensRange, mmToFovDeg, nextPrime, previousPrime } from "./lens-scrub-math.js";
6
+ import "./lens-scrub.css";
7
+
8
+ // A drag starts after this many px; less is a click (the next prime).
9
+ const DRAG_SLOP = 3;
10
+ // A click waits this long so a double-click (reset) does not also step.
11
+ const CLICK_DELAY_MS = 220;
12
+
13
+ /** The Shot Camera card's focal length: drag left/right to change the lens.
14
+ * A drag is one undo step (shotsDomain's gesture commits on pointer-up, Esc
15
+ * cancels); a click steps to the next prime (the left arrow: the previous
16
+ * one); a double-click resets. It is drawn as a field with arrows and a thumb
17
+ * so it reads as something to drag, not as a label. */
18
+ export default function LensScrub({ fovDeg, sensorHeightMm, shotsDomain, disabled = false }) {
19
+ const off = disabled || !shotsDomain || !Number.isFinite(fovDeg) || !(sensorHeightMm > 0);
20
+ const range = off ? { min: 0, max: 0 } : lensRange(sensorHeightMm);
21
+ const mm = off ? 0 : clampMm(fovDegToMm(fovDeg, sensorHeightMm), range);
22
+ const drag = useRef(null);
23
+ const clickTimer = useRef(0);
24
+ const [dragging, setDragging] = useState(false);
25
+ useEffect(() => () => clearTimeout(clickTimer.current), []);
26
+
27
+ const setMm = (value) => shotsDomain.changeLens(mmToFovDeg(clampMm(value, range), sensorHeightMm));
28
+ const onPointerDown = (event) => {
29
+ if (off || event.button !== 0) return;
30
+ event.currentTarget.setPointerCapture?.(event.pointerId);
31
+ drag.current = { x: event.clientX, mm, moved: false, back: event.target.closest?.("[data-step]")?.dataset.step === "back" };
32
+ };
33
+ const onPointerMove = (event) => {
34
+ const state = drag.current;
35
+ if (!state) return;
36
+ const dx = event.clientX - state.x;
37
+ if (!state.moved) {
38
+ if (Math.abs(dx) < DRAG_SLOP) return;
39
+ state.moved = true;
40
+ setDragging(true);
41
+ shotsDomain.beginGesture();
42
+ }
43
+ setMm(dragMm(state.mm, dx, { fine: event.shiftKey, coarse: event.altKey }, range));
44
+ };
45
+ const onPointerUp = () => {
46
+ const state = drag.current;
47
+ drag.current = null;
48
+ setDragging(false);
49
+ if (!state || state.moved) return;
50
+ clearTimeout(clickTimer.current);
51
+ clickTimer.current = setTimeout(() => setMm((state.back ? previousPrime : nextPrime)(state.mm, range)), CLICK_DELAY_MS);
52
+ };
53
+ const onDoubleClick = () => {
54
+ if (off) return;
55
+ clearTimeout(clickTimer.current);
56
+ shotsDomain.changeLens(PRESETS.medium.fov);
57
+ };
58
+ const onKeyDown = (event) => {
59
+ if (off || (event.key !== "ArrowLeft" && event.key !== "ArrowRight")) return;
60
+ event.preventDefault();
61
+ event.stopPropagation();
62
+ const step = (event.shiftKey ? 10 : 1) * (event.key === "ArrowRight" ? 1 : -1);
63
+ setMm(Math.round(mm + step));
64
+ };
65
+
66
+ return (
67
+ <span
68
+ className={"dock-lens-scrub" + (dragging ? " dragging" : "")}
69
+ role="slider"
70
+ tabIndex={off ? -1 : 0}
71
+ aria-label={ko("Focal length", "초점 거리")}
72
+ aria-disabled={off || undefined}
73
+ aria-valuemin={range.min}
74
+ aria-valuemax={range.max}
75
+ aria-valuenow={mm}
76
+ aria-valuetext={off ? ko("No shot camera", "샷 카메라 없음") : formatMm(mm)}
77
+ aria-orientation="horizontal"
78
+ title={ko("Focal length — drag to change, click an arrow for the next lens", "초점 거리 — 드래그해 변경, 화살표를 눌러 다음 렌즈")}
79
+ data-testid="dock-lens-scrub"
80
+ onPointerDown={onPointerDown}
81
+ onPointerMove={onPointerMove}
82
+ onPointerUp={onPointerUp}
83
+ onPointerCancel={() => { drag.current = null; setDragging(false); }}
84
+ onDoubleClick={onDoubleClick}
85
+ onKeyDown={onKeyDown}
86
+ >
87
+ <span className="dock-lens-step" data-step="back" aria-hidden="true"><FiChevronLeft size={9} /></span>
88
+ <span className="dock-lens-body">
89
+ <span className="dock-lens-value">{off ? "— mm" : formatMm(mm)}</span>
90
+ <span className="dock-lens-track" aria-hidden="true" style={{ "--lens-at": `${off ? 0 : ((mm - range.min) / (range.max - range.min)) * 100}%` }}>
91
+ <span className="dock-lens-fill" />
92
+ <span className="dock-lens-thumb" />
93
+ </span>
94
+ </span>
95
+ <span className="dock-lens-step" data-step="next" aria-hidden="true"><FiChevronRight size={9} /></span>
96
+ </span>
97
+ );
98
+ }
@@ -1,12 +1,13 @@
1
1
  /* Pose mode (#521) is IK editing on the motion workspace: it hides what
2
- Motion hides (scene transform and camera framing chrome, the inset and the
3
- shot preview) and shows the mode hint. The timeline's pose-correction
4
- tools are hidden only in Scene and Camera, so they stay visible here. */
2
+ Motion hides (scene transform and camera framing chrome, the inset) and
3
+ shows the mode hint. The shot preview stays: it is drawn into the dock's
4
+ Shot Camera frame, and hiding it left that frame a hole onto the editor
5
+ view. The timeline's pose-correction tools are hidden only in Scene and
6
+ Camera, so they stay visible here. */
5
7
  .app[data-workflow-mode="pose"] .workflow-scene-context,
6
8
  .app[data-workflow-mode="pose"] .workflow-camera-context { display: none; }
7
9
  .app[data-workflow-mode="pose"] .workflow-toolbar-hint { display: inline-flex; }
8
- .app[data-workflow-mode="pose"] .vp-inset,
9
- .app[data-workflow-mode="pose"] .vp-shot-preview { display: none !important; }
10
+ .app[data-workflow-mode="pose"] .vp-inset { display: none !important; }
10
11
 
11
12
  /* A storyboard project (#650) is stills: the take, motion and pose-correction
12
13
  tools, the root-path (waypoint) switch and a prop's Top-View travel path
@@ -203,8 +203,7 @@
203
203
  .v2-preferences .v2-preferences__select:focus-visible,
204
204
  .v2-preferences .v2-preferences__toggle:focus-visible,
205
205
  .v2-preferences .v2-preferences__primary-button:focus-visible,
206
- .v2-preferences .v2-preferences__reset:focus-visible,
207
- .v2-preferences .v2-preferences__help button:focus-visible {
206
+ .v2-preferences .v2-preferences__reset:focus-visible {
208
207
  outline: none;
209
208
  box-shadow: var(--prefs-focus-ring, 0 0 0 1px var(--select, #e8a33d));
210
209
  }
@@ -506,36 +505,6 @@
506
505
  font: 500 var(--prefs-secondary-size, 11px) var(--mono, 'JetBrains Mono');
507
506
  }
508
507
 
509
- .v2-preferences .v2-preferences__help {
510
- padding-top: var(--sp-3, 16px);
511
- }
512
-
513
- .v2-preferences .v2-preferences__help h4 {
514
- margin: 0 0 var(--sp-1, 8px);
515
- color: var(--text-3, #636369);
516
- font-size: var(--prefs-secondary-size, 11px);
517
- font-weight: 500;
518
- letter-spacing: 0;
519
- text-transform: none;
520
- }
521
-
522
- .v2-preferences .v2-preferences__help button {
523
- min-height: var(--h-control, 28px);
524
- padding: 0 var(--sp-2, 12px);
525
- border: 0;
526
- border-radius: var(--r-1, 4px);
527
- background: var(--field-fill, var(--surface-raised, #1e1e21));
528
- color: var(--text-2, #9b9ba1);
529
- font: inherit;
530
- font-size: var(--prefs-secondary-size, 11px);
531
- cursor: pointer;
532
- }
533
-
534
- .v2-preferences .v2-preferences__help button:hover {
535
- background: var(--surface-raised, #1e1e21);
536
- color: var(--text-1, #e4e4e7);
537
- }
538
-
539
508
  .v2-preferences .v2-preferences__motion-setup {
540
509
  max-width: var(--prefs-control-width, 240px);
541
510
  display: grid;
@@ -24,6 +24,9 @@
24
24
  --shell-center-left: calc(var(--shell-left) + var(--shell-gap) * 2);
25
25
  --shell-center-right: calc(var(--shell-right) + var(--shell-gap) * 2);
26
26
  --shell-center-bottom: calc(var(--shell-dock) + var(--shell-status-h) + var(--shell-dock-bottom) + var(--shell-gap));
27
+ /* Top edge of the open stage: the cut-out the frame mask leaves under the
28
+ top bar (.workspace::after). */
29
+ --shell-stage-top: calc(var(--shell-gap) * 2 + 40px);
27
30
  /* The shot card's picture. The slot is the room the card leaves between
28
31
  its 48px head and the 84px of ratios + footer under it; the picture is
29
32
  the shot aspect fitted into that slot and centred in it, so the stage
@@ -242,7 +245,7 @@
242
245
  this frame through their glass, and on the darker --surface-0 they
243
246
  went a step darker than designed. */
244
247
  background: var(--shell-frame, var(--surface-panel));
245
- --shell-view-top: calc(var(--shell-gap) * 2 + 40px);
248
+ --shell-view-top: var(--shell-stage-top);
246
249
  --shell-cut-w: calc(100% - var(--shell-center-left) - var(--shell-center-right));
247
250
  --shell-view-h: max(0px, calc(100% - var(--shell-view-top) - var(--shell-center-bottom)));
248
251
  --shell-shot-w: var(--shot-pic-w);
@@ -268,6 +271,20 @@
268
271
  }
269
272
  .app[data-dock-tab="assets"]:not([data-embed-mode]) .workspace::after,
270
273
  .app[data-shot-look]:not([data-embed-mode]) .workspace::after { --shell-shot-w: 0px; --shell-shot-h: 0px; }
274
+ /* Look through: the panels and the frame mask cover the canvas outside the
275
+ open stage, and inside it the tool rails (44px, a gutter off each side,
276
+ glass.css) and the top-centre exit pill (styles.css) float over it. The
277
+ shot pane is the open stage less those bands, the same on both sides so
278
+ its centre is the stage centre. DualRender fits the shot aspect into
279
+ this pane, centred, with bars (fitAspect); the gizmo and the guides map
280
+ onto the same rect. Drawn under the panels or the rails, the frame lost
281
+ picture the export keeps. */
282
+ .app[data-shot-look]:not([data-embed-mode]) .vp-main,
283
+ .app[data-shot-look]:not([data-embed-mode]) .shot-guides.lookthrough {
284
+ --shot-look-x: calc(var(--shell-gap) * 2 + 44px);
285
+ --shot-look-y: 36px;
286
+ inset: calc(var(--shell-stage-top) + var(--shot-look-y)) calc(var(--shell-center-right) + var(--shot-look-x)) calc(var(--shell-center-bottom) + var(--shot-look-y)) calc(var(--shell-center-left) + var(--shot-look-x));
287
+ }
271
288
  .app[data-dock-tab="assets"]:not([data-embed-mode]) .vp-shot-preview,
272
289
  .app[data-dock-tab="assets"]:not([data-embed-mode]) .shell-camera-splitter { display: none; }
273
290
 
@@ -1,4 +1,5 @@
1
1
  import { createContext, useContext } from "react";
2
+ import { playbackFrame, usePlaybackSnapshot } from "../motion/playback-frame-store.js";
2
3
 
3
4
  // App owns the document/domain wiring. Region components consume this one
4
5
  // render snapshot and call its named actions; they never acquire domain state
@@ -20,3 +21,7 @@ export const StudioShellContext = createContext(null);
20
21
  export function useStudioShell() {
21
22
  return useContext(StudioShellContext);
22
23
  }
24
+
25
+ export function useStudioPlaybackFrame() {
26
+ return usePlaybackSnapshot(playbackFrame);
27
+ }
@@ -0,0 +1,26 @@
1
+ // Shot framing: one rule for every surface that shows the shot camera.
2
+ //
3
+ // The frustum is the shot camera's pose, its vertical FOV (camera.fov, the
4
+ // value a shot stores as fovDeg) and the shot aspect. The aspect only changes
5
+ // the horizontal extent, about the optical axis: a wider ratio extends the
6
+ // sides, a narrower one crops them, and the picture centre never moves. The
7
+ // exports render exactly that (CaptureRig: aspect = output width / height,
8
+ // fov untouched). The focal length shown for a ratio is that FOV read on the
9
+ // ratio's gate (shot.js usedSensorHeightMm), so it is a readout, not the
10
+ // invariant.
11
+ //
12
+ // On screen the frame is the largest centred rect of the shot aspect inside
13
+ // the pane, with bars around it. The dock preview pane is the card's picture
14
+ // slot; the Look through pane is the open stage between the panels (shell.css
15
+ // .vp-main under [data-shot-look]), never the whole canvas under them.
16
+
17
+ /** largest centred sub-rect of `rect` with the given aspect */
18
+ export function fitAspect(rect, aspect) {
19
+ let w = rect.w;
20
+ let h = w / aspect;
21
+ if (h > rect.h) {
22
+ h = rect.h;
23
+ w = h * aspect;
24
+ }
25
+ return { x: rect.x + (rect.w - w) / 2, y: rect.y + (rect.h - h) / 2, w, h };
26
+ }
@@ -5,13 +5,14 @@ import { aimAt } from "./controls.jsx";
5
5
  import { SUBJECT_HEIGHT_M } from "./shot.js";
6
6
  import { shotAtFrame } from "./cuts.js";
7
7
  import { sampleAt } from "./sample-at.js";
8
+ import { playbackProbe, probeVisibility } from "./motion/playback-probe.js";
8
9
  import { RENDER_ACTIVITY_EVENT } from "./use-render-activity.js";
9
10
  import { PRESETS, editorFrameForSubject } from "./stage-startup-defaults.js";
10
11
 
11
12
  /** Selection marker for the picked cast member: a Unity-style XYZ tripod
12
13
  * plus a ground ring at the feet. X/Z arrows also drag the character on the
13
14
  * floor (Y is display-only — characters stand on the deck). */
14
- export function ShotRig({ preset, nonce, fovDeg, charA, charB, showB, probeX, probeZ, camRef, look, onMetrics, appliedPresetRef, lastPosRef }) {
15
+ export function ShotRig({ preset, nonce, fovDeg, charA, charB, showB, probe: playbackProbeInput = {}, camRef, look, onMetrics, appliedPresetRef, lastPosRef }) {
15
16
  useEffect(() => {
16
17
  const cam = camRef.current;
17
18
  if (!cam) return;
@@ -65,13 +66,11 @@ export function ShotRig({ preset, nonce, fovDeg, charA, charB, showB, probeX, pr
65
66
  useFrame(() => {
66
67
  const cam = camRef.current;
67
68
  if (!cam) return;
68
- let visible = false;
69
- for (const y of [0.1, SUBJECT_HEIGHT_M * 0.94]) {
70
- // During motion playback the probe follows the played root, so the
71
- // "subject out of frame" caption stays honest.
72
- probe.current.set(probeX ?? charA.x, y, probeZ ?? charA.z).project(cam);
73
- if (probe.current.z < 1 && Math.abs(probe.current.x) <= 1 && Math.abs(probe.current.y) <= 1) visible = true;
74
- }
69
+ // During motion playback the probe follows the played root, so the
70
+ // "subject out of frame" caption stays honest.
71
+ const frame = playbackProbeInput.frameRef?.current ?? 0;
72
+ const current = playbackProbe(playbackProbeInput, frame, charA);
73
+ const visible = probeVisibility(probe.current, cam, current, SUBJECT_HEIGHT_M);
75
74
  const previous = lastMetrics.current;
76
75
  if (
77
76
  Math.abs(previous.x - cam.position.x) +
@@ -8,12 +8,38 @@ import { isProxyFigure } from "./scenes.js";
8
8
  import { applyPartColours } from "./part-colours.js";
9
9
  import { applyHipsOffset, applyPose, primeBindPose } from "./poses.js";
10
10
  import { REST_BONES } from "./stage-startup-defaults.js";
11
+ import { playbackFrame, usePlaybackSnapshot } from "./motion/playback-frame-store.js";
12
+ import { placementAt } from "./root-path.js";
13
+ import { shotAtFrame } from "./cuts.js";
14
+ import { sampleAt } from "./sample-at.js";
11
15
 
12
16
  // Memoized: unchanged cast members skip re-rendering on every playhead tick.
13
17
  export const Character = memo(function Character(props) {
14
18
  return isProxyFigure(props) ? <ProxyFigure {...props} /> : <RiggedCharacter {...props} />;
15
19
  });
16
20
 
21
+ export const PlaybackCharacter = memo(function PlaybackCharacter({
22
+ entry,
23
+ clip,
24
+ shots,
25
+ ...props
26
+ }) {
27
+ const frame = usePlaybackSnapshot(playbackFrame);
28
+ const placement = placementAt(entry, frame, {
29
+ shotAt: (at) => shotAtFrame(shots, at),
30
+ takeRoot: clip ? sampleAt({ frameCount: clip.frames, motion: clip }, null, frame).subject : null,
31
+ });
32
+ return (
33
+ <Character
34
+ {...props}
35
+ position={[placement.x, entry.y ?? 0, placement.z]}
36
+ rot={placement.rot}
37
+ pose={clip ? null : (placement.pose ?? props.pose)}
38
+ posture={placement.posture}
39
+ />
40
+ );
41
+ });
42
+
17
43
  const RiggedCharacter = memo(function RiggedCharacter({ url, position, rot, tint, pose, scale = 1, onRig, pickId, partColoursEnabled = false, partColoursMode = "shaded" }) {
18
44
  const fbx = useFBX(url);
19
45
  const model = useMemo(() => {