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.
- package/CHANGELOG.md +5 -0
- package/bin/live/cli.mjs +21 -5
- package/dist/app/index.html +5 -5
- package/dist/assets/analytics-C9pMpW7s.js +1 -0
- package/dist/assets/{app-Do-Og5Pu.css → app-B_2XB9rw.css} +1 -1
- package/dist/assets/app-DAuvKPP8.js +4920 -0
- package/dist/assets/{demo-Dt0qZfS7.js → demo-CvKuBgy5.js} +1 -1
- package/dist/assets/{first-shot-handoff-BlT33CGf.js → first-shot-handoff-msLI71g1.js} +1 -1
- package/dist/assets/{landing-BSesrCpL.js → landing-ChUvB_zL.js} +1 -1
- package/dist/assets/{scene-asset-sync-BCWDJB1d.js → scene-asset-sync-DKkpjKBV.js} +1 -1
- package/dist/assets/{ticket-jqMhqL88.js → ticket-BPk2Ni95.js} +1 -1
- package/dist/assets/{workflow-Br-K7Srs.js → workflow-jJRBR94Z.js} +1 -1
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +3 -3
- package/dist/llms.txt +14 -0
- package/dist/workflow/index.html +3 -3
- package/package.json +1 -1
- package/src/App.jsx +24 -15
- package/src/ardy/timeline.jsx +8 -8
- package/src/panels/PoseImagePanel.jsx +1 -1
- package/src/shell/BottomDock.jsx +2 -2
- package/src/shell/CompositionGuideButton.jsx +20 -0
- package/src/shell/ShotPreviewHeader.jsx +17 -0
- package/src/shell/TopBar.jsx +1 -0
- package/src/shell/ViewportToolbar.jsx +18 -28
- package/src/shell/viewport.css +12 -0
- package/src/studio-agent-commands.js +27 -0
- package/src/studio-agent-protocol.js +5 -2
- package/src/studio-app-binding.js +5 -1
- package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
- package/tools/gt-render/browser.mjs +23 -0
- package/tools/headless-studio.mjs +85 -0
- package/tools/loc-baseline.json +6 -6
- package/tools/run-tests.mjs +1 -0
- package/dist/assets/analytics-B41FmPYI.js +0 -1
- package/dist/assets/app-DwP-tl2x.js +0 -4920
|
@@ -1 +1 @@
|
|
|
1
|
-
{"payload":"{\"distribution\":\"npm\",\"package\":\"cozyclay\",\"version\":\"2.1.
|
|
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-
|
|
305
|
-
<link rel="modulepreload" crossorigin href="/assets/analytics-
|
|
306
|
-
<link rel="modulepreload" crossorigin href="/assets/first-shot-handoff-
|
|
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
|
package/dist/workflow/index.html
CHANGED
|
@@ -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-
|
|
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-
|
|
14
|
-
<link rel="modulepreload" crossorigin href="/assets/scene-asset-sync-
|
|
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.
|
|
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"
|
package/src/ardy/timeline.jsx
CHANGED
|
@@ -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", "프레임 시점")}>
|
package/src/shell/BottomDock.jsx
CHANGED
|
@@ -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
|
+
}
|
package/src/shell/TopBar.jsx
CHANGED
|
@@ -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,
|
|
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">{
|
|
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"
|
package/src/shell/viewport.css
CHANGED
|
@@ -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)
|
|
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.");
|
|
Binary file
|
|
@@ -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
|
+
}
|