cozyclay 2.1.0 → 2.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 (36) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/bin/live/cli.mjs +21 -5
  3. package/dist/app/index.html +5 -5
  4. package/dist/assets/analytics-C9pMpW7s.js +1 -0
  5. package/dist/assets/{app-Do-Og5Pu.css → app-B_2XB9rw.css} +1 -1
  6. package/dist/assets/app-DAuvKPP8.js +4920 -0
  7. package/dist/assets/{demo-Dt0qZfS7.js → demo-CvKuBgy5.js} +1 -1
  8. package/dist/assets/{first-shot-handoff-BlT33CGf.js → first-shot-handoff-msLI71g1.js} +1 -1
  9. package/dist/assets/{landing-BSesrCpL.js → landing-ChUvB_zL.js} +1 -1
  10. package/dist/assets/{scene-asset-sync-BCWDJB1d.js → scene-asset-sync-DKkpjKBV.js} +1 -1
  11. package/dist/assets/{ticket-jqMhqL88.js → ticket-BPk2Ni95.js} +1 -1
  12. package/dist/assets/{workflow-Br-K7Srs.js → workflow-jJRBR94Z.js} +1 -1
  13. package/dist/cozyclay-package.json +1 -1
  14. package/dist/index.html +3 -3
  15. package/dist/llms.txt +14 -0
  16. package/dist/workflow/index.html +3 -3
  17. package/package.json +1 -1
  18. package/src/App.jsx +24 -15
  19. package/src/ardy/timeline.jsx +8 -8
  20. package/src/panels/PoseImagePanel.jsx +1 -1
  21. package/src/shell/BottomDock.jsx +2 -2
  22. package/src/shell/CompositionGuideButton.jsx +20 -0
  23. package/src/shell/ShotPreviewHeader.jsx +17 -0
  24. package/src/shell/TopBar.jsx +1 -0
  25. package/src/shell/ViewportToolbar.jsx +18 -28
  26. package/src/shell/viewport.css +12 -0
  27. package/src/studio-agent-commands.js +27 -0
  28. package/src/studio-agent-protocol.js +5 -2
  29. package/src/studio-app-binding.js +5 -1
  30. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  31. package/tools/gt-render/browser.mjs +23 -0
  32. package/tools/headless-studio.mjs +85 -0
  33. package/tools/loc-baseline.json +6 -6
  34. package/tools/run-tests.mjs +1 -0
  35. package/dist/assets/analytics-B41FmPYI.js +0 -1
  36. package/dist/assets/app-DwP-tl2x.js +0 -4920
@@ -1 +1 @@
1
- {"payload":"{\"distribution\":\"npm\",\"package\":\"cozyclay\",\"version\":\"2.1.0\",\"repository\":\"NomaDamas/CozyClay\",\"content_sha256\":\"94d0d33855916922202e0ff1c4afc9aff0e6db281adae8072ae72da7936dd906\"}","signature":"BDrFE1pch5PcqsC6Q9eeqhgX+hfAdOLbJEbdZGM+upY9ACMIItqHsHEmqan1Qkl1i636NrVs0e0zGH7cVmFPCQ=="}
1
+ {"payload":"{\"distribution\":\"npm\",\"package\":\"cozyclay\",\"version\":\"2.1.1\",\"repository\":\"NomaDamas/CozyClay\",\"content_sha256\":\"05301230e82860f17fdf3043274840c0f9959ec99907187cb5d516cc937dfabb\"}","signature":"GuUScV2s3XXtftHL6hEMO+nBbLKD37dy3TSdrqeM4mg0+Fm5V+sFbISCkGAMLKyaGkNDCeMUmtNGWsWGENm9Aw=="}
package/dist/index.html CHANGED
@@ -301,9 +301,9 @@
301
301
  .btn, a.card, .seg button, .tutorial-steps li { transition: none; }
302
302
  }
303
303
  </style>
304
- <script type="module" crossorigin src="/assets/landing-BSesrCpL.js"></script>
305
- <link rel="modulepreload" crossorigin href="/assets/analytics-B41FmPYI.js">
306
- <link rel="modulepreload" crossorigin href="/assets/first-shot-handoff-BlT33CGf.js">
304
+ <script type="module" crossorigin src="/assets/landing-ChUvB_zL.js"></script>
305
+ <link rel="modulepreload" crossorigin href="/assets/analytics-C9pMpW7s.js">
306
+ <link rel="modulepreload" crossorigin href="/assets/first-shot-handoff-msLI71g1.js">
307
307
  </head>
308
308
  <body>
309
309
  <header class="site">
