cozyclay 1.10.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +137 -0
- package/bin/agent/agent-routes.mjs +74 -70
- package/bin/agent/agent-runner.mjs +10 -3
- package/bin/agent/motion-runtime.mjs +32 -260
- package/bin/agent/providers.mjs +46 -3
- package/bin/agent/session-store.mjs +41 -5
- package/bin/agent/studio-prompt.mjs +1 -1
- package/bin/agent/studio-tools.mjs +48 -11
- package/bin/cozyclay.mjs +26 -8
- package/bin/live/cli.mjs +22 -3
- package/bin/mcp-runtime.mjs +35 -20
- package/bin/telemetry-state.mjs +5 -2
- package/bin/update-check.mjs +15 -0
- package/dist/app/index.html +6 -6
- package/dist/assets/analytics-MVZOEW8O.js +1 -0
- package/dist/assets/app-C1sdVbg4.css +1 -0
- package/dist/assets/app-CA55tcI3.js +4876 -0
- package/dist/assets/{demo-B-eg1CT8.js → demo-ByeYp-dY.js} +1 -1
- package/dist/assets/{first-shot-handoff-CGgnvNu8.js → first-shot-handoff-DgT28ZiV.js} +1 -1
- package/dist/assets/{landing-IbRp3FnQ.js → landing-BaXkpFip.js} +1 -1
- package/dist/assets/shot-prompt-CbqWcWYi.css +1 -0
- package/dist/assets/shot-prompt-Yrt99wZx.js +17 -0
- package/dist/assets/{ticket-BgIzOBOf.js → ticket-BcmsnHn5.js} +1 -1
- package/dist/assets/workflow-DRdwoDBT.css +1 -0
- package/dist/assets/workflow-dNPGY9xz.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/fonts/IBM-Plex-Sans-OFL.txt +92 -0
- package/dist/fonts/JetBrains-Mono-OFL.txt +93 -0
- package/dist/fonts/README.md +13 -9
- package/dist/fonts/ibm-plex-sans-400-latin.woff2 +0 -0
- package/dist/fonts/ibm-plex-sans-500-latin.woff2 +0 -0
- package/dist/fonts/ibm-plex-sans-600-latin.woff2 +0 -0
- package/dist/fonts/jetbrains-mono-400-latin.woff2 +0 -0
- package/dist/fonts/jetbrains-mono-500-latin.woff2 +0 -0
- package/dist/index.html +7 -7
- package/dist/privacy/index.html +13 -8
- package/dist/sitemap.xml +6 -6
- package/dist/workflow/index.html +5 -5
- package/mcp/live-hub.mjs +35 -93
- package/mcp/mesh-file.mjs +48 -0
- package/mcp/server.mjs +6 -77
- package/mcp/tool-handlers.mjs +219 -376
- package/package.json +2 -1
- package/src/App.jsx +1249 -9076
- package/src/analytics.js +393 -9
- package/src/app-context.js +211 -0
- package/src/app-stage.jsx +25 -24
- package/src/ardy/auto-fix-panel.css +108 -0
- package/src/ardy/fix-collisions.js +10 -66
- package/src/ardy/ik-drag.js +101 -0
- package/src/ardy/ik-key-json.js +43 -0
- package/src/ardy/ik.js +97 -67
- package/src/ardy/physics-panel.css +2 -0
- package/src/ardy/physics-panel.jsx +17 -5
- package/src/ardy/platform-fit-panel.css +64 -0
- package/src/ardy/platform-fit-panel.jsx +40 -0
- package/src/ardy/platform-fit.js +326 -0
- package/src/ardy/playback.js +166 -20
- package/src/ardy/range-pin.js +299 -0
- package/src/ardy/timeline-coordinates.js +26 -0
- package/src/ardy/timeline.css +1199 -0
- package/src/ardy/timeline.jsx +149 -41
- package/src/ardy/waypoints.js +2 -2
- package/src/asset-pane.css +462 -0
- package/src/asset-pane.jsx +461 -232
- package/src/command-bus.js +349 -0
- package/src/commands/ai.js +31 -0
- package/src/commands/cast.js +140 -0
- package/src/commands/elements/character.js +13 -0
- package/src/commands/elements/motion.js +14 -0
- package/src/commands/elements/object.js +22 -0
- package/src/commands/elements/scene.js +16 -0
- package/src/commands/elements/shot.js +9 -0
- package/src/commands/elements/stage.js +11 -0
- package/src/commands/elements.js +155 -0
- package/src/commands/export.js +20 -0
- package/src/commands/index.js +40 -0
- package/src/commands/motion.js +231 -0
- package/src/commands/objects.js +174 -0
- package/src/commands/project.js +53 -0
- package/src/commands/scene.js +81 -0
- package/src/commands/shared.js +19 -0
- package/src/commands/shot.js +103 -0
- package/src/commands/stage.js +17 -0
- package/src/commands/view.js +42 -0
- package/src/document-store.js +144 -0
- package/src/domains/cast.js +1007 -0
- package/src/domains/motion.js +3219 -0
- package/src/domains/objects.js +932 -0
- package/src/domains/scenes.js +817 -0
- package/src/domains/shots.js +404 -0
- package/src/domains/stage.js +93 -0
- package/src/dualview.jsx +113 -57
- package/src/facing-marks.js +3 -0
- package/src/first-success-guide.jsx +14 -14
- package/src/grid-view.js +18 -5
- package/src/hierarchy-panel.css +386 -0
- package/src/hierarchy-panel.jsx +88 -19
- package/src/{fal-motion-client.js → i2v-motion-client.js} +13 -13
- package/src/i2v-motion-studio.jsx +180 -0
- package/src/ik-camera.js +3 -0
- package/src/main.jsx +6 -0
- package/src/motion/generation.js +105 -0
- package/src/motion-readiness-ui.jsx +4 -4
- package/src/motion-readiness.js +11 -0
- package/src/motion-trail.js +366 -9
- package/src/object-gizmo.jsx +7 -1
- package/src/panels/CameraPanel.jsx +60 -0
- package/src/panels/CharacterTransformPanel.jsx +48 -0
- package/src/panels/EnvironmentPanel.jsx +40 -0
- package/src/panels/Foldout.jsx +32 -0
- package/src/panels/LightPanel.jsx +24 -0
- package/src/panels/ObjectTransformPanel.jsx +554 -0
- package/src/panels/PosePanel.jsx +97 -0
- package/src/panels/ProjectPanel.jsx +33 -0
- package/src/panels/PromptBlocksPanel.jsx +442 -0
- package/src/panels/PropsPanel.jsx +70 -0
- package/src/panels/ReferenceImageField.jsx +91 -0
- package/src/panels/RigControlPanel.jsx +78 -0
- package/src/panels/RigPanel.jsx +37 -0
- package/src/panels/SubjectBox.jsx +47 -0
- package/src/panels/SubjectsPanel.jsx +39 -0
- package/src/panels/VideoCapturePanel.jsx +180 -0
- package/src/panels/details.css +1171 -0
- package/src/panels/motion.css +197 -0
- package/src/panels/pose.css +39 -0
- package/src/planview.jsx +60 -31
- package/src/posestudio.jsx +59 -4
- package/src/project-browser.css +1073 -0
- package/src/project-browser.jsx +284 -123
- package/src/range-pin-object-transform.js +19 -0
- package/src/range-pin-panel.css +518 -0
- package/src/range-pin-panel.jsx +344 -0
- package/src/result-modal.jsx +5 -5
- package/src/room.jsx +79 -51
- package/src/scenes.js +5 -0
- package/src/semantic-edit.js +2 -0
- package/src/settings-menu.jsx +39 -105
- package/src/shell/BottomDock.jsx +450 -0
- package/src/shell/DetailsSlot.jsx +513 -0
- package/src/shell/LibrarySlot.jsx +86 -0
- package/src/shell/MenuBar.jsx +601 -0
- package/src/shell/OutlinerSlot.jsx +50 -0
- package/src/shell/PreferencesDialog.jsx +538 -0
- package/src/shell/PreferencesSlot.jsx +40 -0
- package/src/shell/StatusBar.jsx +125 -0
- package/src/shell/StudioShell.jsx +92 -0
- package/src/shell/TopBar.jsx +129 -0
- package/src/shell/ViewportToolbar.jsx +656 -0
- package/src/shell/agent-glass.css +296 -0
- package/src/shell/dock.css +93 -0
- package/src/shell/glass-regions.css +985 -0
- package/src/shell/glass.css +687 -0
- package/src/shell/log-store.js +52 -0
- package/src/shell/mode.css +9 -0
- package/src/shell/preferences.css +651 -0
- package/src/shell/shell.css +282 -0
- package/src/shell/studio-shell-context.js +19 -0
- package/src/shell/topbar.css +435 -0
- package/src/shell/viewport.css +451 -0
- package/src/store/authored-intent.js +22 -0
- package/src/store/runtime-adapters.js +92 -0
- package/src/store/scene-stage.js +21 -0
- package/src/store/use-document-store.js +14 -0
- package/src/studio-actions.js +210 -0
- package/src/studio-agent-commands.js +38 -268
- package/src/studio-agent-context.js +38 -9
- package/src/studio-agent-motion.js +122 -378
- package/src/studio-agent-protocol.js +66 -30
- package/src/studio-app-binding.js +379 -0
- package/src/studio-contact-sheet.js +73 -0
- package/src/studio-elements.js +58 -51
- package/src/styles/themes.css +200 -0
- package/src/styles/tokens.css +69 -0
- package/src/styles.css +443 -1753
- package/src/theme.js +44 -0
- package/src/timeline-extent.js +16 -0
- package/src/trail-key-conflicts.js +39 -0
- package/src/trail-pick.js +59 -0
- package/src/ui.jsx +8 -8
- package/src/use-case-question.jsx +66 -0
- package/src/workflow/AgentPanel.jsx +82 -69
- package/src/workflow/agent-client.js +12 -2
- package/src/workflow/agent-panel.css +34 -32
- package/src/workflow/cozy-scene-node.css +2 -0
- package/src/workflow/workflow.css +2 -0
- package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
- package/tools/ardy/bridge.mjs +164 -146
- package/tools/ardy/visual-qa.mjs +5 -5
- package/tools/bench/EXP3.md +61 -0
- package/tools/bench/cclay_bench_extract_incam.py +125 -0
- package/tools/bench/cclay_bench_extract_obs.py +204 -0
- package/tools/bench/cclay_bench_runner.py +42 -0
- package/tools/bench/cube-contact.mjs +162 -0
- package/tools/bench/exp3.mjs +152 -0
- package/tools/bench/extract-bench-lib.mjs +216 -0
- package/tools/bench/extract-bench.mjs +302 -0
- package/tools/bench/fal-generate.mjs +88 -0
- package/tools/bench/fit/README.md +189 -0
- package/tools/bench/fit/camera.mjs +59 -0
- package/tools/bench/fit/contact.mjs +419 -0
- package/tools/bench/fit/footlock.mjs +234 -0
- package/tools/bench/fit/motion.mjs +85 -0
- package/tools/bench/fit/pin.mjs +27 -0
- package/tools/bench/fit/remote.mjs +105 -0
- package/tools/bench/fit-bench.mjs +126 -0
- package/tools/bench/fit-sanity.mjs +45 -0
- package/tools/bench/metrics.mjs +313 -0
- package/tools/bench/obs/depth.mjs +258 -0
- package/tools/bench/obs/extrinsics.mjs +175 -0
- package/tools/bench/obs/fit_mannequin_betas.py +340 -0
- package/tools/bench/obs/ground.mjs +294 -0
- package/tools/bench/obs/heading.mjs +151 -0
- package/tools/bench/obs/ladder.mjs +555 -0
- package/tools/bench/obs/mannequin-betas.json +124 -0
- package/tools/bench/obs/remote.mjs +83 -0
- package/tools/bench/obs/rest_joints.py +52 -0
- package/tools/bench/obs/ybot-targets.mjs +49 -0
- package/tools/bench/obs-bench.mjs +520 -0
- package/tools/bench/score.mjs +343 -0
- package/tools/bench/summarize.mjs +86 -0
- package/tools/dev/pages/privacy.html +13 -8
- package/tools/gt-render/browser.mjs +159 -0
- package/tools/gt-render/camera-math.mjs +276 -0
- package/tools/gt-render/page.mjs +267 -0
- package/tools/gt-render/render.mjs +514 -0
- package/tools/gt-render/scene-box.mjs +31 -0
- package/tools/gt-render/take-transform.mjs +87 -0
- package/tools/qa/css-rule-usage.mjs +283 -0
- package/tools/qa/studio-control-count.mjs +70 -32
- package/tools/run-tests.mjs +58 -3
- package/tools/track/DESIGN.md +513 -0
- package/tools/track/backfill-provenance.mjs +116 -0
- package/tools/track/budget.mjs +8 -0
- package/tools/track/check-rig.mjs +209 -0
- package/tools/track/diagnostics.schema.json +60 -0
- package/tools/track/export-rig.mjs +256 -0
- package/tools/track/fallback.mjs +7 -0
- package/tools/track/fk-parity-fixture.mjs +161 -0
- package/tools/track/gate.mjs +349 -0
- package/tools/track/masks.mjs +318 -0
- package/tools/track/metrics.mjs +201 -0
- package/tools/track/publish-obs.mjs +108 -0
- package/tools/track/py/check_env.py +61 -0
- package/tools/track/py/eval_lr.py +229 -0
- package/tools/track/py/lr_viterbi.py +294 -0
- package/tools/track/py/masks.py +405 -0
- package/tools/track/py/objective.py +382 -0
- package/tools/track/py/rig.py +508 -0
- package/tools/track/py/scene.py +326 -0
- package/tools/track/py/test_joint_indices.py +123 -0
- package/tools/track/py/test_lr_viterbi.py +195 -0
- package/tools/track/py/test_masks.py +229 -0
- package/tools/track/py/test_rig.py +288 -0
- package/tools/track/py/test_scene.py +354 -0
- package/tools/track/py/test_track.py +328 -0
- package/tools/track/py/track.py +411 -0
- package/tools/track/remote.mjs +140 -0
- package/tools/track/rig-dump.mjs +227 -0
- package/tools/track/run-box-tests.mjs +14 -0
- package/tools/track/run-box.mjs +168 -0
- package/tools/track/setup-box.sh +59 -0
- package/tools/track/study-2d.mjs +728 -0
- package/dist/assets/analytics-B1hnH66c.js +0 -1
- package/dist/assets/app-Cu1cexZR.css +0 -1
- package/dist/assets/app-Dxdh8kds.js +0 -4861
- package/dist/assets/shot-prompt-C_g2BHVc.css +0 -1
- package/dist/assets/shot-prompt-HVJRP4Gr.js +0 -17
- package/dist/assets/workflow-CJd-PDtV.js +0 -179
- package/dist/assets/workflow-DsudxKHF.css +0 -1
- package/src/fal-motion-studio.jsx +0 -180
- package/src/scene-history.js +0 -129
package/src/settings-menu.jsx
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from "react";
|
|
2
|
-
import { getAnalyticsOptOut, setAnalyticsOptOut } from "./analytics.js";
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
2
|
import { LOCALE, ko, localeChosen, setLocale } from "./locale.js";
|
|
3
|
+
import PreferencesDialog from "./shell/PreferencesDialog.jsx";
|
|
4
4
|
|
|
5
5
|
// App settings — language and analytics — behind one labelled topbar trigger
|
|
6
6
|
// (#193, docs/studio-ui-ia.md R4). Neither item edits the document, so they do
|
|
7
7
|
// not belong on the Save/Export row as bare toggles.
|
|
8
8
|
//
|
|
9
|
-
// The
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// the trigger.
|
|
9
|
+
// The trigger now opens the v2 Preferences dialog. The old language strings stay
|
|
10
|
+
// here because the locale contract and the camera-tutorial source contract both
|
|
11
|
+
// treat this file as the Settings entry point.
|
|
13
12
|
const LANGUAGES = [
|
|
14
13
|
{ id: "en", label: "English", action: "Switch to English" },
|
|
15
14
|
{ id: "ko", label: "한국어", action: "한국어로 전환" },
|
|
@@ -17,43 +16,35 @@ const LANGUAGES = [
|
|
|
17
16
|
|
|
18
17
|
export default function SettingsMenu({ motionSetup, motionSetupReveal = 0 }) {
|
|
19
18
|
const [open, setOpen] = useState(false);
|
|
20
|
-
const [setupOpen, setSetupOpen] = useState(false);
|
|
21
|
-
const [optedOut, setOptedOut] = useState(getAnalyticsOptOut);
|
|
22
19
|
const triggerRef = useRef(null);
|
|
23
|
-
const
|
|
20
|
+
const close = useCallback(() => {
|
|
21
|
+
setOpen(false);
|
|
22
|
+
triggerRef.current?.focus();
|
|
23
|
+
}, []);
|
|
24
|
+
const openCameraTutorial = useCallback(() => {
|
|
25
|
+
window.dispatchEvent(new CustomEvent("cozyclay:camera-tutorial", { detail: { open: true } }));
|
|
26
|
+
setOpen(false);
|
|
27
|
+
}, []);
|
|
28
|
+
const cameraTutorial = (
|
|
29
|
+
<>
|
|
30
|
+
<h4>{ko("Help", "도움말")}</h4>
|
|
31
|
+
<button
|
|
32
|
+
type="button"
|
|
33
|
+
data-testid="settings-camera-tutorial"
|
|
34
|
+
title={ko("Learn the camera in seven steps", "일곱 단계로 카메라 익히기")}
|
|
35
|
+
onClick={openCameraTutorial}
|
|
36
|
+
>
|
|
37
|
+
{ko("Camera tutorial", "카메라 튜토리얼")}
|
|
38
|
+
</button>
|
|
39
|
+
</>
|
|
40
|
+
);
|
|
24
41
|
|
|
25
|
-
//
|
|
26
|
-
//
|
|
42
|
+
// A contextual readiness action can reveal Preferences directly on the
|
|
43
|
+
// motion connection page without changing the topbar or adding a second
|
|
44
|
+
// settings surface.
|
|
27
45
|
useEffect(() => {
|
|
28
|
-
if (
|
|
29
|
-
setOpen(true);
|
|
30
|
-
setSetupOpen(true);
|
|
46
|
+
if (motionSetupReveal) setOpen(true);
|
|
31
47
|
}, [motionSetupReveal]);
|
|
32
|
-
useEffect(() => {
|
|
33
|
-
if (open && setupOpen) setupRef.current?.focus();
|
|
34
|
-
}, [open, setupOpen, motionSetupReveal]);
|
|
35
|
-
|
|
36
|
-
// Dismissal mirrors the project menu: listen only while open, ignore
|
|
37
|
-
// presses inside the wrap so the trigger keeps toggling, close on Escape
|
|
38
|
-
// and hand focus back to the control the operator came from.
|
|
39
|
-
useEffect(() => {
|
|
40
|
-
if (!open) return undefined;
|
|
41
|
-
const onPointerDown = (event) => {
|
|
42
|
-
if (event.target instanceof Element && event.target.closest(".settings-menu-wrap")) return;
|
|
43
|
-
setOpen(false);
|
|
44
|
-
};
|
|
45
|
-
const onKeyDown = (event) => {
|
|
46
|
-
if (event.key !== "Escape") return;
|
|
47
|
-
setOpen(false);
|
|
48
|
-
triggerRef.current?.focus();
|
|
49
|
-
};
|
|
50
|
-
document.addEventListener("pointerdown", onPointerDown);
|
|
51
|
-
window.addEventListener("keydown", onKeyDown);
|
|
52
|
-
return () => {
|
|
53
|
-
document.removeEventListener("pointerdown", onPointerDown);
|
|
54
|
-
window.removeEventListener("keydown", onKeyDown);
|
|
55
|
-
};
|
|
56
|
-
}, [open]);
|
|
57
48
|
|
|
58
49
|
// First-run cue: the studio starts in English even on a Korean browser, so
|
|
59
50
|
// the only visible hint that Korean exists used to be the old locale button.
|
|
@@ -68,78 +59,21 @@ export default function SettingsMenu({ motionSetup, motionSetupReveal = 0 }) {
|
|
|
68
59
|
className="topbar-action settings-menu-trigger"
|
|
69
60
|
data-testid="settings-menu-trigger"
|
|
70
61
|
aria-expanded={open}
|
|
71
|
-
aria-haspopup="
|
|
62
|
+
aria-haspopup="dialog"
|
|
72
63
|
title={ko("Language and analytics", "언어 및 사용 통계")}
|
|
73
64
|
ref={triggerRef}
|
|
74
|
-
onClick={() =>
|
|
65
|
+
onClick={() => (open ? close() : setOpen(true))}
|
|
75
66
|
>
|
|
76
67
|
{label}
|
|
77
68
|
<span className="caret">▾</span>
|
|
78
69
|
</button>
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
data-testid="settings-motion-setup"
|
|
87
|
-
aria-expanded={setupOpen}
|
|
88
|
-
ref={setupRef}
|
|
89
|
-
onClick={() => setSetupOpen((value) => !value)}
|
|
90
|
-
>
|
|
91
|
-
{ko("Motion generation", "모션 생성")}
|
|
92
|
-
</button>
|
|
93
|
-
{setupOpen && motionSetup}
|
|
94
|
-
</>
|
|
95
|
-
)}
|
|
96
|
-
<h4>{ko("Language", "언어")}</h4>
|
|
97
|
-
{LANGUAGES.map((language) => (
|
|
98
|
-
<button
|
|
99
|
-
key={language.id}
|
|
100
|
-
type="button"
|
|
101
|
-
data-testid={`settings-locale-${language.id}`}
|
|
102
|
-
aria-pressed={LOCALE === language.id}
|
|
103
|
-
title={language.action}
|
|
104
|
-
onClick={() => setLocale(language.id)}
|
|
105
|
-
>
|
|
106
|
-
{language.label}
|
|
107
|
-
<span className="mark" aria-hidden="true">{LOCALE === language.id ? "✓" : ""}</span>
|
|
108
|
-
</button>
|
|
109
|
-
))}
|
|
110
|
-
<h4>{ko("Privacy", "개인정보")}</h4>
|
|
111
|
-
<button
|
|
112
|
-
type="button"
|
|
113
|
-
data-testid="settings-analytics"
|
|
114
|
-
aria-pressed={!optedOut}
|
|
115
|
-
title={optedOut
|
|
116
|
-
? ko("Turn anonymous analytics on", "익명 사용 통계 켜기")
|
|
117
|
-
: ko("Turn anonymous analytics off", "익명 사용 통계 끄기")}
|
|
118
|
-
onClick={async () => {
|
|
119
|
-
const actual = await setAnalyticsOptOut(!optedOut);
|
|
120
|
-
setOptedOut(actual);
|
|
121
|
-
}}
|
|
122
|
-
>
|
|
123
|
-
{ko("Anonymous analytics", "익명 사용 통계")}
|
|
124
|
-
<span className="mark">{optedOut ? ko("off", "끔") : ko("on", "켬")}</span>
|
|
125
|
-
</button>
|
|
126
|
-
{/* Learning the camera is not a document edit and not a topbar
|
|
127
|
-
button (R4): the tutorial opens from this closed popover, so the
|
|
128
|
-
mode budgets in docs/studio-ui-ia.md §1 are untouched. */}
|
|
129
|
-
<h4>{ko("Help", "도움말")}</h4>
|
|
130
|
-
<button
|
|
131
|
-
type="button"
|
|
132
|
-
data-testid="settings-camera-tutorial"
|
|
133
|
-
title={ko("Learn the camera in seven steps", "일곱 단계로 카메라 익히기")}
|
|
134
|
-
onClick={() => {
|
|
135
|
-
window.dispatchEvent(new CustomEvent("cozyclay:camera-tutorial", { detail: { open: true } }));
|
|
136
|
-
setOpen(false);
|
|
137
|
-
}}
|
|
138
|
-
>
|
|
139
|
-
{ko("Camera tutorial", "카메라 튜토리얼")}
|
|
140
|
-
</button>
|
|
141
|
-
</div>
|
|
142
|
-
)}
|
|
70
|
+
<PreferencesDialog
|
|
71
|
+
open={open}
|
|
72
|
+
onClose={close}
|
|
73
|
+
motionSetup={motionSetup}
|
|
74
|
+
initialSection={motionSetupReveal ? "ARDY Connection" : "Appearance"}
|
|
75
|
+
cameraTutorial={cameraTutorial}
|
|
76
|
+
/>
|
|
143
77
|
</div>
|
|
144
78
|
);
|
|
145
79
|
}
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import { useStudioShell } from "./studio-shell-context.js";
|
|
3
|
+
import { logStore } from "./log-store.js";
|
|
4
|
+
import "./dock.css";
|
|
5
|
+
import LibrarySlot from "./LibrarySlot.jsx";
|
|
6
|
+
import { ko } from "../locale.js";
|
|
7
|
+
import Timeline from "../ardy/timeline.jsx";
|
|
8
|
+
import { DEFAULT_PLAYBACK_SPEED, SHOT_ASPECT_PRESETS, sceneObjectNameDisplayKo } from "../app-stage.jsx";
|
|
9
|
+
import { motionEditLayout, createMotionEdit } from "../ardy/motion-edit.js";
|
|
10
|
+
import { trackFeature } from "../analytics.js";
|
|
11
|
+
import { defaultRailRange } from "../camera-rail-schedule.js";
|
|
12
|
+
|
|
13
|
+
// G11: the dock resizes within [220, 480] px, and never so far that the 3D
|
|
14
|
+
// viewport drops under 480 px (top bar 44 + status bar 24 + three 1 px gaps).
|
|
15
|
+
// Never below the sequencer's own grid (shell.css --shell-dock-default, 276
|
|
16
|
+
// px): a shorter dock would have to scroll its lanes.
|
|
17
|
+
export const DOCK_MIN_HEIGHT = 276;
|
|
18
|
+
export const DOCK_MAX_HEIGHT = 480;
|
|
19
|
+
const VIEWPORT_MIN_HEIGHT = 480;
|
|
20
|
+
const SHELL_FIXED_ROWS = 44 + 24 + 3;
|
|
21
|
+
// v2: v1 heights were clamped to a 324px floor that left an empty band under
|
|
22
|
+
// the lanes; they are dropped so the dock opens at its real grid height.
|
|
23
|
+
const DOCK_HEIGHT_KEY = "cozyclay.dock.height.v2";
|
|
24
|
+
|
|
25
|
+
function clampDockHeight(height) {
|
|
26
|
+
const roomy = Math.min(DOCK_MAX_HEIGHT, window.innerHeight - SHELL_FIXED_ROWS - VIEWPORT_MIN_HEIGHT);
|
|
27
|
+
return Math.round(Math.min(Math.max(DOCK_MIN_HEIGHT, roomy), Math.max(DOCK_MIN_HEIGHT, height)));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readStored(key) {
|
|
31
|
+
try {
|
|
32
|
+
return globalThis.localStorage?.getItem(key) ?? null;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function writeStored(key, value) {
|
|
39
|
+
try {
|
|
40
|
+
globalThis.localStorage?.setItem(key, value);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
console.warn(`[cozyclay] could not store ${key}`, error);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readDockHeight() {
|
|
47
|
+
const stored = Number(readStored(DOCK_HEIGHT_KEY));
|
|
48
|
+
return Number.isFinite(stored) && stored > 0 ? stored : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const DOCK_TABS = [
|
|
52
|
+
{ key: "animation", label: () => ko("Animation", "애니메이션") },
|
|
53
|
+
{ key: "assets", label: () => ko("Assets", "에셋") },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
/** Animation | Assets: both stay mounted, so switching keeps the Sequencer's
|
|
57
|
+
* scroll and zoom and the Library's folder and search. */
|
|
58
|
+
export default function BottomDock({ tab = "animation", onTabChange, embedded = false }) {
|
|
59
|
+
const {
|
|
60
|
+
tlFrame, motion, waypointMode, craneSelectedIndex,
|
|
61
|
+
isCameraSelection, addActiveCranePoint, deleteSelectedCranePoint, setCraneSelectedIndex, tlFrameCount,
|
|
62
|
+
tlFps, characters, activeCharIndex, ghostLayers, pathSpeed,
|
|
63
|
+
tlPlaying, workflowMode, waypoints, pendingWaypointFrame, promptClips,
|
|
64
|
+
selectedPromptId, stateBadge, ikMode, ikChains, applyMotionTrim,
|
|
65
|
+
resetMotionTrim, cutMotionAtPlayhead, changeMotionSegmentSpeed, removeMotionSegmentById, ikFrames,
|
|
66
|
+
rangePins, rangePinSelection, ikEditTool, rangePinPreview, footSnap,
|
|
67
|
+
bodyContact, shots, shotAspectKey, activeShotIdx, railDraw,
|
|
68
|
+
pathDraw, selectedSceneObject, setPathDraw, setRailDraw, setWorkspaceLayout,
|
|
69
|
+
changeSceneObject, timingTokenRef, beginSceneTransaction, endSceneTransaction, railCurve,
|
|
70
|
+
posing, ikAddKeyframe, ikDeleteKeyframe, setRangePinSelection, setIkEditTool,
|
|
71
|
+
setBodyContact, setToast, setFootSnap, setTlFrame, advanceFrame,
|
|
72
|
+
stepFrame, cameraPreviewEndRef, manualCameraOverrideRef, setTlPlaying, toggleWaypointMode,
|
|
73
|
+
setWaypointMode, selectActiveCharacterInHierarchy, setActiveWaypointId, setPendingWaypointFrame, removeWaypoint,
|
|
74
|
+
queueRootWaypointFrame, addPromptClip, revealPromptBlocks, setSelectedPromptId, setArdyPrompt,
|
|
75
|
+
changePromptClip, resizePromptClip, movePromptClip, removePromptClip, setSelectedHierarchyId,
|
|
76
|
+
selectWorkflowMode, addCameraKeyframe, moveCameraKeyframe, removeCameraKeyframe, syncActiveCameraFraming,
|
|
77
|
+
activeCamera, activeShotDuration, changeActiveCamera, cameraRail, previewCameraShot,
|
|
78
|
+
toggleCameraRailDraw, deleteCameraRail, selectTimelineShot, shotsDomain, runStudioAction,
|
|
79
|
+
clearMotion, subscribeToasts, exportStatus, exportPhaseLabel, ardyRunning, ardyStatus,
|
|
80
|
+
ardyOutcome,
|
|
81
|
+
} = useStudioShell();
|
|
82
|
+
const dockRef = useRef(null);
|
|
83
|
+
const [dockHeight, setDockHeight] = useState(readDockHeight);
|
|
84
|
+
const dockHeightRef = useRef(dockHeight);
|
|
85
|
+
dockHeightRef.current = dockHeight;
|
|
86
|
+
|
|
87
|
+
// The dock height is the shell row's --shell-dock-height. null keeps the
|
|
88
|
+
// shell's responsive default; a stored size is re-clamped on every window
|
|
89
|
+
// resize so the viewport never drops under its minimum height.
|
|
90
|
+
useLayoutEffect(() => {
|
|
91
|
+
const app = dockRef.current?.closest(".app");
|
|
92
|
+
if (!app) return undefined;
|
|
93
|
+
const apply = () => {
|
|
94
|
+
if (dockHeightRef.current === null) app.style.removeProperty("--shell-dock-height");
|
|
95
|
+
else app.style.setProperty("--shell-dock-height", `${clampDockHeight(dockHeightRef.current)}px`);
|
|
96
|
+
};
|
|
97
|
+
apply();
|
|
98
|
+
window.addEventListener("resize", apply);
|
|
99
|
+
return () => window.removeEventListener("resize", apply);
|
|
100
|
+
}, [dockHeight]);
|
|
101
|
+
|
|
102
|
+
function commitDockHeight(next) {
|
|
103
|
+
const height = clampDockHeight(next);
|
|
104
|
+
setDockHeight(height);
|
|
105
|
+
writeStored(DOCK_HEIGHT_KEY, String(height));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function beginDockResize(event) {
|
|
109
|
+
if (event.button !== 0) return;
|
|
110
|
+
event.preventDefault();
|
|
111
|
+
event.stopPropagation();
|
|
112
|
+
const startY = event.clientY;
|
|
113
|
+
const startHeight = dockRef.current.getBoundingClientRect().height;
|
|
114
|
+
let latest = startHeight;
|
|
115
|
+
const onMove = (move) => {
|
|
116
|
+
latest = clampDockHeight(startHeight - (move.clientY - startY));
|
|
117
|
+
setDockHeight(latest);
|
|
118
|
+
};
|
|
119
|
+
const onUp = () => {
|
|
120
|
+
document.body.classList.remove("is-resizing", "resize-timeline");
|
|
121
|
+
window.removeEventListener("pointermove", onMove);
|
|
122
|
+
window.removeEventListener("pointerup", onUp);
|
|
123
|
+
window.removeEventListener("pointercancel", onUp);
|
|
124
|
+
commitDockHeight(latest);
|
|
125
|
+
};
|
|
126
|
+
document.body.classList.add("is-resizing", "resize-timeline");
|
|
127
|
+
window.addEventListener("pointermove", onMove);
|
|
128
|
+
window.addEventListener("pointerup", onUp);
|
|
129
|
+
window.addEventListener("pointercancel", onUp);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function onDockResizeKey(event) {
|
|
133
|
+
const step = event.shiftKey ? 48 : 16;
|
|
134
|
+
const current = dockRef.current.getBoundingClientRect().height;
|
|
135
|
+
if (event.key === "ArrowUp") commitDockHeight(current + step);
|
|
136
|
+
else if (event.key === "ArrowDown") commitDockHeight(current - step);
|
|
137
|
+
else if (event.key === "Home") commitDockHeight(DOCK_MIN_HEIGHT);
|
|
138
|
+
else if (event.key === "End") commitDockHeight(DOCK_MAX_HEIGHT);
|
|
139
|
+
else return;
|
|
140
|
+
event.preventDefault();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Session Log: every toast (App fans each one out to subscribeToasts),
|
|
144
|
+
// generation jobs and export phases. The toast sink set lives for the
|
|
145
|
+
// whole session, so one subscription on mount is enough.
|
|
146
|
+
useEffect(() => subscribeToasts((toast) => logStore.push({ kind: "toast", text: toast.uiMessage })),
|
|
147
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
148
|
+
[]);
|
|
149
|
+
const generationWasRunning = useRef(false);
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
if (ardyRunning && !generationWasRunning.current) logStore.push({ kind: "generation", text: ko("Motion generation started", "모션 생성을 시작했어요") });
|
|
152
|
+
generationWasRunning.current = ardyRunning;
|
|
153
|
+
}, [ardyRunning]);
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
if (ardyRunning && ardyStatus) logStore.push({ kind: "generation", key: "generation:status", text: ardyStatus });
|
|
156
|
+
}, [ardyRunning, ardyStatus]);
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (!ardyOutcome) return;
|
|
159
|
+
logStore.push({ kind: "generation", text: ardyOutcome.ok
|
|
160
|
+
? ko("Motion generation finished", "모션 생성을 마쳤어요")
|
|
161
|
+
: ko(`Motion generation failed — ${ardyOutcome.message}`, `모션 생성 실패 — ${ardyOutcome.message}`) });
|
|
162
|
+
}, [ardyOutcome]);
|
|
163
|
+
const exportPhase = exportStatus?.phase, exportLabel = exportStatus?.label, exportMessage = exportStatus?.message;
|
|
164
|
+
const exportDone = exportStatus?.completedFrames, exportTotal = exportStatus?.frameCount;
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
if (!exportPhase) return;
|
|
167
|
+
const progress = exportPhase === "encoding" && exportTotal ? ` ${exportDone ?? 0}/${exportTotal}` : "";
|
|
168
|
+
const detail = ["completed", "failed", "cancelled"].includes(exportPhase) && exportMessage ? ` — ${exportMessage}` : "";
|
|
169
|
+
logStore.push({
|
|
170
|
+
kind: "export",
|
|
171
|
+
key: `export:${exportLabel}:${exportPhase}`,
|
|
172
|
+
text: `${ko("Export", "내보내기")} ${exportPhaseLabel(exportPhase)}${exportLabel ? ` · ${exportLabel}` : ""}${progress}${detail}`,
|
|
173
|
+
});
|
|
174
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
175
|
+
}, [exportPhase, exportLabel, exportMessage, exportDone, exportTotal]);
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<div className="bottom-window v2-dock" ref={dockRef}>
|
|
179
|
+
<div
|
|
180
|
+
className="v2-dock-resize"
|
|
181
|
+
data-testid="dock-resize-handle"
|
|
182
|
+
role="separator"
|
|
183
|
+
tabIndex={0}
|
|
184
|
+
aria-orientation="horizontal"
|
|
185
|
+
aria-label={ko("Resize bottom dock", "하단 도크 크기 조절")}
|
|
186
|
+
aria-valuemin={DOCK_MIN_HEIGHT}
|
|
187
|
+
aria-valuemax={DOCK_MAX_HEIGHT}
|
|
188
|
+
aria-valuenow={dockHeight ?? undefined}
|
|
189
|
+
onPointerDown={beginDockResize}
|
|
190
|
+
onKeyDown={onDockResizeKey}
|
|
191
|
+
/>
|
|
192
|
+
{!embedded && <div className="dock-tabs" role="tablist" aria-label={ko("Dock", "도크")}>
|
|
193
|
+
{DOCK_TABS.map((entry) => (
|
|
194
|
+
<button
|
|
195
|
+
type="button"
|
|
196
|
+
role="tab"
|
|
197
|
+
key={entry.key}
|
|
198
|
+
data-testid={`dock-tab-${entry.key}`}
|
|
199
|
+
aria-selected={tab === entry.key}
|
|
200
|
+
onClick={() => onTabChange?.(entry.key)}
|
|
201
|
+
>
|
|
202
|
+
{entry.label()}
|
|
203
|
+
</button>
|
|
204
|
+
))}
|
|
205
|
+
</div>}
|
|
206
|
+
{!embedded && <LibrarySlot active={tab === "assets"} />}
|
|
207
|
+
<div className="bottom-timeline" hidden={tab !== "animation"}>
|
|
208
|
+
<Timeline
|
|
209
|
+
frame={tlFrame}
|
|
210
|
+
craneSelectedIndex={craneSelectedIndex}
|
|
211
|
+
cameraSelected={isCameraSelection}
|
|
212
|
+
onCranePointAdd={addActiveCranePoint}
|
|
213
|
+
onCranePointDelete={deleteSelectedCranePoint}
|
|
214
|
+
onCranePointSelect={setCraneSelectedIndex}
|
|
215
|
+
frameCount={tlFrameCount}
|
|
216
|
+
fps={tlFps}
|
|
217
|
+
playbackSpeed={DEFAULT_PLAYBACK_SPEED}
|
|
218
|
+
trackOwner={characters.length > 1 ? `S${activeCharIndex + 1}` : null}
|
|
219
|
+
ghostLayers={ghostLayers}
|
|
220
|
+
pathSpeed={pathSpeed}
|
|
221
|
+
playing={tlPlaying}
|
|
222
|
+
workflowMode={workflowMode === "pose" ? "motion" : workflowMode}
|
|
223
|
+
waypointMode={waypointMode}
|
|
224
|
+
waypoints={waypoints}
|
|
225
|
+
pathSpeed={pathSpeed}
|
|
226
|
+
pendingWaypointFrame={pendingWaypointFrame}
|
|
227
|
+
promptClips={promptClips}
|
|
228
|
+
selectedPromptId={selectedPromptId}
|
|
229
|
+
badge={stateBadge}
|
|
230
|
+
ikMode={ikMode}
|
|
231
|
+
ikDisabled={!ikChains}
|
|
232
|
+
motion={motion ? {
|
|
233
|
+
frames: motion.frames,
|
|
234
|
+
label: motion.prompt || ko("Loaded take", "불러온 테이크"),
|
|
235
|
+
segments: motionEditLayout(motion.editSegments ?? createMotionEdit(motion.frames)),
|
|
236
|
+
} : null}
|
|
237
|
+
onMotionTrim={applyMotionTrim}
|
|
238
|
+
onMotionTrimReset={resetMotionTrim}
|
|
239
|
+
onMotionCut={cutMotionAtPlayhead}
|
|
240
|
+
onMotionSpeedChange={changeMotionSegmentSpeed}
|
|
241
|
+
onMotionSegmentRemove={removeMotionSegmentById}
|
|
242
|
+
ikFrames={ikFrames}
|
|
243
|
+
rangePins={rangePins}
|
|
244
|
+
selectedPinId={rangePinSelection}
|
|
245
|
+
pendingPinRange={ikMode && ikEditTool === "pin" ? rangePinPreview?.draft ?? null : null}
|
|
246
|
+
footSnap={footSnap}
|
|
247
|
+
bodyContact={bodyContact}
|
|
248
|
+
shots={shots}
|
|
249
|
+
shotAspect={shotAspectKey}
|
|
250
|
+
activeShotIdx={activeShotIdx}
|
|
251
|
+
railDraw={railDraw}
|
|
252
|
+
pathDraw={pathDraw}
|
|
253
|
+
pathObject={selectedSceneObject ? { id: selectedSceneObject.id, name: sceneObjectNameDisplayKo(selectedSceneObject.name), path: selectedSceneObject.path } : null}
|
|
254
|
+
onObjectPathDrawToggle={() => {
|
|
255
|
+
setPathDraw((current) => !current);
|
|
256
|
+
if (!pathDraw) setRailDraw(false);
|
|
257
|
+
setWorkspaceLayout((current) => ({ ...current, insetCollapsed: false }));
|
|
258
|
+
}}
|
|
259
|
+
onObjectPathChange={(path) => {
|
|
260
|
+
if (selectedSceneObject) changeSceneObject(selectedSceneObject.id, { path }, timingTokenRef.current ?? undefined);
|
|
261
|
+
}}
|
|
262
|
+
onObjectPathClear={() => {
|
|
263
|
+
if (!selectedSceneObject) return;
|
|
264
|
+
const token = beginSceneTransaction({ owner: "object-path", cancel: () => {} });
|
|
265
|
+
changeSceneObject(selectedSceneObject.id, { path: null }, token);
|
|
266
|
+
endSceneTransaction(token, { commit: true });
|
|
267
|
+
}}
|
|
268
|
+
onObjectTimingGestureStart={() => {
|
|
269
|
+
timingTokenRef.current = beginSceneTransaction({ owner: "object-timing", cancel: () => { timingTokenRef.current = null; } });
|
|
270
|
+
}}
|
|
271
|
+
onObjectTimingGestureEnd={() => {
|
|
272
|
+
if (timingTokenRef.current != null) endSceneTransaction(timingTokenRef.current, { commit: true });
|
|
273
|
+
timingTokenRef.current = null;
|
|
274
|
+
}}
|
|
275
|
+
cameraRailLength={railCurve?.length ?? null}
|
|
276
|
+
shotCutDisabled={!!posing || ikMode || waypointMode}
|
|
277
|
+
onIkKeyframeAdd={ikAddKeyframe}
|
|
278
|
+
onIkKeyframeRemove={ikDeleteKeyframe}
|
|
279
|
+
onPinSelect={(id) => { setRangePinSelection(id); setIkEditTool("pin"); }}
|
|
280
|
+
onBodyContactToggle={() => {
|
|
281
|
+
setBodyContact((v) => {
|
|
282
|
+
setToast(v ? ko("Body contact off — floor constraints are disabled", "바닥 접촉 꺼짐 — 바닥 제약이 비활성화됩니다") : ko("Body contact on — body markers stay above the floor", "바닥 접촉 켜짐 — 신체 접촉점이 바닥 아래로 내려가지 않습니다"));
|
|
283
|
+
return !v;
|
|
284
|
+
});
|
|
285
|
+
}}
|
|
286
|
+
onFootSnapToggle={() => {
|
|
287
|
+
setFootSnap((v) => {
|
|
288
|
+
setToast(v ? ko("Foot snap off — the feet follow the body", "발 스냅 꺼짐 — 발이 몸을 따라갑니다") : ko("Foot snap on — the feet stay planted while the body moves", "발 스냅 켜짐 — 몸이 움직여도 발은 바닥에 고정됩니다"));
|
|
289
|
+
return !v;
|
|
290
|
+
});
|
|
291
|
+
}}
|
|
292
|
+
onScrub={(frame) => { trackFeature("timeline_scrub"); setTlFrame(frame); }}
|
|
293
|
+
onAdvance={advanceFrame}
|
|
294
|
+
onStep={stepFrame}
|
|
295
|
+
onPlayToggle={() => {
|
|
296
|
+
cameraPreviewEndRef.current = null;
|
|
297
|
+
manualCameraOverrideRef.current = false;
|
|
298
|
+
setTlPlaying((v) => !v);
|
|
299
|
+
}}
|
|
300
|
+
onWaypointToggle={toggleWaypointMode}
|
|
301
|
+
onMarkerSelect={(id) => {
|
|
302
|
+
const waypoint = waypoints.find((entry) => entry.id === id);
|
|
303
|
+
if (!waypoint) throw new Error(`Unknown waypoints ID: ${id}`);
|
|
304
|
+
setTlFrame(Math.min(waypoint.frame, tlFrameCount - 1));
|
|
305
|
+
setWaypointMode(true);
|
|
306
|
+
selectActiveCharacterInHierarchy();
|
|
307
|
+
setActiveWaypointId(id);
|
|
308
|
+
setPendingWaypointFrame(null);
|
|
309
|
+
}}
|
|
310
|
+
onMarkerRemove={removeWaypoint}
|
|
311
|
+
onRootKeyframeAdd={queueRootWaypointFrame}
|
|
312
|
+
onPromptAdd={(frame) => {
|
|
313
|
+
addPromptClip(frame);
|
|
314
|
+
selectActiveCharacterInHierarchy();
|
|
315
|
+
revealPromptBlocks();
|
|
316
|
+
}}
|
|
317
|
+
onPromptSelect={(id) => {
|
|
318
|
+
setSelectedPromptId(id);
|
|
319
|
+
setArdyPrompt(promptClips.find((clip) => clip.id === id)?.text ?? "");
|
|
320
|
+
selectActiveCharacterInHierarchy();
|
|
321
|
+
revealPromptBlocks();
|
|
322
|
+
}}
|
|
323
|
+
onPromptChange={changePromptClip}
|
|
324
|
+
onPromptResize={resizePromptClip}
|
|
325
|
+
onPromptMove={movePromptClip}
|
|
326
|
+
onPromptRemove={removePromptClip}
|
|
327
|
+
onCameraMoveSelect={() => {
|
|
328
|
+
setSelectedHierarchyId("camera");
|
|
329
|
+
if (workflowMode !== "camera") selectWorkflowMode("camera");
|
|
330
|
+
}}
|
|
331
|
+
onCameraKeyframeAdd={addCameraKeyframe}
|
|
332
|
+
onCameraKeyframeMove={moveCameraKeyframe}
|
|
333
|
+
onCameraKeyframeRemove={removeCameraKeyframe}
|
|
334
|
+
onCameraBlockSelect={(shotId) => {
|
|
335
|
+
const selected = shots.find((entry) => entry.id === shotId);
|
|
336
|
+
if (!selected) throw new Error(`Unknown shots ID: ${shotId}`);
|
|
337
|
+
setTlFrame(selected.startFrame);
|
|
338
|
+
setSelectedHierarchyId("camera");
|
|
339
|
+
if (workflowMode !== "camera") selectWorkflowMode("camera");
|
|
340
|
+
}}
|
|
341
|
+
onCameraBlockChange={(patch, shotId) => {
|
|
342
|
+
if (patch.mode === "follow") syncActiveCameraFraming();
|
|
343
|
+
const nextPatch = patch.mode === "rail" && activeCamera.railFollow?.mode === "off"
|
|
344
|
+
? { ...patch, railFollow: defaultRailRange(activeShotDuration) }
|
|
345
|
+
: patch;
|
|
346
|
+
// The embedded dolly graph edits the shot it sits in; the
|
|
347
|
+
// camera bar above edits the selected one.
|
|
348
|
+
changeActiveCamera(nextPatch, shotId);
|
|
349
|
+
if (patch.mode === "follow" && !motion) {
|
|
350
|
+
setToast(ko(
|
|
351
|
+
"Follow rides the subject's motion — without a loaded motion the camera composes a static frame",
|
|
352
|
+
"팔로우 카메라는 인물 모션을 따라 움직입니다 — 모션이 없으면 카메라는 정지 구도를 유지합니다",
|
|
353
|
+
));
|
|
354
|
+
}
|
|
355
|
+
if (patch.mode === "rail" && !cameraRail) {
|
|
356
|
+
setRailDraw(true);
|
|
357
|
+
setWorkspaceLayout((current) => ({ ...current, insetCollapsed: false }));
|
|
358
|
+
setToast(ko("Draw this Camera Block's rail in the Top-View", "탑뷰에서 이 카메라 블록의 레일을 그리세요"));
|
|
359
|
+
}
|
|
360
|
+
}}
|
|
361
|
+
onCameraPreview={previewCameraShot}
|
|
362
|
+
onCameraRailDrawToggle={toggleCameraRailDraw}
|
|
363
|
+
onCameraRailDelete={deleteCameraRail}
|
|
364
|
+
onShotSelect={selectTimelineShot}
|
|
365
|
+
onShotBoundaryMove={shotsDomain.resizeTimelineShot}
|
|
366
|
+
onShotRename={shotsDomain.renameTimelineShot}
|
|
367
|
+
onShotRemove={(shotId) => runStudioAction("shot.remove", { shotId })}
|
|
368
|
+
onShotDuplicate={(shotId) => runStudioAction("shot.duplicate", { shotId })}
|
|
369
|
+
onShotCut={() => runStudioAction("shot.create")}
|
|
370
|
+
onShotSplit={(shotId) => runStudioAction("shot.split", { shotId })}
|
|
371
|
+
onShotMove={(shotId, targetFrame) => runStudioAction("shot.reorder", { shotId, startFrame: Math.max(0, Math.round(targetFrame)) })}
|
|
372
|
+
onClearMotion={motion ? clearMotion : null}
|
|
373
|
+
/>
|
|
374
|
+
</div>
|
|
375
|
+
{tab === "animation" && <ShotCard />}
|
|
376
|
+
</div>
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// #570: the ratios offered on the card; the rest stay in Camera mode's menu.
|
|
381
|
+
const CARD_RATIOS = [
|
|
382
|
+
{ key: "16:9", label: "16:9" },
|
|
383
|
+
{ key: "2.39:1", label: "2.39" },
|
|
384
|
+
{ key: "4:3", label: "4:3" },
|
|
385
|
+
{ key: "1:1", label: "1:1" },
|
|
386
|
+
{ key: "9:16", label: "9:16" },
|
|
387
|
+
];
|
|
388
|
+
|
|
389
|
+
/** The shot camera's card at the dock's right end. The camera itself is drawn
|
|
390
|
+
* into the stage canvas under `.vp-shot-preview`, which sits over the card's
|
|
391
|
+
* frame slot (see glass.css); the card holds its title, ratio and look-through. */
|
|
392
|
+
function ShotCard() {
|
|
393
|
+
const { activeShot, shot, shotAspectKey, runStudioAction, enterShotLook, exitShotLook, lookThroughShot, tlFps, embedMode } = useStudioShell();
|
|
394
|
+
if (embedMode) return null;
|
|
395
|
+
const ratio = SHOT_ASPECT_PRESETS[shotAspectKey]?.label?.replace(/:1$/, "") ?? shotAspectKey;
|
|
396
|
+
const frames = activeShot && Number.isFinite(activeShot.startFrame) && Number.isFinite(activeShot.endFrame)
|
|
397
|
+
? `${activeShot.startFrame}–${activeShot.endFrame} · ${((activeShot.endFrame - activeShot.startFrame + 1) / (tlFps || 24)).toFixed(1)}s`
|
|
398
|
+
: null;
|
|
399
|
+
return (
|
|
400
|
+
<section className="dock-shot-card" aria-label={ko("Shot Camera", "샷 카메라")}>
|
|
401
|
+
<header className="dock-shot-head">
|
|
402
|
+
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2 5.75A1.5 1.5 0 0 1 3.5 4.25h6A1.5 1.5 0 0 1 11 5.75v4.5a1.5 1.5 0 0 1-1.5 1.5h-6A1.5 1.5 0 0 1 2 10.25z M11 7l3-1.5v5L11 9" /></svg>
|
|
403
|
+
<span className="dock-shot-title">{ko("Shot Camera", "샷 카메라")}</span>
|
|
404
|
+
<span className="dock-shot-meta">{shot.focalMm} mm · {ratio}</span>
|
|
405
|
+
</header>
|
|
406
|
+
{/* While the main view looks through this camera the card has nothing
|
|
407
|
+
of its own to show (the preview pass is off): it says where the
|
|
408
|
+
picture went instead of leaving a hole onto the stage. */}
|
|
409
|
+
<div className="dock-shot-frame" aria-hidden={!lookThroughShot || undefined}>
|
|
410
|
+
{lookThroughShot && (
|
|
411
|
+
<span className="dock-shot-live">
|
|
412
|
+
<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>
|
|
413
|
+
{ko("Viewing in the main view", "메인 뷰에서 보는 중")}
|
|
414
|
+
</span>
|
|
415
|
+
)}
|
|
416
|
+
</div>
|
|
417
|
+
<div className="dock-shot-ratios" role="radiogroup" aria-label={ko("Shot aspect ratio", "샷 화면 비율")}>
|
|
418
|
+
{CARD_RATIOS.map((entry) => (
|
|
419
|
+
<button
|
|
420
|
+
type="button"
|
|
421
|
+
role="radio"
|
|
422
|
+
key={entry.key}
|
|
423
|
+
data-aspect={entry.key}
|
|
424
|
+
aria-checked={shotAspectKey === entry.key}
|
|
425
|
+
onClick={() => runStudioAction("stage.setFilmback", { shotAspect: entry.key })}
|
|
426
|
+
>
|
|
427
|
+
{entry.label}
|
|
428
|
+
</button>
|
|
429
|
+
))}
|
|
430
|
+
</div>
|
|
431
|
+
<footer className="dock-shot-foot">
|
|
432
|
+
<span className="dock-shot-name">
|
|
433
|
+
{activeShot?.name ?? ko("No shot yet", "샷 없음")}
|
|
434
|
+
{frames && <span className="dock-shot-range"> · {frames}</span>}
|
|
435
|
+
</span>
|
|
436
|
+
{lookThroughShot ? (
|
|
437
|
+
<button type="button" className="dock-shot-look" data-active="true" aria-pressed="true" onClick={exitShotLook} title={ko("Back to the editor view (Esc)", "편집 시점으로 돌아가기 (Esc)")}>
|
|
438
|
+
{ko("Exit", "나가기")}
|
|
439
|
+
<kbd>Esc</kbd>
|
|
440
|
+
</button>
|
|
441
|
+
) : (
|
|
442
|
+
<button type="button" className="dock-shot-look" aria-pressed="false" onClick={enterShotLook} title={ko("Look through the shot camera (Esc returns)", "샷 카메라 시점으로 보기 (Esc로 복귀)")}>
|
|
443
|
+
<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>
|
|
444
|
+
{ko("Look through", "샷 시점")}
|
|
445
|
+
</button>
|
|
446
|
+
)}
|
|
447
|
+
</footer>
|
|
448
|
+
</section>
|
|
449
|
+
);
|
|
450
|
+
}
|