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,1007 @@
|
|
|
1
|
+
import { copyPhysicsKeys } from "../ardy/physics-review.js";
|
|
2
|
+
import { useState, useMemo, useEffect, useSyncExternalStore, useContext } from "react";
|
|
3
|
+
import { AppContext } from '../app-context.js';
|
|
4
|
+
import { createDocumentStore } from "../document-store.js";
|
|
5
|
+
import { useDocumentDomain } from "../store/use-document-store.js";
|
|
6
|
+
import { arrangement } from "../studio-agent-commands.js";
|
|
7
|
+
import { timelineContentExtent, timelineSpan } from '../timeline-extent.js';
|
|
8
|
+
import {
|
|
9
|
+
loadCustomPoses,
|
|
10
|
+
DEFAULT_POSE,
|
|
11
|
+
capturePose,
|
|
12
|
+
captureHipsOffset,
|
|
13
|
+
saveCustomPoses,
|
|
14
|
+
} from "../poses.js";
|
|
15
|
+
import { createCharacterEntry, createCharacterLayer } from "../scenes.js";
|
|
16
|
+
import {
|
|
17
|
+
DEFAULT_SUBJECT,
|
|
18
|
+
DEFAULT_SUBJECT2,
|
|
19
|
+
nextCharacterId,
|
|
20
|
+
MAX_WAYPOINTS,
|
|
21
|
+
MULTIMODEL_REASONS,
|
|
22
|
+
ARDY_DURATION_MIN,
|
|
23
|
+
TIMELINE_FPS,
|
|
24
|
+
} from "../app-stage.jsx";
|
|
25
|
+
import { ko, isKo } from "../locale.js";
|
|
26
|
+
import { createIkState } from "../ardy/ik.js";
|
|
27
|
+
import { judgeNextWaypoint } from "../ardy/waypoints.js";
|
|
28
|
+
import { StudioProtocolError } from "../studio-agent-protocol.js";
|
|
29
|
+
import { studioActionRefusal } from "../studio-actions.js";
|
|
30
|
+
import { createStableItemId, removeStableItem } from "../stable-items.js";
|
|
31
|
+
import { trackFeature } from "../analytics.js";
|
|
32
|
+
import { requestBridgeExtract } from "../multimodel-ingest.js";
|
|
33
|
+
import { loadMotionFromUrl } from "../ardy/npz.js";
|
|
34
|
+
import { snapshotPlaybackBones, applyMotionFrame, restorePlaybackBones } from "../ardy/playback.js";
|
|
35
|
+
|
|
36
|
+
import { HISTORY_LIMIT } from "../history.js";
|
|
37
|
+
|
|
38
|
+
const sameCastValue = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
39
|
+
|
|
40
|
+
export function createCastDomain(appContext, initial, customPoses = []) {
|
|
41
|
+
const normalize = value => ({ characters: (Array.isArray(value) ? value : value.characters).map(createCharacterEntry),
|
|
42
|
+
customPoses: Array.isArray(value) ? customPoses : value.customPoses ?? customPoses });
|
|
43
|
+
let native = createDocumentStore({ owned: { cast: normalize(initial) } });
|
|
44
|
+
const listeners = new Set(), motions = new Map();
|
|
45
|
+
const notify = () => { for (const listener of listeners) listener(); };
|
|
46
|
+
let release = native.subscribe(notify), loading = false;
|
|
47
|
+
const documentStore = { ...Object.fromEntries(Object.keys(native).map(key => [key, (...args) => native[key](...args)])),
|
|
48
|
+
subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); } };
|
|
49
|
+
const state = () => documentStore.read('cast'), read = () => state().characters;
|
|
50
|
+
let projection = read(), previous = state();
|
|
51
|
+
function publish() {
|
|
52
|
+
projection = read().map(entry => motions.has(entry.id) ? { ...entry, sessionMotion: motions.get(entry.id) } : entry);
|
|
53
|
+
appContext.publishCharacters(projection);
|
|
54
|
+
if (appContext.live.state) appContext.patchLive({ characters: projection });
|
|
55
|
+
domain.syncLayer?.();
|
|
56
|
+
if (!sameCastValue(previous.customPoses, state().customPoses) && !saveCustomPoses(state().customPoses)) console.warn('[cozyclay] could not save the pose library');
|
|
57
|
+
previous = state();
|
|
58
|
+
}
|
|
59
|
+
const unsubscribe = documentStore.subscribe(publish);
|
|
60
|
+
function writeState(update) {
|
|
61
|
+
const before = state();
|
|
62
|
+
const after = documentStore.write('cast', before => {
|
|
63
|
+
const next = normalize(typeof update === 'function' ? update(before) : update);
|
|
64
|
+
return sameCastValue(before, next) ? before : next;
|
|
65
|
+
});
|
|
66
|
+
if (after !== before) {
|
|
67
|
+
const motion = appContext.storeDomain('motion');
|
|
68
|
+
const removed = before.characters.filter(entry => !after.characters.some(next => next.id === entry.id));
|
|
69
|
+
if (motion && removed.length) appContext.recordAction('motion', () => { for (const entry of removed) motion.removeLayer(entry.id); }, null, true);
|
|
70
|
+
domain.syncTimeline?.();
|
|
71
|
+
}
|
|
72
|
+
return after;
|
|
73
|
+
}
|
|
74
|
+
function write(update) {
|
|
75
|
+
return writeState(before => ({ ...before, characters: typeof update === 'function' ? update(before.characters) : update }));
|
|
76
|
+
}
|
|
77
|
+
function publishMotion(characterId, motion) {
|
|
78
|
+
if (motion) motions.set(characterId, motion); else motions.delete(characterId);
|
|
79
|
+
publish(); notify();
|
|
80
|
+
}
|
|
81
|
+
function run(id, args = {}) {
|
|
82
|
+
const receipt = appContext.bus.run(id, args);
|
|
83
|
+
if (!receipt.ok) throw new StudioProtocolError(receipt.code, receipt.message);
|
|
84
|
+
return receipt;
|
|
85
|
+
}
|
|
86
|
+
let gesture = null;
|
|
87
|
+
function finishGesture(cancel = false) {
|
|
88
|
+
if (!gesture) return;
|
|
89
|
+
const active = gesture; gesture = null; active.unsubscribe?.(); active.release();
|
|
90
|
+
return active.txId ? run(cancel ? 'run.cancel' : 'run.commit', { txId: active.txId }) : undefined;
|
|
91
|
+
}
|
|
92
|
+
function beginGesture() {
|
|
93
|
+
if (gesture) return;
|
|
94
|
+
const commit = () => finishGesture(), cancel = () => finishGesture(true);
|
|
95
|
+
const keydown = event => { if (event.key === 'Escape') cancel(); };
|
|
96
|
+
const events = { pointerup: commit, pointercancel: cancel, focusout: commit, blur: commit, keydown };
|
|
97
|
+
for (const [name, handler] of Object.entries(events)) globalThis.window?.addEventListener?.(name, handler);
|
|
98
|
+
gesture = { release() { for (const [name, handler] of Object.entries(events)) globalThis.window?.removeEventListener?.(name, handler); } };
|
|
99
|
+
}
|
|
100
|
+
function edit(id, args) {
|
|
101
|
+
if (!gesture) return run(id, args);
|
|
102
|
+
if (gesture.id && gesture.id !== id) { finishGesture(); beginGesture(); }
|
|
103
|
+
if (!gesture.txId) {
|
|
104
|
+
gesture.id = id; gesture.txId = run('run.begin', { id, args }).txId;
|
|
105
|
+
gesture.unsubscribe = appContext.bus.subscribe(event => {
|
|
106
|
+
if (event.type !== 'transaction.cancelled' || event.txId !== gesture?.txId) return;
|
|
107
|
+
const active = gesture; gesture = null; active.unsubscribe(); active.release();
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return run('run.update', { txId: gesture.txId, args });
|
|
111
|
+
}
|
|
112
|
+
const domain = { documentStore, state, read, write, writeState, run, edit, beginGesture, finishGesture,
|
|
113
|
+
activeId: read()[0]?.id ?? null, projection: () => projection, publishMotion, normalizeCharacters: rows => rows.map(createCharacterEntry),
|
|
114
|
+
bindRender(context) { appContext = context; },
|
|
115
|
+
beginAction: () => documentStore.beginAction('cast'), canUndo: id => documentStore.canUndo(id),
|
|
116
|
+
stepHistory: redo => { finishGesture(); return Boolean((redo ? documentStore.redo : documentStore.undo)()); },
|
|
117
|
+
document: () => ({ characters: read(), customPoses: state().customPoses }), publish: value => write(value.characters), commitDraft: write,
|
|
118
|
+
arrange(args) {
|
|
119
|
+
const current = appContext.ports.read();
|
|
120
|
+
const plan = arrangement({ name: 'arrange_characters', args }, { ...current, characters: read(), frame: current.view.frame }, { bounds: appContext.ports.bounds });
|
|
121
|
+
write(plan.draft); return plan;
|
|
122
|
+
},
|
|
123
|
+
load(value) {
|
|
124
|
+
finishGesture(true); loading = true;
|
|
125
|
+
try {
|
|
126
|
+
release(); native.dispose(); motions.clear();
|
|
127
|
+
const next = normalize(Array.isArray(value) ? { characters: value, customPoses: state().customPoses } : value);
|
|
128
|
+
native = createDocumentStore({ owned: { cast: next } }); release = native.subscribe(notify);
|
|
129
|
+
domain.activeId = read()[0]?.id ?? null; domain.loadView?.(); notify();
|
|
130
|
+
} finally { loading = false; }
|
|
131
|
+
},
|
|
132
|
+
dispose() { finishGesture(true); unregister(); unsubscribe(); release(); native.dispose(); listeners.clear(); },
|
|
133
|
+
};
|
|
134
|
+
const unregister = appContext.registerStoreDomain('cast', domain);
|
|
135
|
+
return domain;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function useCastTransaction() {
|
|
139
|
+
const app = useContext(AppContext), owner = app.storeDomain('cast');
|
|
140
|
+
return { run: owner.edit, begin: owner.beginGesture, commit: () => owner.finishGesture(), cancel: () => owner.finishGesture(true), characterId: owner.activeId };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function useCast(appContext) {
|
|
144
|
+
const [domain] = useState(() => appContext.storeDomain('cast') ?? createCastDomain(appContext, appContext.shared.startupStage.characters, loadCustomPoses()));
|
|
145
|
+
domain.bindRender(appContext);
|
|
146
|
+
const { customPoses } = useDocumentDomain(domain.documentStore, 'cast');
|
|
147
|
+
const characters = useSyncExternalStore(domain.documentStore.subscribe, domain.projection, domain.projection);
|
|
148
|
+
const editCharacters = value => domain.write(value);
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
const start = () => domain.beginGesture();
|
|
151
|
+
window.addEventListener('pointerdown', start, true);
|
|
152
|
+
return () => { window.removeEventListener('pointerdown', start, true); domain.finishGesture(true); };
|
|
153
|
+
}, [domain]);
|
|
154
|
+
|
|
155
|
+
const [posing, setPosing] = useState(null);
|
|
156
|
+
|
|
157
|
+
const [posingClosing, setPosingClosing] = useState(false);
|
|
158
|
+
|
|
159
|
+
const [studioPick, setStudioPick] = useState(null);
|
|
160
|
+
|
|
161
|
+
const [rigs, setRigs] = useState({});
|
|
162
|
+
|
|
163
|
+
const [rigMountEpoch, setRigMountEpoch] = useState(0);
|
|
164
|
+
|
|
165
|
+
const [poseRevision, setPoseTick] = useState(0);
|
|
166
|
+
|
|
167
|
+
/* --------------------- derived cast view + shims ---------------------- */
|
|
168
|
+
// Fallback second slot mirrors the old charB defaults so preset math and
|
|
169
|
+
// the two-subject inspector never see a hole before B exists.
|
|
170
|
+
const charA = characters[0] ?? createCharacterEntry(null, 0);
|
|
171
|
+
|
|
172
|
+
const charB = characters[1] ?? { ...createCharacterEntry(null, 1), x: 1.15, z: 0.1, rot: -14 };
|
|
173
|
+
|
|
174
|
+
const showB = characters.some((entry, index) => index > 0 && !entry.hidden);
|
|
175
|
+
|
|
176
|
+
const poseA = charA.pose ?? DEFAULT_POSE;
|
|
177
|
+
|
|
178
|
+
const poseB = charB.pose ?? DEFAULT_POSE;
|
|
179
|
+
|
|
180
|
+
const subject = charA.subject ?? DEFAULT_SUBJECT;
|
|
181
|
+
|
|
182
|
+
const subject2 = charB.subject ?? DEFAULT_SUBJECT2;
|
|
183
|
+
|
|
184
|
+
const rigA = rigs[charA.id] ?? null;
|
|
185
|
+
|
|
186
|
+
const rigB = (characters[1] ? rigs[charB.id] : null) ?? null;
|
|
187
|
+
|
|
188
|
+
function updateCharacterAt(index, next) {
|
|
189
|
+
const entry = domain.read()[index];
|
|
190
|
+
if (entry) return domain.edit('character.update', { characterId: entry.id, patch: typeof next === 'function' ? next(entry) : next });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// The shims keep the legacy call sites (inspector sliders, presets, pose
|
|
194
|
+
// studio, prompts) untouched while the list stays the source of truth.
|
|
195
|
+
const setCharA = (next) => updateCharacterAt(0, next);
|
|
196
|
+
|
|
197
|
+
const setCharB = (next) => updateCharacterAt(1, next);
|
|
198
|
+
|
|
199
|
+
const setPoseA = (pose) => updateCharacterAt(0, (entry) => ({ pose: typeof pose === "function" ? pose(entry.pose ?? DEFAULT_POSE) : pose }));
|
|
200
|
+
|
|
201
|
+
const setPoseB = (pose) => updateCharacterAt(1, (entry) => ({ pose: typeof pose === "function" ? pose(entry.pose ?? DEFAULT_POSE) : pose }));
|
|
202
|
+
|
|
203
|
+
const setSubject = (value) => updateCharacterAt(0, (entry) => ({ subject: typeof value === "function" ? value(entry.subject) : value }));
|
|
204
|
+
|
|
205
|
+
const setSubject2 = (value) => updateCharacterAt(1, (entry) => ({ subject: typeof value === "function" ? value(entry.subject) : value }));
|
|
206
|
+
|
|
207
|
+
function setShowB(next) {
|
|
208
|
+
return domain.run('cast.showExtras', { show: typeof next === 'function' ? next(domain.read().some((entry, i) => i > 0 && !entry.hidden)) : next });
|
|
209
|
+
}
|
|
210
|
+
function applyShowB(on) {
|
|
211
|
+
editCharacters((list) => {
|
|
212
|
+
const anyVisibleExtra = list.some((entry, i) => i > 0 && !entry.hidden);
|
|
213
|
+
if (on) {
|
|
214
|
+
if (anyVisibleExtra) return list;
|
|
215
|
+
const hiddenIdx = list.findIndex((entry, i) => i > 0 && entry.hidden);
|
|
216
|
+
if (hiddenIdx > 0) return list.map((entry, i) => (i === hiddenIdx ? { ...entry, hidden: false } : entry));
|
|
217
|
+
return [...list, createCharacterEntry({ id: nextCharacterId(list), x: 1.15, z: 0.1, rot: -14, pose: DEFAULT_POSE, subject: DEFAULT_SUBJECT2 }, list.length)];
|
|
218
|
+
}
|
|
219
|
+
return list.map((entry, i) => (i > 0 && !entry.hidden ? { ...entry, hidden: true } : entry));
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function moveCharacter(charId, next) {
|
|
224
|
+
const entry = domain.read().find(entry => entry.id === charId);
|
|
225
|
+
return domain.edit('character.update', { characterId: charId, patch: typeof next === 'function' ? next(entry) : next });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function removeCharacter(charId) {
|
|
229
|
+
const list = appContext.live.characters;
|
|
230
|
+
if (list.length <= 1) return;
|
|
231
|
+
domain.run('character.remove', { characterId: charId });
|
|
232
|
+
// The full-take cache remains native until the motion owner migrates.
|
|
233
|
+
if (!appContext.storeDomain('motion')) appContext.shared.motionFullRef.current.delete(charId);
|
|
234
|
+
setRigs((current) => {
|
|
235
|
+
if (!(charId in current)) return current;
|
|
236
|
+
const next = { ...current };
|
|
237
|
+
delete next[charId];
|
|
238
|
+
return next;
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const reportRig = (charId) => {
|
|
243
|
+
if (!appContext.shared.rigReportersRef.current.has(charId)) {
|
|
244
|
+
appContext.shared.rigReportersRef.current.set(charId, (rig) => {
|
|
245
|
+
setRigs((current) => (current[charId] === rig ? current : { ...current, [charId]: rig }));
|
|
246
|
+
window.__cozyclayMcpRigReady = [...new Set([...(window.__cozyclayMcpRigReady ?? []), charId])];
|
|
247
|
+
window.dispatchEvent(new CustomEvent("cozyclay:mcp-rig-ready", { detail: charId }));
|
|
248
|
+
const waiter = appContext.shared.rigWaitersRef.current.get(charId);
|
|
249
|
+
if (waiter) {
|
|
250
|
+
appContext.shared.rigWaitersRef.current.delete(charId);
|
|
251
|
+
waiter(rig);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
return appContext.shared.rigReportersRef.current.get(charId);
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const spawnCharacter = (model, x, z) => {
|
|
259
|
+
const id = nextCharacterId(domain.read());
|
|
260
|
+
domain.run('character.add', { character: { id, model, x, z, pose: DEFAULT_POSE, subject: 'a person' } });
|
|
261
|
+
appContext.shared.setSelectedHierarchyId(`character:${id}`);
|
|
262
|
+
appContext.notify(ko("Character added to the scene", "인물을 씬에 추가했어요"));
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
// Viewport picks tag bodies with "A"/"B"/charId and surfaces route the
|
|
266
|
+
// result to a hierarchy row: the first two keep their legacy row ids.
|
|
267
|
+
const charKeyToHierarchyId = (key) => {
|
|
268
|
+
if (key === "A" || key === "a" || key === "char:A") return "characterA";
|
|
269
|
+
if (key === "B" || key === "b" || key === "char:B") return "characterB";
|
|
270
|
+
return `character:${key.startsWith("char:") ? key.slice(5) : key}`;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
/* ------------------- active character (motion layer) ------------------- */
|
|
274
|
+
// Every character owns an animation layer (root path, prompt blocks,
|
|
275
|
+
// generated clip, IK keys). The studio's motion machinery edits ONE layer
|
|
276
|
+
// at a time — the ACTIVE character's — and selection decides who that is.
|
|
277
|
+
const charIdFromHierarchyId = (hierarchyId) => {
|
|
278
|
+
if (hierarchyId === "characterA") return characters[0]?.id ?? null;
|
|
279
|
+
if (hierarchyId === "characterB") return characters[1]?.id ?? null;
|
|
280
|
+
if (hierarchyId?.startsWith("character:")) return hierarchyId.slice(10);
|
|
281
|
+
return null;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
// State, not a ref: a ref written inside an effect never re-renders, so
|
|
285
|
+
// with an idle app the active character silently stayed behind the row
|
|
286
|
+
// the user just clicked.
|
|
287
|
+
const [activeCharacterId, renderActiveCharacterId] = useState(domain.activeId);
|
|
288
|
+
function setActiveCharacterId(value) {
|
|
289
|
+
domain.activeId = typeof value === 'function' ? value(domain.activeId) : value;
|
|
290
|
+
renderActiveCharacterId(domain.activeId);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** The hierarchy row id a cast LIST index owns — mirror of
|
|
294
|
+
* hierarchy-model's characterRowId, for building namespaced rig ids. */
|
|
295
|
+
const rowIdForCharIndex = (index) => index === 0 ? "characterA" : index === 1 ? "characterB" : characters[index] ? `character:${characters[index].id}` : "characterA";
|
|
296
|
+
|
|
297
|
+
const activeChar = characters.find((entry) => entry.id === activeCharacterId) ?? characters[0] ?? charA;
|
|
298
|
+
|
|
299
|
+
// Root paths and prompt blocks are the active character's animation layer.
|
|
300
|
+
// With the Inspector driven by selection, showing those tools means putting
|
|
301
|
+
// that character in the hierarchy selection.
|
|
302
|
+
const selectActiveCharacterInHierarchy = () => {
|
|
303
|
+
const id = activeCharacterId ?? characters[0]?.id;
|
|
304
|
+
if (id) appContext.shared.setSelectedHierarchyId(`character:${id}`);
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const activeCharIndex = Math.max(0, characters.findIndex((entry) => entry.id === activeChar.id));
|
|
308
|
+
|
|
309
|
+
const activeRig = rigs[activeChar.id] ?? null;
|
|
310
|
+
|
|
311
|
+
const waitForRig = (charId, timeoutMs = 10000) => {
|
|
312
|
+
const current = rigs[charId];
|
|
313
|
+
if (current) return Promise.resolve(current);
|
|
314
|
+
return new Promise((resolve, reject) => {
|
|
315
|
+
const timer = setTimeout(() => {
|
|
316
|
+
appContext.shared.rigWaitersRef.current.delete(charId);
|
|
317
|
+
reject(new Error(ko("The active character's rig is not loaded", "활성 인물의 리그가 로드되지 않았어요")));
|
|
318
|
+
}, timeoutMs);
|
|
319
|
+
appContext.shared.rigWaitersRef.current.set(charId, (rig) => {
|
|
320
|
+
clearTimeout(timer);
|
|
321
|
+
resolve(rig);
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
// Read-only previews of the other cast members' layers for the timeline,
|
|
327
|
+
// memoized: a fresh array every render would re-render every lane on
|
|
328
|
+
// every playhead tick.
|
|
329
|
+
const ghostLayers = useMemo(() => characters.flatMap((entry, index) => entry.id === activeChar.id || entry.hidden ? [] : [{
|
|
330
|
+
owner: `S${index + 1}`,
|
|
331
|
+
promptClips: entry.layer?.promptClips ?? [],
|
|
332
|
+
waypointFrames: (entry.layer?.waypoints ?? []).map((waypoint) => waypoint.frame),
|
|
333
|
+
}]), [characters, activeChar.id]);
|
|
334
|
+
|
|
335
|
+
// Undo/redo (plan §6.5). The store settles any open drag first, so a
|
|
336
|
+
// mid-drag press commits that drag as one entry and then steps past it.
|
|
337
|
+
// After a step the selection can point at a deleted object — drop it to
|
|
338
|
+
/* Cast authoring uses the registered store. The native compatibility
|
|
339
|
+
* snapshot remains for motion/IK until its owner migrates; it must not
|
|
340
|
+
* restore the owned cast over newer character edits. */
|
|
341
|
+
const snapshotCast = (includeShots = false) => appContext.storeDomain('cast')?.snapshotMotion() ?? ({
|
|
342
|
+
characters: appContext.live.characters.map((entry) => ({
|
|
343
|
+
...entry,
|
|
344
|
+
layer: entry.id === activeChar.id
|
|
345
|
+
? { waypoints: appContext.shared.bufferRef.current.waypoints, promptClips: appContext.shared.bufferRef.current.promptClips }
|
|
346
|
+
: entry.layer,
|
|
347
|
+
})),
|
|
348
|
+
bufferMotion: appContext.shared.bufferRef.current.motion,
|
|
349
|
+
bufferCharId: appContext.shared.loadedLayerCharRef.current,
|
|
350
|
+
// The ACTIVE character's authored IK layer. Only the keys (deep-copied so
|
|
351
|
+
// undo can never hand back live quaternions) and the committed-edit list
|
|
352
|
+
// travel: targets/plants/tracked are transient solver state the next
|
|
353
|
+
// seed/drag rebuilds anyway.
|
|
354
|
+
ikKeys: appContext.shared.snapshotIkKeys(appContext.shared.ikStateRef.current),
|
|
355
|
+
committedIkEdits: appContext.shared.committedIkEdits,
|
|
356
|
+
// Shot-op entries carry the shot list too; character-op entries leave it
|
|
357
|
+
// out so undoing a character move never rolls back unrecorded shot edits.
|
|
358
|
+
...(includeShots ? { shots: appContext.shared.shots } : {}),
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
/** The Inspector's character Transform rows. The viewport gizmo already
|
|
362
|
+
* records on drag start; these numeric rows are the same edit through
|
|
363
|
+
* another door, so they record once per scrub / typed commit. */
|
|
364
|
+
function changeInspectorCharacter(gesture, patch) {
|
|
365
|
+
return updateCharacterAt(activeCharIndex, patch);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const [hasCharSheet, setHasCharSheet] = useState(appContext.shared.startupStage.hasCharSheet);
|
|
369
|
+
|
|
370
|
+
// Bumped when something outside the Inspector needs the Prompt Blocks panel
|
|
371
|
+
// on screen — selecting or adding a block on the timeline.
|
|
372
|
+
const [promptBlocksReveal, setPromptBlocksReveal] = useState(0);
|
|
373
|
+
|
|
374
|
+
const revealPromptBlocks = () => setPromptBlocksReveal((n) => n + 1);
|
|
375
|
+
|
|
376
|
+
// Root waypoints {frame, x, z, heading: null}, kept sorted by frame —
|
|
377
|
+
// the fixed bridge contract rejects out-of-order or duplicate frames.
|
|
378
|
+
const [waypointMode, setWaypointMode] = useState(false);
|
|
379
|
+
|
|
380
|
+
const waypoints = activeChar.layer.waypoints;
|
|
381
|
+
const setWaypoints = value => setLayerField('waypoints', value);
|
|
382
|
+
|
|
383
|
+
const [activeWaypointId, setActiveWaypointId] = useState(null);
|
|
384
|
+
|
|
385
|
+
const [pendingWaypointFrame, setPendingWaypointFrame] = useState(null);
|
|
386
|
+
|
|
387
|
+
const promptClips = activeChar.layer.promptClips;
|
|
388
|
+
const setPromptClips = value => setLayerField('promptClips', value);
|
|
389
|
+
const editPromptClips = value => writeLayerField(domain.activeId, 'promptClips', value);
|
|
390
|
+
function writeLayerField(characterId, key, value) {
|
|
391
|
+
domain.write(rows => rows.map(entry => entry.id === characterId ? { ...entry, layer: { ...entry.layer,
|
|
392
|
+
[key]: typeof value === 'function' ? value(entry.layer[key]) : value } } : entry));
|
|
393
|
+
}
|
|
394
|
+
function setLayerField(key, value) {
|
|
395
|
+
const entry = domain.read().find(entry => entry.id === domain.activeId) ?? domain.read()[0];
|
|
396
|
+
const next = typeof value === 'function' ? value(entry.layer[key]) : value;
|
|
397
|
+
if (sameCastValue(entry.layer[key], next)) return;
|
|
398
|
+
return domain.run('cast.setLayer', { characterId: entry.id, layer: { [key]: next } });
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const [selectedPromptId, setSelectedPromptId] = useState(null);
|
|
402
|
+
|
|
403
|
+
const [photoPoseState, setPhotoPoseState] = useState("idle");
|
|
404
|
+
|
|
405
|
+
const [photoPoseError, setPhotoPoseError] = useState("");
|
|
406
|
+
|
|
407
|
+
// The library is the user's own material: poses read from photographs and
|
|
408
|
+
// poses saved off the rig, accumulating across sessions and projects. No
|
|
409
|
+
// presets ship in it — DEFAULT_POSE is the character's spawn state, not a
|
|
410
|
+
// library entry.
|
|
411
|
+
const allPoses = customPoses;
|
|
412
|
+
|
|
413
|
+
// The dropdowns must be able to show and re-select the pose a character is
|
|
414
|
+
// actually in, and a fresh character is in the default — which is not a
|
|
415
|
+
// library entry. An empty library would otherwise render a blank select.
|
|
416
|
+
const selectablePoses = useMemo(() => [DEFAULT_POSE, ...customPoses], [customPoses]);
|
|
417
|
+
|
|
418
|
+
// The pose studio follows the character it was opened for: `posing` is a
|
|
419
|
+
// charId, so every cast member gets the same studio, not just the first two.
|
|
420
|
+
const posingIndex = characters.findIndex((entry) => entry.id === posing);
|
|
421
|
+
|
|
422
|
+
const posingChar = posingIndex >= 0 ? characters[posingIndex] : null;
|
|
423
|
+
|
|
424
|
+
const posedRig = () => rigs[posing] ?? null;
|
|
425
|
+
|
|
426
|
+
const setPosed = (pose) => {
|
|
427
|
+
if (posingIndex >= 0) updateCharacterAt(posingIndex, { pose: typeof pose === "function" ? pose(posingChar?.pose ?? DEFAULT_POSE) : pose });
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
/* ------------------------- waypoint workspace --------------------------- */
|
|
431
|
+
// A walking pace turns clicked distance into clip time, so pins land at
|
|
432
|
+
// frames the character can actually reach without ice-skating.
|
|
433
|
+
const WALK_SPEED_MPS = 1.4;
|
|
434
|
+
|
|
435
|
+
const ROOT_ROOM_LIMIT = 11;
|
|
436
|
+
|
|
437
|
+
const clampRootPosition = (value) => Math.max(-ROOT_ROOM_LIMIT, Math.min(ROOT_ROOM_LIMIT, value));
|
|
438
|
+
|
|
439
|
+
// Frame 0 of a root path is the ACTIVE character's spot — each layer's
|
|
440
|
+
// path starts from its own cast member.
|
|
441
|
+
const rootStart = () => ({ frame: 0, x: activeChar.x, z: activeChar.z });
|
|
442
|
+
|
|
443
|
+
function validateWaypointAt(ordered, index, candidate, start = rootStart()) {
|
|
444
|
+
const previous = index > 0 ? ordered[index - 1] : start;
|
|
445
|
+
const beforePrevious = index > 1 ? ordered[index - 2] : null;
|
|
446
|
+
const inbound = judgeNextWaypoint(previous, candidate, appContext.shared.tlFps, beforePrevious);
|
|
447
|
+
if (!inbound.ok) return inbound;
|
|
448
|
+
const next = ordered[index + 1];
|
|
449
|
+
if (!next) return inbound;
|
|
450
|
+
const outbound = judgeNextWaypoint(candidate, next, appContext.shared.tlFps, previous);
|
|
451
|
+
if (!outbound.ok) return outbound;
|
|
452
|
+
return { ok: true, warnings: [...inbound.warnings, ...outbound.warnings] };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function queueRootWaypointFrame(frame) {
|
|
456
|
+
const target = Math.max(1, Math.min(Math.round(frame), appContext.shared.tlFrameCount - 1));
|
|
457
|
+
const existing = waypoints.find((waypoint) => waypoint.frame === target);
|
|
458
|
+
if (existing) {
|
|
459
|
+
setActiveWaypointId(existing.id);
|
|
460
|
+
setPendingWaypointFrame(null);
|
|
461
|
+
appContext.shared.setTlFrame(target);
|
|
462
|
+
setWaypointMode(true);
|
|
463
|
+
selectActiveCharacterInHierarchy();
|
|
464
|
+
appContext.notify(isKo ? `프레임 ${target}의 루트 웨이포인트를 선택했어요. 탑뷰에서 점을 드래그해 위치를 조정하세요.` : `Root waypoint at frame ${target} selected — drag the pin in the Top-View to reposition.`);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
setPendingWaypointFrame(target);
|
|
468
|
+
setActiveWaypointId(null);
|
|
469
|
+
appContext.shared.setTlFrame(target);
|
|
470
|
+
setWaypointMode(true);
|
|
471
|
+
selectActiveCharacterInHierarchy();
|
|
472
|
+
appContext.notify(isKo ? `프레임 ${target}이 예약됐어요. 샷 뷰 바닥을 클릭하면 그 위치에 루트 웨이포인트가 생성됩니다.` : `Frame ${target} is reserved — click the Shot-view floor to drop the root waypoint there.`);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/* One root-path core for every cast member, shared by the Shot-view floor
|
|
476
|
+
* click, the plan-board drag, the timeline marker and run_action. It takes
|
|
477
|
+
* the character explicitly: the loaded layer's path lives in the editing
|
|
478
|
+
* buffer, every other character's on its cast entry. Refusals throw a
|
|
479
|
+
* StudioProtocolError naming the fix; the UI door shows it as a toast. */
|
|
480
|
+
function castMemberOf(characterId) {
|
|
481
|
+
const character = appContext.live.characters.find((entry) => entry.id === characterId);
|
|
482
|
+
if (!character) throw new StudioProtocolError("STALE_TARGET", `Character ${characterId} is not in this scene.`);
|
|
483
|
+
return character;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function readCharacterWaypoints(characterId) {
|
|
487
|
+
if (appContext.storeDomain('cast')) return appContext.storeDomain('cast').read().find(entry => entry.id === characterId)?.layer.waypoints ?? [];
|
|
488
|
+
if (characterId === appContext.shared.loadedLayerCharRef.current) return appContext.shared.bufferRef.current.waypoints;
|
|
489
|
+
return appContext.live.characters.find((entry) => entry.id === characterId)?.layer?.waypoints ?? [];
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function writeCharacterWaypoints(characterId, next) {
|
|
493
|
+
if (appContext.storeDomain('cast')) return appContext.storeDomain('cast').write(rows => rows.map(entry => entry.id === characterId ? { ...entry, layer: { ...entry.layer, waypoints: next } } : entry));
|
|
494
|
+
if (characterId === appContext.shared.loadedLayerCharRef.current) {
|
|
495
|
+
appContext.shared.bufferRef.current = { ...appContext.shared.bufferRef.current, waypoints: next };
|
|
496
|
+
setWaypoints(next);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
appContext.shared.publishStudioCharacters(appContext.live.characters.map((entry) => entry.id === characterId
|
|
500
|
+
? { ...entry, layer: { ...(entry.layer ?? createCharacterLayer()), waypoints: next } }
|
|
501
|
+
: entry), true);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Pin the character's root at `point` ({x, z}) on `frame`, or — frame null —
|
|
505
|
+
* at walking-distance pacing from the previous pin. Returns the placed
|
|
506
|
+
* waypoint, its index on the path and the judge's warnings. */
|
|
507
|
+
function addCharacterWaypoint(characterId, point, frame = null) {
|
|
508
|
+
const character = castMemberOf(characterId);
|
|
509
|
+
const ordered = [...readCharacterWaypoints(characterId)].sort((a, b) => a.frame - b.frame);
|
|
510
|
+
if (ordered.length + 1 > MAX_WAYPOINTS) {
|
|
511
|
+
throw studioActionRefusal("TARGET_NOT_READY", `The root path is capped at ${MAX_WAYPOINTS} waypoints; remove one first.`,
|
|
512
|
+
isKo ? `루트 경로는 웨이포인트 ${MAX_WAYPOINTS}개까지 사용할 수 있어요` : `The root path is capped at ${MAX_WAYPOINTS} waypoints`);
|
|
513
|
+
}
|
|
514
|
+
const x = clampRootPosition(point.x);
|
|
515
|
+
const z = clampRootPosition(point.z);
|
|
516
|
+
const start = { frame: 0, x: character.x, z: character.z };
|
|
517
|
+
const last = ordered[ordered.length - 1] ?? start;
|
|
518
|
+
const lastFrame = appContext.shared.frameCountRef.current - 1;
|
|
519
|
+
if (frame !== null && (frame < 1 || frame > lastFrame)) throw new StudioProtocolError("INVALID_RANGE", `Frame ${frame} is outside the root path's frames 1-${lastFrame}.`);
|
|
520
|
+
const at = frame ?? last.frame + Math.max(8, Math.round((Math.hypot(x - last.x, z - last.z) / WALK_SPEED_MPS) * appContext.shared.tlFps));
|
|
521
|
+
if (at > lastFrame) {
|
|
522
|
+
throw studioActionRefusal("INVALID_RANGE", "The path already fills the clip — extend the duration or clear a waypoint.",
|
|
523
|
+
ko("The path already fills the clip — extend the duration or clear a waypoint", "경로가 이미 클립 길이를 채웠어요. 시간을 늘리거나 웨이포인트를 지워 주세요"));
|
|
524
|
+
}
|
|
525
|
+
if (ordered.some((waypoint) => waypoint.frame === at)) {
|
|
526
|
+
throw studioActionRefusal("INVALID_ARGUMENT", `Frame ${at} already has a root waypoint — pick an empty frame or move that one.`,
|
|
527
|
+
isKo ? `프레임 ${at}에는 이미 루트 웨이포인트가 있어요. 타임라인에서 빈 프레임을 선택하세요.` : `Frame ${at} already has a root waypoint — pick an empty frame on the timeline.`);
|
|
528
|
+
}
|
|
529
|
+
// The generator cannot refuse an impossible pin, so placement is the
|
|
530
|
+
// last moment to: block out-of-band legs with the fix named.
|
|
531
|
+
const insertAt = ordered.findIndex((waypoint) => waypoint.frame > at);
|
|
532
|
+
const index = insertAt === -1 ? ordered.length : insertAt;
|
|
533
|
+
const waypoint = { id: createStableItemId("waypoint"), frame: at, x, z, heading: null };
|
|
534
|
+
const next = [...ordered.slice(0, index), waypoint, ...ordered.slice(index)];
|
|
535
|
+
const verdict = validateWaypointAt(next, index, waypoint, start);
|
|
536
|
+
if (!verdict.ok) throw studioActionRefusal("INVALID_ARGUMENT", `Not placed — ${verdict.error}`, isKo ? `배치하지 못했어요 — ${verdict.error}` : `Not placed — ${verdict.error}`);
|
|
537
|
+
writeCharacterWaypoints(characterId, next);
|
|
538
|
+
return { waypoint, index, warnings: verdict.warnings };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function moveCharacterWaypoint(characterId, frame, point) {
|
|
542
|
+
const character = castMemberOf(characterId);
|
|
543
|
+
const ordered = [...readCharacterWaypoints(characterId)].sort((a, b) => a.frame - b.frame);
|
|
544
|
+
const index = ordered.findIndex((waypoint) => waypoint.frame === frame);
|
|
545
|
+
if (index === -1) throw new StudioProtocolError("STALE_TARGET", `${character.subject || character.id} has no root waypoint at frame ${frame}.`);
|
|
546
|
+
const moved = { ...ordered[index], x: clampRootPosition(point.x), z: clampRootPosition(point.z) };
|
|
547
|
+
if (moved.x === ordered[index].x && moved.z === ordered[index].z) return { waypoint: ordered[index], index, warnings: [] };
|
|
548
|
+
const next = ordered.map((waypoint, i) => (i === index ? moved : waypoint));
|
|
549
|
+
const verdict = validateWaypointAt(next, index, moved, { frame: 0, x: character.x, z: character.z });
|
|
550
|
+
if (!verdict.ok) {
|
|
551
|
+
throw studioActionRefusal("INVALID_ARGUMENT", `This position doesn't fit the root path: ${verdict.error}`,
|
|
552
|
+
isKo ? `이 위치는 루트 경로에 맞지 않아요: ${verdict.error}` : `This position doesn't fit the root path: ${verdict.error}`);
|
|
553
|
+
}
|
|
554
|
+
writeCharacterWaypoints(characterId, next);
|
|
555
|
+
return { waypoint: moved, index, warnings: verdict.warnings };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function removeCharacterWaypoint(characterId, frame) {
|
|
559
|
+
const character = castMemberOf(characterId);
|
|
560
|
+
const current = readCharacterWaypoints(characterId);
|
|
561
|
+
const waypoint = current.find((entry) => entry.frame === frame);
|
|
562
|
+
if (!waypoint) throw new StudioProtocolError("STALE_TARGET", `${character.subject || character.id} has no root waypoint at frame ${frame}.`);
|
|
563
|
+
writeCharacterWaypoints(characterId, removeStableItem(current, waypoint.id, "waypoints"));
|
|
564
|
+
return waypoint;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function clearCharacterWaypoints(characterId) {
|
|
568
|
+
castMemberOf(characterId);
|
|
569
|
+
const current = readCharacterWaypoints(characterId);
|
|
570
|
+
if (!current.length) return 0;
|
|
571
|
+
writeCharacterWaypoints(characterId, []);
|
|
572
|
+
return current.length;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** ARDY-demo style authoring: each empty-floor press in the Shot view drops
|
|
576
|
+
the next waypoint where it was clicked; the frame gap comes from walking
|
|
577
|
+
distance. The bird's-eye board selects and drags existing waypoints. */
|
|
578
|
+
function addFloorWaypoint(point) {
|
|
579
|
+
const ordered = [...waypoints].sort((a, b) => a.frame - b.frame);
|
|
580
|
+
const last = ordered[ordered.length - 1] ?? rootStart();
|
|
581
|
+
const pendingFrame = pendingWaypointFrame == null ? null : Math.max(1, Math.min(Math.round(pendingWaypointFrame), appContext.shared.tlFrameCount - 1));
|
|
582
|
+
// A scrubbed playhead is an explicit statement of time: a click lands on
|
|
583
|
+
// that exact frame. An untouched playhead (it snaps to the last pin
|
|
584
|
+
// after every placement) falls back to walking-distance pacing.
|
|
585
|
+
const playhead = Math.round(appContext.shared.tlFrame);
|
|
586
|
+
const pinned = pendingFrame != null || playhead > last.frame;
|
|
587
|
+
const frame = pendingFrame ?? (pinned ? Math.min(playhead, appContext.shared.tlFrameCount - 1) : null);
|
|
588
|
+
const before = readCharacterWaypoints(activeChar.id);
|
|
589
|
+
const placedAction = appContext.shared.runStudioAction("character.addWaypoint", { characterId: activeChar.id, position: { x: point.x, z: point.z }, ...(frame == null ? {} : { frame }) });
|
|
590
|
+
if (!placedAction) {
|
|
591
|
+
if (pendingFrame != null && ordered.some((waypoint) => waypoint.frame === pendingFrame)) setPendingWaypointFrame(null);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
const path = readCharacterWaypoints(activeChar.id);
|
|
595
|
+
const index = path.findIndex((waypoint) => !before.includes(waypoint));
|
|
596
|
+
const waypoint = path[index];
|
|
597
|
+
appContext.shared.setTlFrame(waypoint.frame);
|
|
598
|
+
setActiveWaypointId(waypoint.id);
|
|
599
|
+
setPendingWaypointFrame(null);
|
|
600
|
+
const { warnings } = validateWaypointAt(path, index, waypoint);
|
|
601
|
+
const placed = isKo
|
|
602
|
+
? `루트 웨이포인트 ${index + 1} 추가: 프레임 ${waypoint.frame}${pendingFrame != null ? " (타임라인 예약 프레임)" : pinned ? " (재생 헤드 위치)" : ` (~${(waypoint.frame / appContext.shared.tlFps).toFixed(1)}초 걷기 기준)`}`
|
|
603
|
+
: `Waypoint ${index + 1} — frame ${waypoint.frame} ${pendingFrame != null ? "(at the reserved frame)" : pinned ? "(at the playhead)" : `(~${(waypoint.frame / appContext.shared.tlFps).toFixed(1)}s at a walk)`}`;
|
|
604
|
+
appContext.notify(warnings.length ? `${placed} · ⚠ ${warnings[0]}` : placed);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function moveWaypoint(id, x, z) {
|
|
608
|
+
const waypoint = waypoints.find((entry) => entry.id === id);
|
|
609
|
+
if (!waypoint) throw new Error(`Unknown waypoints ID: ${id}`);
|
|
610
|
+
domain.edit('character.moveWaypoint', { characterId: activeChar.id, frame: waypoint.frame, position: { x, z } });
|
|
611
|
+
setActiveWaypointId(id);
|
|
612
|
+
setPendingWaypointFrame((current) => (current === waypoint.frame ? null : current));
|
|
613
|
+
const path = readCharacterWaypoints(activeChar.id);
|
|
614
|
+
const index = path.findIndex((entry) => entry.id === id);
|
|
615
|
+
const { warnings } = index === -1 ? { warnings: [] } : validateWaypointAt(path, index, path[index]);
|
|
616
|
+
if (warnings.length) appContext.notify(isKo ? `루트 웨이포인트 이동됨: ${warnings[0]}` : `Root waypoint moved: ${warnings[0]}`);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function removeWaypoint(id) {
|
|
620
|
+
const waypoint = waypoints.find((entry) => entry.id === id);
|
|
621
|
+
if (!waypoint) throw new Error(`Unknown waypoints ID: ${id}`);
|
|
622
|
+
if (!appContext.shared.runStudioAction("character.removeWaypoint", { characterId: activeChar.id, frame: waypoint.frame })) return;
|
|
623
|
+
setActiveWaypointId((current) => (current === id ? null : current));
|
|
624
|
+
setPendingWaypointFrame((current) => (current === waypoint.frame ? null : current));
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function toggleWaypointMode() {
|
|
628
|
+
const next = !waypointMode;
|
|
629
|
+
// Both modes want the viewport pointer; the last one switched on wins,
|
|
630
|
+
// the other stands down rather than fighting for pointerdown.
|
|
631
|
+
if (next && appContext.shared.lineEditMode) appContext.shared.exitLineEditMode();
|
|
632
|
+
setWaypointMode(next);
|
|
633
|
+
if (!next) {
|
|
634
|
+
setPendingWaypointFrame(null);
|
|
635
|
+
appContext.notify(ko("Root path constraints off", "루트 경로 제약 꺼짐"));
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
appContext.notify(ko("Root path on — click the set floor in the Shot view to drop waypoints; Subject 1 is the frame 0 start", "루트 경로 켜짐 — 샷 뷰의 세트 바닥을 클릭해 웨이포인트를 놓으세요. 인물 1이 0프레임 시작점입니다"));
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function openStudio(charId) {
|
|
643
|
+
setPosing(charId);
|
|
644
|
+
setPosingClosing(false);
|
|
645
|
+
const entry = characters.find((item) => item.id === charId);
|
|
646
|
+
setStudioPick((entry?.pose ?? DEFAULT_POSE)?.id ?? null);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function closeStudio() {
|
|
650
|
+
// let the panel play its exit animation before it leaves the tree
|
|
651
|
+
setPosingClosing(true);
|
|
652
|
+
window.setTimeout(() => {
|
|
653
|
+
setPosing(null);
|
|
654
|
+
setPosingClosing(false);
|
|
655
|
+
}, 190);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** Save the ACTIVE character's rig exactly as it stands — the motion frame
|
|
659
|
+
* with any IK corrections already composited — into the pose library.
|
|
660
|
+
* Unlike savePose (the studio's FK author), this never writes back onto the
|
|
661
|
+
* character: a running take must survive having its best frame bottled. */
|
|
662
|
+
function saveCurrentPose() {
|
|
663
|
+
if (!activeRig) return;
|
|
664
|
+
// Saving to the library, not editing: real bone edits are `pose_edit`
|
|
665
|
+
// from the semantic boundary (#466).
|
|
666
|
+
trackFeature("pose_save");
|
|
667
|
+
const pose = {
|
|
668
|
+
id: `custom_${Date.now()}`,
|
|
669
|
+
label: isKo ? `내 포즈 ${customPoses.length + 1}` : `My Pose ${customPoses.length + 1}`,
|
|
670
|
+
prompt: "in the exact body pose shown in the blocking frame",
|
|
671
|
+
bones: capturePose(activeRig),
|
|
672
|
+
// A take frame carries its measured hips height; bottling the frame
|
|
673
|
+
// without it would save every crouch as a float.
|
|
674
|
+
rootY: captureHipsOffset(activeRig),
|
|
675
|
+
custom: true,
|
|
676
|
+
};
|
|
677
|
+
domain.run('cast.savePose', { pose });
|
|
678
|
+
setStudioPick(pose.id);
|
|
679
|
+
appContext.notify(appContext.shared.motion
|
|
680
|
+
? ko(`Saved this frame's pose to the library as “${pose.label}”`, `지금 프레임의 자세를 “${pose.label}”로 라이브러리에 저장했어요`)
|
|
681
|
+
: ko(`Saved the current pose to the library as “${pose.label}”`, `지금 자세를 “${pose.label}”로 라이브러리에 저장했어요`));
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function savePose() {
|
|
685
|
+
const rig = posedRig();
|
|
686
|
+
if (!rig) return;
|
|
687
|
+
trackFeature("pose_save");
|
|
688
|
+
const pose = {
|
|
689
|
+
id: `custom_${Date.now()}`,
|
|
690
|
+
label: isKo ? `내 포즈 ${customPoses.length + 1}` : `My Pose ${customPoses.length + 1}`,
|
|
691
|
+
prompt: "in the exact body pose shown in the blocking frame",
|
|
692
|
+
bones: capturePose(rig),
|
|
693
|
+
custom: true,
|
|
694
|
+
};
|
|
695
|
+
domain.run('cast.savePose', { pose, ...(posingChar ? { characterId: posingChar.id } : {}) });
|
|
696
|
+
setStudioPick(pose.id);
|
|
697
|
+
appContext.notify(ko("Pose saved", "포즈 저장됨"));
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Read a body pose out of one photograph.
|
|
702
|
+
*
|
|
703
|
+
* A still is the degenerate footage case, so it walks the same proven path:
|
|
704
|
+
* landmarks -> one-frame take -> applyMotionFrame -> capturePose. Posing the
|
|
705
|
+
* rig and reading it back is what makes the result an ordinary editable pose
|
|
706
|
+
* rather than a motion layer — the IK handles keep working on it, and the
|
|
707
|
+
* playback bones are restored so nothing about the take survives the read.
|
|
708
|
+
*
|
|
709
|
+
* Depth in a single frame is inferred, not measured, so this is a starting
|
|
710
|
+
* pose to refine, which is why it lands in the studio instead of on the
|
|
711
|
+
* character directly.
|
|
712
|
+
*/
|
|
713
|
+
async function posePhotoFile(file) {
|
|
714
|
+
if (!file || photoPoseState === "running") return;
|
|
715
|
+
// Reachable from the Inspector as well as the studio panel, and posedRig()
|
|
716
|
+
// only answers while the studio is open — fall back to the character the
|
|
717
|
+
// hierarchy has selected, which is the one the pose will be applied to.
|
|
718
|
+
const rig = posedRig() ?? activeRig;
|
|
719
|
+
let objectUrl = "";
|
|
720
|
+
setPhotoPoseState("running");
|
|
721
|
+
setPhotoPoseError("");
|
|
722
|
+
try {
|
|
723
|
+
if (!rig) throw new Error("rig-not-loaded");
|
|
724
|
+
objectUrl = URL.createObjectURL(file);
|
|
725
|
+
let bones = null;
|
|
726
|
+
let rootY = 0;
|
|
727
|
+
let gpuError = null;
|
|
728
|
+
// GVHMR on the box measures the body over the whole clip,
|
|
729
|
+
// which is more reliable than a single-frame depth estimate.
|
|
730
|
+
// The bridge wraps the still into a second of video and runs the exact
|
|
731
|
+
// GVHMR footage pipeline. A missing bridge or another backend is an error.
|
|
732
|
+
try {
|
|
733
|
+
const health = await fetch("/ardy/health", { signal: AbortSignal.timeout(2000) }).catch(() => null);
|
|
734
|
+
if (!health?.ok) throw new Error("extract-bridge-required");
|
|
735
|
+
const healthPayload = await health.json().catch(() => null);
|
|
736
|
+
if (healthPayload?.extractionBackend !== "gvhmr") throw new Error("extract-backend-unsupported");
|
|
737
|
+
const done = await requestBridgeExtract(file, {});
|
|
738
|
+
const take = await loadMotionFromUrl(done.motionUrl);
|
|
739
|
+
// The middle frame: the wrap's smoothing passes have settled
|
|
740
|
+
// there, while frame 0 can still carry filter warm-up.
|
|
741
|
+
const frame = Math.floor((take.frames - 1) / 2);
|
|
742
|
+
const snapshot = snapshotPlaybackBones(rig);
|
|
743
|
+
try {
|
|
744
|
+
applyMotionFrame(rig, { ...take, anchorFrame: frame }, frame);
|
|
745
|
+
bones = capturePose(rig);
|
|
746
|
+
// GVHMR measured the hips' true height — a crouch is a crouch
|
|
747
|
+
// because the hips came DOWN, not just because the knees bent.
|
|
748
|
+
rootY = captureHipsOffset(rig);
|
|
749
|
+
} finally {
|
|
750
|
+
restorePlaybackBones(rig, snapshot);
|
|
751
|
+
}
|
|
752
|
+
} catch (error) {
|
|
753
|
+
gpuError = error;
|
|
754
|
+
console.warn("photo pose: GVHMR extract failed", error);
|
|
755
|
+
}
|
|
756
|
+
if (!bones) throw new Error(gpuError?.message || "extract-run-failed");
|
|
757
|
+
const pose = {
|
|
758
|
+
id: `photo_${Date.now()}`,
|
|
759
|
+
label: isKo ? `사진 포즈 ${customPoses.length + 1}` : `Photo Pose ${customPoses.length + 1}`,
|
|
760
|
+
prompt: "in the exact body pose shown in the reference photograph",
|
|
761
|
+
bones,
|
|
762
|
+
rootY,
|
|
763
|
+
custom: true,
|
|
764
|
+
};
|
|
765
|
+
setStudioPick(pose.id);
|
|
766
|
+
// The studio poses whichever character it was opened on; the Inspector
|
|
767
|
+
// poses the selected one. Write the pose to whichever that is.
|
|
768
|
+
const poseTargetIndex = posingIndex >= 0 ? posingIndex : activeCharIndex;
|
|
769
|
+
// A running take drives the same bones a pose writes, so the read would
|
|
770
|
+
// land invisibly underneath it. Applying from a photo follows the same
|
|
771
|
+
// rule the Apply button already states: the motion goes first.
|
|
772
|
+
const hadMotion = Boolean(appContext.shared.motion);
|
|
773
|
+
domain.run('cast.savePose', { pose, characterId: characters[poseTargetIndex].id, clearMotion: hadMotion });
|
|
774
|
+
setPhotoPoseState("done");
|
|
775
|
+
// The pose is already saved and written by this point. GVHMR either
|
|
776
|
+
// returns a measured pose or the named error above reaches the user.
|
|
777
|
+
appContext.notify(hadMotion
|
|
778
|
+
? ko("Cleared the motion and posed from the photo — refine it with the handles", "모션을 지우고 사진으로 자세를 잡았어요 — 핸들로 다듬어 보세요")
|
|
779
|
+
: ko("Pose read from the photo — refine it with the handles", "사진에서 자세를 읽었어요 — 핸들로 다듬어 보세요"));
|
|
780
|
+
} catch (error) {
|
|
781
|
+
const code = error?.message ?? String(error);
|
|
782
|
+
// fitLandmarksToPose refuses a sample whose torso is not visible; that is
|
|
783
|
+
// a photograph problem, not an engine problem, so it is named as one.
|
|
784
|
+
const named = code.startsWith("fitLandmarksToPose:") ? "pose-partly-occluded" : code;
|
|
785
|
+
setPhotoPoseState("error");
|
|
786
|
+
setPhotoPoseError(MULTIMODEL_REASONS[named]?.[isKo ? 1 : 0] ?? named);
|
|
787
|
+
} finally {
|
|
788
|
+
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function removePose(id) {
|
|
793
|
+
domain.run('cast.removePose', { id });
|
|
794
|
+
if (studioPick === id) setStudioPick(DEFAULT_POSE.id);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function addPromptClip(frame, surface = "timeline") {
|
|
798
|
+
const before = domain.read().find(entry => entry.id === domain.activeId).layer.promptClips;
|
|
799
|
+
const receipt = domain.run('character.addPromptBlock', { characterId: domain.activeId, frame });
|
|
800
|
+
const clip = domain.read().find(entry => entry.id === domain.activeId).layer.promptClips.find(clip => !before.some(row => row.id === clip.id));
|
|
801
|
+
setSelectedPromptId(clip.id);
|
|
802
|
+
appContext.shared.setArdyDuration(Math.max(ARDY_DURATION_MIN, clip.endFrame / TIMELINE_FPS));
|
|
803
|
+
trackFeature('prompt_block_add'); return receipt;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function changePromptClip(id, text) {
|
|
807
|
+
domain.beginGesture();
|
|
808
|
+
const receipt = domain.edit('character.changePromptBlock', { characterId: domain.activeId, id, text });
|
|
809
|
+
if (id === selectedPromptId) appContext.shared.setArdyPrompt(text);
|
|
810
|
+
return receipt;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Quality policy: one prompt block never spans more than 5 s. Kimodo
|
|
814
|
+
// walk-to-run sweeps (seeds 7/21/99; seam stall ratio, 1.0 = no stall)
|
|
815
|
+
// scored 0.79 for 5 s blocks (best of the sweep), close to a seam-free
|
|
816
|
+
// single take at 0.85; 8 s blocks collapsed to 0.32. <2 s blocks lose
|
|
817
|
+
// about a third of their frames to the transition window, so 3-5 s is the recommended
|
|
818
|
+
// authoring range.
|
|
819
|
+
const PROMPT_BLOCK_MAX_FRAMES = 5 * TIMELINE_FPS;
|
|
820
|
+
|
|
821
|
+
function resizePromptClip(id, edge, frame) {
|
|
822
|
+
return domain.edit('character.resizePromptBlock', { characterId: domain.activeId, id, edge, frame });
|
|
823
|
+
}
|
|
824
|
+
function movePromptClip(id, frame) {
|
|
825
|
+
return domain.edit('character.movePromptBlock', { characterId: domain.activeId, id, frame });
|
|
826
|
+
}
|
|
827
|
+
function removePromptClip(id) {
|
|
828
|
+
const receipt = domain.run('character.removePromptBlock', { characterId: domain.activeId, id });
|
|
829
|
+
if (selectedPromptId === id) setSelectedPromptId(null);
|
|
830
|
+
return receipt;
|
|
831
|
+
}
|
|
832
|
+
function applyExternalCharacters(characters) {
|
|
833
|
+
appContext.storeDomain('cast').run('cast.replace', { characters });
|
|
834
|
+
appContext.shared.restoreMotionRefs(characters);
|
|
835
|
+
}
|
|
836
|
+
function publishStudioCharacters(next) {
|
|
837
|
+
if (typeof next === 'function') next = next(appContext.live.characters);
|
|
838
|
+
const owner = appContext.storeDomain('cast');
|
|
839
|
+
if (appContext.shared.studioActionGroupRef.current) appContext.ports.recordAction('cast', () => owner.write(next), null, true);
|
|
840
|
+
else owner.run('cast.replace', { characters: owner.normalizeCharacters(next) });
|
|
841
|
+
for (const entry of next) if (Object.hasOwn(entry, 'sessionMotion')) owner.publishMotion(entry.id, entry.sessionMotion);
|
|
842
|
+
}
|
|
843
|
+
/** The active character's layer lives in the editing buffer, and the read
|
|
844
|
+
* model folds that buffer back over the cast. A published or restored prompt
|
|
845
|
+
* schedule or root path has to reach it in the same tick, or the next read
|
|
846
|
+
* would revert it. */
|
|
847
|
+
function syncStudioLayerBuffer(rows) {
|
|
848
|
+
if (appContext.storeDomain('cast')) return appContext.storeDomain('cast').syncLayer();
|
|
849
|
+
const layer = rows.find(entry => entry.id === appContext.shared.loadedLayerCharRef.current)?.layer;
|
|
850
|
+
const changed = key => Array.isArray(layer?.[key]) && JSON.stringify(layer[key]) !== JSON.stringify(appContext.shared.bufferRef.current[key]);
|
|
851
|
+
const clips = changed("promptClips"), path = changed("waypoints");
|
|
852
|
+
if (!clips && !path) return;
|
|
853
|
+
appContext.shared.bufferRef.current = { ...appContext.shared.bufferRef.current, ...(clips ? { promptClips: layer.promptClips } : {}), ...(path ? { waypoints: layer.waypoints } : {}) };
|
|
854
|
+
if (clips) setPromptClips(layer.promptClips);
|
|
855
|
+
if (path) setWaypoints(layer.waypoints);
|
|
856
|
+
}
|
|
857
|
+
function undoScene() {
|
|
858
|
+
const receipt = appContext.bus.run('edit.undo');
|
|
859
|
+
if (receipt.status === 'noop') appContext.notify(ko("Nothing to undo", "실행 취소할 작업이 없어요"));
|
|
860
|
+
return receipt;
|
|
861
|
+
}
|
|
862
|
+
function redoScene() {
|
|
863
|
+
const receipt = appContext.bus.run('edit.redo');
|
|
864
|
+
if (receipt.status === 'noop') appContext.notify(ko("Nothing to redo", "다시 실행할 작업이 없어요"));
|
|
865
|
+
return receipt;
|
|
866
|
+
}
|
|
867
|
+
function snapshotStudioDomain(domain, targetId) {
|
|
868
|
+
if (domain === 'motion' && appContext.storeDomain('motion')) return appContext.storeDomain('motion').snapshotTarget(targetId);
|
|
869
|
+
const state = appContext.shared.readStudioState();
|
|
870
|
+
if (domain === "shot") return { shots: state.shots, camera: state.camera, manual: state.manual };
|
|
871
|
+
if (domain === "stage") return { stage: state.stage };
|
|
872
|
+
if (domain === "cast") return { characters: state.characters };
|
|
873
|
+
const target = state.targets.get(targetId);
|
|
874
|
+
return { character: state.characters.find(c => c.id === targetId), fullMotion: appContext.shared.motionFullRef.current.get(targetId),
|
|
875
|
+
ikState: { ...createIkState(), keys: copyPhysicsKeys(target?.ikState?.keys ?? new Map()), tracked: new Set(target?.ikState?.tracked ?? []) },
|
|
876
|
+
frameCount: state.frameCount, committedIkEdits: targetId === appContext.shared.loadedLayerCharRef.current ? appContext.shared.committedIkEdits : [],
|
|
877
|
+
renderer: target?.rig ? appContext.shared.snapshotExportRig(target.rig) : null };
|
|
878
|
+
}
|
|
879
|
+
function removeLegacyRootWaypoint() {
|
|
880
|
+
// Subject 1 is the sole frame-zero root start. Drop any legacy seeded
|
|
881
|
+
// waypoint so Top-View never renders two start markers.
|
|
882
|
+
setWaypoints((current) => current.filter((waypoint) => waypoint.frame !== 0));
|
|
883
|
+
setActiveWaypointId(null);
|
|
884
|
+
setPendingWaypointFrame((current) => (current === 0 ? null : current));
|
|
885
|
+
}
|
|
886
|
+
function snapshotNativeMotion() {
|
|
887
|
+
if (appContext.storeDomain('motion')) return appContext.storeDomain('motion').snapshotLegacy();
|
|
888
|
+
return { bufferMotion: appContext.shared.bufferRef.current.motion, bufferCharId: appContext.shared.loadedLayerCharRef.current,
|
|
889
|
+
ikKeys: appContext.shared.snapshotIkKeys(appContext.shared.ikStateRef.current), committedIkEdits: appContext.shared.committedIkEdits };
|
|
890
|
+
}
|
|
891
|
+
function switchNativeMotionLayer(previous, entry) {
|
|
892
|
+
return appContext.storeDomain('motion').switchLayer(entry.id);
|
|
893
|
+
}
|
|
894
|
+
function switchActiveCharacterLayer() {
|
|
895
|
+
const entry = domain.projection().find(entry => entry.id === domain.activeId) ?? domain.projection()[0];
|
|
896
|
+
const previous = appContext.shared.loadedLayerCharRef.current;
|
|
897
|
+
if (previous === entry.id) return;
|
|
898
|
+
switchNativeMotionLayer(previous, entry);
|
|
899
|
+
appContext.shared.loadedLayerCharRef.current = entry.id;
|
|
900
|
+
domain.syncLayer();
|
|
901
|
+
setSelectedPromptId(null); setWaypointMode(false); setActiveWaypointId(null); setPendingWaypointFrame(null);
|
|
902
|
+
}
|
|
903
|
+
function createLegacyCastHandlers(finitePatch, characterForRef) {
|
|
904
|
+
function replaceCharacters(next) { return domain.run('cast.replace', { characters: next }); }
|
|
905
|
+
function addLiveCharacter(args) {
|
|
906
|
+
if (typeof args.subject !== "string") throw new Error("Invalid subject");
|
|
907
|
+
const live = appContext.live.state;
|
|
908
|
+
const patch = finitePatch(args, ["x", "z", "rot"]);
|
|
909
|
+
const id = nextCharacterId(live.characters);
|
|
910
|
+
replaceCharacters([...live.characters, createCharacterEntry({ id, model: args.model, subject: args.subject, pose: DEFAULT_POSE, ...patch }, live.characters.length)]);
|
|
911
|
+
return { id };
|
|
912
|
+
}
|
|
913
|
+
function updateLiveCharacter(args) {
|
|
914
|
+
const live = appContext.live.state;
|
|
915
|
+
const character = characterForRef(live.characters, args.ref);
|
|
916
|
+
if (!character) throw new Error("Character not found");
|
|
917
|
+
const patch = finitePatch(args, ["x", "y", "z", "rot"]);
|
|
918
|
+
if (args.subject !== undefined) {
|
|
919
|
+
if (typeof args.subject !== "string") throw new Error("Invalid subject");
|
|
920
|
+
patch.subject = args.subject;
|
|
921
|
+
}
|
|
922
|
+
if (args.hidden !== undefined) {
|
|
923
|
+
if (typeof args.hidden !== "boolean") throw new Error("Invalid hidden");
|
|
924
|
+
patch.hidden = args.hidden;
|
|
925
|
+
}
|
|
926
|
+
if (!Object.keys(patch).some((key) => patch[key] !== character[key])) return { id: character.id };
|
|
927
|
+
replaceCharacters(live.characters.map((entry) => entry.id === character.id ? { ...entry, ...patch } : entry));
|
|
928
|
+
return { id: character.id };
|
|
929
|
+
}
|
|
930
|
+
function removeLiveCharacter(args) {
|
|
931
|
+
const live = appContext.live.state;
|
|
932
|
+
const character = characterForRef(live.characters, args.ref);
|
|
933
|
+
if (!character) throw new Error("Character not found");
|
|
934
|
+
if (live.characters.length <= 1) throw new Error("Cannot remove the final character");
|
|
935
|
+
domain.run('character.remove', { characterId: character.id });
|
|
936
|
+
return { id: character.id };
|
|
937
|
+
}
|
|
938
|
+
function setLivePromptBlocks(args) {
|
|
939
|
+
if (!Array.isArray(args.blocks)) throw new Error("Invalid blocks");
|
|
940
|
+
const stamp = Date.now();
|
|
941
|
+
const clips = args.blocks.map((block, i) => {
|
|
942
|
+
const startFrame = Math.round(Number(block.startFrame));
|
|
943
|
+
const endFrame = Math.round(Number(block.endFrame));
|
|
944
|
+
if (!Number.isFinite(startFrame) || !Number.isFinite(endFrame) || endFrame <= startFrame) {
|
|
945
|
+
throw new Error(`Invalid frame range on block ${i + 1}`);
|
|
946
|
+
}
|
|
947
|
+
if (typeof block.text !== "string" || !block.text.trim()) throw new Error(`Block ${i + 1} needs text`);
|
|
948
|
+
return { id: `prompt-${stamp}-${i}`, startFrame, endFrame, text: block.text.trim() };
|
|
949
|
+
});
|
|
950
|
+
const live = appContext.live.state;
|
|
951
|
+
domain.run('character.setPromptBlocks', { characterId: domain.activeId, blocks: clips });
|
|
952
|
+
return { blocks: clips.length };
|
|
953
|
+
}
|
|
954
|
+
return { add_character: addLiveCharacter, update_character: updateLiveCharacter, remove_character: removeLiveCharacter, set_prompt_blocks: setLivePromptBlocks };
|
|
955
|
+
}
|
|
956
|
+
function toggleCharacterHidden(charId) { return domain.run('character.update', { characterId: charId, patch: { hidden: castMemberOf(charId).hidden !== true } }); }
|
|
957
|
+
domain.snapshotMotion = snapshotNativeMotion;
|
|
958
|
+
domain.syncLayer = () => {
|
|
959
|
+
const entry = domain.read().find(entry => entry.id === appContext.shared.loadedLayerCharRef.current) ?? domain.read()[0];
|
|
960
|
+
appContext.shared.bufferRef.current = { ...appContext.shared.bufferRef.current, ...entry.layer };
|
|
961
|
+
setSelectedPromptId(id => entry.layer.promptClips.some(clip => clip.id === id) ? id : null);
|
|
962
|
+
setActiveWaypointId(id => entry.layer.waypoints.some(waypoint => waypoint.id === id) ? id : null);
|
|
963
|
+
};
|
|
964
|
+
domain.loadView = () => { appContext.shared.loadedLayerCharRef.current = domain.activeId; setActiveCharacterId(domain.activeId); };
|
|
965
|
+
domain.poses = () => [...new Map([...appContext.ports.poses(), ...domain.state().customPoses].map(pose => [pose.id, pose])).values()];
|
|
966
|
+
domain.showExtras = applyShowB;
|
|
967
|
+
domain.setTimeline = frameCount => {
|
|
968
|
+
const shots = appContext.storeDomain('shot');
|
|
969
|
+
if (shots && frameCount !== shots.state().frameCount) appContext.recordAction('shot', () => shots.writeState(before => ({ ...before, frameCount })), null, true);
|
|
970
|
+
};
|
|
971
|
+
domain.syncTimeline = () => {
|
|
972
|
+
const shots = appContext.storeDomain('shot');
|
|
973
|
+
if (!shots) return;
|
|
974
|
+
const current = domain.projection(), active = current.find(entry => entry.id === domain.activeId) ?? current[0];
|
|
975
|
+
const extent = timelineContentExtent(current, active.id, appContext.shared.bufferRef.current.motion,
|
|
976
|
+
active.layer.promptClips, appContext.shared.motionDomain.multiModelFootage?.frames);
|
|
977
|
+
domain.setTimeline(Math.max(24, timelineSpan(extent, shots.read(), shots.state().frameCount)));
|
|
978
|
+
};
|
|
979
|
+
domain.extendTimeline = domain.syncTimeline;
|
|
980
|
+
domain.applyPose = (characterId, pose, clearMotion) => {
|
|
981
|
+
if (clearMotion) appContext.ports.recordAction('motion', () => {
|
|
982
|
+
const motion = appContext.storeDomain('motion');
|
|
983
|
+
if (motion) motion.clear(characterId); else appContext.shared.motionDomain.clearMotionNative();
|
|
984
|
+
}, characterId, true);
|
|
985
|
+
domain.write(rows => rows.map(entry => entry.id === characterId ? { ...entry, pose } : entry));
|
|
986
|
+
};
|
|
987
|
+
appContext.updateActionPorts({ addCharacterWaypoint, moveCharacterWaypoint, removeCharacterWaypoint, clearCharacterWaypoints, setWaypointMode });
|
|
988
|
+
return {
|
|
989
|
+
...domain,
|
|
990
|
+
applyExternalCharacters, publishStudioCharacters, syncStudioLayerBuffer, undoScene, redoScene, snapshotStudioDomain, removeLegacyRootWaypoint, switchActiveCharacterLayer, createLegacyCastHandlers, toggleCharacterHidden,
|
|
991
|
+
characters, editCharacters, customPoses, posing, setPosing, posingClosing,
|
|
992
|
+
studioPick, setStudioPick, rigs, rigMountEpoch, setRigMountEpoch, setPoseTick, charA, charB, showB,
|
|
993
|
+
poseA, poseB, subject, subject2, updateCharacterAt, setShowB, moveCharacter, removeCharacter, reportRig,
|
|
994
|
+
spawnCharacter, charKeyToHierarchyId, charIdFromHierarchyId, activeCharacterId, setActiveCharacterId,
|
|
995
|
+
rowIdForCharIndex, activeChar, selectActiveCharacterInHierarchy, activeCharIndex, activeRig, waitForRig,
|
|
996
|
+
ghostLayers, snapshotCast, changeInspectorCharacter, hasCharSheet,
|
|
997
|
+
setHasCharSheet, promptBlocksReveal, setPromptBlocksReveal, revealPromptBlocks, waypointMode,
|
|
998
|
+
setWaypointMode, waypoints, setWaypoints, activeWaypointId, setActiveWaypointId, pendingWaypointFrame,
|
|
999
|
+
setPendingWaypointFrame, promptClips, setPromptClips, editPromptClips, selectedPromptId,
|
|
1000
|
+
setSelectedPromptId, photoPoseState, photoPoseError, setPhotoPoseError, allPoses, selectablePoses,
|
|
1001
|
+
posingIndex, posingChar, posedRig, setPosed, rootStart, queueRootWaypointFrame, castMemberOf,
|
|
1002
|
+
addCharacterWaypoint, moveCharacterWaypoint, removeCharacterWaypoint, clearCharacterWaypoints,
|
|
1003
|
+
addFloorWaypoint, moveWaypoint, removeWaypoint, toggleWaypointMode, openStudio, closeStudio,
|
|
1004
|
+
saveCurrentPose, savePose, posePhotoFile, removePose, addPromptClip, changePromptClip,
|
|
1005
|
+
PROMPT_BLOCK_MAX_FRAMES, resizePromptClip, movePromptClip, removePromptClip,
|
|
1006
|
+
};
|
|
1007
|
+
}
|