package/dist/llms.txt CHANGED
@@ -11,6 +11,20 @@
11
11
  - [Camera control for AI video](https://cozyclay.org/ai-camera-control/): why camera prompts drift and what the models actually accept.
12
12
  - [Privacy](https://cozyclay.org/privacy/): analytics and telemetry disclosure.
13
13
 
14
+ ## Agent loop
15
+
16
+ 1. Install and open the Studio: `npx cozyclay`.
17
+ 2. Wait for a live editor: `cclay live status --wait`.
18
+ 3. Read before editing: `cclay live inspect --scope scene` or `--scope catalogue`.
19
+ 4. Apply one semantic batch with `cclay live arrange-objects`, `arrange-characters`, `frame-shot`, or `patch`.
20
+ 5. Verify the result. For a whole shot, use `cclay live verify --targets <id> --checks framing --range whole_clip --visual contact_sheet --out verify.png`.
21
+ 6. Use `--strict` when sampled framing findings should make the command fail. A sampled result is evidence for the returned frames, not proof of every frame.
22
+ 7. Read the receipt before retrying. `UNCERTAIN_APPLY` means reconcile or inspect first. `STALE_HANDLE` means status and workspace selection again. `NOT_IN_MODE` means switch Storyboard/Animation before using motion tools.
23
+
24
+ The Studio, MCP server and `cclay live` use the same command contract. `script` can call several tools in one QuickJS transaction for computed layouts. Every authored edit returns a receipt and can be undone.
25
+
26
+ For source-checkout CI, start the same Studio in owned headless Chrome with `node tools/headless-studio.mjs --project shot.cclayproject --port 5920 --live-port 5924`; then point `COZYCLAY_LIVE_PORT=5924 cclay live ...` at it. The launcher owns the Vite and Chrome children and cleans them up on exit.
27
+
14
28
  ## Source
15
29
 
16
30
  - GitHub: https://github.com/NomaDamas/CozyClay
@@ -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-Br-K7Srs.js"></script>
11
+ <script type="module" crossorigin src="/assets/workflow-jJRBR94Z.js"></script>
12
12
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-hePW80VL.js">
13
- <link rel="modulepreload" crossorigin href="/assets/analytics-B41FmPYI.js">
14
- <link rel="modulepreload" crossorigin href="/assets/scene-asset-sync-BCWDJB1d.js">
13
+ <link rel="modulepreload" crossorigin href="/assets/analytics-C9pMpW7s.js">
14
+ <link rel="modulepreload" crossorigin href="/assets/scene-asset-sync-DKkpjKBV.js">
15
15
  <link rel="stylesheet" crossorigin href="/assets/scene-asset-sync-DjJ_cJ2R.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.1.0",
3
+ "version": "2.1.1",
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
@@ -6,6 +6,7 @@ import { useScenes } from "./domains/scenes.js";
6
6
  import { useStage } from "./domains/stage.js";
7
7
  import StudioShell from "./shell/StudioShell.jsx";
8
8
  import ViewportToolbar from "./shell/ViewportToolbar.jsx";
9
+ import CompositionGuideButton from "./shell/CompositionGuideButton.jsx";
9
10
  import { StudioShellContext } from "./shell/studio-shell-context.js";
10
11
 
11
12
  import {
@@ -275,9 +276,7 @@ import { exportOffscreenVideo } from "./offscreen-export.js";
275
276
  import { parseRigNodeId } from "./hierarchy-model.js";
276
277
  import { timelineContentExtent } from "./timeline-extent.js";
277
278
  import {
278
- GUIDE_LABELS,
279
279
  guideGeometry,
280
- nextGuideMode,
281
280
  readStoredGuideMode,
282
281
  writeStoredGuideMode,
283
282
  } from "./shot-guides.js";
@@ -4219,7 +4218,7 @@ export default function App() {
4219
4218
  generate: (kind) => void generateI2vMotion(kind),
4220
4219
  enableShaded: () => runStudioAction("view.setPartColours", { mode: "shaded" }),
4221
4220
  };
4222
- const viewLooksActive = gridView || autoColor || partColoursEnabled;
4221
+ const viewLooksActive = gridView || autoColor || partColoursEnabled || guideMode !== "off";
4223
4222
  const rigSelection = parseRigNodeId(selectedHierarchyId);
4224
4223
  const isRigSelection = rigSelection !== null;
4225
4224
  const inspectorHasContent = isSceneSelection || isCameraSelection || isCharacterSelection || isRigSelection
@@ -6857,6 +6856,25 @@ export default function App() {
6857
6856
  bridgeReady: Boolean(bridge?.ok), busy: storeRef.current.present() !== storeRef.current.objects || Boolean(studioGestureRef.current ||
6858
6857
  ikBodyDragRef.current || lineDragRef.current || lineDrawRef.current || linePinDragRef.current || live.studioPhysicsRunning || recRef.current) };
6859
6858
  }
6859
+ function sampleStudioStateAt(frame, current = readStudioState()) {
6860
+ const shot = shotAtFrame(current.shots ?? current.shotDocument?.shots ?? [], frame);
6861
+ const sampledPlayback = sampleAt(playbackScene, shot, frame);
6862
+ const sampledSubject = sampledPlayback.subject;
6863
+ const primaryId = current.activeCharacterId ?? current.characters[0]?.id;
6864
+ const characters = sampledSubject && primaryId
6865
+ ? current.characters.map(character => character.id === primaryId ? { ...character, x: sampledSubject.x, z: sampledSubject.z } : character)
6866
+ : current.characters;
6867
+ const sampled = sampledPlayback.camera;
6868
+ if (!sampled) return { ...current, frame, characters, view: { ...current.view, frame } };
6869
+ const position = { x: sampled.pos.x, y: sampled.pos.y, z: sampled.pos.z };
6870
+ const direction = forwardFrom(sampled.yaw, sampled.pitch);
6871
+ return { ...current, frame, characters, view: { ...current.view, frame }, camera: {
6872
+ position,
6873
+ lookAt: { x: position.x + direction.x, y: position.y + direction.y, z: position.z + direction.z },
6874
+ focalMm: fovToFocalMm(sampled.fovDeg * Math.PI / 180, current.filmback.sensorId, current.filmback.aspectRatio),
6875
+ sensorId: current.filmback.sensorId,
6876
+ } };
6877
+ }
6860
6878
 
6861
6879
  /** The authored stage envelope (key light, environment, filmback) published
6862
6880
  * as one body: the live read model first, so the next synchronous read sees
@@ -6994,6 +7012,7 @@ export default function App() {
6994
7012
  appContext.updatePorts({
6995
7013
  read: readStudioState, revision: sceneRevisionRef, bounds: studioBounds, commit: commitStudioDraft,
6996
7014
  operate: operateStudio, undo: () => stepStudioHistory(false), redo: () => stepStudioHistory(true),
7015
+ sampleStateAt: sampleStudioStateAt,
6997
7016
  history: redo => appContext.historyEntry(redo), finishHistoryGesture: finishStudioHistoryGesture,
6998
7017
  stepHistory: stepStudioHistory, capture: () => liveQueries.capture_framing_png({}), capturePlan: () => liveQueries.capture_plan_png({}),
6999
7018
  // One shot frame as raw read-back pixels (rows bottom-up), from the export
@@ -7165,6 +7184,7 @@ export default function App() {
7165
7184
  fovDeg, shotsDomain, shot, setNonce, workspaceLayout,
7166
7185
  viewMenuTriggerRef, viewMenuOpen, setViewMenuAnchor, setViewMenuOpen, viewLooksActive,
7167
7186
  viewMenuAnchor, gridView, setGridView, autoColor, setAutoColor,
7187
+ guideMode,
7168
7188
  isCharacterSelection, partColoursChoice, embedMode, agentCollapsed, sceneSaveError,
7169
7189
  studioAgentError, studioAgentMode, setStudioAgentMode, AgentPanel, setAgentCollapsed,
7170
7190
  buildStudioAgentContext, highlightAgentTargets, generateI2vMotionFromUi, selectedSceneObject, rigSelection,
@@ -7253,6 +7273,7 @@ export default function App() {
7253
7273
  }
7254
7274
  }}>
7255
7275
  <ViewportToolbar />
7276
+ {(embedMode || playgroundMode) && <CompositionGuideButton guideMode={guideMode} onCycle={(mode) => runStudioAction("view.setGuideMode", { mode })} />}
7256
7277
  {sculptMode && !sculptTarget && (
7257
7278
  <div className="vp-sculpt-hint" role="status" data-testid="sculpt-empty">
7258
7279
  <strong>{ko("New sculpt", "새 조형물")}</strong>
@@ -7907,18 +7928,6 @@ export default function App() {
7907
7928
  <span className="vp-inset-tag vp-shot-preview-tag">
7908
7929
  <span className="vp-rec-dot" aria-hidden="true" />
7909
7930
  {ko("Shot", "샷")}
7910
- <button
7911
- type="button"
7912
- className={"vp-guide-cycle" + (guideMode === "off" ? "" : " on")}
7913
- aria-label={ko("Cycle composition guides", "구도 가이드 전환")}
7914
- title={ko(GUIDE_LABELS[guideMode].en, GUIDE_LABELS[guideMode].ko) + ko(" · click to cycle", " · 클릭으로 전환")}
7915
- onClick={() => runStudioAction("view.setGuideMode", { mode: nextGuideMode(guideMode) })}
7916
- >
7917
- <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
7918
- <path d="M3 3h18v18H3z" />
7919
- <path d="M9 3v18M15 3v18M3 9h18M3 15h18" />
7920
- </svg>
7921
- </button>
7922
7931
  <button
7923
7932
  type="button"
7924
7933
  className="vp-look-through"
@@ -1863,7 +1863,7 @@ export default function Timeline({
1863
1863
  {ko("+ Block", "+ 블록")}
1864
1864
  </button>}
1865
1865
  </>) : workflowMode === "pose" || ikMode ? (
1866
- <button
1866
+ selectedMotionSegment ? <button
1867
1867
  type="button"
1868
1868
  className="tl-seq-action cut"
1869
1869
  data-testid="sequencer-cut"
@@ -1871,9 +1871,9 @@ export default function Timeline({
1871
1871
  onClick={() => handlers.current.onMotionCut?.()}
1872
1872
  >
1873
1873
  {ko("Cut", "컷")}
1874
- </button>
1874
+ </button> : null
1875
1875
  ) : (<>
1876
- <button
1876
+ {shots.length > 0 && <button
1877
1877
  type="button"
1878
1878
  className="tl-seq-action cut"
1879
1879
  data-testid="sequencer-cut"
@@ -1882,8 +1882,8 @@ export default function Timeline({
1882
1882
  onClick={() => activeShotForCut && handlers.current.onShotSplit?.(activeShotForCut.id)}
1883
1883
  >
1884
1884
  {ko("Cut", "컷")}
1885
- </button>
1886
- <button
1885
+ </button>}
1886
+ {cameraBlockIdx != null && <button
1887
1887
  type="button"
1888
1888
  className="tl-seq-action rail"
1889
1889
  data-testid="sequencer-draw-rail"
@@ -1894,8 +1894,8 @@ export default function Timeline({
1894
1894
  onClick={() => handlers.current.onCameraRailDrawToggle?.()}
1895
1895
  >
1896
1896
  {ko("Draw rail", "레일 그리기")}
1897
- </button>
1898
- <button
1897
+ </button>}
1898
+ {!shotCutDisabled && <button
1899
1899
  type="button"
1900
1900
  className="tl-seq-action"
1901
1901
  data-testid="sequencer-add-track"
@@ -1904,7 +1904,7 @@ export default function Timeline({
1904
1904
  onClick={() => handlers.current.onShotCut?.()}
1905
1905
  >
1906
1906
  {ko("+ Track", "+ 트랙")}
1907
- </button>
1907
+ </button>}
1908
1908
  </>) }
1909
1909
  </div>
1910
1910
  <div className="tl-head-group tl-view-tools" role="group" aria-label={ko("Timeline view tools", "타임라인 보기 도구")}>
@@ -82,7 +82,7 @@ export default function PoseImagePanel({ hidden, capturePoseImage }) {
82
82
  };
83
83
 
84
84
  return (
85
- <Foldout hidden={hidden} title={ko("Image from pose", "포즈로 이미지 생성")}>
85
+ <Foldout hidden={hidden} defaultOpen={false} title={ko("Image from pose", "포즈로 이미지 생성")}>
86
86
  <div className="pose-image" data-testid="pose-image">
87
87
  <div className="pose-image-frame">
88
88
  <div className="pose-image-source" role="radiogroup" aria-label={ko("Frame source", "프레임 시점")}>
@@ -482,12 +482,12 @@ function ShotCard() {
482
482
  {ko("Exit", "나가기")}
483
483
  <kbd>Esc</kbd>
484
484
  </button>
485
- ) : (
485
+ ) : activeShot ? (
486
486
  <button type="button" className="dock-shot-look" aria-pressed="false" onClick={enterShotLook} title={ko("Look through the shot camera (Esc returns)", "샷 카메라 시점으로 보기 (Esc로 복귀)")}>
487
487
  <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M1.75 8S4 3.75 8 3.75 14.25 8 14.25 8 12 12.25 8 12.25 1.75 8 1.75 8z M8 6.25a1.75 1.75 0 1 1 0 3.5 1.75 1.75 0 1 1 0-3.5z" /></svg>
488
488
  {ko("Look through", "샷 시점")}
489
489
  </button>
490
- )}
490
+ ) : null}
491
491
  </footer>
492
492
  </section>
493
493
  );
@@ -0,0 +1,20 @@
1
+ import { GUIDE_LABELS, nextGuideMode } from "../shot-guides.js";
2
+ import { ko } from "../locale.js";
3
+
4
+ export default function CompositionGuideButton({ guideMode, onCycle }) {
5
+ return (
6
+ <button
7
+ type="button"
8
+ className={"vp-guide-cycle vp-embed-guide" + (guideMode === "off" ? "" : " on")}
9
+ aria-label={ko("Cycle composition guides", "구도 가이드 전환")}
10
+ aria-pressed={guideMode !== "off"}
11
+ title={ko(GUIDE_LABELS[guideMode].en, GUIDE_LABELS[guideMode].ko) + ko(" · click to cycle", " · 클릭으로 전환")}
12
+ onClick={() => onCycle(nextGuideMode(guideMode))}
13
+ >
14
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
15
+ <path d="M3 3h18v18H3z" />
16
+ <path d="M9 3v18M15 3v18M3 9h18M3 15h18" />
17
+ </svg>
18
+ </button>
19
+ );
20
+ }
@@ -0,0 +1,17 @@
1
+ import { useLayoutEffect, useState } from "react";
2
+ import { createPortal } from "react-dom";
3
+
4
+ export default function ShotPreviewHeader({ name, lens }) {
5
+ const [host, setHost] = useState(null);
6
+ useLayoutEffect(() => {
7
+ setHost(document.querySelector(".vp-shot-preview .vp-shot-preview-tag"));
8
+ }, []);
9
+ if (!host) return null;
10
+ return createPortal(
11
+ <span className="vp-shot-preview-meta" data-testid="shot-preview-header">
12
+ <span className="vp-shot-preview-name">{name}</span>
13
+ <span className="vp-shot-preview-lens">{lens}</span>
14
+ </span>,
15
+ host,
16
+ );
17
+ }
@@ -124,6 +124,7 @@ function GenerateMotion() {
124
124
  key={action.id}
125
125
  className="menubar-item"
126
126
  data-generate-action={action.id}
127
+ disabled={Boolean(action.reason)}
127
128
  aria-disabled={action.reason ? "true" : undefined}
128
129
  data-disabled-reason={action.reason || undefined}
129
130
  onClick={() => {
@@ -1,6 +1,6 @@
1
- import { useEffect, useLayoutEffect, useState } from "react";
2
- import { createPortal } from "react-dom";
1
+ import { useEffect, useState } from "react";
3
2
  import { useStudioShell } from "./studio-shell-context.js";
3
+ import ShotPreviewHeader from "./ShotPreviewHeader.jsx";
4
4
  import { ko } from "../locale.js";
5
5
  import { PRESETS, SHOT_ASPECT_PRESETS } from "../app-stage.jsx";
6
6
  import { CAMERA_PRESETS } from "../camera-move.js";
@@ -10,6 +10,7 @@ import { CHARACTER_MODEL_IDS } from "../scenes.js";
10
10
  import { elementByPath } from "../studio-elements.js";
11
11
  import { saveAutoColor } from "../auto-color.js";
12
12
  import { trackFeature } from "../analytics.js";
13
+ import { GUIDE_LABELS, nextGuideMode } from "../shot-guides.js";
13
14
  import "./viewport.css";
14
15
 
15
16
  // G1: the single mode+tool pill. Keys 1-5 and W/E/R are bound in App's
@@ -113,30 +114,13 @@ function MenuItem({ checked, role = "menuitemradio", className = "", children, .
113
114
  );
114
115
  }
115
116
 
116
- /** The inset card's header (violet dot, shot name, lens) lives in App's
117
- * `.vp-shot-preview-tag`; this region only fills in the words it shows. */
118
- function ShotPreviewHeader({ name, lens }) {
119
- const [host, setHost] = useState(null);
120
- useLayoutEffect(() => {
121
- setHost(document.querySelector(".vp-shot-preview .vp-shot-preview-tag"));
122
- }, []);
123
- if (!host) return null;
124
- return createPortal(
125
- <span className="vp-shot-preview-meta" data-testid="shot-preview-header">
126
- <span className="vp-shot-preview-name">{name}</span>
127
- <span className="vp-shot-preview-lens">{lens}</span>
128
- </span>,
129
- host,
130
- );
131
- }
132
-
133
117
  export default function ViewportToolbar() {
134
118
  const {
135
119
  workflowMode, selectWorkflowMode, poseRefusal, gizmoMode, setGizmoMode, snapEnabled,
136
120
  setSnapEnabled, preset, applyPreset, cameraPresetId, i2vMotionCameraLocked,
137
121
  runStudioAction, shotAspectKey, fovDeg, shotsDomain, shot, shots, activeShot,
138
122
  setNonce, workspaceLayout, viewMenuTriggerRef, viewMenuOpen,
139
- setViewMenuOpen, viewLooksActive, gridView, setGridView,
123
+ setViewMenuOpen, viewLooksActive, gridView, setGridView, guideMode,
140
124
  autoColor, setAutoColor, isCharacterSelection, partColoursChoice, embedMode,
141
125
  agentCollapsed, addSceneObject, spawnCharacter, characters, flySpeed,
142
126
  ikEditTool, setIkEditTool, showTrails, setShowTrails, motion, setRangePinPartPick,
@@ -515,20 +499,13 @@ export default function ViewportToolbar() {
515
499
  onClick={() => menu.toggle("shading")}
516
500
  >
517
501
  <RailIcon name="shading" />
518
- <span className="vp-label">{autoColor ? ko("Auto Color", "자동 색") : ko("Clay Lit", "클레이 조명")}</span>
502
+ <span className="vp-label">{ko("Clay Lit", "클레이 조명")}</span>
519
503
  </button>
520
504
  {menu.open === "shading" && (
521
505
  <div className="vp-menu" role="menu" aria-label={ko("Shading", "셰이딩")}>
522
506
  <MenuItem checked={!autoColor} onClick={() => { setAutoColorOn(false); menu.close(); }}>
523
507
  {ko("Clay Lit", "클레이 조명")}
524
508
  </MenuItem>
525
- <MenuItem
526
- checked={autoColor}
527
- title={ko("Flat, distinct colour per object — captures include them while on", "오브젝트별 평면 구분 색 — 켜둔 동안 캡처에도 포함됩니다")}
528
- onClick={() => { setAutoColorOn(true); menu.close(); }}
529
- >
530
- {ko("Auto Color", "자동 색")}
531
- </MenuItem>
532
509
  </div>
533
510
  )}
534
511
  </div>
@@ -587,6 +564,19 @@ export default function ViewportToolbar() {
587
564
  <span className="view-menu-mark" aria-hidden="true">{autoColor ? "✓" : ""}</span>
588
565
  {ko("Auto Color", "자동 색")}
589
566
  </button>
567
+ <button
568
+ type="button"
569
+ role="menuitemcheckbox"
570
+ className={"view-menu-item vp-guide-cycle" + (guideMode === "off" ? "" : " active")}
571
+ aria-checked={guideMode !== "off"}
572
+ aria-pressed={guideMode !== "off"}
573
+ title={ko(GUIDE_LABELS[guideMode].en, GUIDE_LABELS[guideMode].ko) + ko(" · click to cycle", " · 클릭으로 전환")}
574
+ onClick={() => runStudioAction("view.setGuideMode", { mode: nextGuideMode(guideMode) })}
575
+ >
576
+ <span className="view-menu-mark" aria-hidden="true">{guideMode !== "off" ? "✓" : ""}</span>
577
+ {ko("Composition guides", "구도 가이드")}
578
+ <small>{ko(GUIDE_LABELS[guideMode].en, GUIDE_LABELS[guideMode].ko)}</small>
579
+ </button>
590
580
  <button
591
581
  type="button"
592
582
  role="menuitemcheckbox"
@@ -32,6 +32,18 @@
32
32
  color: var(--text-1);
33
33
  }
34
34
  .app[data-embed-mode] .viewport-titlebar { display: none; }
35
+ .app[data-embed-mode] .vp-embed-guide {
36
+ position: absolute;
37
+ top: var(--sp-1);
38
+ right: var(--sp-1);
39
+ z-index: 6;
40
+ padding: var(--sp-1);
41
+ border-radius: var(--r-2);
42
+ background: var(--surface-panel);
43
+ color: var(--text-2);
44
+ }
45
+ .app[data-embed-mode] .vp-embed-guide:hover,
46
+ .app[data-embed-mode] .vp-embed-guide.on { color: var(--select); }
35
47
 
36
48
  .viewport-titlebar button { font-family: inherit; }
37
49
  .viewport-titlebar button:focus-visible {
@@ -11,6 +11,7 @@ import { coplanarConflicts } from './coplanar-depth.js';
11
11
  import { clampWarning } from './receipt-findings.js';
12
12
  import { occludersOf, primarySubjects, sideOfAxis } from './studio-geometry-facts.js';
13
13
  import { StudioProtocolError, StudioSchemas, STUDIO_PATCH_KINDS, STUDIO_PATCH_DESCRIPTORS, validateStudioSchema, validateStudioIdentity, validateReceipt, freezeStudioData } from './studio-agent-protocol.js';
14
+ import { sampleContactSheetFrames } from './studio-contact-sheet.js';
14
15
 
15
16
  const DEG = Math.PI / 180, EPS = 1e-8, CHARACTER_SUPPORT_TOLERANCE = 5e-3;
16
17
  const fail = (code, message) => { throw new StudioProtocolError(code, message); };
@@ -399,6 +400,32 @@ export function framingChecks(ids, state, ports) {
399
400
  const projected = projectSubject(boxes.flat(), state.camera, focalMmToFov(state.camera.focalMm, state.filmback.sensorId, state.filmback.aspectRatio), state.filmback.aspectRatio).checks;
400
401
  return { ...projected, ...sightChecks(subjects, boxes, state.camera.position, state, ports).checks };
401
402
  }
403
+
404
+ /** Framing evidence over explicit timeline samples. The caller supplies the
405
+ * sampled state so camera keys and animated subject bounds use the same frame.
406
+ * A sample is evidence for that frame only; it is never advertised as an
407
+ * all-frame proof. */
408
+ export function sampledFramingChecks(ids, state, ports, frames) {
409
+ if (!Array.isArray(frames) || !frames.length) fail('INVALID_RANGE', 'Framing sampling needs at least one timeline frame.');
410
+ if (typeof ports.sampleStateAt !== 'function') fail('CAPABILITY_MISSING', 'This editor cannot sample framing at timeline frames.');
411
+ const samples = frames.map(frame => {
412
+ const sampledState = ports.sampleStateAt(frame, state);
413
+ if (!sampledState) fail('TARGET_NOT_READY', `The editor could not read timeline frame ${frame}.`);
414
+ const checks = framingChecks(ids, sampledState, ports), codes = [checks.clipped ? 'CLIPPED' : null, checks.behindCamera ? 'BEHIND_CAMERA' : null, checks.occluded ? 'OCCLUDED' : null].filter(Boolean);
415
+ return { frame, checks, codes };
416
+ });
417
+ const sampledChecks = samples.map(({ frame, checks }) => ({ frame, screenFraction: checks.screenFraction, clipped: checks.clipped, behindCamera: checks.behindCamera, occluded: checks.occluded }));
418
+ const issueFrames = samples.filter(sample => sample.codes.length).map(({ frame, checks, codes }) => ({ frame, codes, screenFraction: checks.screenFraction }));
419
+ return { ...samples.at(-1).checks, coverage: 'sampled-timeline-subject-bounds-projection', evaluatedFrames: frames.length, sampledFrames: frames, sampledChecks, issueFrames };
420
+ }
421
+ export function sampledFramingResult(args, scene, ports, frameCount, measured, receipt) {
422
+ const range = args.range === 'whole_clip' ? { startFrame: 0, endFrameExclusive: frameCount } : args.range;
423
+ if (range.endFrameExclusive > frameCount) fail('INVALID_RANGE', 'Framing verification range exceeds the scene timeline.');
424
+ const ids = measured ?? (args.targets ?? receipt?.affectedIds ?? []).filter(id => scene.objects.some(o => o.id === id) || scene.characters.some(c => c.id === id));
425
+ if (!ids.length) return { reason: 'No target is available for sampled framing verification.' };
426
+ try { return sampledFramingChecks(ids, scene, ports, sampleContactSheetFrames(args.range, frameCount)); }
427
+ catch (error) { if (!(error instanceof StudioProtocolError)) throw error; return { reason: error.message }; }
428
+ }
402
429
  export function frameDraft(command, state, ports) {
403
430
  if (state.frameCount <= 0) fail('TARGET_NOT_READY', 'A nonempty timeline is required.');
404
431
  const subject = state.characters.find(e => e.id === command.args.subjectIds[0]);
@@ -277,7 +277,7 @@ const readback = object({}, { position: vec3, yawDeg: number(), rotationDeg: vec
277
277
  const CAMERA_SIDES = ["left", "right", "on-axis"];
278
278
  const checks = object({ coverage: name }, { relationSatisfied: bool, overlapIds: ids(100, 0), actualGapM: number(), requestedGapM: number(0), maximumFootprintOverlapM: number(0),
279
279
  basis: choices(STUDIO_VARIANTS.positionBases), clipped: bool, occluded: bool, behindCamera: bool, screenFraction: number(0), derivedSize: choices(STUDIO_VARIANTS.framingSizes), support: name, baseY: number(), facesTargetId: id,
280
- cameraSide: choices(CAMERA_SIDES), axisConsistent: bool });
280
+ cameraSide: choices(CAMERA_SIDES), axisConsistent: bool, evaluatedFrames: integer(1), sampledFrames: array(integer(), 64, 1), sampledChecks: array(object({ frame: integer(), screenFraction: number(), clipped: bool, behindCamera: bool, occluded: bool }), 64, 1), issueFrames: array(object({ frame: integer(), codes: array(name, 4, 1), screenFraction: number() }), 64, 0) });
281
281
  // Code-computed shot geometry verify_result carries beside a framing check
282
282
  // (src/studio-geometry-facts.js): bounded to 8 shots and 24 subjects.
283
283
  const geometryFacts = object({
@@ -511,7 +511,10 @@ export function validateStudioCommand(command) {
511
511
  }
512
512
  if (command.name === "verify_result") {
513
513
  if (Boolean(args.receiptId) === Boolean(args.targets)) fail("INVALID_ARGUMENT", "Exactly one of receiptId or targets is required.");
514
- if (args.checks.includes("motion") && args.range === undefined) args.range = "whole_clip";
514
+ if (args.checks.includes("motion") && args.range === undefined) {
515
+ args.range = "whole_clip";
516
+ Object.defineProperty(args, "rangeWasDefaulted", { value: true, enumerable: false });
517
+ }
515
518
  }
516
519
  return { name: command.name, args };
517
520
  }
@@ -8,7 +8,7 @@ import { shotAtFrame } from "./cuts.js";
8
8
  import { isProxyFigure } from "./scenes.js";
9
9
  import { CUTOUT_KIND, MESH_KIND, OBJECT_LIBRARY, supportHeightForObject } from "./scene-objects.js";
10
10
  import { buildStudioContext, physicsFingerprintInput, studioEntityCursor, validateStudioCursor } from "./studio-agent-context.js";
11
- import { createStudioCommandJournal, entityWorldBounds, framingChecks, localTransformReadback, placementChecks, studioObjectCatalogue } from "./studio-agent-commands.js";
11
+ import { createStudioCommandJournal, entityWorldBounds, framingChecks, localTransformReadback, placementChecks, sampledFramingResult, studioObjectCatalogue } from "./studio-agent-commands.js";
12
12
  import { verifyInstalledTake } from "./studio-agent-motion.js";
13
13
  import { shotGeometryFacts } from "./studio-geometry-facts.js";
14
14
  import { STUDIO_TOOL_FAMILIES, StudioProtocolError, validateReceipt, validateStudioCommand, validateStudioIdentity } from "./studio-agent-protocol.js";
@@ -293,6 +293,10 @@ export function createStudioAppBinding(ports) {
293
293
  try { result.checks[check] = check === "placement" ? placementChecks(measured, scene, { bounds: ports.bounds }) : framingChecks(measured, scene, { bounds: ports.bounds }); }
294
294
  catch (error) { if (!(error instanceof StudioProtocolError)) throw error; reasons[check] = error.message; }
295
295
  }
296
+ if (args.checks.includes("framing") && args.range !== undefined && !args.rangeWasDefaulted) {
297
+ const sampled = sampledFramingResult(args, scene, ports, s.frameCount, measured, receipt);
298
+ if (sampled.reason) reasons.framing = sampled.reason; else result.checks.framing = sampled;
299
+ }
296
300
  if (args.checks.includes("motion") && !receipt?.verification) {
297
301
  const subjects = args.targets ?? receipt.affectedIds.filter(id => s.characters.some(c => c.id === id));
298
302
  if (!subjects.length) skipped.push("The receipt affected no character.");
@@ -154,6 +154,29 @@ export async function openStudioClean(cdp, studioUrl) {
154
154
  await loaded;
155
155
  }
156
156
 
157
+ /** Load a portable project into the same Studio that a user opens. The project
158
+ * document is seeded before the app bundle runs, so this path exercises the
159
+ * real scene store and live-control connection instead of a test renderer. */
160
+ export async function openStudioProject(cdp, studioUrl, project) {
161
+ const origin = new URL(studioUrl).origin;
162
+ if (!project?.scenesDocument || typeof project.scenesDocument !== "object") throw new Error("headless Studio needs a parsed project with scenesDocument");
163
+ await cdp.send("Page.enable");
164
+ await cdp.send("Storage.clearDataForOrigin", { origin, storageTypes: "all" });
165
+ const scenes = JSON.stringify(project.scenesDocument), name = String(project.name || "Headless Project");
166
+ const values = [
167
+ `localStorage.setItem('cozyclay.scenes.v5', ${JSON.stringify(scenes)})`,
168
+ `localStorage.setItem('cozyclay.project-session.v1', ${JSON.stringify(JSON.stringify({ name, previsMode: project.previsMode || "animation", updatedAt: Date.now() }))})`,
169
+ project.workflow ? `localStorage.setItem('cozyclay.workflow.v1', ${JSON.stringify(JSON.stringify(project.workflow))})` : null,
170
+ project.workspaceLayout ? `localStorage.setItem('cozyclay.workspace-layout.v1', ${JSON.stringify(JSON.stringify(project.workspaceLayout))})` : null,
171
+ ].filter(Boolean);
172
+ const source = `${values.join("; ")};`;
173
+ await cdp.send("Page.addScriptToEvaluateOnNewDocument", { source });
174
+ const loaded = cdp.once("Page.loadEventFired");
175
+ await cdp.send("Page.navigate", { url: studioUrl });
176
+ await loaded;
177
+ await waitFor("Studio canvas", async () => cdp.evaluate("Boolean(document.querySelector('canvas'))"), { timeoutMs: 60000 });
178
+ }
179
+
157
180
  export async function terminateAll(children) {
158
181
  await Promise.allSettled(children.splice(0).reverse().map((child) => terminateOwned(child)));
159
182
  }
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Start the real CozyClay Studio in an owned headless Chrome process.
4
+ *
5
+ * This is intentionally a source-checkout tool. It starts the same dev server,
6
+ * seeds a portable .cclayproject into the browser's storage before the app
7
+ * loads, and leaves the live hub available to `cclay live` or an MCP client.
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+ import { join, resolve } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { spawnOwned, terminateOwned } from "./process-supervisor.mjs";
13
+ import { openStudioProject, startChrome, waitFor } from "./gt-render/browser.mjs";
14
+ import { readProjectDocument } from "../src/project.js";
15
+
16
+ const ROOT = resolve(fileURLToPath(new URL("..", import.meta.url)));
17
+ const usage = `headless-studio - start the real Studio without a visible tab
18
+
19
+ node tools/headless-studio.mjs --project shot.cclayproject
20
+
21
+ Options:
22
+ --project <file> portable .cclayproject to load (required)
23
+ --port <port> Vite/Studio port (default 5920)
24
+ --live-port <port> live hub port (default 5924)
25
+ --cdp-port <port> Chrome DevTools port (default 9320)
26
+ `;
27
+
28
+ function value(argv, name, fallback = undefined) {
29
+ const index = argv.findIndex((arg) => arg === name || arg.startsWith(`${name}=`));
30
+ if (index < 0) return fallback;
31
+ const arg = argv[index];
32
+ return arg.includes("=") ? arg.slice(name.length + 1) : argv[index + 1];
33
+ }
34
+ function port(argv, name, fallback) {
35
+ const parsed = Number(value(argv, name, fallback));
36
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) throw new Error(`${name} must be an integer in 1..65535`);
37
+ return parsed;
38
+ }
39
+
40
+ const argv = process.argv.slice(2);
41
+ if (argv.includes("--help") || argv.includes("-h")) { console.log(usage); process.exit(0); }
42
+ const projectPath = value(argv, "--project");
43
+ if (!projectPath) { console.error(usage); process.exit(2); }
44
+ const projectRaw = readFileSync(resolve(projectPath), "utf8");
45
+ const parsed = readProjectDocument(projectRaw);
46
+ if (!parsed.ok) throw new Error(`Cannot load project: ${parsed.reason}`);
47
+ const studioPort = port(argv, "--port", 5920);
48
+ const livePort = port(argv, "--live-port", 5924);
49
+ const cdpPort = port(argv, "--cdp-port", 9320);
50
+ const children = [];
51
+ const cleanups = [];
52
+ let cdp = null;
53
+ let shuttingDown = false;
54
+ const shutdown = async (code = 0) => {
55
+ if (shuttingDown) return;
56
+ shuttingDown = true;
57
+ try { cdp?.close(); } catch { /* already closed */ }
58
+ for (const cleanup of cleanups.splice(0).reverse()) { try { cleanup(); } catch { /* best effort */ } }
59
+ await Promise.allSettled(children.reverse().map((child) => terminateOwned(child)));
60
+ process.exitCode = code;
61
+ };
62
+ for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => { void shutdown(128 + (signal === "SIGINT" ? 2 : 15)); });
63
+
64
+ let dev = null;
65
+ try {
66
+ dev = spawnOwned(process.execPath, [join(ROOT, "tools/dev-full.mjs"), "--host", "127.0.0.1", "--port", String(studioPort)], {
67
+ cwd: ROOT,
68
+ env: { ...process.env, COZYCLAY_LIVE_PORT: String(livePort) },
69
+ stdio: ["ignore", "inherit", "inherit"],
70
+ });
71
+ children.push(dev);
72
+ await waitFor("Studio server", async () => (await fetch(`http://127.0.0.1:${studioPort}/app/`)).ok, { child: dev });
73
+ cdp = await startChrome({ port: cdpPort, children, cleanups });
74
+ await openStudioProject(cdp, `http://127.0.0.1:${studioPort}/app/`, parsed.project);
75
+ console.log(JSON.stringify({ ok: true, studio: `http://127.0.0.1:${studioPort}/app/`, livePort, cdpPort, project: parsed.project.name, warnings: parsed.warnings }, null, 2));
76
+ await new Promise((resolve) => {
77
+ const done = () => resolve();
78
+ dev.once("exit", done);
79
+ if (dev.exitCode !== null || dev.signalCode !== null) done();
80
+ });
81
+ await shutdown(dev.exitCode ?? 1);
82
+ } catch (error) {
83
+ console.error(`headless Studio: ${error?.message || error}`);
84
+ await shutdown(1);
85
+ }