cozyclay 2.0.0 → 2.0.2

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.
@@ -1 +1 @@
1
- {"payload":"{\"distribution\":\"npm\",\"package\":\"cozyclay\",\"version\":\"2.0.0\",\"repository\":\"NomaDamas/CozyClay\",\"content_sha256\":\"2df2a5c5568a32133c5f4efc43abbd1ecb7bd07d89538eb6860a24afe12a98c5\"}","signature":"stRY9RUg5dAQVwVhfhYrieP1HpXK+ahX3U5g8qFMzsQYw2ZFEF+iXRblkvYNjk5MS2g43qKAEMN9F8K0MP3vAA=="}
1
+ {"payload":"{\"distribution\":\"npm\",\"package\":\"cozyclay\",\"version\":\"2.0.2\",\"repository\":\"NomaDamas/CozyClay\",\"content_sha256\":\"6b21f4b8112041967b14ed57d28da217b04fae78e0adeb9dd0da143622814822\"}","signature":"72GMEbX6c47ZRDJrLMi7FipjXz9cKt/piGTYyzGUXpUtNNPx3WCkP6GWJp4PoXZdVoaB9mOrEA71YGN9TEktAA=="}
package/dist/sitemap.xml CHANGED
@@ -6,19 +6,19 @@
6
6
  </url>
7
7
  <url>
8
8
  <loc>https://cozyclay.org/greybox-to-video/</loc>
9
- <lastmod>2026-10-01</lastmod>
9
+ <lastmod>2026-09-14</lastmod>
10
10
  </url>
11
11
  <url>
12
12
  <loc>https://cozyclay.org/previs-software/</loc>
13
- <lastmod>2026-10-01</lastmod>
13
+ <lastmod>2026-09-10</lastmod>
14
14
  </url>
15
15
  <url>
16
16
  <loc>https://cozyclay.org/seedance-camera-control/</loc>
17
- <lastmod>2026-10-01</lastmod>
17
+ <lastmod>2026-09-10</lastmod>
18
18
  </url>
19
19
  <url>
20
20
  <loc>https://cozyclay.org/ai-camera-control/</loc>
21
- <lastmod>2026-10-01</lastmod>
21
+ <lastmod>2026-09-10</lastmod>
22
22
  </url>
23
23
  <url>
24
24
  <loc>https://cozyclay.org/privacy/</loc>
@@ -8,10 +8,10 @@
8
8
  <meta name="theme-color" content="#101116" />
9
9
  <meta name="description" content="CozyClay visual workflow builder" />
10
10
  <title>CozyClay Workflow</title>
11
- <script type="module" crossorigin src="/assets/workflow-dNPGY9xz.js"></script>
11
+ <script type="module" crossorigin src="/assets/workflow-B5XZwVfe.js"></script>
12
12
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-hePW80VL.js">
13
13
  <link rel="modulepreload" crossorigin href="/assets/analytics-MVZOEW8O.js">
14
- <link rel="modulepreload" crossorigin href="/assets/shot-prompt-Yrt99wZx.js">
14
+ <link rel="modulepreload" crossorigin href="/assets/shot-prompt-hN0j_O-p.js">
15
15
  <link rel="stylesheet" crossorigin href="/assets/shot-prompt-CbqWcWYi.css">
16
16
  <link rel="stylesheet" crossorigin href="/assets/workflow-DRdwoDBT.css">
17
17
  </head>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cozyclay",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "description": "Open source previs software in the browser: block a scene, pose characters, author camera moves and cuts, then take the same shots to an AI video model.",
