cozyclay 3.0.3 → 3.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 (204) hide show
  1. package/CHANGELOG.md +67 -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 +22 -56
  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/session-store.mjs +2 -2
  10. package/bin/agent/studio-prompt.mjs +1 -1
  11. package/bin/agent/studio-task-routes.mjs +185 -0
  12. package/bin/agent/studio-tasks.mjs +156 -0
  13. package/bin/agent/studio-tools.mjs +3 -2
  14. package/bin/agent/studio-turn-transaction.mjs +76 -0
  15. package/bin/cozyclay.mjs +5 -7
  16. package/dist/app/index.html +7 -6
  17. package/dist/assets/TaskBoard-DdDXMgZm.js +1 -0
  18. package/dist/assets/app-B2BpfCl2.js +5088 -0
  19. package/dist/assets/app-BlwOpest.css +1 -0
  20. package/dist/assets/compile-worker-D0pKioa0.js +5 -0
  21. package/dist/assets/landing-y3EqDgSZ.js +1 -0
  22. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
  23. package/dist/assets/motion3-compile-B887n77J.js +1 -0
  24. package/dist/assets/scene-asset-sync-COGOlF5T.css +1 -0
  25. package/dist/assets/scene-asset-sync-CQzEdsqu.js +13 -0
  26. package/dist/assets/task-board-BKaM7d-9.js +14 -0
  27. package/dist/assets/workflow-B8Dhzexj.js +179 -0
  28. package/dist/assets/x-bot-tpose-body-BusyR3eg.json +1 -0
  29. package/dist/assets/y-bot-tpose-body-Dy1VKChZ.json +1 -0
  30. package/dist/cozyclay-package.json +1 -1
  31. package/dist/greybox-to-video/index.html +2 -3
  32. package/dist/index.html +7 -57
  33. package/dist/llms.txt +2 -1
  34. package/dist/previs-software/index.html +2 -2
  35. package/dist/privacy/index.html +3 -7
  36. package/dist/seedance-camera-control/index.html +2 -2
  37. package/dist/sitemap.xml +5 -5
  38. package/dist/sw.js +5 -1
  39. package/dist/thumbs/blank-stage-dark.webp +0 -0
  40. package/dist/thumbs/blank-stage-light.webp +0 -0
  41. package/dist/workflow/index.html +5 -4
  42. package/mcp/mesh-file.mjs +3 -1
  43. package/mcp/server.mjs +3 -0
  44. package/package.json +3 -3
  45. package/src/App.jsx +50 -281
  46. package/src/analytics.js +3 -148
  47. package/src/app-stage.jsx +1 -1
  48. package/src/asset-pane.jsx +5 -10
  49. package/src/auto-flat.js +10 -0
  50. package/src/bus-tasks.js +11 -5
  51. package/src/command-bus.js +1 -1
  52. package/src/commands/motion3-compile.js +51 -27
  53. package/src/commands/motion3.js +3 -3
  54. package/src/commands/project.js +1 -1
  55. package/src/commands/shot.js +16 -4
  56. package/src/controls.jsx +0 -11
  57. package/src/cuts.js +11 -0
  58. package/src/domains/cast.js +3 -2
  59. package/src/domains/motion.js +3 -10
  60. package/src/domains/playback.js +32 -0
  61. package/src/domains/scenes.js +3 -22
  62. package/src/domains/shots.js +5 -5
  63. package/src/dualview.jsx +11 -15
  64. package/src/hierarchy-panel.css +18 -4
  65. package/src/hierarchy-panel.jsx +12 -11
  66. package/src/kits/held.js +3 -2
  67. package/src/light-blockers.js +2 -2
  68. package/src/main.jsx +5 -0
  69. package/src/motion/ground.js +5 -14
  70. package/src/motion/playback-frame-store.js +79 -0
  71. package/src/motion/playback-probe.js +16 -0
  72. package/src/motion/playback-subject.js +7 -0
  73. package/src/motion/timeline.css +43 -5
  74. package/src/motion/timeline.jsx +4 -3
  75. package/src/motion3/body.js +37 -5
  76. package/src/motion3/carried.js +11 -3
  77. package/src/motion3/compile-job.js +1 -1
  78. package/src/motion3/compiler/compile.js +7 -1
  79. package/src/motion3/compiler/finger-close.js +137 -0
  80. package/src/motion3/compiler/limbs.js +18 -3
  81. package/src/motion3/compiler/pose.js +6 -0
  82. package/src/motion3/compiler/validate-objects.js +17 -2
  83. package/src/motion3/compiler/validate-parts.js +2 -1
  84. package/src/motion3/compiler/vocab.js +4 -4
  85. package/src/motion3/describe.js +2 -2
  86. package/src/motion3/fix/index.js +5 -3
  87. package/src/motion3/flight.js +38 -0
  88. package/src/motion3/free-motion.js +10 -7
  89. package/src/motion3/load.js +125 -0
  90. package/src/motion3/mass.js +23 -0
  91. package/src/motion3/object-keys.js +11 -6
  92. package/src/motion3/object-pose.js +11 -3
  93. package/src/motion3/pipeline.js +3 -0
  94. package/src/motion3/qa/explain.js +3 -1
  95. package/src/motion3/qa/grasp-checks.js +123 -0
  96. package/src/motion3/qa/hints-grasp.js +71 -0
  97. package/src/motion3/qa/index.js +3 -1
  98. package/src/motion3/qa/object-checks.js +7 -2
  99. package/src/motion3/qa/patch.js +2 -1
  100. package/src/motion3/qa/spec-checks.js +10 -1
  101. package/src/motion3/schema/fields.js +3 -3
  102. package/src/motion3/schema/topics.js +9 -8
  103. package/src/motion3/schema/types.js +4 -1
  104. package/src/motion3/seat.js +84 -0
  105. package/src/motion3/settle.js +11 -6
  106. package/src/motion3/shapes.js +70 -0
  107. package/src/motion3/skills/catch.js +71 -0
  108. package/src/motion3/skills/index.js +10 -3
  109. package/src/motion3/skills/info.js +13 -0
  110. package/src/motion3/skills/throw.js +72 -0
  111. package/src/object-catalog.jsx +0 -4
  112. package/src/object-gizmo.jsx +2 -1
  113. package/src/object-path.js +1 -1
  114. package/src/panels/ProjectPanel.jsx +42 -21
  115. package/src/panels/details.css +15 -4
  116. package/src/popover.js +37 -0
  117. package/src/posestudio.jsx +15 -10
  118. package/src/project-browser-art.jsx +19 -0
  119. package/src/project-browser.css +74 -121
  120. package/src/project-browser.jsx +36 -60
  121. package/src/props.jsx +4 -7
  122. package/src/retired-kinds.js +37 -0
  123. package/src/scene-objects.js +3 -7
  124. package/src/scenes.js +4 -5
  125. package/src/settings-menu.jsx +1 -20
  126. package/src/shell/BottomDock.jsx +9 -9
  127. package/src/shell/MenuBar.jsx +1 -9
  128. package/src/shell/PreferencesDialog.jsx +3 -4
  129. package/src/shell/ViewportToolbar.jsx +3 -3
  130. package/src/shell/add-camera-shot.js +11 -0
  131. package/src/shell/glass-regions.css +3 -2
  132. package/src/shell/glass.css +0 -5
  133. package/src/shell/lens-scrub-math.js +51 -0
  134. package/src/shell/lens-scrub.css +73 -0
  135. package/src/shell/lens-scrub.jsx +98 -0
  136. package/src/shell/mode.css +6 -5
  137. package/src/shell/preferences.css +1 -32
  138. package/src/shell/shell.css +18 -1
  139. package/src/shell/studio-shell-context.js +5 -0
  140. package/src/shot-frame.js +26 -0
  141. package/src/stage-canvas-rigs.jsx +7 -8
  142. package/src/stage-characters.jsx +26 -0
  143. package/src/stage-gizmo.js +43 -0
  144. package/src/stage-labels-ko.js +0 -6
  145. package/src/stage-startup-defaults.js +3 -9
  146. package/src/starter-scenes.js +5 -11
  147. package/src/studio-agent-commands.js +2 -2
  148. package/src/studio-agent-protocol.js +2 -7
  149. package/src/studio-app-binding.js +1 -1
  150. package/src/studio-elements.js +1 -1
  151. package/src/styles/themes.css +13 -0
  152. package/src/styles.css +6 -674
  153. package/src/text/text-object.jsx +1 -1
  154. package/src/ui.jsx +8 -16
  155. package/src/use-embed-bridge.js +3 -6
  156. package/src/use-render-activity.js +3 -1
  157. package/src/view-ghosts.jsx +5 -3
  158. package/src/viewport-visible-rect.js +37 -0
  159. package/src/workflow/AgentPanel.jsx +75 -42
  160. package/src/workflow/AgentProgressCard.jsx +1 -1
  161. package/src/workflow/TaskBoard.jsx +102 -0
  162. package/src/workflow/TaskInline.jsx +114 -0
  163. package/src/workflow/agent-client.js +11 -25
  164. package/src/workflow/agent-panel.css +14 -1
  165. package/src/workflow/attachment-image.js +10 -3
  166. package/src/workflow/attachment-video.js +91 -0
  167. package/src/workflow/task-board.css +98 -0
  168. package/src/workflow/task-board.js +169 -0
  169. package/tools/check-eslint.mjs +2 -1
  170. package/tools/check-knip.mjs +2 -1
  171. package/tools/dev/pages/greybox-to-video.html +2 -3
  172. package/tools/dev/pages/previs-software.html +2 -2
  173. package/tools/dev/pages/privacy.html +3 -7
  174. package/tools/dev/pages/seedance-camera-control.html +2 -2
  175. package/tools/dev-full.mjs +11 -2
  176. package/tools/eslint-baseline.json +6 -6
  177. package/tools/knip-baseline.json +0 -11
  178. package/tools/loc-baseline.json +30 -31
  179. package/tools/metrics-snapshot.mjs +43 -5
  180. package/tools/render-start-thumbs.mjs +175 -0
  181. package/tools/run-tests.mjs +7 -14
  182. package/tools/sitemap.mjs +1 -1
  183. package/dist/assets/analytics-BibyE5ar.js +0 -6
  184. package/dist/assets/app-DhRJ-PBz.js +0 -5088
  185. package/dist/assets/app-f-fkV5uP.css +0 -1
  186. package/dist/assets/compile-worker-DqiA4AaN.js +0 -5
  187. package/dist/assets/first-shot-handoff-Qx7SKl38.js +0 -1
  188. package/dist/assets/landing-DYg87MDY.js +0 -1
  189. package/dist/assets/scene-asset-sync-DBYQBR0T.js +0 -20
  190. package/dist/assets/scene-asset-sync-_S8NQl5B.css +0 -1
  191. package/dist/assets/workflow-BSA22Ofr.js +0 -179
  192. package/dist/scenes/city-block.cclayproject +0 -1
  193. package/src/burn-in.js +0 -91
  194. package/src/camera-tutorial.jsx +0 -520
  195. package/src/facing-marks.js +0 -88
  196. package/src/first-shot-handoff.js +0 -44
  197. package/src/panels/motion.css +0 -197
  198. package/src/set-pieces.jsx +0 -183
  199. package/src/tutorial-analytics.js +0 -86
  200. package/src/tutorial-walk.js +0 -17
  201. package/src/use-camera-tutorial.js +0 -50
  202. package/src/use-semantic-state.js +0 -12
  203. package/tools/metrics/archive-traffic.mjs +0 -138
  204. package/tools/perf/ik-camera-drag.mjs +0 -138
