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,33 @@
|
|
|
1
|
+
import { ko } from "../locale.js";
|
|
2
|
+
import ResourceStatus from "../resource-status.jsx";
|
|
3
|
+
import { useBus } from "../app-context.js";
|
|
4
|
+
import "./details.css";
|
|
5
|
+
|
|
6
|
+
export default function ProjectPanel({
|
|
7
|
+
projectMenuOpen, setProjectMenuOpen, projectDirty, projectName, projectStartupOpen, projectManifest,
|
|
8
|
+
}) {
|
|
9
|
+
const { run } = useBus();
|
|
10
|
+
return (
|
|
11
|
+
<div className="project-menu-wrap v2-project-menu">
|
|
12
|
+
<button
|
|
13
|
+
type="button"
|
|
14
|
+
className="project-menu-trigger"
|
|
15
|
+
aria-expanded={projectMenuOpen}
|
|
16
|
+
onClick={() => setProjectMenuOpen((open) => !open)}
|
|
17
|
+
>
|
|
18
|
+
{projectDirty && <i className="project-dirty-dot" aria-label={ko("Unsaved changes", "저장되지 않은 변경사항")} />}
|
|
19
|
+
{projectName ?? (projectStartupOpen ? ko("Choose Project", "프로젝트 선택") : ko("Untitled Project", "제목 없는 프로젝트"))}
|
|
20
|
+
<span className="caret">▾</span>
|
|
21
|
+
</button>
|
|
22
|
+
{projectMenuOpen && (
|
|
23
|
+
<div className="project-menu" role="menu" onClick={() => setProjectMenuOpen(false)}>
|
|
24
|
+
<button type="button" role="menuitem" onClick={() => run("project.new")}>{ko("New Project", "새 프로젝트")}</button>
|
|
25
|
+
<button type="button" role="menuitem" onClick={() => run("project.browse")}>{ko("Open Project…", "프로젝트 열기…")}</button>
|
|
26
|
+
<button type="button" role="menuitem" onClick={() => run("project.save")}>{ko("Save Project", "프로젝트 저장")}</button>
|
|
27
|
+
<button type="button" role="menuitem" onClick={() => run("project.saveAs")}>{ko("Save Project As…", "다른 이름으로 저장…")}</button>
|
|
28
|
+
<ResourceStatus manifest={projectManifest} compact />
|
|
29
|
+
</div>
|
|
30
|
+
)}
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import Foldout from "./Foldout.jsx";
|
|
3
|
+
import { ko, isKo } from "../locale.js";
|
|
4
|
+
import { Field, Dropdown } from "../ui.jsx";
|
|
5
|
+
import { LINE_EDIT_TRACK_OPTIONS, MIN_CURVE_POINTS, lineTrackLabel } from "../app-stage.jsx";
|
|
6
|
+
import { LINE_EDIT_PINS_MAX, DRAG_RADIUS_MIN, DRAG_RADIUS_MAX, MAX_LINE_POINTS, PINNED_CURVE_ENDS } from "../line-edit.js";
|
|
7
|
+
import { motionReadinessMessage, MotionReadiness } from "../motion-readiness-ui.jsx";
|
|
8
|
+
import { useCastTransaction } from '../domains/cast.js';
|
|
9
|
+
import { useMotionCommands } from '../domains/motion.js';
|
|
10
|
+
import { useStudioShell } from '../shell/studio-shell-context.js';
|
|
11
|
+
import "./motion.css";
|
|
12
|
+
|
|
13
|
+
export default function PromptBlocksPanel({
|
|
14
|
+
isCharacterSelection, promptBlocksReveal, promptClips, selectedPromptId, setSelectedPromptId,
|
|
15
|
+
setArdyPrompt, setTlFrame, tlFrameCount, ardySeed, changeArdySeed, motion, lineEditMode,
|
|
16
|
+
toggleLineEditMode, linePreviewUrl, lineCurve, lineDrifted, lineTrack, setLineTrack, linePinMode,
|
|
17
|
+
setLinePinMode, linePins, lineClipFrames, lineEditRange, setLineRange, lineRadius, changeLineRadius,
|
|
18
|
+
lineCurveDirty, lineEditFrom, lineEditTo, lineCurvePointCount, lineDriftHint, lineCurveHidden,
|
|
19
|
+
linePreviewBusy, linePreviewMs, linePreviewError, generationBusy, bridgeChecking, bridge,
|
|
20
|
+
lineReadinessState, runLineEdit, openMotionSetup, recheckMotionHealth, resetLineCurve, exitLineEditMode,
|
|
21
|
+
readinessState, ardyRunning, cancelArdy, ardyStatus, ardyOutcome,
|
|
22
|
+
}) {
|
|
23
|
+
const { run, begin, commit, characterId } = useCastTransaction();
|
|
24
|
+
const { refineDisabledReason, workflowMode } = useStudioShell();
|
|
25
|
+
const selectedClip = promptClips.find((clip) => clip.id === selectedPromptId) ?? null;
|
|
26
|
+
const refineReason = refineDisabledReason();
|
|
27
|
+
// Generate, Start over, Take it again and Add block live in the top bar's
|
|
28
|
+
// Generate Motion button and its caret menu, their one home (R1).
|
|
29
|
+
return (
|
|
30
|
+
<>
|
|
31
|
+
<Foldout hidden={!isCharacterSelection} defaultOpen={false} openSignal={promptBlocksReveal} title={ko("Prompt Blocks", "프롬프트 블록")}>
|
|
32
|
+
<p className="inspector-hint">{ko("Blocks define what is generated over each frame range. Selecting one also moves editing context to that prompt.", "블록은 각 프레임 범위에서 생성될 내용을 정합니다. 블록을 선택하면 편집 기준도 해당 프롬프트로 이동합니다.")}</p>
|
|
33
|
+
<div className="inspector-list">
|
|
34
|
+
{promptClips.map((clip) => (
|
|
35
|
+
<button
|
|
36
|
+
type="button"
|
|
37
|
+
key={clip.id}
|
|
38
|
+
className={selectedPromptId === clip.id ? "active" : ""}
|
|
39
|
+
onClick={() => {
|
|
40
|
+
setSelectedPromptId(clip.id);
|
|
41
|
+
setArdyPrompt(clip.text);
|
|
42
|
+
setTlFrame(Math.min(clip.startFrame, tlFrameCount - 1));
|
|
43
|
+
}}
|
|
44
|
+
>
|
|
45
|
+
<span>{clip.text || ko("Untitled motion", "이름 없는 모션")}</span>
|
|
46
|
+
<small>{clip.startFrame}–{clip.endFrame}f</small>
|
|
47
|
+
</button>
|
|
48
|
+
))}
|
|
49
|
+
</div>
|
|
50
|
+
{selectedClip && (
|
|
51
|
+
<Field label={ko("Range", "구간")}>
|
|
52
|
+
<span className="motion-block-range" data-block-range={`${selectedClip.startFrame}-${selectedClip.endFrame}`}>
|
|
53
|
+
{selectedClip.startFrame}–{selectedClip.endFrame} f
|
|
54
|
+
</span>
|
|
55
|
+
</Field>
|
|
56
|
+
)}
|
|
57
|
+
{selectedPromptId && (
|
|
58
|
+
<Field label={ko("Prompt", "프롬프트")}>
|
|
59
|
+
<input
|
|
60
|
+
type="text"
|
|
61
|
+
value={promptClips.find((clip) => clip.id === selectedPromptId)?.text ?? ""}
|
|
62
|
+
onBlur={commit}
|
|
63
|
+
onChange={(event) => {
|
|
64
|
+
begin();
|
|
65
|
+
run('character.changePromptBlock', { characterId, id: selectedPromptId, text: event.target.value });
|
|
66
|
+
setArdyPrompt(event.target.value);
|
|
67
|
+
}}
|
|
68
|
+
placeholder={ko("describe this motion block", "이 모션 블록을 설명하세요")}
|
|
69
|
+
/>
|
|
70
|
+
</Field>
|
|
71
|
+
)}
|
|
72
|
+
{/* The seed belongs with the button that consumes it. Duration does
|
|
73
|
+
not appear at all: the blocks' own frame ranges are the length. */}
|
|
74
|
+
<Field label={ko("Seed", "시드")}>
|
|
75
|
+
<input
|
|
76
|
+
type="text"
|
|
77
|
+
inputMode="numeric"
|
|
78
|
+
value={ardySeed}
|
|
79
|
+
onChange={(e) => changeArdySeed(e.target.value)}
|
|
80
|
+
placeholder={ko("empty = random", "비우면 랜덤")}
|
|
81
|
+
/>
|
|
82
|
+
</Field>
|
|
83
|
+
{/* Scheduled inpainting used to live here, beside the batch button.
|
|
84
|
+
It now folds into Scene > Advanced above the timeline (contract
|
|
85
|
+
C12): it is a dial on a regeneration, so it belongs with the
|
|
86
|
+
regeneration entry rather than with the block list. */}
|
|
87
|
+
{/* Line editing (contract C6). It sits beside the preserve slider
|
|
88
|
+
because it answers the same question from the other side —
|
|
89
|
+
preserve says how much of the take to KEEP, a line says exactly
|
|
90
|
+
where one joint must GO — and because both need a take with a
|
|
91
|
+
bridge source, so the whole section is absent until there is one
|
|
92
|
+
rather than present and inert. */}
|
|
93
|
+
{motion?.url && (
|
|
94
|
+
<Field label={ko("Line editing", "라인 편집")}>
|
|
95
|
+
{/* Refine: the take bar's second entry, until it becomes motion tool E. */}
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
className={"btn full" + (lineEditMode ? " primary" : "")}
|
|
99
|
+
data-take-mode="refine"
|
|
100
|
+
data-disabled-reason={refineReason || undefined}
|
|
101
|
+
aria-pressed={lineEditMode}
|
|
102
|
+
title={ko(
|
|
103
|
+
"The joint's own path is drawn on the viewport — grab a point on it and pull, or draw a new path on empty space; the joint then follows it exactly. The view still orbits normally (Alt+drag).",
|
|
104
|
+
"관절이 지나가는 궤적이 뷰포트에 그려집니다 — 궤적 위의 점을 잡아 끌거나, 빈 곳에 새 궤적을 그리면 관절이 그 경로를 정확히 따라갑니다. 시점은 평소처럼 돌릴 수 있어요 (Alt+드래그).",
|
|
105
|
+
)}
|
|
106
|
+
onClick={toggleLineEditMode}
|
|
107
|
+
>
|
|
108
|
+
{lineEditMode ? ko("Path editing on", "궤적 편집 켜짐") : ko("Drag the path", "궤적을 잡아 끌기")}
|
|
109
|
+
</button>
|
|
110
|
+
{lineEditMode && (
|
|
111
|
+
<div
|
|
112
|
+
className="line-edit-panel"
|
|
113
|
+
data-line-preview={linePreviewUrl ? "true" : undefined}
|
|
114
|
+
// "The line is detached from this view" — the panel's own copy of
|
|
115
|
+
// what the stage already carries, so a test (or a screenshot) can
|
|
116
|
+
// read the state from whichever of the two it is looking at.
|
|
117
|
+
data-line-drift={lineCurve && lineDrifted ? "true" : undefined}
|
|
118
|
+
>
|
|
119
|
+
<Field label={ko("Joint", "관절")}>
|
|
120
|
+
<Dropdown
|
|
121
|
+
value={lineTrack}
|
|
122
|
+
options={LINE_EDIT_TRACK_OPTIONS}
|
|
123
|
+
onChange={setLineTrack}
|
|
124
|
+
ariaLabel={ko("Joint whose path is edited", "궤적을 편집할 관절")}
|
|
125
|
+
/>
|
|
126
|
+
</Field>
|
|
127
|
+
{/* THE GESTURE SELECTOR. Pressing on the joint is how BOTH a
|
|
128
|
+
curve drag and a pin start, so the two cannot share a
|
|
129
|
+
press and a modal toggle is the honest way to say which
|
|
130
|
+
one the next press means. Named for what it does at the
|
|
131
|
+
moment it does it, not for the wire field. */}
|
|
132
|
+
<button
|
|
133
|
+
type="button"
|
|
134
|
+
className={"btn full" + (linePinMode ? " primary" : "")}
|
|
135
|
+
data-line-pin-mode={linePinMode ? "true" : "false"}
|
|
136
|
+
onClick={() => setLinePinMode((on) => !on)}
|
|
137
|
+
>
|
|
138
|
+
{linePinMode
|
|
139
|
+
? ko("Pinning moments", "순간 찍는 중")
|
|
140
|
+
: ko("Pin a moment", "순간 찍기")}
|
|
141
|
+
</button>
|
|
142
|
+
{linePinMode && (
|
|
143
|
+
<p className="inspector-hint">
|
|
144
|
+
{linePins.length
|
|
145
|
+
? (isKo
|
|
146
|
+
? `${linePins.length}개(최대 ${LINE_EDIT_PINS_MAX}개)를 찍었어요 — 프레임 ${linePins.map((pin) => pin.frame).join(", ")}. 사이 동작은 모델이 채웁니다`
|
|
147
|
+
: `${linePins.length} pinned (max ${LINE_EDIT_PINS_MAX}) — frames ${linePins.map((pin) => pin.frame).join(", ")}. The model fills the movement between them`)
|
|
148
|
+
: ko(
|
|
149
|
+
"Scrub to a moment, then drag the green handle to where the joint should be. The take keeps its own timing; only that instant is pinned.",
|
|
150
|
+
"원하는 순간으로 재생 위치를 옮긴 뒤, 초록 손잡이를 관절이 있어야 할 자리로 끌어 주세요. 그 순간만 고정되고 나머지 타이밍은 그대로예요.",
|
|
151
|
+
)}
|
|
152
|
+
</p>
|
|
153
|
+
)}
|
|
154
|
+
{/* No range picker exists elsewhere in the app that a line
|
|
155
|
+
edit could borrow, so the whole clip is the default and
|
|
156
|
+
these two numbers only ever NARROW it. endFrame is
|
|
157
|
+
exclusive, like every other half-open range on this wire. */}
|
|
158
|
+
<Field label={ko("Frame range", "프레임 구간")}>
|
|
159
|
+
<div className="line-edit-range-row">
|
|
160
|
+
<input
|
|
161
|
+
type="number"
|
|
162
|
+
min={0}
|
|
163
|
+
max={Math.max(0, lineClipFrames - 2)}
|
|
164
|
+
value={lineEditRange?.startFrame ?? 0}
|
|
165
|
+
onChange={(event) => setLineRange((current) => ({
|
|
166
|
+
startFrame: Math.round(Number(event.target.value)) || 0,
|
|
167
|
+
endFrame: current?.endFrame ?? lineClipFrames,
|
|
168
|
+
}))}
|
|
169
|
+
/>
|
|
170
|
+
<span aria-hidden="true">–</span>
|
|
171
|
+
<input
|
|
172
|
+
type="number"
|
|
173
|
+
min={2}
|
|
174
|
+
max={lineClipFrames}
|
|
175
|
+
value={lineEditRange?.endFrame ?? lineClipFrames}
|
|
176
|
+
onChange={(event) => setLineRange((current) => ({
|
|
177
|
+
startFrame: current?.startFrame ?? 0,
|
|
178
|
+
endFrame: Math.round(Number(event.target.value)) || 0,
|
|
179
|
+
}))}
|
|
180
|
+
/>
|
|
181
|
+
</div>
|
|
182
|
+
</Field>
|
|
183
|
+
{/* How far a pull carries along the path, in FRAMES — the
|
|
184
|
+
sigma of the Gaussian falloff, said in the unit the user
|
|
185
|
+
is looking at. Narrow is a beat, wide is a whole gesture. */}
|
|
186
|
+
<Field label={ko("Influence", "영향 범위")}>
|
|
187
|
+
<div className="line-edit-radius-row">
|
|
188
|
+
<input
|
|
189
|
+
type="range"
|
|
190
|
+
min={DRAG_RADIUS_MIN}
|
|
191
|
+
max={DRAG_RADIUS_MAX}
|
|
192
|
+
step={1}
|
|
193
|
+
value={lineRadius}
|
|
194
|
+
aria-label={ko("How many frames a pull carries along the path", "잡아당길 때 궤적을 따라 함께 움직이는 프레임 수")}
|
|
195
|
+
onChange={(event) => changeLineRadius(event.target.value)}
|
|
196
|
+
/>
|
|
197
|
+
<span className="line-edit-radius-value">
|
|
198
|
+
{isKo ? `${lineRadius}프레임` : `${lineRadius} frames`}
|
|
199
|
+
</span>
|
|
200
|
+
</div>
|
|
201
|
+
</Field>
|
|
202
|
+
<p className="inspector-hint">
|
|
203
|
+
{lineCurveDirty
|
|
204
|
+
? (isKo
|
|
205
|
+
? `${lineEditFrom}–${lineEditTo} 프레임을 편집했어요 — ${lineCurvePointCount}개 점(최대 ${MAX_LINE_POINTS}개)을 보내고, 양 끝은 원래 궤적에서 부드럽게 이어져 이음매가 튀지 않아요. 다시 끌거나 다시 그려서 다듬을 수 있어요`
|
|
206
|
+
: `Frames ${lineEditFrom}–${lineEditTo} edited — ${lineCurvePointCount} points (max ${MAX_LINE_POINTS}); both ends ease out of the original path, so the seams do not pop. Pull it again, or draw over it, to refine`)
|
|
207
|
+
: ko(
|
|
208
|
+
"Draw along the path to reroute that section — the frames you drew over become the range, and the take's own timing is kept. Or grab a yellow dot and pull.",
|
|
209
|
+
"궤적을 따라 그리면 그 구간만 새로 지나갑니다 — 그린 만큼이 편집 구간이 되고, 원래 속도감은 그대로 유지돼요. 노란 점을 잡아 끌어도 됩니다.",
|
|
210
|
+
)}
|
|
211
|
+
</p>
|
|
212
|
+
{/* The one thing users assume a modal viewport tool takes away.
|
|
213
|
+
Said out loud, because "can I still orbit?" is the first
|
|
214
|
+
question and the answer decides whether the mode is usable. */}
|
|
215
|
+
{/* THE DETACHED STATE. Said inline, next to the edit it is about,
|
|
216
|
+
and in the same words the refused gesture answers with. It is
|
|
217
|
+
a status, not a warning: nothing was lost and nothing needs
|
|
218
|
+
doing — the only thing that changed is that the line cannot be
|
|
219
|
+
drawn in a view it was not aimed through. */}
|
|
220
|
+
{lineCurve && lineDrifted && (
|
|
221
|
+
<p className="inspector-hint line-edit-drift" aria-live="polite">
|
|
222
|
+
{lineDriftHint()}
|
|
223
|
+
</p>
|
|
224
|
+
)}
|
|
225
|
+
<p className="inspector-hint">
|
|
226
|
+
{lineCurveDirty
|
|
227
|
+
? ko(
|
|
228
|
+
"You can still orbit (Alt+drag), pan and fly freely — the edit survives it. It was aimed through one lens, so while the view is elsewhere the line is drawn ghosted and a new pull waits; Generate, undo and Reset work from anywhere.",
|
|
229
|
+
"시점은 자유롭게 돌리고(Alt+드래그) 옮길 수 있어요 — 편집은 그대로 남습니다. 다만 이 궤적은 처음 시점 기준이라, 시점을 옮기면 흐리게만 보이고 새로 끌기는 잠시 멈춰요. 생성·되돌리기·원래대로는 언제든 됩니다.",
|
|
230
|
+
)
|
|
231
|
+
: ko(
|
|
232
|
+
"You can still orbit (Alt+drag), pan and fly freely; the path follows the view until you pull or draw it.",
|
|
233
|
+
"시점은 평소처럼 자유롭게 돌리고(Alt+드래그) 옮길 수 있어요. 끌거나 그리기 전까지 궤적은 시점을 따라갑니다.",
|
|
234
|
+
)}
|
|
235
|
+
</p>
|
|
236
|
+
{lineEditRange && lineEditRange.endFrame - lineEditRange.startFrame < MIN_CURVE_POINTS && (
|
|
237
|
+
<p className="inspector-hint">
|
|
238
|
+
{isKo
|
|
239
|
+
? `구간이 너무 짧아요 — 양 끝 ${PINNED_CURVE_ENDS}프레임씩이 고정이라 ${MIN_CURVE_POINTS}프레임 이상이어야 잡을 점이 생겨요`
|
|
240
|
+
: `This range is too short — with ${PINNED_CURVE_ENDS} pinned frames at each end it needs at least ${MIN_CURVE_POINTS} frames before anything can be grabbed`}
|
|
241
|
+
</p>
|
|
242
|
+
)}
|
|
243
|
+
{lineCurveHidden > 0 && (
|
|
244
|
+
<p className="inspector-hint">
|
|
245
|
+
{isKo
|
|
246
|
+
? `${lineCurveHidden}프레임이 화면 밖이라 잡을 수 없어요 — 구간 전체가 보이도록 카메라를 잡아 주세요`
|
|
247
|
+
: `${lineCurveHidden} frame(s) are outside the frame and cannot be grabbed — frame the whole range in view`}
|
|
248
|
+
</p>
|
|
249
|
+
)}
|
|
250
|
+
{/* ------------------------- the preview line -------------------
|
|
251
|
+
One quiet row that says which of three things is true: a
|
|
252
|
+
draft is being made, a draft is on screen (and how long it
|
|
253
|
+
took), or the last one failed and the curve is still here.
|
|
254
|
+
Deliberately not a spinner over the viewport — the artist
|
|
255
|
+
is LOOKING at the viewport, and the answer arrives there. */}
|
|
256
|
+
{linePreviewBusy && (
|
|
257
|
+
<p className="inspector-hint line-preview-busy" aria-live="polite">
|
|
258
|
+
{ko("Previewing the pull…", "당긴 결과 미리보는 중…")}
|
|
259
|
+
</p>
|
|
260
|
+
)}
|
|
261
|
+
{!linePreviewBusy && linePreviewUrl && (
|
|
262
|
+
<p className="inspector-hint line-preview-live">
|
|
263
|
+
{ko(
|
|
264
|
+
"The viewport is showing this edit at full quality — press Generate to keep it as the take.",
|
|
265
|
+
"뷰포트가 지금 이 편집의 최종 품질 결과예요 — 아래 생성을 누르면 테이크로 확정됩니다.",
|
|
266
|
+
)}
|
|
267
|
+
{linePreviewMs > 0 && (
|
|
268
|
+
<span className="line-preview-time">
|
|
269
|
+
{isKo ? ` 미리보기 ${(linePreviewMs / 1000).toFixed(1)}s` : ` preview ${(linePreviewMs / 1000).toFixed(1)}s`}
|
|
270
|
+
</span>
|
|
271
|
+
)}
|
|
272
|
+
</p>
|
|
273
|
+
)}
|
|
274
|
+
{/* A failed draft is not a failed edit: the pull survives it and
|
|
275
|
+
the button below still runs the real thing. */}
|
|
276
|
+
{linePreviewError && (
|
|
277
|
+
<p className="inspector-hint line-preview-error">
|
|
278
|
+
{isKo ? `미리보기 실패 — ${linePreviewError} (생성은 그대로 됩니다)` : `Preview failed — ${linePreviewError} (Generate still works)`}
|
|
279
|
+
</p>
|
|
280
|
+
)}
|
|
281
|
+
<button
|
|
282
|
+
type="button"
|
|
283
|
+
className="btn primary full generate"
|
|
284
|
+
disabled={!lineCurveDirty || generationBusy || bridgeChecking || bridge === null}
|
|
285
|
+
title={generationBusy ? ko("A generation is already running", "이미 생성이 돌고 있어요")
|
|
286
|
+
: !lineCurveDirty
|
|
287
|
+
? ko("Pull the path on the viewport first", "먼저 뷰포트에서 궤적을 잡아당겨 주세요")
|
|
288
|
+
: motionReadinessMessage(lineReadinessState)}
|
|
289
|
+
onClick={runLineEdit}
|
|
290
|
+
>
|
|
291
|
+
{ko("Generate the line edit", "라인 편집 생성")}
|
|
292
|
+
</button>
|
|
293
|
+
<MotionReadiness state={lineReadinessState} checking={bridgeChecking} onSetup={() => openMotionSetup("line")} onRetry={recheckMotionHealth} />
|
|
294
|
+
<button type="button" className="btn ghost full" disabled={!lineCurveDirty} onClick={resetLineCurve}>
|
|
295
|
+
{ko("Reset the curve", "원래대로")}
|
|
296
|
+
</button>
|
|
297
|
+
<button type="button" className="btn ghost full" onClick={exitLineEditMode}>
|
|
298
|
+
{ko("Exit line editing (Esc)", "라인 편집 끝내기 (Esc)")}
|
|
299
|
+
</button>
|
|
300
|
+
</div>
|
|
301
|
+
)}
|
|
302
|
+
</Field>
|
|
303
|
+
)}
|
|
304
|
+
{ardyRunning && (
|
|
305
|
+
<button type="button" className="btn ghost full" onClick={cancelArdy}>
|
|
306
|
+
{ko("Cancel run", "실행 취소")}
|
|
307
|
+
</button>
|
|
308
|
+
)}
|
|
309
|
+
{!lineEditMode && <MotionReadiness state={readinessState} checking={bridgeChecking} onSetup={openMotionSetup} onRetry={recheckMotionHealth} />}
|
|
310
|
+
{ardyRunning && ardyStatus && <p className="ardy-status" role="status">{ardyStatus}</p>}
|
|
311
|
+
{!ardyRunning && ardyOutcome?.ok === false && <p className="ardy-status" role="alert">{ardyOutcome.message}</p>}
|
|
312
|
+
{!ardyRunning && ardyOutcome?.ok === true && <p className="ardy-status" role="status">{ko("Motion generation complete", "모션 생성 완료")}</p>}
|
|
313
|
+
{motion?.url && <MotionAdvanced />}
|
|
314
|
+
</Foldout>
|
|
315
|
+
<TakesSection hidden={!isCharacterSelection || workflowMode === "scene" || workflowMode === "camera"} />
|
|
316
|
+
</>
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/* Motion › Advanced: the dials that decide how much of the loaded take a
|
|
321
|
+
regeneration keeps. Folded away because the default (half preserved, whole
|
|
322
|
+
body free) is right almost always. */
|
|
323
|
+
function MotionAdvanced() {
|
|
324
|
+
const { preserveStrength, setPreserveStrength, waypointMode, preserveTracksLine } = useStudioShell();
|
|
325
|
+
const [open, setOpen] = useState(false);
|
|
326
|
+
return (
|
|
327
|
+
<div className="motion-advanced">
|
|
328
|
+
<button type="button" className="motion-advanced-head" aria-expanded={open} onClick={() => setOpen((value) => !value)}>
|
|
329
|
+
<span className="v2-details-foldout-arrow foldout-arrow" data-open={open ? "true" : "false"} aria-hidden="true" />
|
|
330
|
+
{ko("Advanced", "고급")}
|
|
331
|
+
</button>
|
|
332
|
+
{open && (
|
|
333
|
+
<div className="motion-advanced-body">
|
|
334
|
+
<label className="row">
|
|
335
|
+
<span>{ko("Keep current take", "현재 테이크 유지")}</span>
|
|
336
|
+
<input
|
|
337
|
+
type="range"
|
|
338
|
+
data-preserve-strength
|
|
339
|
+
min={0}
|
|
340
|
+
max={1}
|
|
341
|
+
step={0.05}
|
|
342
|
+
value={preserveStrength}
|
|
343
|
+
title={ko(
|
|
344
|
+
"How hard the regeneration holds the loaded take outside the frames you edited.",
|
|
345
|
+
"수정하지 않은 프레임에서 로드된 테이크를 얼마나 강하게 유지할지 정합니다.",
|
|
346
|
+
)}
|
|
347
|
+
onChange={(event) => setPreserveStrength(Number(event.target.value))}
|
|
348
|
+
/>
|
|
349
|
+
<span className="val preserve-strength-value">{Math.round(preserveStrength * 100)}%</span>
|
|
350
|
+
</label>
|
|
351
|
+
{/* The slider value IS the preserve strength: 0 (left) is a fresh
|
|
352
|
+
take and 1 (right) holds the original hardest. */}
|
|
353
|
+
<p className="inspector-hint motion-preserve-scale">
|
|
354
|
+
<span>{ko("generate fresh", "새로 생성")}</span>
|
|
355
|
+
<span>{ko("keep original", "원본 유지")}</span>
|
|
356
|
+
</p>
|
|
357
|
+
{waypointMode && preserveStrength > 0 && (
|
|
358
|
+
<p className="inspector-hint">
|
|
359
|
+
{ko(
|
|
360
|
+
"the drawn path replaces the root; the body keeps the take's style",
|
|
361
|
+
"경로는 새로 그려지고, 동작 스타일은 원본을 유지해요",
|
|
362
|
+
)}
|
|
363
|
+
</p>
|
|
364
|
+
)}
|
|
365
|
+
{preserveStrength > 0 && preserveTracksLine && (
|
|
366
|
+
<p className="inspector-hint preserve-tracks-summary">{preserveTracksLine}</p>
|
|
367
|
+
)}
|
|
368
|
+
</div>
|
|
369
|
+
)}
|
|
370
|
+
</div>
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/* Takes (G6): every successful run leaves a checkpoint. Clicking a row loads
|
|
375
|
+
that take back and restores the recipe it was saved with; loading never
|
|
376
|
+
drops a row, so an experiment can always be walked back. */
|
|
377
|
+
function TakesSection({ hidden }) {
|
|
378
|
+
const { run } = useMotionCommands();
|
|
379
|
+
const { takeVersions, takeSourceUrl, takeRecipe, replayNotices, linePreviewUrl } = useStudioShell();
|
|
380
|
+
return (
|
|
381
|
+
<Foldout hidden={hidden} title={ko("Takes", "테이크")}>
|
|
382
|
+
<div
|
|
383
|
+
className="motion-takes"
|
|
384
|
+
data-take-source={takeSourceUrl || undefined}
|
|
385
|
+
// The draft's url beside the take's own, so "the viewport swapped but
|
|
386
|
+
// the take did not" is one comparison rather than an inference.
|
|
387
|
+
data-line-preview={linePreviewUrl ? "true" : undefined}
|
|
388
|
+
data-line-preview-url={linePreviewUrl || undefined}
|
|
389
|
+
>
|
|
390
|
+
{takeVersions.length > 0 ? (
|
|
391
|
+
<div className="motion-take-list" role="group" aria-label={ko("Take versions", "테이크 버전")}>
|
|
392
|
+
{takeVersions.map((entry, index) => {
|
|
393
|
+
const current = entry.motionUrl === takeSourceUrl;
|
|
394
|
+
return (
|
|
395
|
+
<button
|
|
396
|
+
type="button"
|
|
397
|
+
key={entry.motionUrl}
|
|
398
|
+
className={"motion-take-row" + (current ? " current" : "")}
|
|
399
|
+
data-version-url={entry.motionUrl}
|
|
400
|
+
data-version-current={current ? "true" : undefined}
|
|
401
|
+
aria-pressed={current}
|
|
402
|
+
title={`${entry.label} · ${new Date(entry.savedAt).toLocaleTimeString()}`}
|
|
403
|
+
onClick={() => run('motion.loadVersion', { motionUrl: entry.motionUrl })}
|
|
404
|
+
>
|
|
405
|
+
<span className="motion-take-index">v{index + 1}</span>
|
|
406
|
+
<span className="motion-take-label">{entry.label}</span>
|
|
407
|
+
<span className="motion-take-time">
|
|
408
|
+
{new Date(entry.savedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hourCycle: "h23" })}
|
|
409
|
+
</span>
|
|
410
|
+
</button>
|
|
411
|
+
);
|
|
412
|
+
})}
|
|
413
|
+
</div>
|
|
414
|
+
) : (
|
|
415
|
+
<p className="inspector-hint">{ko("No takes yet. Generate creates one.", "아직 테이크가 없어요. 생성하면 하나가 생깁니다.")}</p>
|
|
416
|
+
)}
|
|
417
|
+
{/* C10's per-entry replay verdict. Non-blocking: the take is loaded,
|
|
418
|
+
one refinement just did not survive the trip onto it. */}
|
|
419
|
+
{replayNotices.map((entry) => (
|
|
420
|
+
<p className="inspector-hint replay-notice" key={`${entry.index}-${entry.track}`} data-replay-index={entry.index} data-replay-track={entry.track}>
|
|
421
|
+
{entry.ok === false
|
|
422
|
+
? (isKo
|
|
423
|
+
? `다듬기 ${entry.index + 1}(${lineTrackLabel(entry.track)})은 다시 적용되지 않았어요 — 나머지는 그대로 이어졌습니다${entry.error ? ` (${entry.error})` : ""}`
|
|
424
|
+
: `Refinement ${entry.index + 1} (${lineTrackLabel(entry.track)}) was not re-applied — the rest carried over${entry.error ? ` (${entry.error})` : ""}`)
|
|
425
|
+
: (isKo
|
|
426
|
+
? `다듬기 ${entry.index + 1}(${lineTrackLabel(entry.track)})은 블록 경계에 걸쳐 있어요 — 결과가 이전과 조금 다를 수 있습니다`
|
|
427
|
+
: `Refinement ${entry.index + 1} (${lineTrackLabel(entry.track)}) straddles a block boundary — the result may differ slightly from before`)}
|
|
428
|
+
</p>
|
|
429
|
+
))}
|
|
430
|
+
{/* A seedless recipe is an IMPORTED take: it reloads but cannot be
|
|
431
|
+
rebuilt, so the footer says so rather than printing "seed null". */}
|
|
432
|
+
{takeRecipe && (
|
|
433
|
+
<p className="motion-takes-footer take-recipe-summary">
|
|
434
|
+
{isKo
|
|
435
|
+
? `시드 ${Number.isInteger(takeRecipe.seed) ? takeRecipe.seed : "알 수 없음(불러온 테이크)"} · 블록 ${takeRecipe.blocks.length}개 · 다듬기 ${takeRecipe.lineEdits.length}개`
|
|
436
|
+
: `Seed ${Number.isInteger(takeRecipe.seed) ? takeRecipe.seed : "unknown (imported take)"} · ${takeRecipe.blocks.length} block(s) · ${takeRecipe.lineEdits.length} refinement(s)`}
|
|
437
|
+
</p>
|
|
438
|
+
)}
|
|
439
|
+
</div>
|
|
440
|
+
</Foldout>
|
|
441
|
+
);
|
|
442
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import Foldout from "./Foldout.jsx";
|
|
2
|
+
import { ko } from "../locale.js";
|
|
3
|
+
import { ASSET_IMAGE_TYPES } from "../scene-assets.js";
|
|
4
|
+
import { sceneObjectNameDisplayKo, sceneRendererLabelKo } from "../app-stage.jsx";
|
|
5
|
+
|
|
6
|
+
export default function PropsPanel({
|
|
7
|
+
selectedHierarchyId, inspectorDrop, cutoutInputRef, meshInputRef, importCutout,
|
|
8
|
+
importMesh, sceneObjects, selectHierarchy,
|
|
9
|
+
}) {
|
|
10
|
+
return (
|
|
11
|
+
<Foldout hidden={selectedHierarchyId !== "props"} title={ko("Props", "소품")}>
|
|
12
|
+
<div className="props-drop v2-details-props" data-drop={inspectorDrop.over ? "over" : "target"} {...inspectorDrop.handlers}>
|
|
13
|
+
<p className="inspector-hint v2-details-hint">{ko("Everything you add to the set lives here. Pick one to edit it, or click it in the shot view. Drop a picture anywhere here — or on the shot view — to stand it up as a cutout. You can also drop a .glb, .obj or .fbx to import a 3D object.", "세트에 추가한 모든 소품이 여기에 모입니다. 편집하려면 하나를 고르거나 샷 뷰에서 클릭하세요. 사진을 이 영역이나 샷 뷰에 끌어다 놓으면 컷아웃으로 세워집니다. .glb, .obj 또는 .fbx 파일을 놓으면 3D 오브젝트로 가져옵니다.")}</p>
|
|
14
|
+
<button
|
|
15
|
+
type="button"
|
|
16
|
+
className="btn ghost full"
|
|
17
|
+
onClick={() => cutoutInputRef.current?.click()}
|
|
18
|
+
title={ko("A photo of the real thing, standing in the set as a card", "실제 사진을 판때기로 세워 세트에 배치합니다")}
|
|
19
|
+
>
|
|
20
|
+
{ko("Import image as cutout", "이미지를 컷아웃으로 가져오기")}
|
|
21
|
+
</button>
|
|
22
|
+
<button
|
|
23
|
+
type="button"
|
|
24
|
+
className="btn ghost full"
|
|
25
|
+
onClick={() => meshInputRef.current?.click()}
|
|
26
|
+
title={ko("A GLB, OBJ or FBX model standing in the set", "GLB, OBJ 또는 FBX 모델을 세트에 배치합니다")}
|
|
27
|
+
>
|
|
28
|
+
{ko("Import 3D object", "3D 오브젝트 가져오기")}
|
|
29
|
+
</button>
|
|
30
|
+
<input
|
|
31
|
+
ref={cutoutInputRef}
|
|
32
|
+
type="file"
|
|
33
|
+
hidden
|
|
34
|
+
accept={ASSET_IMAGE_TYPES.join(",")}
|
|
35
|
+
onChange={(event) => {
|
|
36
|
+
const [file] = event.target.files ?? [];
|
|
37
|
+
// Cleared before the await: picking the same file twice in a
|
|
38
|
+
// row has to fire change twice, and it will not if the input
|
|
39
|
+
// still holds it.
|
|
40
|
+
event.target.value = "";
|
|
41
|
+
importCutout(file);
|
|
42
|
+
}}
|
|
43
|
+
/>
|
|
44
|
+
<input
|
|
45
|
+
ref={meshInputRef}
|
|
46
|
+
type="file"
|
|
47
|
+
hidden
|
|
48
|
+
accept=".glb,.obj,.fbx,model/gltf-binary,model/obj,model/fbx"
|
|
49
|
+
onChange={(event) => {
|
|
50
|
+
const [file] = event.target.files ?? [];
|
|
51
|
+
event.target.value = "";
|
|
52
|
+
importMesh(file);
|
|
53
|
+
}}
|
|
54
|
+
/>
|
|
55
|
+
<div className="inspector-list compact">
|
|
56
|
+
{sceneObjects.map((object) => (
|
|
57
|
+
<button
|
|
58
|
+
type="button"
|
|
59
|
+
key={object.id}
|
|
60
|
+
onClick={() => selectHierarchy(`object:${object.id}`)}
|
|
61
|
+
>
|
|
62
|
+
<span>{sceneObjectNameDisplayKo(object.name)}</span>
|
|
63
|
+
<small>{sceneRendererLabelKo(object.renderer)}</small>
|
|
64
|
+
</button>
|
|
65
|
+
))}
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
</Foldout>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { useState, useRef } from "react";
|
|
2
|
+
import { ko, isKo } from "../locale.js";
|
|
3
|
+
import { ASSET_IMAGE_TYPES, downscaleTarget } from "../scene-assets.js";
|
|
4
|
+
|
|
5
|
+
const REFERENCE_IMAGE_MAX_DIMENSION = 1024;
|
|
6
|
+
|
|
7
|
+
async function readReferenceImage(file, { maxDimension = REFERENCE_IMAGE_MAX_DIMENSION } = {}) {
|
|
8
|
+
if (!file) throw new Error("No file");
|
|
9
|
+
if (!ASSET_IMAGE_TYPES.includes(String(file.type).toLowerCase())) {
|
|
10
|
+
throw new Error("unsupported image type");
|
|
11
|
+
}
|
|
12
|
+
const dataUrl = await new Promise((resolve, reject) => {
|
|
13
|
+
const reader = new FileReader();
|
|
14
|
+
reader.onerror = () => reject(new Error("could not read the file"));
|
|
15
|
+
reader.onload = () => resolve(String(reader.result));
|
|
16
|
+
reader.readAsDataURL(file);
|
|
17
|
+
});
|
|
18
|
+
const bitmap = await createImageBitmap(file);
|
|
19
|
+
try {
|
|
20
|
+
const target = downscaleTarget(bitmap.width, bitmap.height, maxDimension);
|
|
21
|
+
if (!target) throw new Error("could not decode that image");
|
|
22
|
+
if (!target.scaled) return dataUrl;
|
|
23
|
+
const canvas = document.createElement("canvas");
|
|
24
|
+
canvas.width = target.width;
|
|
25
|
+
canvas.height = target.height;
|
|
26
|
+
const context = canvas.getContext("2d");
|
|
27
|
+
context.drawImage(bitmap, 0, 0, target.width, target.height);
|
|
28
|
+
// GIF and WebP re-encode to PNG: a still frame is what a reference is.
|
|
29
|
+
const type = file.type === "image/jpeg" ? "image/jpeg" : "image/png";
|
|
30
|
+
return canvas.toDataURL(type, type === "image/jpeg" ? 0.92 : undefined);
|
|
31
|
+
} finally {
|
|
32
|
+
bitmap.close?.();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default function ReferenceImageField({ label, hint, value, alt, onPick, onClear, inputProps = {} }) {
|
|
37
|
+
const inputRef = useRef(null);
|
|
38
|
+
const [error, setError] = useState("");
|
|
39
|
+
return (
|
|
40
|
+
<div className="reference-slot v2-reference-slot">
|
|
41
|
+
<div className="reference-slot-head">
|
|
42
|
+
<span className="reference-slot-label">{label}</span>
|
|
43
|
+
{value && (
|
|
44
|
+
<button type="button" className="btn ghost small" onClick={() => { setError(""); onClear(); }}>
|
|
45
|
+
{ko("Clear", "지우기")}
|
|
46
|
+
</button>
|
|
47
|
+
)}
|
|
48
|
+
</div>
|
|
49
|
+
<div className="reference-slot-body">
|
|
50
|
+
<button
|
|
51
|
+
type="button"
|
|
52
|
+
className="reference-slot-thumb"
|
|
53
|
+
data-empty={value ? undefined : "true"}
|
|
54
|
+
onClick={() => inputRef.current?.click()}
|
|
55
|
+
title={ko("Choose a reference picture", "참고 이미지를 선택합니다")}
|
|
56
|
+
>
|
|
57
|
+
{value
|
|
58
|
+
? <img src={value} alt={alt ?? label} />
|
|
59
|
+
: <span className="reference-slot-plus" aria-hidden="true">+</span>}
|
|
60
|
+
</button>
|
|
61
|
+
<div className="reference-slot-copy">
|
|
62
|
+
<p className="inspector-hint">{hint}</p>
|
|
63
|
+
<button type="button" className="btn ghost small" onClick={() => inputRef.current?.click()}>
|
|
64
|
+
{value ? ko("Replace", "교체") : ko("Choose image", "이미지 선택")}
|
|
65
|
+
</button>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
{error && <p className="inspector-hint reference-slot-error" role="status">{error}</p>}
|
|
69
|
+
<input
|
|
70
|
+
ref={inputRef}
|
|
71
|
+
type="file"
|
|
72
|
+
className="multimodel-file-input"
|
|
73
|
+
accept="image/*"
|
|
74
|
+
{...inputProps}
|
|
75
|
+
onChange={async (event) => {
|
|
76
|
+
const file = event.target.files?.[0];
|
|
77
|
+
// Cleared before the await: re-picking the same file after an
|
|
78
|
+
// error must fire change again.
|
|
79
|
+
event.target.value = "";
|
|
80
|
+
if (!file) return;
|
|
81
|
+
setError("");
|
|
82
|
+
try {
|
|
83
|
+
onPick(await readReferenceImage(file));
|
|
84
|
+
} catch (failure) {
|
|
85
|
+
setError(isKo ? `이미지를 불러오지 못했어요 — ${failure.message}` : `Could not load that image — ${failure.message}`);
|
|
86
|
+
}
|
|
87
|
+
}}
|
|
88
|
+
/>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|