cozyclay 1.9.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 +171 -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-PROTOCOL.md +1 -1
- 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 +224 -377
- package/package.json +2 -1
- package/src/App.jsx +1273 -9075
- 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/collision-blockers.js +9 -3
- package/src/ardy/fix-collisions.js +10 -66
- package/src/ardy/ground.js +4 -4
- package/src/ardy/ik-drag.js +101 -0
- package/src/ardy/ik-key-json.js +43 -0
- package/src/ardy/ik.js +122 -76
- 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-model.js +24 -16
- package/src/hierarchy-panel.css +386 -0
- package/src/hierarchy-panel.jsx +150 -21
- 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/otio.js +2 -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/scene-objects.js +37 -1
- 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 +41 -271
- package/src/studio-agent-context.js +39 -10
- package/src/studio-agent-motion.js +122 -373
- package/src/studio-agent-protocol.js +67 -31
- 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 +460 -1742
- 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/morphgs/assets/gen_truth.py +39 -0
- package/tools/morphgs/assets/mesh_ori_rig.txt +27 -0
- package/tools/morphgs/assets/playback-check.mjs +30 -0
- package/tools/morphgs/demo-gate.sh +26 -0
- package/tools/morphgs/fbx2morphgs.mjs +68 -0
- package/tools/morphgs/morphgs-to-cskel27.mjs +105 -0
- package/tools/morphgs/patches/preprocess_src-none-mode.patch +76 -0
- package/tools/morphgs/setup-on-cluster.sh +126 -0
- package/tools/qa/css-rule-usage.mjs +283 -0
- package/tools/qa/studio-control-count.mjs +70 -32
- package/tools/run-tests.mjs +134 -12
- 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-BWusbqgO.js +0 -4861
- package/dist/assets/app-qKDo4PBX.css +0 -1
- package/dist/assets/shot-prompt-CHUtw6af.js +0 -17
- package/dist/assets/shot-prompt-C_g2BHVc.css +0 -1
- package/dist/assets/workflow-CVwzMhz2.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
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
import { createContext, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import { useStudioShell } from "./studio-shell-context.js";
|
|
4
|
+
import SourceOffer from "../source-offer.jsx";
|
|
5
|
+
import { ko } from "../locale.js";
|
|
6
|
+
|
|
7
|
+
const MAC = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform ?? "");
|
|
8
|
+
const MOD = MAC ? "⌘" : "Ctrl+";
|
|
9
|
+
const SHIFT = MAC ? "⇧" : "Shift+";
|
|
10
|
+
const MARGIN = 8;
|
|
11
|
+
|
|
12
|
+
/** The items of one menu, not of a flyout nested inside it. */
|
|
13
|
+
function menuItems(menu) {
|
|
14
|
+
return [...menu.querySelectorAll('[role^="menuitem"]')]
|
|
15
|
+
.filter((item) => item.closest('[role="menu"]') === menu && !item.disabled);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Fixed-position placement from an anchor rect, the portal Dropdown's
|
|
19
|
+
// geometry (src/ui.jsx): below the anchor, or beside it for a flyout, and
|
|
20
|
+
// always clamped inside the viewport so narrow windows keep every item.
|
|
21
|
+
function place(anchor, popup, side, align) {
|
|
22
|
+
const a = anchor.getBoundingClientRect();
|
|
23
|
+
const width = popup.offsetWidth;
|
|
24
|
+
const vw = window.innerWidth;
|
|
25
|
+
const vh = window.innerHeight;
|
|
26
|
+
const clampX = (x) => Math.max(MARGIN, Math.min(x, vw - width - MARGIN));
|
|
27
|
+
let left;
|
|
28
|
+
let top;
|
|
29
|
+
if (side === "right" && a.right + width + MARGIN <= vw) {
|
|
30
|
+
left = a.right;
|
|
31
|
+
top = a.top - 4;
|
|
32
|
+
} else if (side === "right" && a.left - width - MARGIN >= 0) {
|
|
33
|
+
left = a.left - width;
|
|
34
|
+
top = a.top - 4;
|
|
35
|
+
} else {
|
|
36
|
+
left = clampX(align === "end" ? a.right - width : a.left);
|
|
37
|
+
top = a.bottom + (side === "right" ? 0 : 4);
|
|
38
|
+
}
|
|
39
|
+
const maxHeight = Math.max(120, vh - top - MARGIN);
|
|
40
|
+
return { left: clampX(left), top: Math.max(MARGIN, top), maxHeight };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A flyout is placed from its trigger, which only has its final rect once the
|
|
44
|
+
// parent menu is placed; the parent's box is therefore a dependency.
|
|
45
|
+
const ParentBox = createContext(null);
|
|
46
|
+
|
|
47
|
+
function usePlacement(anchorRef, popupRef, side, align, ready) {
|
|
48
|
+
const [box, setBox] = useState(null);
|
|
49
|
+
useLayoutEffect(() => {
|
|
50
|
+
if (!ready) return undefined;
|
|
51
|
+
const update = () => {
|
|
52
|
+
if (!anchorRef.current || !popupRef.current) return;
|
|
53
|
+
const next = place(anchorRef.current, popupRef.current, side, align);
|
|
54
|
+
setBox((prev) => (prev && prev.left === next.left && prev.top === next.top && prev.maxHeight === next.maxHeight ? prev : next));
|
|
55
|
+
};
|
|
56
|
+
update();
|
|
57
|
+
const observer = new ResizeObserver(update);
|
|
58
|
+
if (popupRef.current) observer.observe(popupRef.current);
|
|
59
|
+
return () => observer.disconnect();
|
|
60
|
+
}, [anchorRef, popupRef, side, align, ready]);
|
|
61
|
+
return box;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Keyboard opening moves focus to the first item, once the menu is placed:
|
|
65
|
+
* an unplaced menu is still visibility:hidden and cannot take focus. */
|
|
66
|
+
function useFirstItemFocus(ref, box, autoFocus, onAutoFocused) {
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (!box || !autoFocus || !ref.current) return;
|
|
69
|
+
menuItems(ref.current)[0]?.focus();
|
|
70
|
+
onAutoFocused?.();
|
|
71
|
+
}, [ref, box, autoFocus, onAutoFocused]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One portaled menu. Outside presses, Escape and window resizes dismiss it;
|
|
75
|
+
* arrow keys walk its items. `keepOpen` reports a nested flyout that owns
|
|
76
|
+
* Escape first (App closes the Export flyout on the same key). */
|
|
77
|
+
export function MenuPopover({ anchorRef, ignoreRef, onClose, align = "start", label, className = "", keepOpen, onKeyDown, autoFocus, onAutoFocused, children, menuRef: externalRef, ...props }) {
|
|
78
|
+
const ownRef = useRef(null);
|
|
79
|
+
const menuRef = externalRef ?? ownRef;
|
|
80
|
+
const box = usePlacement(anchorRef, menuRef, "below", align, true);
|
|
81
|
+
useFirstItemFocus(menuRef, box, autoFocus, onAutoFocused);
|
|
82
|
+
const close = useRef(onClose);
|
|
83
|
+
close.current = onClose;
|
|
84
|
+
const keep = useRef(keepOpen);
|
|
85
|
+
keep.current = keepOpen;
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
const onPointerDown = (event) => {
|
|
88
|
+
const target = event.target;
|
|
89
|
+
if (!(target instanceof Node)) return;
|
|
90
|
+
if (menuRef.current?.contains(target) || anchorRef.current?.contains(target) || ignoreRef?.current?.contains(target)) return;
|
|
91
|
+
close.current(false);
|
|
92
|
+
};
|
|
93
|
+
const onKey = (event) => {
|
|
94
|
+
if (event.key !== "Escape" || keep.current?.()) return;
|
|
95
|
+
event.preventDefault();
|
|
96
|
+
close.current(true);
|
|
97
|
+
};
|
|
98
|
+
const onResize = () => close.current(false);
|
|
99
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
100
|
+
document.addEventListener("keydown", onKey);
|
|
101
|
+
window.addEventListener("resize", onResize);
|
|
102
|
+
return () => {
|
|
103
|
+
document.removeEventListener("pointerdown", onPointerDown);
|
|
104
|
+
document.removeEventListener("keydown", onKey);
|
|
105
|
+
window.removeEventListener("resize", onResize);
|
|
106
|
+
};
|
|
107
|
+
}, [anchorRef, ignoreRef, menuRef]);
|
|
108
|
+
const walk = (event) => {
|
|
109
|
+
onKeyDown?.(event);
|
|
110
|
+
if (event.defaultPrevented || !["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
|
|
111
|
+
const menu = document.activeElement?.closest?.('[role="menu"]') ?? menuRef.current;
|
|
112
|
+
const items = menuItems(menu);
|
|
113
|
+
if (!items.length) return;
|
|
114
|
+
event.preventDefault();
|
|
115
|
+
const at = items.indexOf(document.activeElement);
|
|
116
|
+
const next = event.key === "Home" ? 0
|
|
117
|
+
: event.key === "End" ? items.length - 1
|
|
118
|
+
: event.key === "ArrowDown" ? (at + 1) % items.length
|
|
119
|
+
: (at - 1 + items.length) % items.length;
|
|
120
|
+
items[next].focus();
|
|
121
|
+
};
|
|
122
|
+
return createPortal(
|
|
123
|
+
<div
|
|
124
|
+
{...props}
|
|
125
|
+
ref={menuRef}
|
|
126
|
+
role="menu"
|
|
127
|
+
tabIndex={-1}
|
|
128
|
+
aria-label={label}
|
|
129
|
+
className={"menubar-menu " + className}
|
|
130
|
+
style={{ position: "fixed", left: box?.left ?? -9999, top: box?.top ?? 0, maxHeight: box?.maxHeight, visibility: box ? "visible" : "hidden" }}
|
|
131
|
+
onKeyDown={walk}
|
|
132
|
+
>
|
|
133
|
+
<ParentBox.Provider value={box}>{children}</ParentBox.Provider>
|
|
134
|
+
</div>,
|
|
135
|
+
document.body,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The Export flyout. It stays a child of `.export-menu-wrap` (App dismisses it
|
|
140
|
+
* on presses outside that wrap) but is fixed beside its trigger. */
|
|
141
|
+
function ExportFlyout({ anchorRef, autoFocus, onAutoFocused, children }) {
|
|
142
|
+
const ref = useRef(null);
|
|
143
|
+
const parent = useContext(ParentBox);
|
|
144
|
+
const box = usePlacement(anchorRef, ref, "right", "start", parent);
|
|
145
|
+
useFirstItemFocus(ref, box, autoFocus, onAutoFocused);
|
|
146
|
+
return (
|
|
147
|
+
<div
|
|
148
|
+
ref={ref}
|
|
149
|
+
className="menubar-menu export-menu"
|
|
150
|
+
role="menu"
|
|
151
|
+
aria-label={ko("Export", "내보내기")}
|
|
152
|
+
style={{ position: "fixed", left: box?.left ?? -9999, top: box?.top ?? 0, right: "auto", maxHeight: box?.maxHeight, visibility: box ? "visible" : "hidden" }}
|
|
153
|
+
>
|
|
154
|
+
{children}
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function Shortcut({ keys }) {
|
|
160
|
+
return <kbd className="menubar-item-key">{keys}</kbd>;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const MENUS = [
|
|
164
|
+
{ id: "file", label: () => ko("File", "파일") },
|
|
165
|
+
{ id: "edit", label: () => ko("Edit", "편집") },
|
|
166
|
+
{ id: "window", label: () => ko("Window", "창") },
|
|
167
|
+
{ id: "help", label: () => ko("Help", "도움말") },
|
|
168
|
+
];
|
|
169
|
+
|
|
170
|
+
export default function MenuBar({ preferences }) {
|
|
171
|
+
const shell = useStudioShell();
|
|
172
|
+
const {
|
|
173
|
+
runStudioAction, projectSaveState, recState, exportMenuTriggerRef, exportMenuOpen,
|
|
174
|
+
exportShotIdRef, setExportMenuOpen, shots, exportKeyframePacks, hasCameraKeys, motion,
|
|
175
|
+
exportRenderPasses, exportDepthVideo, exportStoryboard, downloadOtioCutList,
|
|
176
|
+
castDomain, preferencesOpen, setPreferencesOpen, agentOpen, toggleAgent,
|
|
177
|
+
setToast, embedMode, setProjectStartupOpen, setProjectBrowserOpen,
|
|
178
|
+
} = shell;
|
|
179
|
+
const [open, setOpen] = useState(null);
|
|
180
|
+
const [focusFirst, setFocusFirst] = useState(false);
|
|
181
|
+
const [dialog, setDialog] = useState(null);
|
|
182
|
+
const rootRef = useRef(null);
|
|
183
|
+
const menuRef = useRef(null);
|
|
184
|
+
const triggerRefs = useRef({});
|
|
185
|
+
const anchorRef = useRef(null);
|
|
186
|
+
anchorRef.current = open ? triggerRefs.current[open] : null;
|
|
187
|
+
const exportOpenRef = useRef(exportMenuOpen);
|
|
188
|
+
exportOpenRef.current = exportMenuOpen;
|
|
189
|
+
const recording = recState === "recording";
|
|
190
|
+
const packReason = shots.length ? "" : ko("Add a shot first — a pack describes one cut", "샷을 먼저 추가하세요 — 팩은 컷 하나를 설명합니다");
|
|
191
|
+
|
|
192
|
+
// The camera tutorial's handoff opens Export directly; its flyout lives in File.
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
if (exportMenuOpen) setOpen("file");
|
|
195
|
+
}, [exportMenuOpen]);
|
|
196
|
+
|
|
197
|
+
const closeMenus = (focusTrigger) => {
|
|
198
|
+
const was = open;
|
|
199
|
+
if (exportOpenRef.current) {
|
|
200
|
+
exportShotIdRef.current = null;
|
|
201
|
+
setExportMenuOpen(false);
|
|
202
|
+
}
|
|
203
|
+
setOpen(null);
|
|
204
|
+
setFlyoutFocus(false);
|
|
205
|
+
if (focusTrigger && was) triggerRefs.current[was]?.focus();
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// Keyboard focus follows the Export flyout into its first item.
|
|
209
|
+
const [flyoutFocus, setFlyoutFocus] = useState(false);
|
|
210
|
+
const focusedFirst = useRef(() => setFocusFirst(false)).current;
|
|
211
|
+
const focusedFlyout = useRef(() => setFlyoutFocus(false)).current;
|
|
212
|
+
|
|
213
|
+
// The Preferences dialog's own Settings trigger is not part of the 2a bar;
|
|
214
|
+
// when the dialog closes with focus nowhere, focus returns to Edit.
|
|
215
|
+
const preferencesWasOpen = useRef(preferencesOpen);
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
const was = preferencesWasOpen.current;
|
|
218
|
+
preferencesWasOpen.current = preferencesOpen;
|
|
219
|
+
if (!was || preferencesOpen) return;
|
|
220
|
+
const active = document.activeElement;
|
|
221
|
+
if (!active || active === document.body || !active.isConnected) triggerRefs.current.edit?.focus();
|
|
222
|
+
}, [preferencesOpen]);
|
|
223
|
+
|
|
224
|
+
// ⌘S saves and ⌘E exports the keyframe pack (G7), from anywhere in the studio.
|
|
225
|
+
const keys = useRef(null);
|
|
226
|
+
keys.current = { runStudioAction, projectSaveState, recording, packReason, exportKeyframePacks, setToast };
|
|
227
|
+
useEffect(() => {
|
|
228
|
+
if (embedMode) return undefined;
|
|
229
|
+
const onKeyDown = (event) => {
|
|
230
|
+
if (!(event.metaKey || event.ctrlKey) || event.altKey) return;
|
|
231
|
+
const current = keys.current;
|
|
232
|
+
if (event.code === "KeyS" && !event.shiftKey) {
|
|
233
|
+
event.preventDefault();
|
|
234
|
+
if (current.projectSaveState !== "saving") void current.runStudioAction("project.save");
|
|
235
|
+
} else if (event.code === "KeyE") {
|
|
236
|
+
event.preventDefault();
|
|
237
|
+
if (current.recording) return;
|
|
238
|
+
if (current.packReason) current.setToast(current.packReason);
|
|
239
|
+
else void current.exportKeyframePacks(event.shiftKey, null);
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
window.addEventListener("keydown", onKeyDown);
|
|
243
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
244
|
+
}, [embedMode]);
|
|
245
|
+
|
|
246
|
+
const move = (step) => {
|
|
247
|
+
const at = MENUS.findIndex((menu) => menu.id === open);
|
|
248
|
+
const next = MENUS[(at + step + MENUS.length) % MENUS.length].id;
|
|
249
|
+
closeMenus(false);
|
|
250
|
+
setOpen(next);
|
|
251
|
+
setFocusFirst(true);
|
|
252
|
+
triggerRefs.current[next]?.focus();
|
|
253
|
+
};
|
|
254
|
+
const onMenuKey = (event) => {
|
|
255
|
+
const inFlyout = document.activeElement?.closest?.(".export-menu");
|
|
256
|
+
if (event.key === "ArrowRight") {
|
|
257
|
+
event.preventDefault();
|
|
258
|
+
if (document.activeElement?.id === "export-menu-trigger") {
|
|
259
|
+
if (exportMenuOpen) {
|
|
260
|
+
const flyout = menuRef.current?.querySelector(".export-menu");
|
|
261
|
+
if (flyout) menuItems(flyout)[0]?.focus();
|
|
262
|
+
} else {
|
|
263
|
+
setFlyoutFocus(true);
|
|
264
|
+
setExportMenuOpen(true);
|
|
265
|
+
}
|
|
266
|
+
} else if (!inFlyout) move(1);
|
|
267
|
+
} else if (event.key === "ArrowLeft") {
|
|
268
|
+
event.preventDefault();
|
|
269
|
+
if (inFlyout) {
|
|
270
|
+
exportShotIdRef.current = null;
|
|
271
|
+
setExportMenuOpen(false);
|
|
272
|
+
exportMenuTriggerRef.current?.focus();
|
|
273
|
+
} else move(-1);
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
const item = (onSelect) => () => {
|
|
277
|
+
closeMenus(false);
|
|
278
|
+
onSelect();
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const content = {
|
|
282
|
+
file: (
|
|
283
|
+
<>
|
|
284
|
+
{/* New opens the 2b start screen, the same one first launch shows. Its
|
|
285
|
+
Create runs project.new, which still asks before discarding unsaved changes. */}
|
|
286
|
+
<button type="button" role="menuitem" className="menubar-item" data-testid="menubar-new" onClick={item(() => { setProjectBrowserOpen(false); setProjectStartupOpen(true); })}>
|
|
287
|
+
<span className="menubar-item-label">{ko("New", "새로 만들기")}</span>
|
|
288
|
+
</button>
|
|
289
|
+
<button type="button" role="menuitem" className="menubar-item" onClick={item(() => runStudioAction("project.browse"))}>
|
|
290
|
+
<span className="menubar-item-label">{ko("Open…", "열기…")}</span>
|
|
291
|
+
</button>
|
|
292
|
+
<button
|
|
293
|
+
type="button"
|
|
294
|
+
role="menuitem"
|
|
295
|
+
className="menubar-item"
|
|
296
|
+
data-testid="topbar-save"
|
|
297
|
+
disabled={projectSaveState === "saving"}
|
|
298
|
+
onClick={item(() => runStudioAction("project.save"))}
|
|
299
|
+
>
|
|
300
|
+
<span className="menubar-item-label">{projectSaveState === "saving" ? ko("Saving…", "저장 중…") : ko("Save", "저장")}</span>
|
|
301
|
+
<Shortcut keys={`${MOD}S`} />
|
|
302
|
+
</button>
|
|
303
|
+
<button type="button" role="menuitem" className="menubar-item" onClick={item(() => runStudioAction("project.saveAs"))}>
|
|
304
|
+
<span className="menubar-item-label">{ko("Save As…", "다른 이름으로 저장…")}</span>
|
|
305
|
+
</button>
|
|
306
|
+
<hr className="menubar-separator" />
|
|
307
|
+
{/* One Export menu for every delivery this studio makes (#193,
|
|
308
|
+
R4). The keyframe pack leads because it is the pack an AI video
|
|
309
|
+
tool is fed. One element cannot carry two data-testids: the
|
|
310
|
+
topbar contract keeps the attribute, the menu contract gets the
|
|
311
|
+
same handle as an id, so both selectors reach this trigger. */}
|
|
312
|
+
<div className="export-menu-wrap">
|
|
313
|
+
<button
|
|
314
|
+
type="button"
|
|
315
|
+
role="menuitem"
|
|
316
|
+
className={"menubar-item" + (recording ? " recording" : "")}
|
|
317
|
+
data-testid="topbar-export"
|
|
318
|
+
id="export-menu-trigger"
|
|
319
|
+
ref={exportMenuTriggerRef}
|
|
320
|
+
aria-haspopup="menu"
|
|
321
|
+
aria-expanded={exportMenuOpen}
|
|
322
|
+
title={ko("Exports: keyframe pack, video, passes, storyboard, cut list", "내보내기: 키프레임 팩·영상·패스·스토리보드·컷 목록")}
|
|
323
|
+
onClick={() => {
|
|
324
|
+
exportShotIdRef.current = null;
|
|
325
|
+
setExportMenuOpen((value) => !value);
|
|
326
|
+
}}
|
|
327
|
+
>
|
|
328
|
+
<span className="menubar-item-label">{ko("Export", "내보내기")}</span>
|
|
329
|
+
<span className="menubar-item-sub" aria-hidden="true">▸</span>
|
|
330
|
+
</button>
|
|
331
|
+
{exportMenuOpen && (
|
|
332
|
+
<ExportFlyout anchorRef={exportMenuTriggerRef} autoFocus={flyoutFocus} onAutoFocused={focusedFlyout}>
|
|
333
|
+
<button
|
|
334
|
+
type="button"
|
|
335
|
+
role="menuitem"
|
|
336
|
+
className="menubar-item export-menu-primary"
|
|
337
|
+
data-testid="export-keyframe-pack"
|
|
338
|
+
disabled={!shots.length || recording}
|
|
339
|
+
data-disabled-reason={shots.length ? undefined : "no-shots"}
|
|
340
|
+
title={shots.length
|
|
341
|
+
? ko("First/last frames, clip, camera and prompt as one zip — hold Shift for every shot", "첫/마지막 프레임·클립·카메라·프롬프트를 zip 하나로 — Shift를 누르면 모든 샷")
|
|
342
|
+
: packReason}
|
|
343
|
+
onClick={(event) => {
|
|
344
|
+
const every = event.shiftKey;
|
|
345
|
+
const shotId = exportShotIdRef.current;
|
|
346
|
+
closeMenus(false);
|
|
347
|
+
void exportKeyframePacks(every, shotId);
|
|
348
|
+
}}
|
|
349
|
+
>
|
|
350
|
+
<span className="menubar-item-label">{ko("Keyframe pack (zip)", "키프레임 팩 (zip)")}</span>
|
|
351
|
+
<Shortcut keys={`${MOD}E`} />
|
|
352
|
+
</button>
|
|
353
|
+
{(shots.length > 0 || hasCameraKeys || motion) && (
|
|
354
|
+
<button
|
|
355
|
+
type="button"
|
|
356
|
+
role="menuitem"
|
|
357
|
+
className="menubar-item"
|
|
358
|
+
data-testid="export-video"
|
|
359
|
+
disabled={recording}
|
|
360
|
+
title={ko("Render the shot to an MP4 — camera move and character motion, no editor chrome", "샷을 MP4로 렌더링합니다 — 카메라 움직임과 캐릭터 모션만, 편집 UI는 제외")}
|
|
361
|
+
onClick={() => {
|
|
362
|
+
const shotId = exportShotIdRef.current;
|
|
363
|
+
closeMenus(false);
|
|
364
|
+
void runStudioAction("export.shotVideo", shotId ? { shotId } : {});
|
|
365
|
+
}}
|
|
366
|
+
>
|
|
367
|
+
<span className="menubar-item-label">{ko("Video (mp4)", "영상 (mp4)")}</span>
|
|
368
|
+
</button>
|
|
369
|
+
)}
|
|
370
|
+
<button
|
|
371
|
+
type="button"
|
|
372
|
+
role="menuitem"
|
|
373
|
+
className="menubar-item"
|
|
374
|
+
data-testid="export-render-passes"
|
|
375
|
+
disabled={recording}
|
|
376
|
+
title={ko("Depth and normal conditioning plates of the current framing", "현재 프레이밍의 뎁스·노멀 컨디션 플레이트")}
|
|
377
|
+
onClick={item(exportRenderPasses)}
|
|
378
|
+
>
|
|
379
|
+
<span className="menubar-item-label">{ko("Depth + normal passes", "뎁스 + 노멀 패스")}</span>
|
|
380
|
+
</button>
|
|
381
|
+
<button
|
|
382
|
+
type="button"
|
|
383
|
+
role="menuitem"
|
|
384
|
+
className="menubar-item"
|
|
385
|
+
data-testid="export-depth-video"
|
|
386
|
+
disabled={!shots.length || recording}
|
|
387
|
+
data-disabled-reason={shots.length ? undefined : "no-shots"}
|
|
388
|
+
title={ko("Depth pass of the whole shot as an mp4 for video-model conditioning", "샷 전체의 뎁스 패스를 mp4로 — 영상 모델 컨디셔닝용")}
|
|
389
|
+
onClick={() => {
|
|
390
|
+
const shotId = exportShotIdRef.current;
|
|
391
|
+
closeMenus(false);
|
|
392
|
+
void exportDepthVideo(shotId);
|
|
393
|
+
}}
|
|
394
|
+
>
|
|
395
|
+
<span className="menubar-item-label">{ko("Depth (mp4)", "뎁스 (mp4)")}</span>
|
|
396
|
+
</button>
|
|
397
|
+
<button
|
|
398
|
+
type="button"
|
|
399
|
+
role="menuitem"
|
|
400
|
+
className="menubar-item"
|
|
401
|
+
data-testid="export-storyboard"
|
|
402
|
+
disabled={!shots.length || recording}
|
|
403
|
+
data-disabled-reason={shots.length ? undefined : "no-shots"}
|
|
404
|
+
title={shots.length
|
|
405
|
+
? ko("Contact sheet of every shot with its prompt", "모든 샷과 프롬프트를 담은 콘택트 시트")
|
|
406
|
+
: ko("Add a shot first — a storyboard is one row per shot", "샷을 먼저 추가하세요 — 스토리보드는 샷마다 한 줄입니다")}
|
|
407
|
+
onClick={item(() => void exportStoryboard())}
|
|
408
|
+
>
|
|
409
|
+
<span className="menubar-item-label">{ko("Storyboard (PNG)", "스토리보드 (PNG)")}</span>
|
|
410
|
+
</button>
|
|
411
|
+
{shots.length > 0 && (
|
|
412
|
+
<button
|
|
413
|
+
type="button"
|
|
414
|
+
role="menuitem"
|
|
415
|
+
className="menubar-item"
|
|
416
|
+
data-testid="export-otio"
|
|
417
|
+
title={ko("Download OTIO cut list", "OTIO 컷 목록 다운로드")}
|
|
418
|
+
onClick={item(downloadOtioCutList)}
|
|
419
|
+
>
|
|
420
|
+
<span className="menubar-item-label">{ko("OTIO cut list", "OTIO 컷 목록")}</span>
|
|
421
|
+
</button>
|
|
422
|
+
)}
|
|
423
|
+
{!shots.length && (
|
|
424
|
+
<p className="export-menu-hint">
|
|
425
|
+
{hasCameraKeys || motion
|
|
426
|
+
? ko("Add a shot to export OTIO", "OTIO를 내보내려면 샷을 추가하세요")
|
|
427
|
+
: ko("Add a shot to export video or OTIO", "영상·OTIO를 내보내려면 샷을 추가하세요")}
|
|
428
|
+
</p>
|
|
429
|
+
)}
|
|
430
|
+
</ExportFlyout>
|
|
431
|
+
)}
|
|
432
|
+
</div>
|
|
433
|
+
</>
|
|
434
|
+
),
|
|
435
|
+
edit: (
|
|
436
|
+
<>
|
|
437
|
+
<button type="button" role="menuitem" className="menubar-item" onClick={item(() => castDomain.undoScene())}>
|
|
438
|
+
<span className="menubar-item-label">{ko("Undo", "실행 취소")}</span>
|
|
439
|
+
<Shortcut keys={`${MOD}Z`} />
|
|
440
|
+
</button>
|
|
441
|
+
<button type="button" role="menuitem" className="menubar-item" onClick={item(() => castDomain.redoScene())}>
|
|
442
|
+
<span className="menubar-item-label">{ko("Redo", "다시 실행")}</span>
|
|
443
|
+
<Shortcut keys={`${SHIFT}${MOD}Z`} />
|
|
444
|
+
</button>
|
|
445
|
+
<hr className="menubar-separator" />
|
|
446
|
+
<button type="button" role="menuitem" className="menubar-item" data-testid="menu-preferences" onClick={item(() => setPreferencesOpen(true))}>
|
|
447
|
+
<span className="menubar-item-label">{ko("Preferences…", "환경설정…")}</span>
|
|
448
|
+
</button>
|
|
449
|
+
</>
|
|
450
|
+
),
|
|
451
|
+
window: (
|
|
452
|
+
<>
|
|
453
|
+
<button type="button" role="menuitemcheckbox" aria-checked={agentOpen} className="menubar-item" data-testid="menu-agent" onClick={item(toggleAgent)}>
|
|
454
|
+
<span className="menubar-item-label">{ko("Agent", "에이전트")}</span>
|
|
455
|
+
<Shortcut keys={`${MOD}B`} />
|
|
456
|
+
</button>
|
|
457
|
+
<hr className="menubar-separator" />
|
|
458
|
+
<a role="menuitem" className="menubar-item workflow-topbar-link" href="/workflow/" aria-label={ko("Open Workflow", "워크플로 열기")}>
|
|
459
|
+
<span className="menubar-item-label">{ko("Workflow", "워크플로우")}</span>
|
|
460
|
+
</a>
|
|
461
|
+
</>
|
|
462
|
+
),
|
|
463
|
+
help: (
|
|
464
|
+
<>
|
|
465
|
+
<button
|
|
466
|
+
type="button"
|
|
467
|
+
role="menuitem"
|
|
468
|
+
className="menubar-item"
|
|
469
|
+
onClick={item(() => window.dispatchEvent(new CustomEvent("cozyclay:camera-tutorial", { detail: { open: true, source: "help" } })))}
|
|
470
|
+
>
|
|
471
|
+
<span className="menubar-item-label">{ko("Tutorial", "튜토리얼")}</span>
|
|
472
|
+
</button>
|
|
473
|
+
<button type="button" role="menuitem" className="menubar-item" onClick={item(() => setDialog("shortcuts"))}>
|
|
474
|
+
<span className="menubar-item-label">{ko("Keyboard shortcuts", "단축키")}</span>
|
|
475
|
+
</button>
|
|
476
|
+
<button type="button" role="menuitem" className="menubar-item" onClick={item(() => setDialog("about"))}>
|
|
477
|
+
<span className="menubar-item-label">{ko("About Cozy Clay", "Cozy Clay 정보")}</span>
|
|
478
|
+
</button>
|
|
479
|
+
</>
|
|
480
|
+
),
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
return (
|
|
484
|
+
<nav className="menubar" aria-label={ko("Menu bar", "메뉴 막대")} ref={rootRef}>
|
|
485
|
+
{MENUS.map((menu) => (
|
|
486
|
+
<div className="menubar-slot" key={menu.id}>
|
|
487
|
+
<button
|
|
488
|
+
type="button"
|
|
489
|
+
className="menubar-trigger"
|
|
490
|
+
data-testid={`menu-${menu.id}`}
|
|
491
|
+
ref={(node) => { triggerRefs.current[menu.id] = node; }}
|
|
492
|
+
aria-haspopup="menu"
|
|
493
|
+
aria-expanded={open === menu.id}
|
|
494
|
+
onPointerEnter={() => {
|
|
495
|
+
if (open && open !== menu.id) {
|
|
496
|
+
closeMenus(false);
|
|
497
|
+
setOpen(menu.id);
|
|
498
|
+
}
|
|
499
|
+
}}
|
|
500
|
+
onKeyDown={(event) => {
|
|
501
|
+
if (event.key !== "ArrowDown") return;
|
|
502
|
+
event.preventDefault();
|
|
503
|
+
setOpen(menu.id);
|
|
504
|
+
setFocusFirst(true);
|
|
505
|
+
}}
|
|
506
|
+
onClick={(event) => {
|
|
507
|
+
if (open === menu.id) closeMenus(false);
|
|
508
|
+
else {
|
|
509
|
+
closeMenus(false);
|
|
510
|
+
setOpen(menu.id);
|
|
511
|
+
// Keyboard activation (detail 0) moves focus into the menu.
|
|
512
|
+
if (event.detail === 0) setFocusFirst(true);
|
|
513
|
+
}
|
|
514
|
+
}}
|
|
515
|
+
>
|
|
516
|
+
{menu.label()}
|
|
517
|
+
</button>
|
|
518
|
+
{menu.id === "edit" && <span className="menubar-preferences-host">{preferences}</span>}
|
|
519
|
+
</div>
|
|
520
|
+
))}
|
|
521
|
+
{open && (
|
|
522
|
+
<MenuPopover
|
|
523
|
+
key={open}
|
|
524
|
+
menuRef={menuRef}
|
|
525
|
+
anchorRef={anchorRef}
|
|
526
|
+
ignoreRef={rootRef}
|
|
527
|
+
data-menu={open}
|
|
528
|
+
label={MENUS.find((menu) => menu.id === open).label()}
|
|
529
|
+
keepOpen={() => exportOpenRef.current}
|
|
530
|
+
onClose={closeMenus}
|
|
531
|
+
onKeyDown={onMenuKey}
|
|
532
|
+
autoFocus={focusFirst}
|
|
533
|
+
onAutoFocused={focusedFirst}
|
|
534
|
+
>
|
|
535
|
+
{content[open]}
|
|
536
|
+
</MenuPopover>
|
|
537
|
+
)}
|
|
538
|
+
{dialog && <MenuDialog kind={dialog} onClose={() => setDialog(null)} />}
|
|
539
|
+
</nav>
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const SHORTCUTS = [
|
|
544
|
+
["1 2 3 4", () => ko("Stage / Pose / Camera / Motion mode", "배치 / 포즈 / 카메라 / 모션 모드")],
|
|
545
|
+
["W E R", () => ko("Pick the tool of the current mode", "현재 모드의 도구 선택")],
|
|
546
|
+
["F", () => ko("Frame the selection", "선택 항목으로 화면 맞춤")],
|
|
547
|
+
["Space", () => ko("Play / pause", "재생 / 일시정지")],
|
|
548
|
+
["J K", () => ko("Step one frame", "한 프레임 이동")],
|
|
549
|
+
[`${MOD}Z`, () => ko("Undo", "실행 취소")],
|
|
550
|
+
[`${SHIFT}${MOD}Z`, () => ko("Redo", "다시 실행")],
|
|
551
|
+
[`${MOD}S`, () => ko("Save", "저장")],
|
|
552
|
+
[`${MOD}E`, () => ko("Export keyframe pack", "키프레임 팩 내보내기")],
|
|
553
|
+
[`${MOD}D`, () => ko("Duplicate object", "오브젝트 복제")],
|
|
554
|
+
[`${MOD}B`, () => ko("Agent", "에이전트")],
|
|
555
|
+
["Delete", () => ko("Delete the selection", "선택 항목 삭제")],
|
|
556
|
+
];
|
|
557
|
+
|
|
558
|
+
function MenuDialog({ kind, onClose }) {
|
|
559
|
+
const closeRef = useRef(null);
|
|
560
|
+
const titleId = useId();
|
|
561
|
+
useEffect(() => {
|
|
562
|
+
closeRef.current?.focus();
|
|
563
|
+
const onKey = (event) => {
|
|
564
|
+
if (event.key !== "Escape") return;
|
|
565
|
+
event.preventDefault();
|
|
566
|
+
onClose();
|
|
567
|
+
};
|
|
568
|
+
document.addEventListener("keydown", onKey);
|
|
569
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
570
|
+
}, [onClose]);
|
|
571
|
+
const version = import.meta.env.VITE_APP_VERSION;
|
|
572
|
+
return createPortal(
|
|
573
|
+
<div className="menubar-dialog-backdrop" onPointerDown={(event) => { if (event.target === event.currentTarget) onClose(); }}>
|
|
574
|
+
<div className="menubar-dialog" role="dialog" aria-modal="true" aria-labelledby={titleId} data-dialog={kind}>
|
|
575
|
+
<header className="menubar-dialog-head">
|
|
576
|
+
<h2 id={titleId}>{kind === "about" ? <span className="menubar-dialog-brand">Cozy <span>Clay</span></span> : ko("Keyboard shortcuts", "단축키")}</h2>
|
|
577
|
+
<button type="button" ref={closeRef} className="menubar-dialog-close" aria-label={ko("Close", "닫기")} onClick={onClose}>
|
|
578
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18" /></svg>
|
|
579
|
+
</button>
|
|
580
|
+
</header>
|
|
581
|
+
{kind === "about" ? (
|
|
582
|
+
<div className="menubar-dialog-body">
|
|
583
|
+
<p>{ko("Previs studio: stage, pose, camera and motion.", "프리비즈 스튜디오: 배치, 포즈, 카메라, 모션.")}</p>
|
|
584
|
+
{version && <p className="menubar-dialog-meta">{ko("Version", "버전")} {version}</p>}
|
|
585
|
+
<SourceOffer />
|
|
586
|
+
</div>
|
|
587
|
+
) : (
|
|
588
|
+
<dl className="menubar-dialog-body menubar-shortcuts">
|
|
589
|
+
{SHORTCUTS.map(([keys, label]) => (
|
|
590
|
+
<div className="menubar-shortcut" key={keys}>
|
|
591
|
+
<dt>{label()}</dt>
|
|
592
|
+
<dd><kbd>{keys}</kbd></dd>
|
|
593
|
+
</div>
|
|
594
|
+
))}
|
|
595
|
+
</dl>
|
|
596
|
+
)}
|
|
597
|
+
</div>
|
|
598
|
+
</div>,
|
|
599
|
+
document.body,
|
|
600
|
+
);
|
|
601
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useStudioShell } from "./studio-shell-context.js";
|
|
2
|
+
import { ko } from "../locale.js";
|
|
3
|
+
import HierarchyPanel from "../hierarchy-panel.jsx";
|
|
4
|
+
|
|
5
|
+
export default function OutlinerSlot() {
|
|
6
|
+
const {
|
|
7
|
+
selectedHierarchyId, selectHierarchy,
|
|
8
|
+
aimEditorAtKeyLight, characters, showB, motion,
|
|
9
|
+
ikMode, rowIdForCharIndex, activeCharIndex, sceneObjects,
|
|
10
|
+
scenes, activeSceneId, selectSceneDocument, createSceneDocumentFromUi, duplicateSceneDocumentFromUi,
|
|
11
|
+
renameSceneDocumentFromUi, deleteSceneDocumentFromUi, addSceneObject, renameSceneObject, runStudioAction,
|
|
12
|
+
deleteSceneObject, frameSelection, toggleHierarchyHidden, propsDrop, hierarchyReparent,
|
|
13
|
+
agentTouchedRows,
|
|
14
|
+
} = useStudioShell();
|
|
15
|
+
return (
|
|
16
|
+
<aside className="panel hierarchy-left" aria-label={ko("Hierarchy", "계층")}>
|
|
17
|
+
{/* 2a: no project row here — the project name and its unsaved dot
|
|
18
|
+
live in the top bar's project menu. */}
|
|
19
|
+
<HierarchyPanel
|
|
20
|
+
selectedId={selectedHierarchyId}
|
|
21
|
+
onSelect={(id) => {
|
|
22
|
+
selectHierarchy(id);
|
|
23
|
+
if (id === "light") aimEditorAtKeyLight();
|
|
24
|
+
}}
|
|
25
|
+
characters={characters}
|
|
26
|
+
showB={showB}
|
|
27
|
+
motionFrames={motion?.frames ?? 0}
|
|
28
|
+
ikMode={ikMode}
|
|
29
|
+
ikRowId={rowIdForCharIndex(activeCharIndex)}
|
|
30
|
+
sceneObjects={sceneObjects}
|
|
31
|
+
scenes={scenes}
|
|
32
|
+
activeSceneId={activeSceneId}
|
|
33
|
+
onSceneSelect={selectSceneDocument}
|
|
34
|
+
onSceneCreate={createSceneDocumentFromUi}
|
|
35
|
+
onSceneDuplicate={duplicateSceneDocumentFromUi}
|
|
36
|
+
onSceneRename={renameSceneDocumentFromUi}
|
|
37
|
+
onSceneDelete={deleteSceneDocumentFromUi}
|
|
38
|
+
onAddObject={addSceneObject}
|
|
39
|
+
onRenameObject={renameSceneObject}
|
|
40
|
+
onDuplicateObject={(objectId) => runStudioAction("object.duplicate", objectId ? { objectId } : {})}
|
|
41
|
+
onDeleteObject={deleteSceneObject}
|
|
42
|
+
onFrameObject={frameSelection}
|
|
43
|
+
onToggleHidden={toggleHierarchyHidden}
|
|
44
|
+
propsDrop={propsDrop}
|
|
45
|
+
reparent={hierarchyReparent}
|
|
46
|
+
touchedIds={agentTouchedRows}
|
|
47
|
+
/>
|
|
48
|
+
</aside>
|
|
49
|
+
);
|
|
50
|
+
}
|