@@ -29,8 +29,8 @@ import {
29
29
  } from "../motion/ik.js";
30
30
  import * as THREE from "three";
31
31
  import { StudioProtocolError } from "../studio-agent-protocol.js";
32
-
33
32
  import { applyTakeFrame } from "../motion3/playback.js";
33
+ import { advancePlayback, installPlaybackApplier } from "./playback.js";
34
34
  import { isKo, ko } from "../locale.js";
35
35
  import { isProxyFigure } from "../scenes.js";
36
36
  import { refuseRigOnly } from "../character-kind.js";
@@ -625,15 +625,7 @@ export function useMotion(appContext) {
625
625
 
626
626
 
627
627
  function advanceFrame(steps = 1) {
628
- const count = Math.max(1, Math.floor(steps));
629
- const previewEnd = appContext.shared.cameraPreviewEndRef.current;
630
- if (previewEnd != null && appContext.shared.tlFrameRef.current + count >= previewEnd) {
631
- appContext.shared.cameraPreviewEndRef.current = null;
632
- appContext.shared.setTlFrame(previewEnd);
633
- appContext.shared.setTlPlaying(false);
634
- return;
635
- }
636
- appContext.shared.setTlFrame((f) => (f + count) % appContext.shared.frameCountRef.current);
628
+ advancePlayback(appContext.shared, steps);
637
629
  }
638
630
 
639
631
  function stepFrame(delta) {
@@ -677,6 +669,7 @@ export function useMotion(appContext) {
677
669
  appContext.shared.poseMemberAtFrame(appContext.shared.rigs[entry.id], entry.sessionMotion, appContext.shared.ikStatesRef.current.get(entry.id), frame, IK_CORRECTION_BLEND_FRAMES);
678
670
  }
679
671
  };
672
+ installPlaybackApplier(appContext, motion, poseOtherCastMembers, ikEvaluate, IK_CORRECTION_BLEND_FRAMES);
680
673
 
681
674
  function toggleIkMode() {
682
675
  const next = !ikMode;
@@ -0,0 +1,32 @@
1
+ import { advancePlaybackFrame, registerPlaybackFrameApplier } from "../motion/playback-frame-store.js";
2
+
3
+ function createPlaybackApplier(appContext, motion, poseOtherCastMembers, ikEvaluate, blendFrames) {
4
+ return (frame) => {
5
+ appContext.shared.tlFrameRef.current = frame;
6
+ appContext.shared.propFrameRef.current = frame;
7
+ appContext.patchTimeline({ currentFrame: frame });
8
+ if (appContext.shared.activeRig) appContext.shared.poseMemberAtFrame(appContext.shared.activeRig, motion, null, frame);
9
+ poseOtherCastMembers(frame);
10
+ const state = appContext.shared.ikStateRef.current;
11
+ if (!appContext.shared.posing && state?.chains && (appContext.shared.ikMode || state.keys.size > 0)) {
12
+ ikEvaluate(state.chains, state, frame, state.fkJoints, motion ? blendFrames : 0);
13
+ }
14
+ };
15
+ }
16
+
17
+ export function installPlaybackApplier(...args) {
18
+ return registerPlaybackFrameApplier(createPlaybackApplier(...args));
19
+ }
20
+
21
+ // A camera-shot preview stops at its shot's end; otherwise the playhead wraps.
22
+ export function advancePlayback(shared, steps) {
23
+ const count = Math.max(1, Math.floor(steps));
24
+ const previewEnd = shared.cameraPreviewEndRef.current;
25
+ if (previewEnd != null && shared.tlFrameRef.current + count >= previewEnd) {
26
+ shared.cameraPreviewEndRef.current = null;
27
+ shared.setTlFrame(previewEnd);
28
+ shared.setTlPlaying(false);
29
+ return;
30
+ }
31
+ advancePlaybackFrame(shared.frameCountRef.current, count);
32
+ }
@@ -208,9 +208,8 @@ export function useScenes(appContext) {
208
208
  // A first-run author should choose a document (or explicitly start a named
209
209
  // local draft). Keep this as a light startup sheet so the studio remains
210
210
  // inspectable while the choice is pending; it never traps the topbar.
211
- // ?tutorial=camera opens the starter scene itself (#209), so the chooser is
212
- // suppressed the same way a ?scene= launch suppresses it.
213
- const [projectStartupOpen, setProjectStartupOpen] = useState(() => !appContext.shared.playgroundMode && !appContext.shared.cameraTutorialQuery && !playgroundSceneUrl(globalThis.location?.search) && !loadProjectSession()?.name);
211
+ // A ?scene= launch opens its starter instead of the chooser.
212
+ const [projectStartupOpen, setProjectStartupOpen] = useState(() => !appContext.shared.playgroundMode && !playgroundSceneUrl(globalThis.location?.search) && !loadProjectSession()?.name);
214
213
 
215
214
  const [projectManifest, setProjectManifest] = useState({ items: [], totals: { embedded: 0, external: 0, missing: 0, bytes: 0 }, missing: [] });
216
215
 
@@ -400,10 +399,6 @@ export function useScenes(appContext) {
400
399
  if (!["valid", "migrated"].includes(checked.status)) throw new StudioProtocolError("INVALID_ARGUMENT", "Invalid project scene document.");
401
400
  project = { ...project, scenesDocument: checked.document };
402
401
  appContext.shared.studioDocumentEpochRef.current = crypto.randomUUID();
403
- appContext.shared.tutorialProjectEpochRef.current += 1;
404
- appContext.shared.tutorialSeedEpochRef.current = null;
405
- appContext.shared.setTutorialSeedPending(false);
406
- appContext.shared.setCameraTutorialHandoff(null);
407
402
  appContext.shared.exportShotIdRef.current = null;
408
403
  const source = project.scenesDocument;
409
404
  // A project FILE carries its own scene document and never passes the
@@ -425,25 +420,16 @@ export function useScenes(appContext) {
425
420
  domain.setDirty(false);
426
421
  storeProjectSession(project.name, projectPrevisMode);
427
422
  setProjectStartupOpen(false);
428
- // Whatever document this is, it is no longer the scene the tutorial opened
429
- // for itself; startCameraTutorial re-arms the flag after its own open.
430
- appContext.shared.tutorialStarterRef.current = false;
431
423
  setProjectManifest(resourceManifest({ scenesDocument: doc, workflow: resolvedWorkflow, poseLibrary: mergedCustomPoses, assets: project.assets ?? [], workflowOutputRefs }));
432
424
  track("project:opened", { age_bucket: bucketProjectAge(Date.now() - (project.savedAt ?? Date.now())) });
433
425
  }
434
426
 
435
427
  /** Open a bundled starter scene as a fresh, saveable project. Used by the
436
- * first-run dialog and by `npx cozyclay --scene <id>` (`?scene=`), which is
437
- * how the landing-page tutorial hands people into the local studio. */
428
+ * first-run dialog and by `npx cozyclay --scene <id>` (`?scene=`). */
438
429
  async function openStarterScene(id, source = "starter", context = null, name = null) {
439
430
  if (source === "starter" && domain.dirtyStore.read("projectDirty") && !window.confirm(ko("Discard unsaved changes and start a new project?", "저장되지 않은 변경사항을 버리고 새 프로젝트를 시작할까요?"))) return false;
440
- const before = source === "tutorial" ? collectProjectSnapshot("Tutorial") : null;
441
- const epoch = appContext.shared.tutorialProjectEpochRef.current;
442
431
  const url = playgroundSceneUrl(`?scene=${encodeURIComponent(id)}`);
443
432
  const project = url ? await fetchSceneProject(url) : null;
444
- // A pending tutorial fetch has no authority over work authored/opened
445
- // while it was loading, including an unnamed project.
446
- if (source === "tutorial" && (epoch !== appContext.shared.tutorialProjectEpochRef.current || before !== collectProjectSnapshot("Tutorial"))) return false;
447
433
  if (!project) {
448
434
  appContext.notify(ko("That starter scene is not in this build", "이 빌드에는 그 시작 장면이 없어요"));
449
435
  return false;
@@ -596,11 +582,6 @@ export function useScenes(appContext) {
596
582
  }
597
583
 
598
584
  function openScene(scene, nextScenes) {
599
- appContext.shared.tutorialProjectEpochRef.current += 1;
600
- appContext.shared.tutorialSeedEpochRef.current = null;
601
- appContext.shared.setTutorialSeedPending(false);
602
- appContext.shared.setCameraTutorial(false);
603
- appContext.shared.setCameraTutorialHandoff(null);
604
585
  appContext.shared.exportShotIdRef.current = null;
605
586
  appContext.shared.studioSceneEpochRef.current = crypto.randomUUID();
606
587
  const shotState = restoredShotState(scene);
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useRef, useState } from 'react';
2
2
  import { aimAt } from '../controls.jsx';
3
3
  import { PRESETS, DEFAULT_DURATION_S, TIMELINE_FPS } from '../app-stage.jsx';
4
+ import { usePlaybackFrameState } from '../motion/playback-frame-store.js';
4
5
  import { CAMERA_MOVES, SUBJECT_HEIGHT_M, focalMmToFov, fovToFocalMm } from '../shot.js';
5
6
  import { CAMERA_PRESETS, cameraPresetFraming } from '../camera-move.js';
6
7
  import { createDocumentStore } from '../document-store.js';
@@ -236,9 +237,8 @@ export function useShots(appContext) {
236
237
  const [moveFollow] = useState(true);
237
238
  const [railDraw, setRailDraw] = useState(false);
238
239
  const [craneSelectedIndex, setCraneSelectedIndex] = useState(null);
239
- const [tlFrame, setTlFrame] = useState(0);
240
- const [tlFps, setTlFps] = useState(TIMELINE_FPS);
241
- const activeShotIdx = shotIndexAtFrame(shots, tlFrame), activeShot = shots[activeShotIdx] ?? null;
240
+ const [tlFrame, setTlFrameState] = useState(0), [tlFps, setTlFps] = useState(TIMELINE_FPS);
241
+ const { setTlFrame, commitPlaybackFrame, activeShotIdx, activeShot } = usePlaybackFrameState({ frameCount: tlFrameCount, setReactFrame: setTlFrameState, shots });
242
242
  const cameraKeys = activeShot?.cameraKeys ?? [], activeCamera = createCameraBlock(activeShot?.camera);
243
243
  const craneActive = !!activeCamera.craneHeight, cameraRail = activeCamera.cameraRail;
244
244
  const activeShotDuration = activeShot ? activeShot.endFrame - activeShot.startFrame + 1 : 0;
@@ -364,7 +364,7 @@ export function useShots(appContext) {
364
364
  const travel = Math.ceil(curve.length / speed * tlFps) + Math.round(tlFps * 0.5);
365
365
  const endFrame = Math.min(tlFrameCount - 1, activeShot.startFrame + Math.max(travel, activeShot.endFrame - activeShot.startFrame));
366
366
  setTimelineShotRange(activeShot.id, activeShot.startFrame, endFrame);
367
- // The landing tutorial's last step is pressing ▶ (#602): open the player
367
+ // In the landing playground a drawn rail opens the player
368
368
  // on the shot camera, but leave playback to the visitor.
369
369
  appContext.shared.enterPreview(); appContext.shared.setTlPlaying(false); setTlFrame(activeShot.startFrame);
370
370
  appContext.notify(isKo ? '레일 완성 — 샷 카메라 시점으로 전환했습니다. ▶ 로 재생, Esc 로 복귀' : 'Rail drawn — you are looking through the shot camera. Press ▶ to ride it; Esc goes back to flying.'); return;
@@ -418,7 +418,7 @@ export function useShots(appContext) {
418
418
  }
419
419
  return { ...domain, shots, setShots, editShots, fovDeg, setFovDeg, cameraMove, customMove, startupShotState,
420
420
  movePlaying, setMovePlaying, moveFollow, railDraw, setRailDraw, craneSelectedIndex, setCraneSelectedIndex,
421
- tlFrame, setTlFrame, tlFrameCount, setTlFrameCount, tlFps, setTlFps, activeShotIdx, activeShot, cameraKeys,
421
+ tlFrame, setTlFrame, commitPlaybackFrame, tlFrameCount, setTlFrameCount, tlFps, setTlFps, activeShotIdx, activeShot, cameraKeys,
422
422
  activeCamera, craneActive, cameraRail, activeShotDuration, hasCameraKeys, changeActiveCamera, changeShotTargetModel,
423
423
  syncActiveCameraFraming, commitManualCameraFraming, beginCameraFramingGesture, beginTimelineEditGesture,
424
424
  setShotCameraRail, clearShotCameraRail, changeCameraRail, addCameraKeyframe, moveCameraKeyframe, removeCameraKeyframe, clearMove,
package/src/dualview.jsx CHANGED
@@ -1,6 +1,8 @@
1
1
  import { useEffect, useMemo, useRef } from "react";
2
2
  import { useFrame, useThree } from "@react-three/fiber";
3
3
  import * as THREE from "three";
4
+ import { fitAspect } from "./shot-frame.js";
5
+ import { updateVisibleViewOffsets } from "./viewport-visible-rect.js";
4
6
 
5
7
  /**
6
8
  * Two live viewports out of one scene.
@@ -104,17 +106,6 @@ const EDGE_FRAGMENT = `
104
106
  }
105
107
  `;
106
108
 
107
- /** largest centred sub-rect of `rect` with the given aspect */
108
- export function fitAspect(rect, aspect) {
109
- let w = rect.w;
110
- let h = w / aspect;
111
- if (h > rect.h) {
112
- h = rect.h;
113
- w = h * aspect;
114
- }
115
- return { x: rect.x + (rect.w - w) / 2, y: rect.y + (rect.h - h) / 2, w, h };
116
- }
117
-
118
109
  export function DualRender({ stageRef, mainRef, insetRef, shotPreviewRef, shotCamRef, planCamRef, poserCamRef, editorCamRef, ikMode = false, planIsMain, playMode = false, lookThrough = false, insetCollapsed = false, planZoom = 1, shotAspect = SHOT_ASPECT, editorLook = false }) {
119
110
  const invalidate = useThree((state) => state.invalidate);
120
111
  const lastPoserPose = useRef({ position: new THREE.Vector3(), quaternion: new THREE.Quaternion(), valid: false });
@@ -245,6 +236,7 @@ export function DualRender({ stageRef, mainRef, insetRef, shotPreviewRef, shotCa
245
236
  return { x: r.left - base.left, y: r.top - base.top, w: r.width, h: r.height };
246
237
  };
247
238
  const mainRect = rectOf(mainRef.current);
239
+ updateVisibleViewOffsets({ editorCam, poserCam, canvas: gl.domElement.getBoundingClientRect(), aspect: mainRect.w / mainRect.h });
248
240
  // A collapsed inset is just its tag pill: skip the whole inset pass
249
241
  // instead of drawing a squished viewport into it.
250
242
  const insetRect = insetCollapsed ? { x: 0, y: 0, w: 0, h: 0 } : rectOf(insetRef.current);
@@ -451,8 +443,6 @@ export function DualRender({ stageRef, mainRef, insetRef, shotPreviewRef, shotCa
451
443
  // IK mode: the main pane is the poser working view (free navigation,
452
444
  // handle layer visible); the inset is the FROZEN shot camera — the
453
445
  // separate camera-placement screen the framing lives on.
454
- poserCam.aspect = mainRect.w / mainRect.h;
455
- poserCam.updateProjectionMatrix();
456
446
  drawEditorPane(poserCam, mainRect, null, !navigatingCamera);
457
447
  if (!navigatingCamera || redrawFrozenPanes) drawVisibleInset(shotCam, insetRect, fitAspect(insetRect, shotAspect), !navigatingCamera);
458
448
  drawPreviewCard();
@@ -465,8 +455,6 @@ export function DualRender({ stageRef, mainRef, insetRef, shotPreviewRef, shotCa
465
455
  // navigation that never touches the recording. The shot camera only
466
456
  // appears in its own preview pane, framed to the export aspect and
467
457
  // stripped of editing chrome, exactly like an exported frame.
468
- editorCam.aspect = mainRect.w / mainRect.h;
469
- editorCam.updateProjectionMatrix();
470
458
  // A right-drag is a camera gesture even with IK off. Keep the main
471
459
  // image readable while the camera is moving by deferring the expensive
472
460
  // edge/ink pass until the gesture ends; the plan inset stays frozen.
@@ -479,6 +467,14 @@ export function DualRender({ stageRef, mainRef, insetRef, shotPreviewRef, shotCa
479
467
  // can fly the recording lens with the same bindings as the free camera.
480
468
  // The QA hook (`window.__cozyclay.setLookThrough`) draws the same path.
481
469
  // Look-through is the recording lens: it keeps the authored stage.
470
+ // The pane is the open stage less the rails (shell.css), so the
471
+ // canvas around it is bars too: clear it, or the last full-canvas
472
+ // draw stays there and reads as picture the export does not have.
473
+ if (lookThrough) {
474
+ gl.setScissorTest(false);
475
+ gl.setClearColor(letterbox, 1);
476
+ gl.clear(true, true, false);
477
+ }
482
478
  draw(shotCam, shotPane, fitAspect(shotPane, shotAspect), !navigatingCamera);
483
479
  if (!navigatingCamera || redrawFrozenPanes) drawVisibleInset(planCam, planPane, null, false);
484
480
  drawPreviewCard();
@@ -326,13 +326,27 @@
326
326
  outline: var(--outliner-border-width, 1px) solid var(--select, #e8a33d);
327
327
  }
328
328
 
329
+ /* The context menu is portaled to <body> inside this layer, which keeps the
330
+ `.v2-outliner` style scope without a box of its own (hierarchy-panel.jsx).
331
+ It and the scene switcher's list share the popover tokens (themes.css). */
332
+ .v2-outliner.v2-outliner-layer {
333
+ display: contents;
334
+ }
335
+
336
+ .v2-outliner .hierarchy-context-menu,
337
+ .dropdown-menu.hierarchy-scene-menu {
338
+ z-index: var(--z-popover, 90);
339
+ border: var(--outliner-border-width, 1px) solid var(--popover-border, var(--row-divider, #1d1d20));
340
+ border-radius: var(--popover-radius, 6px);
341
+ background: var(--popover-surface, var(--surface-raised, #1e1e21));
342
+ -webkit-backdrop-filter: var(--popover-blur, none);
343
+ backdrop-filter: var(--popover-blur, none);
344
+ box-shadow: var(--popover-shadow, 0 8px 24px rgba(0, 0, 0, .45));
345
+ }
346
+
329
347
  .v2-outliner .hierarchy-context-menu {
330
348
  min-width: var(--outliner-menu-width, 220px);
331
349
  padding: var(--outliner-menu-padding, 4px);
332
- border: var(--outliner-border-width, 1px) solid var(--row-divider, #1d1d20);
333
- border-radius: var(--r-2, 6px);
334
- background: var(--surface-raised, #1e1e21);
335
- box-shadow: 0 var(--sp-1, 8px) var(--sp-3, 16px) var(--overlay, rgba(12,12,13,.78));
336
350
  }
337
351
 
338
352
  .v2-outliner .hierarchy-context-item {
@@ -1,9 +1,11 @@
1
1
  import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { createPortal } from "react-dom";
2
3
  import { ko, isKo } from "./locale.js";
3
4
  import { buildHierarchyNodes } from "./hierarchy-model.js";
4
5
  import { sceneObjectIdFromHierarchy } from "./scene-objects.js";
5
6
  import { CatalogueEntries, displayObjectLabel } from "./object-catalog.jsx";
6
7
  import { Dropdown } from "./ui.jsx";
8
+ import { usePopover } from "./popover.js";
7
9
  import { isImeComposing } from "./ime.js";
8
10
  import "./hierarchy-panel.css";
9
11
 
@@ -97,6 +99,7 @@ function ScenePill({ scenes, activeSceneId, onSceneSelect, onSceneCreate, onRena
97
99
  value={activeSceneId}
98
100
  options={options}
99
101
  ariaLabel={ko("Select scene", "장면 선택")}
102
+ menuClassName="hierarchy-scene-menu"
100
103
  onChange={(value) => {
101
104
  if (value === NEW_SCENE_OPTION) onSceneCreate?.();
102
105
  else if (value !== activeSceneId) onSceneSelect?.(value);
@@ -194,11 +197,9 @@ function RowContextMenu({ menu, onClose, onAction, onAddObject }) {
194
197
  const [deleteArmed, setDeleteArmed] = useState(false);
195
198
  useEffect(() => setDeleteArmed(false), [menu]);
196
199
 
200
+ usePopover(menu, onClose, [rootRef]);
197
201
  useEffect(() => {
198
202
  if (!menu) return undefined;
199
- const onDocDown = (event) => {
200
- if (rootRef.current && !rootRef.current.contains(event.target)) onClose();
201
- };
202
203
  const onKey = (event) => {
203
204
  if (event.key !== "Escape") return;
204
205
  // Capture phase + stopImmediatePropagation: closing the menu must
@@ -206,12 +207,8 @@ function RowContextMenu({ menu, onClose, onAction, onAddObject }) {
206
207
  event.stopImmediatePropagation();
207
208
  onClose();
208
209
  };
209
- document.addEventListener("mousedown", onDocDown);
210
210
  window.addEventListener("keydown", onKey, true);
211
- return () => {
212
- document.removeEventListener("mousedown", onDocDown);
213
- window.removeEventListener("keydown", onKey, true);
214
- };
211
+ return () => window.removeEventListener("keydown", onKey, true);
215
212
  }, [menu, onClose]);
216
213
 
217
214
  if (!menu) return null;
@@ -221,8 +218,11 @@ function RowContextMenu({ menu, onClose, onAction, onAddObject }) {
221
218
  left: Math.max(4, Math.min(menu.x, window.innerWidth - 244)),
222
219
  top: Math.max(4, Math.min(menu.y, window.innerHeight - menu.height)),
223
220
  };
224
- return (
225
- <div className="hierarchy-context-menu" role="menu" style={style} ref={rootRef}>
221
+ // Portaled: the glass column clips and blurs its children, so an in-place
222
+ // menu was cut at the column edge and sat under body-level popovers. The
223
+ // display:contents layer keeps the `.v2-outliner` style scope.
224
+ return createPortal(
225
+ <div className="v2-outliner v2-outliner-layer"><div className="hierarchy-context-menu" role="menu" style={style} ref={rootRef}>
226
226
  {menu.kind === "scene" ? (
227
227
  <>
228
228
  <button type="button" role="menuitem" className="hierarchy-context-item" onClick={() => onAction("rename", menu.id)}>
@@ -292,7 +292,8 @@ function RowContextMenu({ menu, onClose, onAction, onAddObject }) {
292
292
  ) : (
293
293
  <CatalogueEntries onPick={onAddObject} />
294
294
  )}
295
- </div>
295
+ </div></div>,
296
+ document.body,
296
297
  );
297
298
  }
298
299
 
package/src/kits/held.js CHANGED
@@ -38,9 +38,10 @@ export const bat = {
38
38
 
39
39
  export const ball = {
40
40
  label: "Baseball", prop: "ballGrip",
41
- description: "7.4 cm baseball held in the right fingers",
41
+ description: "7.4 cm baseball held in the right palm and fingers",
42
42
  props: { ballGrip: { hand: "r", origin: RIGHT_FINGERS, points: { centre: [0, 0, -0.035] } } },
43
- carry: { prop: "ballGrip", at: [0, 0, -0.035], along: [0, 1, 0] },
43
+ // no pinned point: it rests against the palm where the hand takes it and the fingers close round it (motion3 seat.js)
44
+ carry: { prop: "ballGrip", grasp: "spherical" },
44
45
  parts: [{ role: "body", kind: "sphere", at: [0, -0.037, 0], scale: [0.074, 0.074, 0.074], color: "#f1ede3" }],
45
46
  };
46
47
 
@@ -19,9 +19,9 @@
19
19
  import { practicalShadows, withShadowCapWarnings } from "./object-look.js";
20
20
  import { isEffectivelyHidden } from "./object-hidden.js";
21
21
 
22
- /** Renderers whose meshes cast shadows (props.jsx, set-pieces.jsx, sculpts, imported meshes).
22
+ /** Renderers whose meshes cast shadows (props.jsx primitives, sculpts, imported meshes).
23
23
  * A plane and a cutout are cards: nothing can be inside them. */
24
- const CASTERS = new Set(["cube", "sphere", "capsule", "cylinder", "cone", "car", "chair", "small-plane", "sculpt", "mesh"]);
24
+ const CASTERS = new Set(["cube", "sphere", "capsule", "cylinder", "cone", "sculpt", "mesh"]);
25
25
  const DEG = Math.PI / 180;
26
26
  const EPS = 1e-6;
27
27
  const MAX_WARNINGS = 4;
package/src/main.jsx CHANGED
@@ -12,6 +12,7 @@ import { registerPwa } from "./pwa.js";
12
12
  import { LOCALE } from "./locale.js";
13
13
  import { initAnalytics } from "./analytics.js";
14
14
  import { fetchPlaygroundProject, isPlaygroundEmbed, playgroundSceneUrl, stashPlaygroundProject } from "./playground.js";
15
+ import { preloadBodies } from "./motion3/body.js";
15
16
 
16
17
  registerPwa();
17
18
  void initAnalytics();
@@ -34,6 +35,10 @@ async function boot() {
34
35
  </ErrorBoundary>
35
36
  </StrictMode>,
36
37
  );
38
+ // The motion compiler's body fits are not in the first-load bundle (motion3/body.js): fetch them once
39
+ // the first screen is up, so a saved take compiled on this thread does not have to wait for them.
40
+ const idle = globalThis.requestIdleCallback ?? (run => setTimeout(run, 200));
41
+ idle(() => preloadBodies().catch(error => console.warn("[cozyclay] could not preload the motion body fits", error)));
37
42
  }
38
43
 
39
44
  void boot();
@@ -3,20 +3,11 @@ import { isEffectivelyHidden, objectFootprintBounds } from "../scene-objects.js"
3
3
  /**
4
4
  * Ground height under a point, from the scene's objects (issue #250).
5
5
  *
6
- * AutoPhysics review measures every contact against ONE flat floor
7
- * (`floorY`). When a character stands on a box or a step its feet get pulled
8
- * down to the floor plane. This module answers "what is the ground here?" so
9
- * contact detection, span anchors, and toe minimum heights can use the
10
- * surface actually under the foot.
11
- *
12
- * DRAFT - the sampler exists and is tested; physics-review.js does not call
13
- * it yet. Wiring plan:
14
- * 1. supportIntervals(): compare the sole to groundAt(toe.x, toe.z), not
15
- * floorY, in the "low enough to be a contact" test.
16
- * 2. Span anchors carry y = groundAt(anchor.x, anchor.z); footLockTrack
17
- * anchors and relaxToePath's toeMinHeight take that value per frame.
18
- * 3. Penetration / float metrics and the root lift measure against the
19
- * local ground under each support site.
6
+ * Against ONE flat floor (`floorY`), a character standing on a box or a step
7
+ * would read as sunk into it. This module answers "what is the ground here?":
8
+ * studio-agent-motion.js samples it per support site, and physics-review.js
9
+ * takes it as `groundAt` for contact spans, foot anchors and the floor/float
10
+ * metrics, so they measure against the surface actually under the foot.
20
11
  *
21
12
  * Surface model: each scene object is an axis-aligned box from
22
13
  * objectFootprintBounds (yaw-exact XZ footprint, flat top at topY). The
@@ -0,0 +1,79 @@
1
+ import { useEffect, useSyncExternalStore } from "react";
2
+ import { shotIndexAtFrame } from "../cuts.js";
3
+
4
+ // The playhead lives here, outside React: playback ticks write it and only the
5
+ // components that show the frame subscribe, so App does not re-render per frame.
6
+ let currentFrame = 0;
7
+ const listeners = new Set();
8
+ let applyPlaybackFrame = null;
9
+
10
+ export function playbackFrame() {
11
+ return currentFrame;
12
+ }
13
+
14
+ export function setPlaybackFrame(next) {
15
+ const frame = typeof next === "function" ? next(currentFrame) : next;
16
+ if (!Number.isFinite(frame) || frame === currentFrame) return currentFrame;
17
+ currentFrame = frame;
18
+ if (typeof window !== "undefined") window.__cozyclayPlaybackFrame = frame;
19
+ for (const listener of listeners) listener();
20
+ return currentFrame;
21
+ }
22
+
23
+ export function advancePlaybackFrame(frameCount, count) {
24
+ const frame = setPlaybackFrame((current) => (current + count) % frameCount);
25
+ applyPlaybackFrame?.(frame);
26
+ return frame;
27
+ }
28
+
29
+ export function registerPlaybackFrameApplier(applier) {
30
+ applyPlaybackFrame = applier;
31
+ return () => {
32
+ if (applyPlaybackFrame === applier) applyPlaybackFrame = null;
33
+ };
34
+ }
35
+
36
+ export function createPlaybackFrameSetter({ getFrameCount, setReactFrame, onFrame }) {
37
+ return (update) => {
38
+ const next = typeof update === "function" ? update(playbackFrame()) : update;
39
+ const frame = Math.max(0, Math.min(getFrameCount() - 1, Math.round(next)));
40
+ setPlaybackFrame(frame);
41
+ setReactFrame(frame);
42
+ onFrame?.(frame);
43
+ };
44
+ }
45
+
46
+ export function commitPlaybackFrame(setReactFrame, onFrame) {
47
+ const frame = playbackFrame();
48
+ setReactFrame(frame);
49
+ onFrame?.(frame);
50
+ return frame;
51
+ }
52
+
53
+ export function usePlaybackSnapshot(getSnapshot) {
54
+ return useSyncExternalStore(subscribePlaybackFrame, getSnapshot, getSnapshot);
55
+ }
56
+
57
+ export function usePlaybackEffects({ playing = false, commit = () => {}, onStop = null } = {}) {
58
+ useEffect(() => {
59
+ if (!playing) {
60
+ commit();
61
+ onStop?.();
62
+ }
63
+ // Runs on play/stop transitions only; commit and onStop are recreated every render.
64
+ }, [playing]);
65
+ }
66
+
67
+ /** The shots domain's timeline frame: React state (owned by the caller) for edits, the store for playback.
68
+ * activeShotIdx subscribes to the store, so it re-renders only when playback crosses a cut. */
69
+ export function usePlaybackFrameState({ frameCount, setReactFrame, shots }) {
70
+ const setTlFrame = createPlaybackFrameSetter({ getFrameCount: () => frameCount, setReactFrame });
71
+ const activeShotIdx = usePlaybackSnapshot(() => shotIndexAtFrame(shots, playbackFrame()));
72
+ const activeShot = shots[activeShotIdx] ?? null;
73
+ return { setTlFrame, commitPlaybackFrame: () => commitPlaybackFrame(setReactFrame), activeShotIdx, activeShot };
74
+ }
75
+
76
+ function subscribePlaybackFrame(listener) {
77
+ listeners.add(listener);
78
+ return () => listeners.delete(listener);
79
+ }
@@ -0,0 +1,16 @@
1
+ import { sampleAt } from "../sample-at.js";
2
+
3
+ export function playbackProbe(probe, frame, fallback) {
4
+ const sampled = probe.scene ? sampleAt(probe.scene, null, frame).subject : null;
5
+ const carried = sampled && probe.carry ? probe.carry(sampled, frame) : sampled;
6
+ return carried ?? fallback;
7
+ }
8
+
9
+ export function probeVisibility(vector, camera, subject, height) {
10
+ let visible = false;
11
+ for (const y of [0.1, height * 0.94]) {
12
+ vector.set(subject.x, y, subject.z).project(camera);
13
+ if (vector.z < 1 && Math.abs(vector.x) <= 1 && Math.abs(vector.y) <= 1) visible = true;
14
+ }
15
+ return visible;
16
+ }
@@ -0,0 +1,7 @@
1
+ import { sampleAt } from "../sample-at.js";
2
+
3
+ export function usePlaybackSubjectPosition({ motion, subjectCarry, scene, frame }) {
4
+ if (!motion && !subjectCarry) return null;
5
+ const subject = sampleAt(scene, null, frame).subject;
6
+ return subjectCarry ? subjectCarry(subject, frame) : subject;
7
+ }
@@ -778,11 +778,13 @@
778
778
  box-shadow: none;
779
779
  }
780
780
 
781
+ /* Shots cut against each other: a hairline between neighbours, not a wide gutter, so a run of cuts reads as
782
+ one sequence. Free-camera time between shots still shows as its own gap. */
781
783
  .v2-sequencer .tl-shot-block {
782
784
  top: var(--tl-clip-inset, 7px);
783
785
  bottom: var(--tl-clip-inset, 7px);
784
- left: calc(var(--tl-f-start) * 100% + var(--tl-clip-inset, 7px));
785
- width: max(var(--tl-min-clip-width, 1px), calc((var(--tl-f-end) - var(--tl-f-start)) * 100% - (var(--tl-clip-inset, 7px) * 2)));
786
+ left: calc(var(--tl-f-start) * 100% + var(--tl-shot-gap, 1px));
787
+ width: max(var(--tl-min-clip-width, 1px), calc((var(--tl-f-end) - var(--tl-f-start)) * 100% - (var(--tl-shot-gap, 1px) * 2)));
786
788
  padding: var(--sp-1, 8px) var(--sp-1, 8px) var(--tl-shot-footer-height, 28px);
787
789
  border: 0;
788
790
  border-radius: var(--r-1, 4px);
@@ -854,6 +856,10 @@
854
856
  background: var(--select-tint, rgba(232, 163, 61, .14));
855
857
  }
856
858
 
859
+ /* The trim grips appear with the shot under the pointer, not as a column of marks at every cut. */
860
+ .v2-sequencer .tl-shot-edge { opacity: 0; transition: opacity .12s; }
861
+ .v2-sequencer .tl-shot-block:is(:hover, :focus-within) .tl-shot-edge { opacity: 1; }
862
+
857
863
  /* The bar hangs above the block's top-left corner instead of inside it: a
858
864
  short cut (40 frames ~ 38px) is narrower than Split + Duplicate + Delete,
859
865
  and an in-block bar would sit on the shot's name, turning a click on the
@@ -926,13 +932,22 @@
926
932
  pointer-events: auto;
927
933
  }
928
934
 
935
+ /* A camera key sits on its Shot's key strip (the line along the box's foot), as a small diamond: at the
936
+ lane's first-row height it landed on the box's top corner, in the gutter between two Shots. */
929
937
  .v2-sequencer .tl-marker.cam {
930
- top: calc(var(--tl-track-row-height, 38px) / 2);
931
- bottom: auto;
938
+ top: auto;
939
+ bottom: calc(var(--tl-clip-inset, 7px) + var(--tl-key-strip-height, 12px) / 2);
940
+ width: 7px;
941
+ height: 7px;
942
+ border-radius: 1.5px;
932
943
  background: var(--camera, #a78bfa);
933
- transform: translate(-50%, -50%) rotate(45deg);
944
+ box-shadow: 0 0 0 1.5px var(--tl-camera-clip, #3d3354);
945
+ transform: translate(-50%, 50%) rotate(45deg);
934
946
  z-index: 7;
935
947
  }
948
+ /* A key on a Shot's first or last frame steps just inside the box instead of straddling its edge. */
949
+ .v2-sequencer .tl-marker.cam[data-edge="start"] { margin-left: 7px; }
950
+ .v2-sequencer .tl-marker.cam[data-edge="end"] { margin-left: -7px; }
936
951
 
937
952
  .v2-sequencer .tl-marker.wp {
938
953
  background: var(--ok, #4fbf7a);
@@ -1151,6 +1166,29 @@
1151
1166
  background: var(--select, #e8a33d);
1152
1167
  }
1153
1168
 
1169
+ /* In a Shot box the height graph is a ~20 px strip: the points are small dots ON the curve, quiet on other
1170
+ shots and full on the selected or hovered one, and their hit area stays a full finger wide. Hovering a curve
1171
+ thickens it and the cursor says which way it moves. The axis labels have no room. */
1172
+ .v2-sequencer .tl-shot-block .tl-crane-knob {
1173
+ width: 7px;
1174
+ height: 7px;
1175
+ border: 1.5px solid var(--tl-camera-rail, #302a40);
1176
+ box-shadow: none;
1177
+ opacity: .5;
1178
+ transition: opacity .12s;
1179
+ }
1180
+ .v2-sequencer .tl-shot-block .sg-shot.height { z-index: 2; }
1181
+ .v2-sequencer .tl-shot-block .tl-crane-knob::before { content: ""; position: absolute; inset: -6px; border-radius: 50%; }
1182
+ .v2-sequencer .tl-shot-block .tl-crane-knob.selected { width: 9px; height: 9px; }
1183
+ .v2-sequencer .tl-shot-block:is(.selected, .active, :hover) .tl-crane-knob { opacity: 1; }
1184
+ .v2-sequencer .tl-shot-block .tl-crane-knob:hover, .v2-sequencer .tl-shot-block .tl-crane-knob:focus-visible { outline: none; transform: translate(-50%, -50%) scale(1.3); }
1185
+ .v2-sequencer .tl-shot-block .tl-crane-line { stroke-width: 1.5px; }
1186
+ .v2-sequencer .tl-shot-block .sg-shot .sg-body svg { cursor: ns-resize; }
1187
+ .v2-sequencer .tl-shot-block .sg-shot:hover :is(.sg-line, .tl-crane-line) { stroke-width: 2.5px; }
1188
+ .v2-sequencer .tl-shot-block .tl-crane-time-pick { opacity: 0; }
1189
+ .v2-sequencer .tl-shot-block .tl-crane-time-pick.selected { opacity: 1; }
1190
+ .v2-sequencer .tl-shot-block .tl-crane-editor :is(.sg-scale-top, .sg-scale-avg, .sg-scale-zero) { display: none; }
1191
+
1154
1192
  .v2-sequencer .sg-shot {
1155
1193
  left: var(--sp-1, 8px);
1156
1194
  right: var(--sp-1, 8px);
@@ -273,8 +273,9 @@ function CraneHeightEditor({ crane, railRange, durationFrames, selectedIndex, on
273
273
  key={index}
274
274
  type="button"
275
275
  className={"tl-crane-knob" + (index === selectedIndex ? " selected" : "")}
276
- // an end point sits ON the window edge; clamp it inside so the knob stays a whole, grabbable circle
277
- style={{ left: `clamp(9px, ${xFor(point.t) * 100}%, calc(100% - 9px))`, top: `clamp(9px, ${yFor(point.height) * 100}%, calc(100% - 9px))` }}
276
+ // an end point sits ON the window edge; clamp it inside so the knob stays whole. The clamp is the
277
+ // knob's own radius: a wider one pinned every knob of a 20 px Shot strip to one row, off its curve.
278
+ style={{ left: `clamp(5px, ${xFor(point.t) * 100}%, calc(100% - 5px))`, top: `clamp(5px, ${yFor(point.height) * 100}%, calc(100% - 5px))` }}
278
279
  aria-label={ko(`Crane point ${index + 1}, ${point.height.toFixed(1)} metres`, `크레인 점 ${index + 1}, ${point.height.toFixed(1)}미터`)}
279
280
  onPointerDown={(event) => {
280
281
  event.preventDefault();
@@ -2199,7 +2200,7 @@ export default function Timeline({
2199
2200
  {name === SHOTS_LANE && shots.flatMap((shot, index) => (shot.cameraKeys ?? []).map((key) => (
2200
2201
  <span
2201
2202
  key={`${shot.id}:${key.frame}`}
2202
- className="tl-marker cam"
2203
+ className="tl-marker cam" data-edge={key.frame <= shot.startFrame ? "start" : key.frame >= shot.endFrame ? "end" : undefined}
2203
2204
  style={{ "--tl-f": framePct(key.frame, displayFrameCount) }}
2204
2205
  title={isKo ? `${key.frame}프레임의 카메라 키 — 클릭해 이동, 드래그로 시간 변경, 오른쪽 클릭으로 삭제` : `Camera key at frame ${key.frame} — click to jump, drag to re-time, right-click to remove`}
2205
2206
  onPointerDown={(e) => beginCameraKeyDrag(e, key, index)}