5
5
  "scripts": {
6
6
  "dev": "node tools/dev-full.mjs --host 127.0.0.1 --port 5180",
package/src/App.jsx CHANGED
@@ -147,6 +147,7 @@ import {
147
147
  } from "./matte.js";
148
148
  import { createMatteEditor } from "./matte-editor.js";
149
149
  import {
150
+ SCENES_STORAGE_KEY,
150
151
  SCENES_VERSION,
151
152
  activeSceneIndex,
152
153
  createSceneStage,
@@ -154,6 +155,7 @@ import {
154
155
  } from "./scenes.js";
155
156
  import {
156
157
  hasFileSystemAccess,
158
+ loadProjectSession,
157
159
  loadStoredProjectHandle,
158
160
  queryHandlePermission,
159
161
  requestHandlePermission,
@@ -498,9 +500,17 @@ export default function App() {
498
500
  // have something to frame. That function is declared with the project
499
501
  // actions further down; the listeners here reach it through a ref so they
500
502
  // always call the current render's closure (the confirm reads projectDirty).
501
- const [cameraTutorialQuery] = useState(() => !embedMode && !playgroundMode
502
- && new URLSearchParams(globalThis.location?.search || "").get("tutorial") === "camera"
503
- && !cameraTutorialSuppressed());
503
+ const [cameraTutorialEntry] = useState(() => {
504
+ const params = new URLSearchParams(globalThis.location?.search || "");
505
+ const query = params.get("tutorial") === "camera";
506
+ let firstRun = false;
507
+ try {
508
+ firstRun = !query && !params.get("scene") && !cameraTutorialSuppressed()
509
+ && !loadProjectSession()?.name && !globalThis.localStorage?.getItem(SCENES_STORAGE_KEY);
510
+ } catch { firstRun = false; }
511
+ return !embedMode && !playgroundMode && !cameraTutorialSuppressed() && (query ? "query" : firstRun ? "first-run" : null);
512
+ });
513
+ const cameraTutorialQuery = Boolean(cameraTutorialEntry);
504
514
  const [cameraTutorial, setCameraTutorial] = useState(false);
505
515
  const cameraTutorialAnalytics = useRef(null);
506
516
  const [cameraTutorialAttempt, setCameraTutorialAttempt] = useState(0);
@@ -524,8 +534,8 @@ export default function App() {
524
534
  useEffect(() => {
525
535
  if (!cameraTutorialQuery || cameraTutorialStarted.current) return;
526
536
  cameraTutorialStarted.current = true;
527
- void startCameraTutorialRef.current?.({ source: "query" });
528
- }, [cameraTutorialQuery]);
537
+ void startCameraTutorialRef.current?.({ source: cameraTutorialEntry });
538
+ }, [cameraTutorialQuery, cameraTutorialEntry]);
529
539
  useEffect(() => {
530
540
  const onTutorial = (event) => {
531
541
  if (event.detail?.open === false) {
@@ -3024,8 +3034,8 @@ export default function App() {
3024
3034
  return [{
3025
3035
  id: entry.id,
3026
3036
  url: characterModelUrl(entry.model),
3027
- position: clip ? [clip.anchorX, entry.y ?? 0, clip.anchorZ] : [entry.x, entry.y ?? 0, entry.z],
3028
- rot: clip ? clip.rotationDeg : entry.rot,
3037
+ position: [entry.x, entry.y ?? 0, entry.z],
3038
+ rot: entry.rot,
3029
3039
  tint: entry.tint ?? defaultCharacterTint(entry, index),
3030
3040
  partColoursEnabled,
3031
3041
  partColoursMode,
@@ -3047,8 +3057,7 @@ export default function App() {
3047
3057
  if (!charIdFromHierarchyId(selectedHierarchyId)) return null;
3048
3058
  const entry = characters.find((item) => item.id === activeChar.id);
3049
3059
  if (!entry || entry.hidden) return null;
3050
- const clip = motion;
3051
- return { position: clip ? [clip.anchorX, entry.y ?? 0, clip.anchorZ] : [entry.x, entry.y ?? 0, entry.z] };
3060
+ return { position: [entry.x, entry.y ?? 0, entry.z] };
3052
3061
  }, [characters, activeChar.id, motion, selectedHierarchyId]);
3053
3062
  // The cast rides the SAME gizmo as scene objects — one movement grammar
3054
3063
  // for everything on stage. The proxy hands ObjectGizmo the object shape
@@ -3950,6 +3959,11 @@ export default function App() {
3950
3959
  });
3951
3960
  return;
3952
3961
  }
3962
+ // The seed dresses a visitor's first, unnamed stage and the starter set a
3963
+ // `?scene=` launch always re-opens ("one character mid-walk"). A named
3964
+ // project is the author's document: reloading it must never put the demo
3965
+ // walk back on a character they deliberately left without a take.
3966
+ if (loadProjectSession()?.name && !new URLSearchParams(window.location.search).get("scene")) { demoSeeded.current = true; return; }
3953
3967
  if (!demoSeed.seed) return;
3954
3968
  demoSeeded.current = true;
3955
3969
  // Loaded, not played: the clip walks the subject out of the default
@@ -6770,6 +6784,8 @@ export default function App() {
6770
6784
  key={cameraTutorialAttempt}
6771
6785
  analytics={cameraTutorialAnalytics.current}
6772
6786
  previewing={lookThroughShot}
6787
+ shotCount={shots.length}
6788
+ railReady={shots.some((shot) => Array.isArray(shot.camera?.cameraRail) && shot.camera.cameraRail.length >= 2)}
6773
6789
  onStepChange={setCameraTutorialStep}
6774
6790
  onComplete={() => { cameraTutorialCompletedRef.current = true; cameraTutorialHandoff?.complete(); }}
6775
6791
  onClose={() => closeCameraTutorial()}
@@ -7455,9 +7471,9 @@ export default function App() {
7455
7471
  openProject();
7456
7472
  }}
7457
7473
  starters={STARTER_SCENES}
7458
- onStarter={(id) => {
7474
+ onStarter={(id, name) => {
7459
7475
  setProjectBrowserOpen(false);
7460
- void openStarterScene(id);
7476
+ void openStarterScene(id, "starter", name);
7461
7477
  }}
7462
7478
  onNew={() => {
7463
7479
  setProjectBrowserOpen(false);
@@ -855,10 +855,25 @@
855
855
  background: var(--select-tint, rgba(232, 163, 61, .14));
856
856
  }
857
857
 
858
+ /* The bar hangs above the block's top-left corner instead of inside it: a
859
+ short cut (40 frames ~ 38px) is narrower than Split + Duplicate + Delete,
860
+ and an in-block bar would sit on the shot's name, turning a click on the
861
+ name into Delete. Hover is kept because the bar is still a child. */
858
862
  .v2-sequencer .tl-shot-actions {
863
+ top: auto;
864
+ bottom: calc(100% + 2px);
865
+ left: 0;
866
+ right: auto;
859
867
  background: var(--overlay, rgba(12, 12, 13, .78));
860
868
  }
861
869
 
870
+ /* The block clips its own box (overflow hidden) so the hanging bar only exists
871
+ while it is wanted; the label clips its own text, so nothing else escapes. */
872
+ .v2-sequencer .tl-shot-block:hover,
873
+ .v2-sequencer .tl-shot-block:focus-within {
874
+ overflow: visible;
875
+ }
876
+
862
877
  .v2-sequencer .tl-shot-actions button {
863
878
  height: var(--h-row, 24px);
864
879
  border-radius: var(--r-1, 4px);
@@ -1809,7 +1809,10 @@ export default function Timeline({
1809
1809
  type="button"
1810
1810
  className="tl-seq-action rail"
1811
1811
  data-testid="sequencer-draw-rail"
1812
- title={ko("Draw or redraw the selected camera rail", "선택한 카메라 레일을 그리거나 다시 그립니다")}
1812
+ // The rail belongs to a shot: with none selected the toggle used to
1813
+ // do nothing at all, so say why instead.
1814
+ disabled={cameraBlockIdx == null}
1815
+ title={cameraBlockIdx == null ? ko("Add or select a shot first — the rail belongs to a shot", "먼저 샷을 추가하거나 선택하세요 — 레일은 샷에 속합니다") : ko("Draw or redraw the selected camera rail", "선택한 카메라 레일을 그리거나 다시 그립니다")}
1813
1816
  onClick={() => handlers.current.onCameraRailDrawToggle?.()}
1814
1817
  >
1815
1818
  {ko("Draw rail", "레일 그리기")}
@@ -1,6 +1,6 @@
1
1
  const cleanZero = (value) => (Object.is(value, -0) || Math.abs(value) < 1e-9 ? 0 : value);
2
2
  import { isKo } from "../locale.js";
3
- import { TIMELINE_FRAME_FPS } from "../scenes.js";
3
+ import { TIMELINE_FRAME_FPS } from "../timeline-fps.js";
4
4
 
5
5
  const DEFAULT_WAYPOINT_SPACING_FRAMES = Math.round(0.4 * TIMELINE_FRAME_FPS);
6
6
 
@@ -11,7 +11,7 @@ import { ko } from "./locale.js";
11
11
  // to them directly.
12
12
  //
13
13
  // cozyclay:nav fly / walk / dolly / orbit (src/controls.jsx)
14
- // cozyclay:playground-signal shot / rail (src/App.jsx)
14
+ // shotCount / railReady shot / rail (authored state)
15
15
  // previewing prop play (lookThroughShot)
16
16
  //
17
17
  // Nothing here drives the studio. A step is done when the operator has done
@@ -69,16 +69,16 @@ export const CAMERA_TUTORIAL_STEPS = [
69
69
  label: ko("Shot", "샷"),
70
70
  // The control lives in another region than the card, so the card says
71
71
  // which way to look before it says what to do.
72
- where: ko("↓ Timeline, Shots lane", "↓ 타임라인 샷 레인"),
72
+ where: ko("↓ Timeline, Camera Cuts", "↓ 타임라인 Camera Cuts"),
73
73
  how: () => ko(
74
- <>In the timeline's Shots lane click <b>+ Add shot</b>. That is your cut.</>,
75
- <>타임라인의 샷 레인에서 <b>+ 샷 추가</b>를 누르세요. 그게 컷입니다.</>,
74
+ <>In the timeline's Camera Cuts lane click <b>+ Add shot</b>. That is your cut.</>,
75
+ <>타임라인의 Camera Cuts 레인에서 <b>+ 샷 추가</b>를 누르세요. 그게 컷입니다.</>,
76
76
  ),
77
77
  },
78
78
  {
79
79
  kind: "rail",
80
80
  label: ko("Rail", "레일"),
81
- where: ko("↓ Timeline, Shots lane", "↓ 타임라인 샷 레인"),
81
+ where: ko("↓ Timeline, Camera Cuts", "↓ 타임라인 Camera Cuts"),
82
82
  how: () => ko(
83
83
  <>Select the shot, click <b>Draw rail</b> in the camera bar, and drag a line across the top view. That line is the dolly move.</>,
84
84
  <>샷을 선택하고 카메라 바의 <b>레일 그리기</b>를 누른 뒤, 탑뷰에 선을 그으세요. 그 선이 돌리 이동입니다.</>,
@@ -87,10 +87,10 @@ export const CAMERA_TUTORIAL_STEPS = [
87
87
  {
88
88
  kind: "play",
89
89
  label: ko("Play", "재생"),
90
- where: ko("→ Viewport", "→ 뷰포트"),
90
+ where: ko("↘ Shot Camera monitor", "↘ Shot Camera 모니터"),
91
91
  how: () => ko(
92
92
  <>Click <b>Look through</b> in the Shot monitor to fly the shot camera; <b>▶</b> rides the rail, <kbd>Esc</kbd> returns to the free camera.</>,
93
- <>뷰포트의 <b>샷 시점</b>을 눌러 샷 카메라를 조종하세요. <b>▶</b>는 레일을 타고, <kbd>Esc</kbd>로 자유 카메라로 돌아옵니다.</>,
93
+ <>Shot Camera 모니터의 <b>샷 시점</b>을 눌러 샷 카메라를 조종하세요. <b>▶</b>는 레일을 타고, <kbd>Esc</kbd>로 자유 카메라로 돌아옵니다.</>,
94
94
  ),
95
95
  },
96
96
  ];
@@ -119,10 +119,13 @@ export const TUTORIAL_BEACONS = {
119
119
  label: () => ko("1. Select the shot", "1. 샷을 선택"),
120
120
  },
121
121
  { role: "draw-rail", selector: ".tl-rail-draw", label: () => ko("2. Draw rail, then drag across the top view", "2. 레일 그리기 후 탑뷰에 선 긋기") },
122
- { role: "top-view", selector: ".vp-inset", requires: ".tl-rail-draw", label: () => ko("Drag a line here", "여기에 선을 그으세요") },
122
+ // The Top-View becomes an active drawing surface after the tool is armed.
123
+ { role: "top-view", selector: ".vp-inset", requires: '[data-rail-draw="1"]', label: () => ko("Drag a line here", "여기에 선을 그으세요") },
123
124
  ],
124
125
  play: [
125
- { role: "look-through", selector: ".vp-look-through", label: () => ko("Click to fly the shot camera", "클릭해 샷 카메라를 조종") },
126
+ // The glass shell keeps the same action in the bottom dock while the
127
+ // compact viewport card owns it in the classic shell.
128
+ { role: "look-through", selector: ".dock-shot-look, .vp-look-through", label: () => ko("Click to fly the shot camera", "클릭해 샷 카메라를 조종") },
126
129
  ],
127
130
  };
128
131
 
@@ -217,7 +220,7 @@ export function TutorialBeacon({ kind, step, role, selector, label, requires = n
217
220
  if (pane) resize.observe(pane);
218
221
  }
219
222
  const mutations = new MutationObserver(schedule);
220
- mutations.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "style"] });
223
+ mutations.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "style", "data-rail-draw"] });
221
224
  return () => {
222
225
  if (frame) cancelAnimationFrame(frame);
223
226
  window.removeEventListener("resize", schedule);
@@ -327,11 +330,18 @@ export function GestureCue({ kind, walked, leaving = false, onLeft }) {
327
330
  * Top-View inset, and never takes the pointer except on its own close button
328
331
  * — every step is completed by working the studio underneath it.
329
332
  */
330
- export function CameraTutorial({ previewing = false, analytics, onStepChange, onClose, onComplete, handoff = null, shotId = null, onOpenExport, onContinue }) {
333
+ export function CameraTutorial({ previewing = false, shotCount = 0, railReady = false, analytics, onStepChange, onClose, onComplete, handoff = null, shotId = null, onOpenExport, onContinue }) {
331
334
  const [done, setDone] = useState(() => new Set());
332
335
  const [walked, setWalked] = useState(() => new Set());
333
336
  const [cue, setCue] = useState(null);
334
337
  const completionReported = useRef(false);
338
+ const effectiveDone = useMemo(() => {
339
+ const next = new Set(done);
340
+ if (shotCount > 0) next.add("shot");
341
+ if (railReady) next.add("rail");
342
+ if (previewing && next.has("rail")) next.add("play");
343
+ return next;
344
+ }, [done, previewing, railReady, shotCount]);
335
345
 
336
346
  useEffect(() => {
337
347
  const complete = (kind) => setDone((current) => (current.has(kind) ? current : new Set(current).add(kind)));
@@ -362,6 +372,16 @@ export function CameraTutorial({ previewing = false, analytics, onStepChange, on
362
372
  };
363
373
  }, []);
364
374
 
375
+ // Studio-native completion comes from the authored state. The old playground
376
+ // signal was never emitted by the Studio shell, so a real Shot or rail could
377
+ // leave the tutorial stuck on its previous step.
378
+ useEffect(() => {
379
+ if (shotCount > 0) setDone((current) => (current.has("shot") ? current : new Set(current).add("shot")));
380
+ }, [shotCount]);
381
+ useEffect(() => {
382
+ if (railReady) setDone((current) => (current.has("rail") ? current : new Set(current).add("rail")));
383
+ }, [railReady]);
384
+
365
385
  // The player is the last step, and only once there is a rail to ride:
366
386
  // look-through before the dolly exists shows a still frame, which teaches
367
387
  // nothing about the move. Analytics v1 observes this existing look-through
@@ -369,12 +389,12 @@ export function CameraTutorial({ previewing = false, analytics, onStepChange, on
369
389
  useEffect(() => {
370
390
  if (!previewing) return;
371
391
  setDone((current) => (!current.has("rail") || current.has("play") ? current : new Set(current).add("play")));
372
- }, [previewing]);
392
+ }, [previewing, railReady]);
373
393
 
374
- const current = useMemo(() => CAMERA_TUTORIAL_STEPS.find((step) => !done.has(step.kind)) ?? null, [done]);
394
+ const current = useMemo(() => CAMERA_TUTORIAL_STEPS.find((step) => !effectiveDone.has(step.kind)) ?? null, [effectiveDone]);
375
395
  const complete = current === null;
376
396
  const currentKind = current?.kind ?? null;
377
- const showHandoff = !!shotId && (handoff?.canShow(done) ?? false);
397
+ const showHandoff = !!shotId && (handoff?.canShow(effectiveDone) ?? false);
378
398
  useEffect(() => {
379
399
  if (!complete || completionReported.current) return;
380
400
  completionReported.current = true;
@@ -384,8 +404,8 @@ export function CameraTutorial({ previewing = false, analytics, onStepChange, on
384
404
  // Observe committed progression, outside state updaters (which StrictMode
385
405
  // may replay). The attempt belongs to App and survives effect cleanup/resume.
386
406
  useEffect(() => {
387
- analytics?.observe(done, currentKind);
388
- }, [analytics, done, currentKind]);
407
+ analytics?.observe(effectiveDone, currentKind);
408
+ }, [analytics, effectiveDone, currentKind]);
389
409
 
390
410
  // The step the operator is on is the studio's business too: App puts it on
391
411
  // the .app root as data-tutorial-step, and styles.css spotlights whichever
@@ -425,10 +445,10 @@ export function CameraTutorial({ previewing = false, analytics, onStepChange, on
425
445
  key={step.kind}
426
446
  data-testid="camera-tutorial-step"
427
447
  data-kind={step.kind}
428
- data-done={done.has(step.kind) ? 1 : 0}
448
+ data-done={effectiveDone.has(step.kind) ? 1 : 0}
429
449
  data-current={step === current ? 1 : 0}
430
450
  >
431
- <i aria-hidden="true">{done.has(step.kind) ? "✓" : index + 1}</i>
451
+ <i aria-hidden="true">{effectiveDone.has(step.kind) ? "✓" : index + 1}</i>
432
452
  {step.label}
433
453
  </li>
434
454
  ))}
@@ -10,7 +10,7 @@ const save = { ...studioActionDeclaration("project.save"), input: input({ name }
10
10
  const saveAs = { ...save, id: "project.saveAs", label: "Save project as" };
11
11
  const opening = document("project.open", "Open a project file", { serialized: { type: "string", maxLength: 400_000_000 }, handleToken: token, projectToken: token });
12
12
  const fresh = document("project.new", "Start a new project", { name });
13
- const starter = document("project.openStarter", "Open a starter project", { id: token, source: { type: "string", enum: ["starter", "tutorial", "launch"] } });
13
+ const starter = document("project.openStarter", "Open a starter project", { id: token, source: { type: "string", enum: ["starter", "tutorial", "launch"] }, name });
14
14
  const restore = { ...document("project.restore", "Restore the remembered project", { handleToken: token }), exposure: "ui-only" };
15
15
  const browse = { id: "project.browse", label: "Browse projects", description: "Show the project browser.", kind: "transient", exposure: "ui-only", input: input() };
16
16
  export const declarations = Object.freeze([rename, save, saveAs, opening, fresh, starter, restore, browse]);
@@ -435,7 +435,8 @@ export function useScenes(appContext) {
435
435
  /** Open a bundled starter scene as a fresh, saveable project. Used by the
436
436
  * first-run dialog and by `npx cozyclay --scene <id>` (`?scene=`), which is
437
437
  * how the landing-page tutorial hands people into the local studio. */
438
- async function openStarterScene(id, source = "starter", context = null) {
438
+ async function openStarterScene(id, source = "starter", context = null, name = null) {
439
+ if (source === "starter" && domain.dirtyStore.read("projectDirty") && !window.confirm(ko("Discard unsaved changes and start a new project?", "저장되지 않은 변경사항을 버리고 새 프로젝트를 시작할까요?"))) return false;
439
440
  const before = source === "tutorial" ? collectProjectSnapshot("Tutorial") : null;
440
441
  const epoch = appContext.shared.tutorialProjectEpochRef.current;
441
442
  const url = playgroundSceneUrl(`?scene=${encodeURIComponent(id)}`);
@@ -448,7 +449,7 @@ export function useScenes(appContext) {
448
449
  return false;
449
450
  }
450
451
  context?.check();
451
- applyProject({ ...project, savedAt: null }, true);
452
+ applyProject({ ...project, name: name?.trim() || project.name, savedAt: null }, true);
452
453
  appContext.shared.projectHandleRef.current = null;
453
454
  track("scene:loaded", { scene_source: source });
454
455
  return true;
@@ -537,6 +538,8 @@ export function useScenes(appContext) {
537
538
  setProjectNameDialog(null);
538
539
  const fresh = createSceneDocument(ko("SCENE 01", "씬 01"));
539
540
  storeWorkflowGraph(createWorkflowGraph());
541
+ appContext.shared.projectMotionsRef.current = new Map();
542
+ appContext.shared.motionFullRef.current.clear();
540
543
  domain.replaceDocument(fresh.scenes, fresh.activeSceneId, name);
541
544
  persistScenes(fresh.scenes, fresh.activeSceneId);
542
545
  openScene(fresh.scenes[0], fresh.scenes);
@@ -763,6 +766,9 @@ export function useScenes(appContext) {
763
766
  domain.pendingCheckpoint = null;
764
767
  if (checkpoint.clock === appContext.undoClock && checkpoint.name === name) appContext.shared.projectSnapshotRef.current = collectProjectSnapshot(name);
765
768
  }
769
+ if (name !== null && !appContext.shared.projectSnapshotRef.current) {
770
+ appContext.shared.projectSnapshotRef.current = collectProjectSnapshot(name);
771
+ }
766
772
  const dirty = name !== null && collectProjectSnapshot(name) !== appContext.shared.projectSnapshotRef.current;
767
773
  domain.setDirty(dirty);
768
774
  setProjectSaveState((current) => current === "saving" ? current : dirty ? "dirty" : "saved");
@@ -782,7 +788,7 @@ export function useScenes(appContext) {
782
788
  };
783
789
  if (id === "project.browse") { setProjectStartupOpen(false); setProjectBrowserOpen(true); return; }
784
790
  if (id === "project.new") return newProject(args.name, true);
785
- if (id === "project.openStarter") return openStarterScene(args.id, args.source, context);
791
+ if (id === "project.openStarter") return openStarterScene(args.id, args.source, context, args.name);
786
792
  if (id === "project.restore") return restoreStoredProject({ handle: runtime("handleToken") }, context);
787
793
  if (args.handleToken) return openProjectByHandle(runtime("handleToken"), context);
788
794
  if (args.projectToken) { applyProject(runtime("projectToken"), true); return true; }
@@ -807,7 +813,7 @@ export function useScenes(appContext) {
807
813
  projectStartupOpen, setProjectStartupOpen, projectManifest, setProjectManifest, saveBlockedReasons,
808
814
  setSaveBlockedReasons, workflowRevision, setWorkflowRevision, collectProjectSnapshot,
809
815
  collectProjectSerialized, projectProblemsNotice, rehydrateProjectAssets, saveProject, applyProject,
810
- openStarterScene: async (id, source = "starter") => (await runProject("project.openStarter", { id, source })).output?.opened === true,
816
+ openStarterScene: async (id, source = "starter", name = null) => (await runProject("project.openStarter", { id, source, ...(name ? { name } : {}) })).output?.opened === true,
811
817
  openProject, openProjectByHandle, requestNewProject, newProject, restoreOffer,
812
818
  setRestoreOffer, restoreStoredProject, flushScenes, openScene, selectSceneDocument,
813
819
  createSceneDocumentFromUi, duplicateSceneDocumentFromUi, renameSceneDocumentFromUi,
@@ -1,7 +1,7 @@
1
1
  // Pure request construction. The caller supplies one rolled seed and sampled
2
2
  // poses; no rig, store, random source, notification or network is touched here.
3
3
  import { StudioProtocolError, compileStudioBeats } from '../studio-agent-protocol.js';
4
- import { TIMELINE_FRAME_FPS as FPS } from '../scenes.js';
4
+ import { TIMELINE_FRAME_FPS as FPS } from '../timeline-fps.js';
5
5
  import { judgeAuthoredPath, alignArdyPath } from '../ardy/waypoints.js';
6
6
  import { planPosePin, PIN_BLOCKED } from '../ardy/pose-pin.js';
7
7
  import { blocksFromRequest, replayPayload, replayTruncated } from '../take-recipe.js';
@@ -190,6 +190,10 @@ export default function ProjectBrowser({ currentName, onOpen, onOpenFile, onNew,
190
190
  event.preventDefault();
191
191
  const projectName = name.trim();
192
192
  if (!projectName) return;
193
+ if (selectedTemplate?.starterId) {
194
+ onStarter?.(selectedTemplate.starterId, projectName);
195
+ return;
196
+ }
193
197
  pendingProjectName = projectName;
194
198
  onNew(projectName);
195
199
  };
package/src/scenes.js CHANGED
@@ -8,6 +8,9 @@ import { normalizeMotionCalibration } from "./ardy/motion-calibration.js";
8
8
  import { normalizeTrailEdits } from "./motion-trail.js";
9
9
  import { elementByPath } from "./studio-elements.js";
10
10
  import { wrapAngle } from "./scene-objects.js";
11
+ // The timeline clock lives in timeline-fps.js (three-free, so the agent
12
+ // sidecar can import it); re-exported here for every scene-side caller.
13
+ import { LEGACY_FRAME_FPS, TIMELINE_FRAME_FPS, toTimelineFrame } from "./timeline-fps.js";
11
14
 
12
15
  const CHARACTER_POSITION_LIMITS = elementByPath("character.position");
13
16
  const CHARACTER_ROTATION_LIMITS = elementByPath("character.rot");
@@ -29,14 +32,7 @@ export const LEGACY_SCENES_STORAGE_KEYS = Object.freeze([
29
32
  V1_SCENES_STORAGE_KEY,
30
33
  ]);
31
34
 
32
- /** v3 and older authored every frame number on ARDY's 20 fps clock; v4 reads
33
- * them on the 24 fps production clock. The numbers are MULTIPLIED, never
34
- * reinterpreted: a waypoint at frame 40 meant 2.0 s and must still mean 2.0 s,
35
- * which is frame 48 — reinterpreting it would silently speed the scene up. */
36
- export const LEGACY_FRAME_FPS = 20;
37
- export const TIMELINE_FRAME_FPS = 24;
38
- export const toTimelineFrame = (frame) =>
39
- Math.round((frame * TIMELINE_FRAME_FPS) / LEGACY_FRAME_FPS);
35
+ export { LEGACY_FRAME_FPS, TIMELINE_FRAME_FPS, toTimelineFrame };
40
36
 
41
37
  /** Rigged character models shipped in public/models. The id is both the FBX
42
38
  * file stem and the wire `source.rig` value sent to ARDY. */
package/src/styles.css CHANGED
@@ -899,7 +899,8 @@ select {
899
899
  .app[data-tutorial-step="shot"] .tl-track.shots .tl-track-add,
900
900
  .app[data-tutorial-step="rail"] .tl-track.shots .tl-shot-block:not(.selected),
901
901
  .app[data-tutorial-step="rail"] .tl-rail-draw,
902
- .app[data-tutorial-step="play"] .vp-look-through {
902
+ .app[data-tutorial-step="play"] .vp-look-through,
903
+ .app[data-tutorial-step="play"] .dock-shot-look {
903
904
  animation: tutorial-spotlight 1.5s var(--ease) infinite;
904
905
  box-shadow: 0 0 0 3px var(--accent);
905
906
  }
@@ -9427,6 +9428,23 @@ input[type=range] {
9427
9428
  width: clamp(220px, calc(50% - 92px), 360px);
9428
9429
  }
9429
9430
 
9431
+ /* The floating shell covers the full-bleed viewport with panels. Keep the
9432
+ instructions inside its open stage, clear of the tool rail and dock. */
9433
+ .app[data-shell="glass"]:not([data-embed-mode]) .camera-tutorial {
9434
+ top: calc(var(--shell-view-top, 52px) + 12px);
9435
+ left: calc(var(--shell-center-left) + 64px);
9436
+ width: min(430px, calc(100% - var(--shell-center-left) - var(--shell-center-right) - 128px));
9437
+ }
9438
+ .app[data-shell="glass"][data-tutorial-step="rail"] .camera-tutorial,
9439
+ .app[data-shell="glass"] .camera-tutorial[data-previewing="1"] {
9440
+ top: auto;
9441
+ bottom: calc(var(--shell-center-bottom) + 12px);
9442
+ }
9443
+ .app[data-shell="glass"]:not([data-embed-mode]) .tutorial-cue {
9444
+ left: calc((var(--shell-center-left) + 100% - var(--shell-center-right)) / 2);
9445
+ bottom: calc(var(--shell-center-bottom) + 18px);
9446
+ }
9447
+
9430
9448
  .camera-tutorial-steps {
9431
9449
  display: flex;
9432
9450
  flex-wrap: wrap;
package/src/theme.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // shell, its regions and body-level popovers all read the same token set.
3
3
  export const THEMES = ["dark", "light"];
4
4
  export const THEME_KEY = "cozyclay.theme.v1";
5
- export const DEFAULT_THEME = "dark";
5
+ export const DEFAULT_THEME = "light";
6
6
 
7
7
  import { useEffect, useState } from "react";
8
8
 
@@ -0,0 +1,13 @@
1
+ // The timeline clock, on its own so the agent sidecar (bin/agent) can read it
2
+ // without importing scenes.js, whose scene-object and motion-trail imports pull
3
+ // in `three` — a devDependency the npm package does not ship
4
+ // (test/verify-package-agent-imports.mjs).
5
+
6
+ /** v3 and older authored every frame number on ARDY's 20 fps clock; v4 reads
7
+ * them on the 24 fps production clock. The numbers are MULTIPLIED, never
8
+ * reinterpreted: a waypoint at frame 40 meant 2.0 s and must still mean 2.0 s,
9
+ * which is frame 48 — reinterpreting it would silently speed the scene up. */
10
+ export const LEGACY_FRAME_FPS = 20;
11
+ export const TIMELINE_FRAME_FPS = 24;
12
+ export const toTimelineFrame = (frame) =>
13
+ Math.round((frame * TIMELINE_FRAME_FPS) / LEGACY_FRAME_FPS);
@@ -382,10 +382,12 @@ export default function AgentPanel({
382
382
  const payload = await transport.loadSession(stored);
383
383
  if (!Array.isArray(payload?.transcript)) throw new Error("missing transcript");
384
384
  if (!cancelled) store.restore(payload.transcript, payload.sessionId || stored);
385
- } catch {
385
+ } catch (error) {
386
386
  if (!cancelled) {
387
387
  store.newSession();
388
- setRestoreNotice("Previous conversation could not be restored");
388
+ // A session id with no record behind it (the pane opened, nothing
389
+ // was ever sent) is nothing to restore, not a failure.
390
+ if (error?.status !== 404) setRestoreNotice("Previous conversation could not be restored");
389
391
  }
390
392
  } finally {
391
393
  if (!cancelled) setRestoreReady(true);
@@ -108,6 +108,7 @@ const NODE_FILES = [
108
108
  "test/verify-agent-host.mjs",
109
109
  "test/verify-agent-routes.mjs",
110
110
  "test/verify-agent-live-runtime.mjs",
111
+ "test/verify-package-agent-imports.mjs",
111
112
  "test/verify-agent-runner-errors.mjs",
112
113
  "test/verify-agent-steer.mjs",
113
114
  "test/verify-pi-tools.mjs",
@@ -292,6 +293,7 @@ const BROWSER_FILES = [
292
293
  "test/verify-object-gizmo.mjs",
293
294
  "test/verify-offscreen-export-browser.mjs",
294
295
  "test/verify-project-menu-browser.mjs",
296
+ "test/verify-project-session-reset.mjs",
295
297
  "test/verify-settings-menu-browser.mjs",
296
298
  "test/verify-static-shot-export-browser.mjs",
297
299
  "test/qa-agent-view-toggle-browser.mjs",