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,3219 @@
|
|
|
1
|
+
import {
|
|
2
|
+
I2V_MOTION_STILL_OUTPUT, I2V_MOTION_SHOT_ASPECT, I2V_MOTION_MIN_DURATION,
|
|
3
|
+
waitForMotionJob, submitMotionJob, buildH3MotionPrompt,
|
|
4
|
+
} from "../i2v-motion-client.js";
|
|
5
|
+
import { useState, useEffect, useSyncExternalStore, useContext, useRef, useMemo } from "react";
|
|
6
|
+
import { AppContext } from '../app-context.js';
|
|
7
|
+
import { createDocumentStore } from '../document-store.js';
|
|
8
|
+
import { useDocumentDomain } from '../store/use-document-store.js';
|
|
9
|
+
import { createPhysicsProgress } from "../ardy/physics-panel.jsx";
|
|
10
|
+
import {
|
|
11
|
+
TIMELINE_FPS,
|
|
12
|
+
hierarchyIdForIkFocus,
|
|
13
|
+
ARDY_PRESERVE_DEFAULT,
|
|
14
|
+
MULTIMODEL_REASONS,
|
|
15
|
+
nextCharacterId,
|
|
16
|
+
REST_BONES,
|
|
17
|
+
ARDY_SEED_MAX,
|
|
18
|
+
lineTrackLabel,
|
|
19
|
+
ARDY_DURATION_MIN,
|
|
20
|
+
buildPromptSchedule,
|
|
21
|
+
toArdyFrameEntries,
|
|
22
|
+
posePlacementFrame,
|
|
23
|
+
toArdySegments,
|
|
24
|
+
ARDY_FPS,
|
|
25
|
+
toArdyFrame,
|
|
26
|
+
} from "../app-stage.jsx";
|
|
27
|
+
import { copyPhysicsKeys, physicsKeyStamp, reviewAutoPhysics } from "../ardy/physics-review.js";
|
|
28
|
+
import { fitPlatforms } from "../ardy/platform-fit.js";
|
|
29
|
+
import {
|
|
30
|
+
createIkState,
|
|
31
|
+
ikTouch,
|
|
32
|
+
ikKeyframes,
|
|
33
|
+
ikRemoveKeyframe,
|
|
34
|
+
ikSeedTargets,
|
|
35
|
+
clampIkTargetToFloor,
|
|
36
|
+
solveIk,
|
|
37
|
+
MID_TRACKS,
|
|
38
|
+
solveMidJoint,
|
|
39
|
+
solveEffectorSwing,
|
|
40
|
+
ikPlantFeet,
|
|
41
|
+
solveHipsTranslateToFloor,
|
|
42
|
+
solveHipsTranslate,
|
|
43
|
+
solveSwingAngle,
|
|
44
|
+
ikSolvePlantedFeet,
|
|
45
|
+
applyBodyContact,
|
|
46
|
+
ikBakeKeyframe,
|
|
47
|
+
ikEvaluate,
|
|
48
|
+
resolveIkRig,
|
|
49
|
+
} from "../ardy/ik.js";
|
|
50
|
+
import * as THREE from "three";
|
|
51
|
+
import { StudioProtocolError } from "../studio-agent-protocol.js";
|
|
52
|
+
import { restorePlaybackBones, snapshotPlaybackBones, applyMotionFrame, captureArdyRoot } from "../ardy/playback.js";
|
|
53
|
+
import { isKo, ko } from "../locale.js";
|
|
54
|
+
import {
|
|
55
|
+
isPlatformPageUrl,
|
|
56
|
+
normalizeSourceUrl,
|
|
57
|
+
sourceLabel,
|
|
58
|
+
requestBridgeFootage,
|
|
59
|
+
fetchFootageBlob,
|
|
60
|
+
probeFootage,
|
|
61
|
+
requestBridgeExtract,
|
|
62
|
+
} from "../multimodel-ingest.js";
|
|
63
|
+
import { characterScaleFor, loadMotionFromUrl } from "../ardy/npz.js";
|
|
64
|
+
import { supportHeightForObject, OBJECT_LIBRARY } from "../scene-objects.js";
|
|
65
|
+
import { applySupportRise, autoRoofDrop, applyAutoFall, applyRootDrop, normalizeRootDrop } from "../ardy/root-drop.js";
|
|
66
|
+
import { takeAnchor, createCharacterEntry } from "../scenes.js";
|
|
67
|
+
import { retimeMotion } from "../ardy/retime.js";
|
|
68
|
+
import {
|
|
69
|
+
createMotionEdit,
|
|
70
|
+
renderMotionEdit,
|
|
71
|
+
remapFrameKeyMap,
|
|
72
|
+
remapTimelineFrame,
|
|
73
|
+
} from "../ardy/motion-edit.js";
|
|
74
|
+
import { DEFAULT_POSE, restoreBindPositions, applyPose, applyHipsOffset } from "../poses.js";
|
|
75
|
+
import { normalizeMotionCalibration, applyMotionCalibration } from "../ardy/motion-calibration.js";
|
|
76
|
+
import { collisionBlockers } from "../ardy/collision-blockers.js";
|
|
77
|
+
import { fixCollisions, fixCollisionsRange } from "../ardy/fix-collisions.js";
|
|
78
|
+
import { trackFeature, startMotionRequest, motionPreflightReason, trackActivation } from "../analytics.js";
|
|
79
|
+
import { buildArdyPose } from "../ardy/export.js";
|
|
80
|
+
import { slateLine } from "../shot.js";
|
|
81
|
+
import { motionReadiness } from "../motion-readiness.js";
|
|
82
|
+
import { motionReadinessMessage } from "../motion-readiness-ui.jsx";
|
|
83
|
+
import {
|
|
84
|
+
resolveSeed,
|
|
85
|
+
stripSourceMotion,
|
|
86
|
+
blocksFromRequest,
|
|
87
|
+
replayPayload,
|
|
88
|
+
freshRecipe,
|
|
89
|
+
withLineEdit,
|
|
90
|
+
pushTakeVersion,
|
|
91
|
+
TAKE_VERSIONS_MAX,
|
|
92
|
+
} from "../take-recipe.js";
|
|
93
|
+
import { buildGenerationRequest, generationRefusal } from '../motion/generation.js';
|
|
94
|
+
import { planPosePin } from '../ardy/pose-pin.js';
|
|
95
|
+
import { applyTrailFalloffDelta, restoreTrailEdits, trailEditRange, worldDeltaToTrailClip } from "../motion-trail.js";
|
|
96
|
+
import { bakeIkDragKey, chainsChangedBy, ikDragRecord, ikDragTouch } from "../ardy/ik-drag.js";
|
|
97
|
+
import { ikKeyJson, ikTrackKeyFromJson } from "../ardy/ik-key-json.js";
|
|
98
|
+
import { findAbsoluteIkKeyConflicts } from "../trail-key-conflicts.js";
|
|
99
|
+
import { chooseIkEntryPose } from "../ik-camera.js";
|
|
100
|
+
import { generate as ardyGenerate } from "../ardy/client.js";
|
|
101
|
+
import { isLineEditUnsupported } from "../line-edit.js";
|
|
102
|
+
import { openMotionDb, getMotion, putMotion } from "../motion-store.js";
|
|
103
|
+
import { resolveMotionSource, decodeMotionResource, encodeMotionResource, sha256Hex } from "../motion-resources.js";
|
|
104
|
+
import { applyRangePin, captureRangePinTarget, normalizeRangePin, rangePinTargetWorld, rangePinTracks, removeRangePinKeys } from "../ardy/range-pin.js";
|
|
105
|
+
import { rangePinObjectMatrixAt } from "../range-pin-object-transform.js";
|
|
106
|
+
|
|
107
|
+
const sameMotionIntent = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
108
|
+
const emptyMotionLayer = id => ({ id, take: null, fullTake: null, ikKeys: [], ikPins: [], ikPinResiduals: [], committedIkEdits: [], takeRecipe: null, takeVersions: [] });
|
|
109
|
+
const keyVector = p => ({ x: p.x, y: p.y, z: p.z });
|
|
110
|
+
const keyQuaternion = q => ({ ...keyVector(q), w: q.w });
|
|
111
|
+
function encodeMotionKeys(keys) {
|
|
112
|
+
return [...keys].sort(([a], [b]) => a - b).map(([frame, tracks]) => ({ frame, tracks: Object.fromEntries([...tracks].map(([track, key]) => [track, {
|
|
113
|
+
...(key.q ? { q: key.q.map(keyQuaternion) } : {}), ...(key.p ? { p: keyVector(key.p) } : {}),
|
|
114
|
+
...(key.baseQ ? { baseQ: key.baseQ.map(keyQuaternion) } : {}), ...(key.basePos ? { basePos: keyVector(key.basePos) } : {}),
|
|
115
|
+
...(key.chainP ? { chainP: key.chainP.map(keyVector) } : {}), ...(key.keepTranslations ? { keepTranslations: true } : {}),
|
|
116
|
+
...(key.blend != null ? { blend: key.blend } : {}),
|
|
117
|
+
...(key.pin ? { pin: key.pin } : {}),
|
|
118
|
+
}])) }));
|
|
119
|
+
}
|
|
120
|
+
function decodeMotionKeys(rows) {
|
|
121
|
+
const q = value => new THREE.Quaternion(value.x, value.y, value.z, value.w).normalize();
|
|
122
|
+
const p = value => new THREE.Vector3(value.x, value.y, value.z);
|
|
123
|
+
return new Map(rows.map(row => [row.frame, new Map(Object.entries(row.tracks).map(([track, key]) => [track, {
|
|
124
|
+
q: key.q?.map(q) ?? null, p: key.p ? p(key.p) : null,
|
|
125
|
+
...(key.baseQ ? { baseQ: key.baseQ.map(q) } : {}), ...(key.basePos ? { basePos: p(key.basePos) } : {}),
|
|
126
|
+
...(key.chainP ? { chainP: key.chainP.map(p) } : {}), ...(key.keepTranslations ? { keepTranslations: true } : {}),
|
|
127
|
+
...(key.blend != null ? { blend: key.blend } : {}),
|
|
128
|
+
...(key.pin ? { pin: key.pin } : {}),
|
|
129
|
+
}]))]));
|
|
130
|
+
}
|
|
131
|
+
function cloneRangePin(pin) {
|
|
132
|
+
return {
|
|
133
|
+
...pin,
|
|
134
|
+
target: pin.target.space === "world"
|
|
135
|
+
? { space: "world", position: [...pin.target.position] }
|
|
136
|
+
: { space: "object", objectId: pin.target.objectId, local: [...pin.target.local] },
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function encodeRangePins(pins) {
|
|
140
|
+
return [...(pins instanceof Map ? pins.values() : pins ?? [])].map(cloneRangePin);
|
|
141
|
+
}
|
|
142
|
+
function decodeRangePins(rows) {
|
|
143
|
+
return new Map((rows ?? []).map((pin) => [pin.id, cloneRangePin(pin)]));
|
|
144
|
+
}
|
|
145
|
+
function encodeRangePinResiduals(values) {
|
|
146
|
+
return [...(values instanceof Map ? values.entries() : values ?? [])].map(([id, entries]) => [id, (entries ?? []).map((entry) => ({ ...entry }))]);
|
|
147
|
+
}
|
|
148
|
+
function decodeRangePinResiduals(rows) {
|
|
149
|
+
return new Map((rows ?? []).map(([id, entries]) => [id, (entries ?? []).map((entry) => ({ ...entry }))]));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Plain intent owns take identities and JSON keys. Decoded buffers and rig
|
|
153
|
+
// preimages are copy-on-record runtime resources, never mutable store values.
|
|
154
|
+
export function createMotionDomain(appContext, characters) {
|
|
155
|
+
const takes = new Map(), decoded = new WeakMap(), rigImages = new WeakMap(), previews = new Map(), prepared = new Map();
|
|
156
|
+
let running = 0;
|
|
157
|
+
const references = entries => entries.map(entry => {
|
|
158
|
+
const ref = entry.motionRef;
|
|
159
|
+
const take = ref ? { resourceId: `restore:${entry.id}:${ref.motionId ?? ref.url}`, url: ref.url ?? null, anchorX: ref.anchorX, anchorZ: ref.anchorZ,
|
|
160
|
+
rotationDeg: ref.rotationDeg, prompt: ref.prompt ?? '', ...(ref.trailEdits ? { trailEdits: ref.trailEdits } : {}), ...(ref.studioTakeId ? { studioTakeId: ref.studioTakeId } : {}) } : null;
|
|
161
|
+
return { id: entry.id, take, fullTake: take, takeVersions: ref?.url ? [{ motionUrl: ref.url, recipe: null, savedAt: Date.now(), label: ko('Loaded', '불러옴') }] : [] };
|
|
162
|
+
});
|
|
163
|
+
function snapshotTake(take) {
|
|
164
|
+
if (!take) return null;
|
|
165
|
+
if (take.resourceId) return { ...take };
|
|
166
|
+
const resourceId = crypto.randomUUID(), copy = structuredClone(take);
|
|
167
|
+
takes.set(resourceId, copy);
|
|
168
|
+
return { resourceId, frames: take.frames, fps: take.fps, url: take.url ?? null, prompt: take.prompt ?? '',
|
|
169
|
+
anchorX: take.anchorX ?? 0, anchorZ: take.anchorZ ?? 0, anchorFrame: take.anchorFrame ?? 0, rotationDeg: take.rotationDeg ?? 0,
|
|
170
|
+
...(take.trailEdits ? { trailEdits: take.trailEdits } : {}),
|
|
171
|
+
editSegments: take.editSegments ?? createMotionEdit(take.frames), studioTakeId: take.studioTakeId ?? resourceId };
|
|
172
|
+
}
|
|
173
|
+
const normalize = rows => rows.map(row => ({ ...emptyMotionLayer(row.id), ...row,
|
|
174
|
+
take: snapshotTake(row.take), fullTake: snapshotTake(row.fullTake ?? row.take),
|
|
175
|
+
ikKeys: row.ikKeys ?? [], ikPins: row.ikPins ?? [], ikPinResiduals: row.ikPinResiduals ?? [], committedIkEdits: row.committedIkEdits ?? [] }));
|
|
176
|
+
let native = createDocumentStore({ owned: { motion: normalize(references(characters)) } });
|
|
177
|
+
const listeners = new Set(), notify = () => { for (const listener of listeners) listener(); };
|
|
178
|
+
let release = native.subscribe(notify), viewRevision = 0;
|
|
179
|
+
const documentStore = { ...Object.fromEntries(Object.keys(native).map(key => [key, (...args) => native[key](...args)])),
|
|
180
|
+
subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); } };
|
|
181
|
+
const read = () => documentStore.read('motion');
|
|
182
|
+
const layer = id => read().find(row => row.id === id) ?? emptyMotionLayer(id);
|
|
183
|
+
function materialize(take) {
|
|
184
|
+
if (!take || !takes.has(take.resourceId)) return null;
|
|
185
|
+
if (!decoded.has(take)) {
|
|
186
|
+
const { resourceId, ...metadata } = take;
|
|
187
|
+
decoded.set(take, { ...takes.get(resourceId), ...metadata });
|
|
188
|
+
}
|
|
189
|
+
return decoded.get(take);
|
|
190
|
+
}
|
|
191
|
+
const motionFor = id => materialize(layer(id).take), fullMotionFor = id => materialize(layer(id).fullTake);
|
|
192
|
+
const visibleMotion = id => previews.get(id) ?? motionFor(id);
|
|
193
|
+
const frame = () => appContext.live.state?.timeline.currentFrame ?? 0;
|
|
194
|
+
const rigFor = id => appContext.shared.rigs[id];
|
|
195
|
+
function rigSnapshots() {
|
|
196
|
+
return new Map(Object.entries(appContext.shared.rigs).map(([id, rig]) => [id, appContext.shared.snapshotExportRig(rig)]));
|
|
197
|
+
}
|
|
198
|
+
let publishedIds = new Set();
|
|
199
|
+
function project() {
|
|
200
|
+
const cast = appContext.storeDomain('cast'), states = appContext.shared.ikStatesRef.current, savedRigs = rigImages.get(read());
|
|
201
|
+
const ids = new Set([...publishedIds, ...read().map(row => row.id)]);
|
|
202
|
+
for (const id of ids) {
|
|
203
|
+
const row = layer(id), take = visibleMotion(id), full = fullMotionFor(id);
|
|
204
|
+
cast.publishMotion(id, take);
|
|
205
|
+
if (full) appContext.shared.motionFullRef.current.set(id, full); else appContext.shared.motionFullRef.current.delete(id);
|
|
206
|
+
const state = states.get(id) ?? createIkState();
|
|
207
|
+
state.keys = decodeMotionKeys(row.ikKeys); state.tracked = new Set([...state.keys.values()].flatMap(entry => [...entry.keys()]));
|
|
208
|
+
state.pins = decodeRangePins(row.ikPins);
|
|
209
|
+
state.pinResiduals = decodeRangePinResiduals(row.ikPinResiduals);
|
|
210
|
+
states.set(id, state);
|
|
211
|
+
const rig = rigFor(id);
|
|
212
|
+
if (rig) {
|
|
213
|
+
const resolved = resolveIkRig(rig); if (resolved) Object.assign(state, resolved, { rig });
|
|
214
|
+
if (savedRigs?.has(id) && !previews.has(id)) appContext.shared.restoreExportRig(savedRigs.get(id));
|
|
215
|
+
else if (resolved) {
|
|
216
|
+
if (take) applyMotionFrame(rig, take, Math.min(frame(), take.frames - 1));
|
|
217
|
+
else {
|
|
218
|
+
const character = cast.read().find(entry => entry.id === id);
|
|
219
|
+
restoreBindPositions(rig); applyPose(rig, { ...REST_BONES, ...(character?.pose ?? DEFAULT_POSE).bones });
|
|
220
|
+
applyHipsOffset(rig, character?.pose?.rootY ?? 0);
|
|
221
|
+
}
|
|
222
|
+
ikEvaluate(resolved.chains, state, frame(), resolved.fkJoints, take ? 6 : 0);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
publishedIds = new Set(read().map(row => row.id));
|
|
227
|
+
const id = appContext.shared.loadedLayerCharRef.current;
|
|
228
|
+
appContext.shared.ikStateRef.current = states.get(id) ?? createIkState();
|
|
229
|
+
appContext.shared.bufferRef.current = { ...appContext.shared.bufferRef.current, motion: visibleMotion(id), ik: appContext.shared.ikStateRef.current };
|
|
230
|
+
appContext.shared.takeRecipeRef.current = layer(id).takeRecipe;
|
|
231
|
+
domain.onProject?.(); viewRevision++;
|
|
232
|
+
}
|
|
233
|
+
const unsubscribe = documentStore.subscribe(project);
|
|
234
|
+
function write(update) {
|
|
235
|
+
return documentStore.write('motion', before => {
|
|
236
|
+
const next = normalize(typeof update === 'function' ? update(before) : update);
|
|
237
|
+
return sameMotionIntent(before, next) ? before : next;
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
function writeLayer(id, patch) {
|
|
241
|
+
return write(rows => rows.some(row => row.id === id) ? rows.map(row => row.id === id ? { ...row, ...patch } : row) : [...rows, { ...emptyMotionLayer(id), ...patch }]);
|
|
242
|
+
}
|
|
243
|
+
function pruneTakes() {
|
|
244
|
+
const history = documentStore.history(), ids = new Set([read(), ...[...history.past, history.present, ...history.future].map(entry => entry.snapshot.motion)]
|
|
245
|
+
.flatMap(rows => rows.flatMap(row => [row.take?.resourceId, row.fullTake?.resourceId])).filter(Boolean));
|
|
246
|
+
for (const id of takes.keys()) if (!ids.has(id)) takes.delete(id);
|
|
247
|
+
}
|
|
248
|
+
function beginAction() {
|
|
249
|
+
rigImages.set(read(), gesture?.rigs ?? rigSnapshots());
|
|
250
|
+
const session = documentStore.beginAction('motion');
|
|
251
|
+
return { ...session,
|
|
252
|
+
run(fn) { return session.run(() => { running++; try { return fn(); } finally { running--; } }); },
|
|
253
|
+
commit() { const result = session.commit(); rigImages.set(read(), rigSnapshots()); pruneTakes(); return result; },
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
function setKeys(id, keys) { return writeLayer(id, { ikKeys: encodeMotionKeys(keys) }); }
|
|
257
|
+
function setRangePinState(id, state) {
|
|
258
|
+
return writeLayer(id, { ikKeys: encodeMotionKeys(state.keys), ikPins: encodeRangePins(state.pins), ikPinResiduals: encodeRangePinResiduals(state.pinResiduals) });
|
|
259
|
+
}
|
|
260
|
+
function rangePinState(id) {
|
|
261
|
+
const row = layer(id), keys = decodeMotionKeys(row.ikKeys);
|
|
262
|
+
return { ...createIkState(), keys, tracked: new Set([...keys.values()].flatMap(entry => [...entry.keys()])), pins: decodeRangePins(row.ikPins), pinResiduals: decodeRangePinResiduals(row.ikPinResiduals) };
|
|
263
|
+
}
|
|
264
|
+
function removePin(id, pinId) {
|
|
265
|
+
const state = rangePinState(id);
|
|
266
|
+
removeRangePinKeys(state, pinId); state.pins.delete(pinId); state.pinResiduals.delete(pinId);
|
|
267
|
+
setRangePinState(id, state);
|
|
268
|
+
}
|
|
269
|
+
function bakeRangePin(id, spec, replaceExisting = false, projectionOnly = false) {
|
|
270
|
+
const motion = motionFor(id), rig = rigFor(id), resolved = rig && resolveIkRig(rig);
|
|
271
|
+
if (!motion || !resolved) throw new StudioProtocolError('TARGET_NOT_READY', 'Load a take and rig for this character first.');
|
|
272
|
+
const pin = normalizeRangePin(spec, { clipFrames: motion.frames }), state = rangePinState(id);
|
|
273
|
+
const tracks = rangePinTracks(pin);
|
|
274
|
+
const overlap = [...state.pins.values()].find(other => other.id !== pin.id && rangePinTracks(other).some(track => tracks.includes(track)) && Math.max(pin.startFrame, other.startFrame) <= Math.min(pin.endFrame, other.endFrame));
|
|
275
|
+
if (overlap) throw new StudioProtocolError('INVALID_ARGUMENT', 'A required track already has an overlapping pin.');
|
|
276
|
+
for (let at = pin.startFrame; at <= pin.endFrame; at++) {
|
|
277
|
+
const entry = state.keys.get(at); if (!entry) continue;
|
|
278
|
+
for (const track of tracks) if (entry.has(track) && entry.get(track).pin !== pin.id) {
|
|
279
|
+
if (!replaceExisting) throw new StudioProtocolError('INVALID_ARGUMENT', 'Confirm Replace existing keys before applying this pin.');
|
|
280
|
+
entry.delete(track);
|
|
281
|
+
}
|
|
282
|
+
if (!entry.size) state.keys.delete(at);
|
|
283
|
+
}
|
|
284
|
+
const objectWorldMatrix = (objectId, at) => {
|
|
285
|
+
const object = appContext.shared.sceneObjects.find(entry => entry.id === objectId);
|
|
286
|
+
if (!object) return null;
|
|
287
|
+
const local = rangePinObjectMatrixAt(object, at, { frameCount: motion.frames, fps: motion.fps }, new THREE.Matrix4());
|
|
288
|
+
const parent = object.attach && appContext.shared.attachFrameRef?.current?.(object.attach.characterId, object.attach.bone ?? null, new THREE.Matrix4());
|
|
289
|
+
return parent ? parent.multiply(local) : local;
|
|
290
|
+
};
|
|
291
|
+
const applyRaw = at => appContext.shared.poseMemberAtFrame(rig, motion, null, at);
|
|
292
|
+
const savedKeys = state.keys;
|
|
293
|
+
let result;
|
|
294
|
+
try {
|
|
295
|
+
result = applyRangePin({ ...resolved, ikState: state, pin, objectWorldMatrix, applyRaw,
|
|
296
|
+
applyLayer(at) { applyRaw(at); ikEvaluate(resolved.chains, state, at, resolved.fkJoints, 6); } });
|
|
297
|
+
} finally { state.keys = savedKeys; }
|
|
298
|
+
removeRangePinKeys(state, pin.id);
|
|
299
|
+
for (const [at, entry] of result.entries) {
|
|
300
|
+
let frameEntry = state.keys.get(at); if (!frameEntry) state.keys.set(at, (frameEntry = new Map()));
|
|
301
|
+
for (const [track, key] of entry) { frameEntry.set(track, key); state.tracked.add(track); }
|
|
302
|
+
}
|
|
303
|
+
state.pins.set(pin.id, cloneRangePin(pin));
|
|
304
|
+
state.pinResiduals.set(pin.id, result.residuals.map(entry => ({ ...entry })));
|
|
305
|
+
// A reload refreshes only the projected bake; authored edits use the bus.
|
|
306
|
+
if (projectionOnly) Object.assign(appContext.shared.ikStatesRef.current.get(id), state);
|
|
307
|
+
else setRangePinState(id, state);
|
|
308
|
+
return { residuals: result.residuals };
|
|
309
|
+
}
|
|
310
|
+
function editKeys(id, mutate) {
|
|
311
|
+
const state = { ...createIkState(), keys: decodeMotionKeys(layer(id).ikKeys) };
|
|
312
|
+
state.tracked = new Set([...state.keys.values()].flatMap(entry => [...entry.keys()]));
|
|
313
|
+
const result = mutate(state); setKeys(id, state.keys); return result;
|
|
314
|
+
}
|
|
315
|
+
function setKey(id, at, tracks) {
|
|
316
|
+
editKeys(id, state => {
|
|
317
|
+
const incoming = decodeMotionKeys([{ frame: at, tracks }]).get(at), entry = state.keys.get(at) ?? new Map();
|
|
318
|
+
for (const [track, key] of incoming) entry.set(track, key); state.keys.set(at, entry);
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
function castWrite(update) { return appContext.recordAction('cast', () => appContext.storeDomain('cast').write(update), null, true); }
|
|
322
|
+
function synchronizeTimeline() { appContext.storeDomain('cast').syncTimeline(); }
|
|
323
|
+
function replace(id, take, { fullTake = take, ikKeys = [], ikPins = [], ikPinResiduals = [],
|
|
324
|
+
recipe = take ? { seed: null, blocks: [{ prompt: take.prompt ?? '', duration: take.frames / take.fps }], lineEdits: [] } : null,
|
|
325
|
+
versions = take?.url ? pushTakeVersion(layer(id).takeVersions, { motionUrl: take.url, recipe, savedAt: Date.now(), label: ko('Loaded', '불러옴') }, TAKE_VERSIONS_MAX) : layer(id).takeVersions,
|
|
326
|
+
} = {}) {
|
|
327
|
+
previews.delete(id);
|
|
328
|
+
writeLayer(id, { take: snapshotTake(take), fullTake: snapshotTake(fullTake), ikKeys, ikPins, ikPinResiduals, takeRecipe: recipe, takeVersions: versions, committedIkEdits: [] });
|
|
329
|
+
synchronizeTimeline();
|
|
330
|
+
}
|
|
331
|
+
function clear(id) {
|
|
332
|
+
previews.delete(id);
|
|
333
|
+
writeLayer(id, { take: null, fullTake: null, ikKeys: [], ikPins: [], ikPinResiduals: [], committedIkEdits: [], takeRecipe: null });
|
|
334
|
+
castWrite(rows => rows.map(row => row.id === id ? { ...row, scale: 1, motionRef: null } : row));
|
|
335
|
+
synchronizeTimeline();
|
|
336
|
+
}
|
|
337
|
+
function editSegments(id, segments) {
|
|
338
|
+
const full = fullMotionFor(id), take = motionFor(id);
|
|
339
|
+
if (!full || !take) throw new StudioProtocolError('TARGET_NOT_READY', 'This character has no take to edit.');
|
|
340
|
+
const previous = take.editSegments, rendered = { ...renderMotionEdit(full, segments), url: null };
|
|
341
|
+
const keys = remapFrameKeyMap(decodeMotionKeys(layer(id).ikKeys), previous, segments);
|
|
342
|
+
writeLayer(id, { take: snapshotTake(rendered), ikKeys: encodeMotionKeys(keys) });
|
|
343
|
+
castWrite(rows => rows.map(row => row.id !== id ? row : { ...row, layer: { ...row.layer,
|
|
344
|
+
promptClips: row.layer.promptClips.flatMap(clip => {
|
|
345
|
+
const start = remapTimelineFrame(previous, segments, clip.startFrame), end = remapTimelineFrame(previous, segments, clip.endFrame);
|
|
346
|
+
if (start === null && end === null) return [];
|
|
347
|
+
const startFrame = Math.max(0, Math.min(start ?? 0, rendered.frames - 1));
|
|
348
|
+
const endFrame = Math.max(0, Math.min(end ?? rendered.frames - 1, rendered.frames - 1));
|
|
349
|
+
return startFrame <= endFrame ? [{ ...clip, startFrame, endFrame }] : [];
|
|
350
|
+
}) } }));
|
|
351
|
+
synchronizeTimeline();
|
|
352
|
+
}
|
|
353
|
+
function fix(id, scope = 'frame') {
|
|
354
|
+
const rig = rigFor(id), resolved = rig && resolveIkRig(rig);
|
|
355
|
+
if (!resolved) throw new StudioProtocolError('TARGET_NOT_READY', 'The character rig is not loaded.');
|
|
356
|
+
const state = appContext.shared.ikStatesRef.current.get(id) ?? { ...createIkState(), ...resolved };
|
|
357
|
+
const blockers = at => collisionBlockers({ rigs: appContext.shared.rigs, activeId: id, characterIds: appContext.live.characters,
|
|
358
|
+
sceneObjects: appContext.ports.read().objects, library: OBJECT_LIBRARY, frame: at, take: { frameCount: motionFor(id)?.frames ?? 1, fps: 24 } });
|
|
359
|
+
if (scope === 'clip') {
|
|
360
|
+
const take = motionFor(id); if (!take) throw new StudioProtocolError('TARGET_NOT_READY', 'Load a take first.');
|
|
361
|
+
const originals = rigSnapshots();
|
|
362
|
+
try {
|
|
363
|
+
fixCollisionsRange({ rig, ...resolved, ikState: state, startFrame: 0, endFrame: take.frames - 1,
|
|
364
|
+
applyFrame(at) { applyMotionFrame(rig, take, at); ikEvaluate(resolved.chains, state, at, resolved.fkJoints, 6); },
|
|
365
|
+
blockersAt(at) { for (const row of read()) if (row.id !== id) appContext.shared.poseMemberAtFrame(rigFor(row.id), motionFor(row.id), appContext.shared.ikStatesRef.current.get(row.id), at, 6); return blockers(at); } });
|
|
366
|
+
} finally { for (const snapshot of originals.values()) appContext.shared.restoreExportRig(snapshot); }
|
|
367
|
+
} else {
|
|
368
|
+
const result = fixCollisions(rig, resolved.chains, { ikState: state, fkJoints: resolved.fkJoints, blockers: blockers(frame()) });
|
|
369
|
+
if (!result.supported) throw new StudioProtocolError('TARGET_NOT_READY', 'This rig does not support collision cleanup.');
|
|
370
|
+
if (result.changed) ikBakeKeyframe(resolved.chains, state, frame(), resolved.fkJoints, result.touched, null, result.baseQuats);
|
|
371
|
+
}
|
|
372
|
+
setKeys(id, state.keys);
|
|
373
|
+
}
|
|
374
|
+
function bakeCurrentKey(id, at, dragIds = null) {
|
|
375
|
+
const rig = rigFor(id), resolved = rig && resolveIkRig(rig);
|
|
376
|
+
if (!resolved) return null;
|
|
377
|
+
const take = motionFor(id);
|
|
378
|
+
if (dragIds) {
|
|
379
|
+
const keyed = bakeIkDragKey(resolved.chains, resolved.fkJoints, at, dragIds, take
|
|
380
|
+
? (rawRig) => applyMotionFrame(rawRig, take, at)
|
|
381
|
+
: null);
|
|
382
|
+
if (keyed && take) for (const key of keyed.values()) key.blend = 6;
|
|
383
|
+
return keyed ? run('ik.setKey', { characterId: id, frame: at, tracks: ikKeyJson(keyed) }) : null;
|
|
384
|
+
}
|
|
385
|
+
const scratch = { ...createIkState(), tracked: new Set(appContext.shared.ikStatesRef.current.get(id)?.tracked) };
|
|
386
|
+
ikBakeKeyframe(resolved.chains, scratch, at, resolved.fkJoints);
|
|
387
|
+
const keyed = encodeMotionKeys(scratch.keys)[0];
|
|
388
|
+
return keyed ? run('ik.setKey', { characterId: id, frame: at, tracks: keyed.tracks }) : null;
|
|
389
|
+
}
|
|
390
|
+
function keyPose(id, at, pose) {
|
|
391
|
+
const rig = rigFor(id), resolved = rig && resolveIkRig(rig);
|
|
392
|
+
if (!resolved) throw new StudioProtocolError('TARGET_NOT_READY', 'The character rig is not loaded.');
|
|
393
|
+
restoreBindPositions(rig); applyPose(rig, { ...REST_BONES, ...pose.bones }); applyHipsOffset(rig, pose.rootY ?? 0);
|
|
394
|
+
editKeys(id, state => {
|
|
395
|
+
state.tracked = new Set([...resolved.chains.keys(), ...resolved.fkJoints.keys()]);
|
|
396
|
+
ikBakeKeyframe(resolved.chains, state, at, resolved.fkJoints);
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
function editTrail(id, { track = "hips", grabFrame, radiusFrames, delta }) {
|
|
400
|
+
const take = motionFor(id), character = appContext.storeDomain('cast').read().find(row => row.id === id);
|
|
401
|
+
if (!take) throw new StudioProtocolError('TARGET_NOT_READY', 'Load a take first.');
|
|
402
|
+
const scale = character.scale ?? 1;
|
|
403
|
+
const rig = rigFor(id);
|
|
404
|
+
const clipDelta = worldDeltaToTrailClip(take, delta, { track, grabFrame, radiusFrames, rig, scale });
|
|
405
|
+
const edited = snapshotTake(applyTrailFalloffDelta(take, { track, grabFrame, radiusFrames, clipDelta }));
|
|
406
|
+
writeLayer(id, { take: edited });
|
|
407
|
+
castWrite(rows => rows.map(row => row.id === id ? { ...row, motionRef: { ...row.motionRef, trailEdits: edited.trailEdits } } : row));
|
|
408
|
+
}
|
|
409
|
+
const physicsReviews = new Map();
|
|
410
|
+
let physicsJob = 0;
|
|
411
|
+
const physicsStamp = id => JSON.stringify([layer(id), appContext.storeDomain('cast').read().find(row => row.id === id)]);
|
|
412
|
+
function applyPhysics(id) {
|
|
413
|
+
const preview = physicsReviews.get(id);
|
|
414
|
+
if (!preview || preview.stamp !== physicsStamp(id)) throw new StudioProtocolError('STALE_TARGET', 'Analyse this take again before applying corrections.');
|
|
415
|
+
setKeys(id, preview.result.candidate.keys);
|
|
416
|
+
domain.onPhysicsPreview?.(null);
|
|
417
|
+
}
|
|
418
|
+
async function autoPhysics(id, options, context) {
|
|
419
|
+
const rig = rigFor(id), resolved = rig && resolveIkRig(rig), take = motionFor(id);
|
|
420
|
+
if (!resolved || !take) throw new StudioProtocolError('TARGET_NOT_READY', 'Load a take and character rig first.');
|
|
421
|
+
const job = ++physicsJob, stamp = physicsStamp(id), original = appContext.shared.snapshotExportRig(rig);
|
|
422
|
+
let lastYield = performance.now();
|
|
423
|
+
const check = () => { context.check(); if (job !== physicsJob) throw new StudioProtocolError('STALE_TARGET', 'Physics analysis was superseded.'); };
|
|
424
|
+
domain.onPhysicsRunning?.(true); domain.onPhysicsPreview?.(null);
|
|
425
|
+
try {
|
|
426
|
+
check();
|
|
427
|
+
const result = await reviewAutoPhysics({ rig, ...resolved, motion: take, sourceKeys: decodeMotionKeys(layer(id).ikKeys),
|
|
428
|
+
applyRaw: at => applyMotionFrame(rig, take, at), sceneObjects: appContext.ports.read().objects,
|
|
429
|
+
...options, cache: appContext.shared.physicsSourceCacheRef.current, onProgress: value => domain.onPhysicsProgress?.(value),
|
|
430
|
+
yieldFrame: async () => {
|
|
431
|
+
check(); if (performance.now() - lastYield < 16) return;
|
|
432
|
+
appContext.shared.restoreExportRig(original);
|
|
433
|
+
await new Promise(resolve => { const channel = new MessageChannel(); channel.port1.onmessage = () => { channel.port1.close(); channel.port2.close(); resolve(); }; channel.port2.postMessage(0); });
|
|
434
|
+
lastYield = performance.now(); check();
|
|
435
|
+
},
|
|
436
|
+
});
|
|
437
|
+
appContext.shared.restoreExportRig(original); check();
|
|
438
|
+
physicsReviews.set(id, { result, stamp });
|
|
439
|
+
if (options.apply) context.commit(() => applyPhysics(id)); else domain.onPhysicsPreview?.(result);
|
|
440
|
+
return { before: result.before, after: result.after, changedFrames: result.changedFrames.length, warnings: result.warnings, unresolved: result.unresolved };
|
|
441
|
+
} finally {
|
|
442
|
+
if (job === physicsJob) { if (stamp === physicsStamp(id)) appContext.shared.restoreExportRig(original); domain.onPhysicsRunning?.(false); }
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
function runPrepared(characterId, apply) {
|
|
446
|
+
if (running) return apply();
|
|
447
|
+
let result;
|
|
448
|
+
const token = crypto.randomUUID(); prepared.set(token, () => { result = apply(); return result; });
|
|
449
|
+
try { run('motion.applyPrepared', { characterId, token }); return result; }
|
|
450
|
+
finally { prepared.delete(token); }
|
|
451
|
+
}
|
|
452
|
+
function persistTake(take, motionRef) {
|
|
453
|
+
if (!take?.sourceBytes) return;
|
|
454
|
+
(async () => {
|
|
455
|
+
let record = appContext.shared.motionEncodingCacheRef.current.get(take.sourceBytes);
|
|
456
|
+
if (!record) { record = await encodeMotionResource(take.sourceBytes, { prompt: motionRef?.prompt, sourceUrl: motionRef?.url }); appContext.shared.motionEncodingCacheRef.current.set(take.sourceBytes, record); }
|
|
457
|
+
appContext.shared.projectMotionsRef.current.set(record.motionId.toLowerCase(), record);
|
|
458
|
+
const db = await openMotionDb(); try { await putMotion(db, record); } finally { db.close(); }
|
|
459
|
+
})().catch(error => console.warn('[cozyclay] could not cache motions', error));
|
|
460
|
+
}
|
|
461
|
+
function run(id, args) {
|
|
462
|
+
const result = appContext.bus.run(id, args);
|
|
463
|
+
const checked = receipt => { if (!receipt.ok) throw new StudioProtocolError(receipt.code, receipt.message); return receipt; };
|
|
464
|
+
return result?.then ? result.then(checked) : checked(result);
|
|
465
|
+
}
|
|
466
|
+
let gesture = null;
|
|
467
|
+
function finishGesture(cancel = false) {
|
|
468
|
+
if (!gesture) return;
|
|
469
|
+
const active = gesture; gesture = null; active.unsubscribe?.(); active.release?.();
|
|
470
|
+
if (cancel && !active.txId) for (const image of active.rigs.values()) appContext.shared.restoreExportRig(image);
|
|
471
|
+
if (active.txId) return run(cancel ? 'run.cancel' : 'run.commit', { txId: active.txId });
|
|
472
|
+
}
|
|
473
|
+
function beginGesture() {
|
|
474
|
+
if (gesture) return;
|
|
475
|
+
const commit = () => finishGesture(), cancel = () => finishGesture(true), key = event => { if (event.key === 'Escape') cancel(); };
|
|
476
|
+
const events = { pointerup: commit, pointercancel: cancel, blur: commit, keydown: key };
|
|
477
|
+
for (const [name, handler] of Object.entries(events)) globalThis.window?.addEventListener?.(name, handler);
|
|
478
|
+
gesture = { rigs: rigSnapshots(), release() { for (const [name, handler] of Object.entries(events)) globalThis.window?.removeEventListener?.(name, handler); } };
|
|
479
|
+
}
|
|
480
|
+
function edit(id, args) {
|
|
481
|
+
if (!gesture) return run(id, args);
|
|
482
|
+
if (gesture.id && gesture.id !== id) { finishGesture(); beginGesture(); }
|
|
483
|
+
if (!gesture.txId) {
|
|
484
|
+
gesture.id = id; gesture.txId = run('run.begin', { id, args }).txId;
|
|
485
|
+
gesture.unsubscribe = appContext.bus.subscribe(event => { if (event.type === 'transaction.cancelled' && event.txId === gesture?.txId) { const current = gesture; gesture = null; current.unsubscribe(); current.release(); } });
|
|
486
|
+
}
|
|
487
|
+
return run('run.update', { txId: gesture.txId, args });
|
|
488
|
+
}
|
|
489
|
+
const domain = { documentStore, read, write, layer, writeLayer, motionFor, fullMotionFor, visibleMotion, snapshotTake, project,
|
|
490
|
+
setKeys, setRangePinState, bakeRangePin, removePin, editKeys, setKey, replace, clear, editSegments, fix, castWrite, run, edit, beginGesture, finishGesture, beginAction, runPrepared, persistTake,
|
|
491
|
+
insideAction: () => running > 0, bakeCurrentKey, keyPose, editTrail, autoPhysics, applyPhysics,
|
|
492
|
+
applyPrepared(token) { const apply = prepared.get(token); if (!apply) throw new StudioProtocolError('STALE_TARGET', 'Prepared motion is no longer available.'); return apply(); },
|
|
493
|
+
bindRender(context) { appContext = context; },
|
|
494
|
+
viewRevision: () => viewRevision, canUndo: id => documentStore.canUndo(id),
|
|
495
|
+
stepHistory: redo => { finishGesture(); return Boolean((redo ? documentStore.redo : documentStore.undo)()); },
|
|
496
|
+
document: () => ({ motion: read().map(row => {
|
|
497
|
+
const take = motionFor(row.id);
|
|
498
|
+
return take ? { ...row, take: { ...row.take, frames: take.frames, fps: take.fps, editSegments: take.editSegments } } : row;
|
|
499
|
+
}) }), sceneSlice: slices => slices.motion ?? references(slices.cast),
|
|
500
|
+
publish: rows => write(rows), commitDraft: write,
|
|
501
|
+
switchLayer(id) { appContext.shared.loadedLayerCharRef.current = id; project(); notify(); },
|
|
502
|
+
snapshotLegacy: () => read(), restoreLegacy: write,
|
|
503
|
+
snapshotTarget(id) {
|
|
504
|
+
const state = appContext.shared.readStudioState(), keys = decodeMotionKeys(layer(id).ikKeys), rig = rigFor(id);
|
|
505
|
+
return { character: state.characters.find(row => row.id === id), fullMotion: fullMotionFor(id),
|
|
506
|
+
ikState: { ...createIkState(), keys, tracked: new Set([...keys.values()].flatMap(entry => [...entry.keys()])) },
|
|
507
|
+
frameCount: state.frameCount, committedIkEdits: layer(id).committedIkEdits, renderer: rig ? appContext.shared.snapshotExportRig(rig) : null };
|
|
508
|
+
},
|
|
509
|
+
removeLayer(id) { write(rows => rows.filter(row => row.id !== id)); },
|
|
510
|
+
preview(id, take) { if (take) previews.set(id, take); else previews.delete(id); project(); notify(); },
|
|
511
|
+
hydrate(id, take, reference) {
|
|
512
|
+
const current = appContext.storeDomain('cast').read().find(row => row.id === id);
|
|
513
|
+
const descriptor = layer(id).take;
|
|
514
|
+
if (!descriptor || !sameMotionIntent(current?.motionRef, reference)) return false;
|
|
515
|
+
takes.set(descriptor.resourceId, structuredClone(take));
|
|
516
|
+
for (const rows of [read(), ...documentStore.history().past.map(entry => entry.snapshot.motion), ...documentStore.history().future.map(entry => entry.snapshot.motion)]) {
|
|
517
|
+
for (const row of rows) for (const value of [row.take, row.fullTake]) if (value?.resourceId === descriptor.resourceId) decoded.delete(value);
|
|
518
|
+
}
|
|
519
|
+
notify(); return true;
|
|
520
|
+
},
|
|
521
|
+
load(rows) { finishGesture(true); previews.clear(); release(); native.dispose(); native = createDocumentStore({ owned: { motion: normalize(rows) } }); release = native.subscribe(notify); pruneTakes(); notify(); },
|
|
522
|
+
dispose() { finishGesture(true); unregister(); unsubscribe(); release(); native.dispose(); listeners.clear(); takes.clear(); },
|
|
523
|
+
};
|
|
524
|
+
const unregister = appContext.registerStoreDomain('motion', domain);
|
|
525
|
+
return domain;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
export function useMotionCommands() {
|
|
529
|
+
const app = useContext(AppContext), owner = app.storeDomain('motion');
|
|
530
|
+
return { run: (id, args = {}) => owner.run(id, { characterId: app.storeDomain('cast').activeId, ...args }) };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export function useMotion(appContext) {
|
|
534
|
+
const [domain] = useState(() => appContext.storeDomain('motion') ?? createMotionDomain(appContext, appContext.shared.characters));
|
|
535
|
+
domain.bindRender(appContext);
|
|
536
|
+
useDocumentDomain(domain.documentStore, 'motion');
|
|
537
|
+
useSyncExternalStore(domain.documentStore.subscribe, domain.viewRevision, domain.viewRevision);
|
|
538
|
+
useEffect(() => {
|
|
539
|
+
domain.switchLayer(appContext.shared.activeChar.id);
|
|
540
|
+
const start = () => domain.beginGesture(); window.addEventListener('pointerdown', start, true);
|
|
541
|
+
return () => { window.removeEventListener('pointerdown', start, true); domain.finishGesture(true); };
|
|
542
|
+
}, [domain, appContext.shared.activeChar.id]);
|
|
543
|
+
const [i2vMotion, setI2vMotion] = useState({ a: null, b: null, job: null, status: "idle", error: "", instruction: "", dailyRemaining: null });
|
|
544
|
+
/* ------------------------------ IK layer ------------------------------ */
|
|
545
|
+
// IK posing for Subject 1: dragging a wrist/ankle handle FOCUSES that
|
|
546
|
+
// joint and solves its chain backward (two-bone analytic IK) on top of
|
|
547
|
+
// the current pose; joints never dragged stay purely on the FK pose.
|
|
548
|
+
// Keys land on the Full-Body lane as sparse per-chain world targets and
|
|
549
|
+
// evaluate as the playhead moves. State lives in a ref — it changes
|
|
550
|
+
// every drag tick and must not re-render the scene; ikTick re-renders
|
|
551
|
+
// only the timeline markers.
|
|
552
|
+
const [ikMode, setIkMode] = useState(false);
|
|
553
|
+
// A drag records only the tracks it actually writes. The record is kept
|
|
554
|
+
// outside React renders so pointer moves stay cheap and the drag-end bake
|
|
555
|
+
// can create a delta key over the raw clip.
|
|
556
|
+
const ikDragRef = useRef(null);
|
|
557
|
+
const ikCameraMemoryRef = useRef(new Map());
|
|
558
|
+
|
|
559
|
+
const [ikChains, setIkChains] = useState(null);
|
|
560
|
+
|
|
561
|
+
const [ikFkJoints, setIkFkJoints] = useState(null);
|
|
562
|
+
|
|
563
|
+
const [ikFocus, setIkFocus] = useState(null);
|
|
564
|
+
|
|
565
|
+
// Foot snap (ground plant): while ON, body (hips) drags keep the feet at
|
|
566
|
+
// the positions captured when the drag started — the knees bend instead
|
|
567
|
+
// of the feet sinking through the floor. Toggleable in the timeline.
|
|
568
|
+
const [footSnap, setFootSnap] = useState(true);
|
|
569
|
+
|
|
570
|
+
const [bodyContact, setBodyContact] = useState(true);
|
|
571
|
+
|
|
572
|
+
// How far (in frames) a correction eases back to the underlying motion
|
|
573
|
+
// outside its keyed range. 6 frames @ 24 fps = 0.25 s — long enough to
|
|
574
|
+
// hide the seam, short enough that a mid-clip fix stays visibly local.
|
|
575
|
+
const IK_CORRECTION_BLEND_FRAMES = 6;
|
|
576
|
+
|
|
577
|
+
const [autoPhysicsRunning, setAutoPhysicsRunning] = useState(false);
|
|
578
|
+
|
|
579
|
+
const [physicsPreview, setPhysicsPreview] = useState(null);
|
|
580
|
+
|
|
581
|
+
const [physicsShow, setPhysicsShow] = useState(true);
|
|
582
|
+
|
|
583
|
+
const [physicsProgress] = useState(createPhysicsProgress);
|
|
584
|
+
|
|
585
|
+
const setPhysicsProgress = physicsProgress.set;
|
|
586
|
+
|
|
587
|
+
const [physicsOptions, setPhysicsOptions] = useState({ overrides: [], protectedFrames: [], strength: 1 });
|
|
588
|
+
|
|
589
|
+
const [platformFitRunning, setPlatformFitRunning] = useState(false);
|
|
590
|
+
const [platformFitLast, setPlatformFitLast] = useState(null);
|
|
591
|
+
const [platformFitProgress] = useState(createPhysicsProgress);
|
|
592
|
+
const platformFitProgressSet = platformFitProgress.set;
|
|
593
|
+
const platformFitJobRef = useRef(0);
|
|
594
|
+
const platformFitAppliedRef = useRef(null);
|
|
595
|
+
const [platformFitApplied, setPlatformFitApplied] = useState(false);
|
|
596
|
+
|
|
597
|
+
const [ikTick, setIkTick] = useState(0);
|
|
598
|
+
|
|
599
|
+
const committedIkEdits = domain.layer(appContext.shared.activeChar.id).committedIkEdits;
|
|
600
|
+
const setCommittedIkEdits = update => domain.writeLayer(appContext.shared.activeChar.id, { committedIkEdits: typeof update === 'function' ? update(domain.layer(appContext.shared.activeChar.id).committedIkEdits) : update });
|
|
601
|
+
domain.onProject = () => setIkTick(value => value + 1);
|
|
602
|
+
|
|
603
|
+
/* --------------------- IK-mode motion trail editing ---------------------
|
|
604
|
+
* Grabbing the viewport trajectory line deforms the loaded take with a
|
|
605
|
+
* smoothstep falloff around the grab frame (pure local preview). The last
|
|
606
|
+
* finished drag stays pending so "Regenerate from trail edit" can send the
|
|
607
|
+
* auto-derived window through the existing motionEdit pipeline. */
|
|
608
|
+
const [trailFalloffS, setTrailFalloffS] = useState(0.5);
|
|
609
|
+
|
|
610
|
+
const [showTrails, setShowTrails] = useState(true);
|
|
611
|
+
|
|
612
|
+
const [ikEditTool, setIkEditTool] = useState("ik");
|
|
613
|
+
|
|
614
|
+
const [trailEdit, setTrailEdit] = useState(null);
|
|
615
|
+
const [rangePinSelection, setRangePinSelection] = useState(null);
|
|
616
|
+
const [rangePinPartPick, setRangePinPartPick] = useState(null);
|
|
617
|
+
const [rangePinPreview, setRangePinPreview] = useState(null);
|
|
618
|
+
const pinLayer = domain.layer(appContext.shared.activeChar.id);
|
|
619
|
+
const rangePins = useMemo(() => encodeRangePins(pinLayer.ikPins), [pinLayer.ikPins]);
|
|
620
|
+
const rangePinResiduals = useMemo(() => decodeRangePinResiduals(pinLayer.ikPinResiduals), [pinLayer.ikPinResiduals]);
|
|
621
|
+
|
|
622
|
+
const trailFalloffFrames = Math.max(1, Math.round(trailFalloffS * TIMELINE_FPS));
|
|
623
|
+
|
|
624
|
+
function focusIkHandle(focus) {
|
|
625
|
+
setIkFocus(focus);
|
|
626
|
+
const hierarchyId = hierarchyIdForIkFocus(focus, appContext.shared.rowIdForCharIndex(appContext.shared.activeCharIndex));
|
|
627
|
+
if (hierarchyId) {
|
|
628
|
+
appContext.shared.setSelectedHierarchyId(hierarchyId);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function ensureRangePinState(state) {
|
|
633
|
+
if (!state) return state;
|
|
634
|
+
if (!(state.pins instanceof Map)) state.pins = new Map();
|
|
635
|
+
if (!(state.pinResiduals instanceof Map)) state.pinResiduals = new Map();
|
|
636
|
+
return state;
|
|
637
|
+
}
|
|
638
|
+
function rangePinObjectWorldMatrix(objectId, frame) {
|
|
639
|
+
const object = appContext.shared.sceneObjects.find((entry) => entry.id === objectId);
|
|
640
|
+
if (!object) return null;
|
|
641
|
+
const local = rangePinObjectMatrixAt(object, frame, { frameCount: motion?.frames ?? appContext.shared.tlFrameCount, fps: motion?.fps ?? appContext.shared.tlFps }, new THREE.Matrix4());
|
|
642
|
+
if (!object.attach) return local;
|
|
643
|
+
const attachRef = appContext.shared.attachFrameRef?.current;
|
|
644
|
+
if (!attachRef) return local;
|
|
645
|
+
const parent = attachRef(object.attach.characterId, object.attach.bone ?? null, new THREE.Matrix4());
|
|
646
|
+
return parent ? parent.multiply(local) : local;
|
|
647
|
+
}
|
|
648
|
+
function rangePinApplyFrame(frame, state = appContext.shared.ikStateRef.current) {
|
|
649
|
+
appContext.shared.poseMemberAtFrame(appContext.shared.activeRig, motion, null, frame);
|
|
650
|
+
if (ikChains && state.keys.size > 0) ikEvaluate(ikChains, state, frame, ikFkJoints, IK_CORRECTION_BLEND_FRAMES);
|
|
651
|
+
}
|
|
652
|
+
function rangePinConflictFrames(draft, state = appContext.shared.ikStateRef.current) {
|
|
653
|
+
if (!Number.isInteger(draft?.startFrame) || !Number.isInteger(draft?.endFrame) || draft.endFrame < draft.startFrame) return [];
|
|
654
|
+
const tracks = rangePinTracks(draft), conflicts = [];
|
|
655
|
+
for (let frame = draft.startFrame; frame <= draft.endFrame; frame += 1) {
|
|
656
|
+
if (tracks.some((track) => { const key = state.keys.get(frame)?.get(track); return key && key.pin !== draft.id; })) conflicts.push(frame);
|
|
657
|
+
}
|
|
658
|
+
return conflicts;
|
|
659
|
+
}
|
|
660
|
+
function overlappingRangePin(draft, state = appContext.shared.ikStateRef.current) {
|
|
661
|
+
return [...ensureRangePinState(state).pins.values()].find((pin) => pin.id !== draft.id && rangePinTracks(pin).some((track) => rangePinTracks(draft).includes(track)) && Math.max(pin.startFrame, draft.startFrame) <= Math.min(pin.endFrame, draft.endFrame));
|
|
662
|
+
}
|
|
663
|
+
function rangePinOverlapMessage(pin) {
|
|
664
|
+
return pin ? ko(`A required body or limb track already has an overlapping pin (${pin.startFrame}–${pin.endFrame}).`, `필요한 몸통 또는 팔다리 파츠에 겹치는 고정이 있어요 (${pin.startFrame}–${pin.endFrame}).`) : "";
|
|
665
|
+
}
|
|
666
|
+
function captureRangePinTargetForDraft(draft) {
|
|
667
|
+
if (!motion || !appContext.shared.activeRig || !ikChains) return null;
|
|
668
|
+
const objectWorldMatrix = draft.targetKind === "object" ? (objectId, frame) => rangePinObjectWorldMatrix(objectId, frame) : null;
|
|
669
|
+
const target = captureRangePinTarget({
|
|
670
|
+
chains: ikChains, track: draft.track, frame: draft.startFrame,
|
|
671
|
+
applyFrame: (frame) => rangePinApplyFrame(frame),
|
|
672
|
+
space: draft.targetKind === "object" ? "object" : "world",
|
|
673
|
+
objectId: draft.targetKind === "object" ? draft.objectId : null,
|
|
674
|
+
objectWorldMatrix,
|
|
675
|
+
});
|
|
676
|
+
const pin = normalizeRangePin({ id: draft.id, track: draft.track, startFrame: draft.startFrame, endFrame: draft.endFrame, blend: draft.blend, reach: draft.reach ?? "body", target }, { clipFrames: motion.frames });
|
|
677
|
+
return { pin, targetWorld: rangePinTargetWorld(pin, appContext.shared.tlFrame, { objectWorldMatrix }) };
|
|
678
|
+
}
|
|
679
|
+
function previewRangePinDraft(draft) {
|
|
680
|
+
const state = ensureRangePinState(appContext.shared.ikStateRef.current);
|
|
681
|
+
const conflictFrames = rangePinConflictFrames(draft, state);
|
|
682
|
+
const overlapping = overlappingRangePin(draft, state);
|
|
683
|
+
try {
|
|
684
|
+
const resolved = captureRangePinTargetForDraft(draft);
|
|
685
|
+
setRangePinPreview({ draft, track: draft.track, target: resolved?.targetWorld?.toArray() ?? null, conflictFrames, overlapError: rangePinOverlapMessage(overlapping) });
|
|
686
|
+
} catch {
|
|
687
|
+
setRangePinPreview({ draft, track: draft.track, target: null, conflictFrames, overlapError: rangePinOverlapMessage(overlapping) });
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
function applyRangePinDraft(draft) {
|
|
691
|
+
if (!motion || !appContext.shared.activeRig || !ikChains) return;
|
|
692
|
+
const state = ensureRangePinState(appContext.shared.ikStateRef.current);
|
|
693
|
+
const overlap = overlappingRangePin(draft, state);
|
|
694
|
+
if (overlap) { appContext.notify(rangePinOverlapMessage(overlap)); return; }
|
|
695
|
+
const conflicts = rangePinConflictFrames(draft, state);
|
|
696
|
+
if (conflicts.length && !draft.replaceExisting) { appContext.notify(ko("Confirm Replace existing keys before applying this pin.", "이 고정을 적용하려면 기존 키 교체를 확인하세요.")); return; }
|
|
697
|
+
try {
|
|
698
|
+
const resolved = captureRangePinTargetForDraft(draft);
|
|
699
|
+
domain.run('motion.rangePin.apply', { characterId: appContext.shared.activeChar.id, pin: resolved.pin, replaceExisting: Boolean(draft.replaceExisting) });
|
|
700
|
+
setRangePinSelection(resolved.pin.id);
|
|
701
|
+
setRangePinPreview({ draft: { ...draft, id: resolved.pin.id }, track: resolved.pin.track, target: resolved.targetWorld.toArray(), conflictFrames: [], overlapError: "" });
|
|
702
|
+
appContext.shared.setTlPlaying(false);
|
|
703
|
+
appContext.notify(isKo ? `${resolved.pin.track} 고정을 적용했어요 · ${resolved.pin.startFrame}–${resolved.pin.endFrame}프레임` : `Applied ${resolved.pin.track} pin · frames ${resolved.pin.startFrame}–${resolved.pin.endFrame}`);
|
|
704
|
+
} catch (error) { appContext.notify(ko(`Could not apply pin — ${error.message}`, `고정을 적용하지 못했어요 — ${error.message}`)); }
|
|
705
|
+
}
|
|
706
|
+
function deleteRangePin(pinId) {
|
|
707
|
+
const state = ensureRangePinState(appContext.shared.ikStateRef.current), pin = state.pins.get(pinId);
|
|
708
|
+
if (!pin) return;
|
|
709
|
+
domain.run('motion.rangePin.remove', { characterId: appContext.shared.activeChar.id, pinId });
|
|
710
|
+
// Persisting the command updates the document before React publishes the
|
|
711
|
+
// next render. Clear the live evaluator now too, so a draft preview made
|
|
712
|
+
// during that transition cannot report the deleted pin as overlapping.
|
|
713
|
+
const liveState = ensureRangePinState(appContext.shared.ikStateRef.current);
|
|
714
|
+
removeRangePinKeys(liveState, pinId);
|
|
715
|
+
liveState.pins.delete(pinId);
|
|
716
|
+
liveState.pinResiduals.delete(pinId);
|
|
717
|
+
liveState.tracked = new Set([...liveState.keys.values()].flatMap((entry) => [...entry.keys()]));
|
|
718
|
+
setRangePinSelection(null); setRangePinPreview(null); setIkTick((value) => value + 1);
|
|
719
|
+
appContext.notify(isKo ? `${pin.track} 고정을 삭제했어요` : `Deleted ${pin.track} pin`);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** Deep copy of an IK state's key map: frame → Map(trackId → {q,p}), with
|
|
723
|
+
* every quaternion/position cloned so a snapshot never shares references
|
|
724
|
+
* with the live rig state (a later bake would otherwise rewrite history). */
|
|
725
|
+
function snapshotIkKeys(ikState) {
|
|
726
|
+
return copyPhysicsKeys(ikState?.keys ?? new Map());
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function editIkKeys(mutate) {
|
|
730
|
+
const before = snapshotIkKeys(appContext.shared.ikStateRef.current);
|
|
731
|
+
const result = mutate();
|
|
732
|
+
const owned = appContext.storeDomain('motion');
|
|
733
|
+
if (owned) owned.setKeys(appContext.shared.loadedLayerCharRef.current, appContext.shared.ikStateRef.current.keys);
|
|
734
|
+
else appContext.shared.markSemanticEdit("pose", before, appContext.shared.ikStateRef.current.keys);
|
|
735
|
+
return result;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/* One IK-key core for every cast member, shared by the Key button, a pose
|
|
739
|
+
* drag's bake, the Full-Body lane's delete and run_action. The loaded
|
|
740
|
+
* layer's keys live on the live IK state, every other character's on its
|
|
741
|
+
* stored one (created on its first key). */
|
|
742
|
+
function ikStateFor(characterId) {
|
|
743
|
+
if (characterId === appContext.shared.loadedLayerCharRef.current) return appContext.shared.ikStateRef.current;
|
|
744
|
+
let state = appContext.shared.ikStatesRef.current.get(characterId);
|
|
745
|
+
if (!state) appContext.shared.ikStatesRef.current.set(characterId, (state = createIkState()));
|
|
746
|
+
return state;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function editCharacterIkKeys(characterId, mutate) {
|
|
750
|
+
if (appContext.storeDomain('motion')) return appContext.storeDomain('motion').editKeys(characterId, mutate);
|
|
751
|
+
const state = ikStateFor(characterId);
|
|
752
|
+
const before = snapshotIkKeys(state);
|
|
753
|
+
mutate(state);
|
|
754
|
+
appContext.shared.markSemanticEdit("pose", before, state.keys);
|
|
755
|
+
setIkTick((value) => value + 1);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** Write one key from its JSON form (studio-actions.js character.setIkKey):
|
|
759
|
+
* each named track replaces its key at `frame` and joins the tracked set. */
|
|
760
|
+
function setCharacterIkKey(characterId, frame, tracks) {
|
|
761
|
+
appContext.shared.castMemberOf(characterId);
|
|
762
|
+
if (appContext.storeDomain('motion')) return appContext.storeDomain('motion').setKey(characterId, frame, tracks);
|
|
763
|
+
editCharacterIkKeys(characterId, (state) => {
|
|
764
|
+
let entry = state.keys.get(frame);
|
|
765
|
+
if (!entry) state.keys.set(frame, (entry = new Map()));
|
|
766
|
+
for (const [track, key] of Object.entries(tracks)) {
|
|
767
|
+
entry.set(track, ikTrackKeyFromJson(key));
|
|
768
|
+
ikTouch(state, track);
|
|
769
|
+
}
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function removeCharacterIkKey(characterId, frame) {
|
|
774
|
+
const character = appContext.shared.castMemberOf(characterId);
|
|
775
|
+
const state = ikStateFor(characterId);
|
|
776
|
+
if (!state.keys.has(frame)) {
|
|
777
|
+
const keyed = ikKeyframes(state);
|
|
778
|
+
throw new StudioProtocolError("STALE_TARGET", `${character.subject || character.id} has no IK key at frame ${frame}${keyed.length ? `; keyed frames: ${keyed.join(", ")}` : ""}.`);
|
|
779
|
+
}
|
|
780
|
+
editCharacterIkKeys(characterId, (target) => ikRemoveKeyframe(target, frame));
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function clearCharacterIkKeys(characterId) {
|
|
784
|
+
appContext.shared.castMemberOf(characterId);
|
|
785
|
+
const count = ikStateFor(characterId).keys.size;
|
|
786
|
+
if (!count) return 0;
|
|
787
|
+
editCharacterIkKeys(characterId, (target) => {
|
|
788
|
+
target.keys.clear();
|
|
789
|
+
target.tracked.clear();
|
|
790
|
+
target.plants.clear();
|
|
791
|
+
target.pins?.clear();
|
|
792
|
+
target.pinResiduals?.clear();
|
|
793
|
+
});
|
|
794
|
+
return count;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/** A baked key entry in the JSON form character.setIkKey takes. */
|
|
798
|
+
function ikKeyJson(entry) {
|
|
799
|
+
return ikKeyJson(entry);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const [bridge, setBridge] = useState(null);
|
|
803
|
+
|
|
804
|
+
const [bridgeChecking, setBridgeChecking] = useState(false);
|
|
805
|
+
|
|
806
|
+
const [motionSetupReveal, setMotionSetupReveal] = useState(0);
|
|
807
|
+
|
|
808
|
+
const [motionSetupKind, setMotionSetupKind] = useState("prompt");
|
|
809
|
+
|
|
810
|
+
const [ardyPrompt, setArdyPrompt] = useState("");
|
|
811
|
+
|
|
812
|
+
const [ardyDuration, setArdyDuration] = useState(4);
|
|
813
|
+
|
|
814
|
+
// Optional native-ARDY seed: empty string = omit from the request (the
|
|
815
|
+
// box picks a fresh random one each run); otherwise a plain integer in
|
|
816
|
+
// 0..2**31-1 to reproduce a result.
|
|
817
|
+
const [ardySeed, setArdySeed] = useState("");
|
|
818
|
+
|
|
819
|
+
// How much of the loaded take a regeneration keeps (scheduled inpainting).
|
|
820
|
+
// 1 = hold the take everywhere the user did not edit, 0 = ignore it and
|
|
821
|
+
// generate fresh. Only ever consulted when the take still has a bridge
|
|
822
|
+
// source to preserve FROM, so the control renders with the take, not with
|
|
823
|
+
// the panel.
|
|
824
|
+
const [preserveStrength, setPreserveStrength] = useState(ARDY_PRESERVE_DEFAULT);
|
|
825
|
+
|
|
826
|
+
// Off by default on purpose: pinning runs the box's pose mode, which builds
|
|
827
|
+
// on a fixed reference base, so it is a choice the operator makes when they
|
|
828
|
+
// actually want the pose in the generated clip.
|
|
829
|
+
const [ardyStartFromPose, setArdyStartFromPose] = useState(false);
|
|
830
|
+
|
|
831
|
+
// WHERE the pose lands in the clip. "start" leaves from it, "end" arrives at
|
|
832
|
+
// it, "middle" passes through it, "playhead" places it on the frame the
|
|
833
|
+
// operator scrubbed to — the box takes any destination frame.
|
|
834
|
+
const [ardyPosePlacement, setArdyPosePlacement] = useState("start");
|
|
835
|
+
|
|
836
|
+
/* ------------------- take recipes and versions (C9/C12) -------------------
|
|
837
|
+
* The recipe of the take that is loaded RIGHT NOW: seed + prompt blocks +
|
|
838
|
+
* the line edits pulled on top of it. It is written in exactly one place
|
|
839
|
+
* (commitTakeRecipe, on a successful run) and read in two: the request
|
|
840
|
+
* assembly, which attaches it as C10 `replay`, and the version strip, which
|
|
841
|
+
* stores a copy beside every motionUrl so clicking v1 restores v1's recipe
|
|
842
|
+
* and not the one the artist has since edited into existence.
|
|
843
|
+
* The ref shadows the state because the recipe is consulted from inside an
|
|
844
|
+
* async job completion, where a stale closure would silently record the
|
|
845
|
+
* previous take's edits against this take's url. */
|
|
846
|
+
const takeRecipe = domain.layer(appContext.shared.activeChar.id).takeRecipe;
|
|
847
|
+
const setTakeRecipe = value => domain.writeLayer(appContext.shared.activeChar.id, { takeRecipe: value });
|
|
848
|
+
const takeVersions = domain.layer(appContext.shared.activeChar.id).takeVersions;
|
|
849
|
+
const setTakeVersions = update => domain.writeLayer(appContext.shared.activeChar.id, { takeVersions: typeof update === 'function' ? update(domain.layer(appContext.shared.activeChar.id).takeVersions) : update });
|
|
850
|
+
|
|
851
|
+
// Which C10 replay entries came back failed or boundary-warned. Non-blocking
|
|
852
|
+
// by contract: the take generated, one refinement may not have survived it,
|
|
853
|
+
// and that is worth a line next to the take rather than a toast that scrolls
|
|
854
|
+
// away before the artist has looked at the result.
|
|
855
|
+
const [replayNotices, setReplayNotices] = useState([]);
|
|
856
|
+
|
|
857
|
+
// Whether the Scene entry's action menu is open. The Refine entry needs no
|
|
858
|
+
// equivalent — it IS its action.
|
|
859
|
+
const [sceneMenuOpen, setSceneMenuOpen] = useState(false);
|
|
860
|
+
|
|
861
|
+
const [ardyRunning, setArdyRunning] = useState(false);
|
|
862
|
+
|
|
863
|
+
const [ardyStatus, setArdyStatus] = useState("");
|
|
864
|
+
|
|
865
|
+
// Keep the latest ARDY status inline with the Prompt Blocks controls. The
|
|
866
|
+
// former bottom Console history was removed because it duplicated this state
|
|
867
|
+
// and exposed an editor surface that is not part of the production workflow.
|
|
868
|
+
function reportArdyStatus(line) {
|
|
869
|
+
setArdyStatus(line);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const [ardyReport, setArdyReport] = useState(null);
|
|
873
|
+
|
|
874
|
+
const [ardyOutcome, setArdyOutcome] = useState(null);
|
|
875
|
+
|
|
876
|
+
// Timeline frames for the configured duration: [0, duration*TIMELINE_FPS-1].
|
|
877
|
+
const maxDst = Math.max(0, Math.round(ardyDuration) * TIMELINE_FPS - 1);
|
|
878
|
+
|
|
879
|
+
// Wave-2 gate: the bridge only routes lineEdit once M4's routing lands.
|
|
880
|
+
// Until the /ardy/health payload says so, the request is never sent —
|
|
881
|
+
// today's bridge ignores unknown fields, so an ungated POST would quietly
|
|
882
|
+
// return a fresh unrelated take instead of an edit.
|
|
883
|
+
const [lineEditBackend, setLineEditBackend] = useState(false);
|
|
884
|
+
|
|
885
|
+
// Loaded motion: decoded arrays plus the world anchor captured at load.
|
|
886
|
+
const motion = domain.visibleMotion(appContext.shared.activeChar.id);
|
|
887
|
+
const rangePinRebuildRef = useRef(null);
|
|
888
|
+
const rangePinSourceStamp = JSON.stringify([pinLayer.take, appContext.shared.activeChar.id]);
|
|
889
|
+
// A fit is invalidated by a different take, trim or preview, not by key
|
|
890
|
+
// writes: every layer write re-snapshots the take descriptor, so the decoded
|
|
891
|
+
// `motion` object is new after the fit's own commit even though the take is
|
|
892
|
+
// unchanged. Key the reset on the descriptor's content instead.
|
|
893
|
+
const platformFitPreview = motion === domain.motionFor(appContext.shared.activeChar.id) ? null : motion;
|
|
894
|
+
useEffect(() => {
|
|
895
|
+
platformFitJobRef.current += 1;
|
|
896
|
+
setPlatformFitRunning(false);
|
|
897
|
+
setPlatformFitLast(null);
|
|
898
|
+
platformFitProgressSet(0);
|
|
899
|
+
platformFitAppliedRef.current = null;
|
|
900
|
+
setPlatformFitApplied(false);
|
|
901
|
+
}, [rangePinSourceStamp, platformFitPreview, appContext.shared.activeRig]);
|
|
902
|
+
// Object-bound pins follow edited prop transforms and paths, as in the
|
|
903
|
+
// source branch. Rebuilding derived keys must not create an undo entry.
|
|
904
|
+
useEffect(() => {
|
|
905
|
+
if (!motion || !ikChains || rangePinRebuildRef.current === rangePinSourceStamp) return;
|
|
906
|
+
const timer = setTimeout(() => {
|
|
907
|
+
rangePinRebuildRef.current = rangePinSourceStamp;
|
|
908
|
+
const state = ensureRangePinState(appContext.shared.ikStateRef.current);
|
|
909
|
+
for (const pin of [...state.pins.values()].filter(pin => pin.target.space === "object")) {
|
|
910
|
+
if (!appContext.shared.sceneObjects.some(object => object.id === pin.target.objectId)) {
|
|
911
|
+
removeRangePinKeys(state, pin.id);
|
|
912
|
+
state.pinResiduals.set(pin.id, []);
|
|
913
|
+
} else {
|
|
914
|
+
try { domain.bakeRangePin(appContext.shared.activeChar.id, pin, false, true); }
|
|
915
|
+
catch { /* A prop can be unavailable while its transform remounts. */ }
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
}, 150);
|
|
919
|
+
return () => clearTimeout(timer);
|
|
920
|
+
}, [rangePinSourceStamp, ikChains]);
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
const [motionBusy, setMotionBusy] = useState(false);
|
|
924
|
+
|
|
925
|
+
const [motionError, setMotionError] = useState("");
|
|
926
|
+
|
|
927
|
+
/* ------------------------- video capture (ingest) ---------------------- */
|
|
928
|
+
const [multiModelUrl, setMultiModelUrl] = useState("");
|
|
929
|
+
|
|
930
|
+
const [multiModelSource, setMultiModelSource] = useState(null);
|
|
931
|
+
|
|
932
|
+
const [multiModelStatus, setMultiModelStatus] = useState("idle");
|
|
933
|
+
|
|
934
|
+
// The ingest is a real download + decode, so it owns real progress and a
|
|
935
|
+
// real receipt: the footage numbers below come from the decoded media.
|
|
936
|
+
const [multiModelStage, setMultiModelStage] = useState("idle");
|
|
937
|
+
|
|
938
|
+
const [multiModelProgress, setMultiModelProgress] = useState(null);
|
|
939
|
+
|
|
940
|
+
const [multiModelFootage, setMultiModelFootage] = useState(null);
|
|
941
|
+
|
|
942
|
+
const [multiModelError, setMultiModelError] = useState("");
|
|
943
|
+
|
|
944
|
+
const [multiModelTake, setMultiModelTake] = useState(null);
|
|
945
|
+
|
|
946
|
+
const [multiModelExtract, setMultiModelExtract] = useState("idle");
|
|
947
|
+
|
|
948
|
+
const [multiModelExtractProgress, setMultiModelExtractProgress] = useState(null);
|
|
949
|
+
|
|
950
|
+
const [multiModelExtractError, setMultiModelExtractError] = useState("");
|
|
951
|
+
|
|
952
|
+
function advanceFrame(steps = 1) {
|
|
953
|
+
const count = Math.max(1, Math.floor(steps));
|
|
954
|
+
const previewEnd = appContext.shared.cameraPreviewEndRef.current;
|
|
955
|
+
if (previewEnd != null && appContext.shared.tlFrameRef.current + count >= previewEnd) {
|
|
956
|
+
appContext.shared.cameraPreviewEndRef.current = null;
|
|
957
|
+
appContext.shared.setTlFrame(previewEnd);
|
|
958
|
+
appContext.shared.setTlPlaying(false);
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
appContext.shared.setTlFrame((f) => (f + count) % appContext.shared.frameCountRef.current);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function stepFrame(delta) {
|
|
965
|
+
appContext.shared.setTlFrame((f) => Math.max(0, Math.min(f + delta, appContext.shared.frameCountRef.current - 1)));
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/* --------------------------- motion playback ---------------------------- */
|
|
969
|
+
function leaveIkMode() {
|
|
970
|
+
const poserCam = appContext.shared.poserCamRef?.current;
|
|
971
|
+
if (ikMode && poserCam && appContext.shared.activeChar?.id) {
|
|
972
|
+
const look = appContext.shared.poserLook?.current ?? {};
|
|
973
|
+
ikCameraMemoryRef.current.set(appContext.shared.activeChar.id, {
|
|
974
|
+
position: poserCam.position.clone(),
|
|
975
|
+
quaternion: poserCam.quaternion.clone(),
|
|
976
|
+
yaw: look.yaw ?? poserCam.rotation.y,
|
|
977
|
+
pitch: look.pitch ?? poserCam.rotation.x,
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
setIkMode(false);
|
|
981
|
+
setIkFocus(null);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/** Hand `rig` over to playback. Every path that starts a clip — a loaded
|
|
985
|
+
* take, a browser-baked one — does the same two things: drop out of IK
|
|
986
|
+
* EDIT mode (playback is the new context; the IK KEYS stay and keep
|
|
987
|
+
* correcting the clip layer-style) and swap the pre-playback bone
|
|
988
|
+
* baseline, so a re-load never snapshots mid-animation and clearing
|
|
989
|
+
* always returns to the blocking pose. */
|
|
990
|
+
function beginPlaybackOn(rig) {
|
|
991
|
+
leaveIkMode();
|
|
992
|
+
const previous = appContext.shared.restoreRef.current;
|
|
993
|
+
appContext.shared.restoreRef.current = null;
|
|
994
|
+
if (previous) restorePlaybackBones(previous.rig, previous.bones);
|
|
995
|
+
appContext.shared.restoreRef.current = { rig, bones: snapshotPlaybackBones(rig) };
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/* ---------------------------- video capture ----------------------------
|
|
999
|
+
* Footage in, takes out. The ingest downloads and decodes a source so the
|
|
1000
|
+
* timeline is sized by what was actually read; extraction then turns it
|
|
1001
|
+
* into one take per tracked performer. Take 0 belongs to the ACTIVE
|
|
1002
|
+
* character's layer, the rest are landed on their own cast members. */
|
|
1003
|
+
// A picked file is already local bytes: no download stage, straight to the
|
|
1004
|
+
// probe that produces the timeline numbers.
|
|
1005
|
+
function chooseMultiModelFile(event) {
|
|
1006
|
+
const file = event.target.files?.[0] ?? null;
|
|
1007
|
+
if (!file) return;
|
|
1008
|
+
setMultiModelUrl("");
|
|
1009
|
+
ingestFootage({ kind: "file", name: file.name, blob: file, bytes: file.size });
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
async function pasteMultiModelUrl() {
|
|
1013
|
+
try {
|
|
1014
|
+
const text = await navigator.clipboard.readText();
|
|
1015
|
+
if (!text.trim()) return;
|
|
1016
|
+
setMultiModelUrl(text.trim());
|
|
1017
|
+
setMultiModelStatus("idle");
|
|
1018
|
+
} catch {
|
|
1019
|
+
appContext.notify(isKo ? "클립보드를 읽지 못했어요 — 직접 붙여넣어 주세요" : "Clipboard is unavailable — paste into the field directly");
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function useMultiModelUrl() {
|
|
1024
|
+
const raw = multiModelUrl.trim();
|
|
1025
|
+
// A platform page (YouTube, Vimeo, …) is never browser-fetchable, but
|
|
1026
|
+
// the dev bridge can fetch it server-side. With the bridge up the
|
|
1027
|
+
// address goes there; without it the named refusal below stands.
|
|
1028
|
+
if (isPlatformPageUrl(raw) && bridge?.ok) {
|
|
1029
|
+
ingestPlatformFootage(raw);
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
const normalized = normalizeSourceUrl(raw);
|
|
1033
|
+
if (!normalized.ok) {
|
|
1034
|
+
setMultiModelStatus("error");
|
|
1035
|
+
setMultiModelStage("error");
|
|
1036
|
+
setMultiModelError(MULTIMODEL_REASONS[normalized.reason]?.[isKo ? 1 : 0] ?? normalized.reason);
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
ingestFootage({ kind: "url", name: sourceLabel(normalized.url), url: normalized.url });
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/** Platform page → bridge download (yt-dlp + normalize) → the local
|
|
1043
|
+
* /ardy/footage/… address rides the ordinary ingest unchanged. */
|
|
1044
|
+
async function ingestPlatformFootage(pageUrl) {
|
|
1045
|
+
const run = appContext.shared.multiModelRunRef.current + 1;
|
|
1046
|
+
appContext.shared.multiModelRunRef.current = run;
|
|
1047
|
+
const live = () => appContext.shared.multiModelRunRef.current === run;
|
|
1048
|
+
setMultiModelSource({ kind: "url", name: sourceLabel(pageUrl), url: pageUrl });
|
|
1049
|
+
setMultiModelFootage(null);
|
|
1050
|
+
setMultiModelError("");
|
|
1051
|
+
setMultiModelStatus("busy");
|
|
1052
|
+
setMultiModelStage("fetching");
|
|
1053
|
+
setMultiModelProgress(null);
|
|
1054
|
+
setMultiModelTake(null);
|
|
1055
|
+
setMultiModelExtract("idle");
|
|
1056
|
+
setMultiModelExtractProgress(null);
|
|
1057
|
+
setMultiModelExtractError("");
|
|
1058
|
+
try {
|
|
1059
|
+
const footage = await requestBridgeFootage(pageUrl, {
|
|
1060
|
+
onProgress: ({ ratio }) => {
|
|
1061
|
+
if (live()) setMultiModelProgress(Number.isFinite(ratio) ? ratio : null);
|
|
1062
|
+
},
|
|
1063
|
+
});
|
|
1064
|
+
if (!live()) return;
|
|
1065
|
+
ingestFootage({ kind: "url", name: footage.title || sourceLabel(pageUrl), url: footage.url, fps: footage.fps, bridgeId: footage.footage ?? null });
|
|
1066
|
+
} catch (error) {
|
|
1067
|
+
if (!live()) return;
|
|
1068
|
+
const code = error?.message ?? String(error);
|
|
1069
|
+
setMultiModelStage("error");
|
|
1070
|
+
setMultiModelStatus("error");
|
|
1071
|
+
setMultiModelProgress(null);
|
|
1072
|
+
setMultiModelError(MULTIMODEL_REASONS[code]?.[isKo ? 1 : 0] ?? code);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/** Download (when remote), decode, measure, then size the timeline from what
|
|
1077
|
+
* was actually read. Each run carries a token so a slow first source can
|
|
1078
|
+
* never land its numbers after a second one replaced it. */
|
|
1079
|
+
async function ingestFootage(source, commandContext = null) {
|
|
1080
|
+
const run = appContext.shared.multiModelRunRef.current + 1;
|
|
1081
|
+
appContext.shared.multiModelRunRef.current = run;
|
|
1082
|
+
const live = () => appContext.shared.multiModelRunRef.current === run;
|
|
1083
|
+
setMultiModelSource(source);
|
|
1084
|
+
setMultiModelFootage(null);
|
|
1085
|
+
setMultiModelError("");
|
|
1086
|
+
setMultiModelStatus("busy");
|
|
1087
|
+
setMultiModelProgress(null);
|
|
1088
|
+
// A take baked from the PREVIOUS clip must not read as this one's
|
|
1089
|
+
// result, so the extraction state resets with the source.
|
|
1090
|
+
setMultiModelTake(null);
|
|
1091
|
+
setMultiModelExtract("idle");
|
|
1092
|
+
setMultiModelExtractProgress(null);
|
|
1093
|
+
setMultiModelExtractError("");
|
|
1094
|
+
try {
|
|
1095
|
+
let blob = source.blob ?? null;
|
|
1096
|
+
let bytes = source.bytes ?? 0;
|
|
1097
|
+
if (!blob) {
|
|
1098
|
+
setMultiModelStage("fetching");
|
|
1099
|
+
const downloaded = await fetchFootageBlob(source.url, {
|
|
1100
|
+
onProgress: ({ ratio }) => {
|
|
1101
|
+
if (live()) setMultiModelProgress(ratio);
|
|
1102
|
+
},
|
|
1103
|
+
});
|
|
1104
|
+
if (!live()) return;
|
|
1105
|
+
blob = downloaded.blob;
|
|
1106
|
+
bytes = downloaded.bytes;
|
|
1107
|
+
}
|
|
1108
|
+
setMultiModelStage("probing");
|
|
1109
|
+
if (appContext.shared.multiModelObjectUrlRef.current) URL.revokeObjectURL(appContext.shared.multiModelObjectUrlRef.current);
|
|
1110
|
+
const objectUrl = URL.createObjectURL(blob);
|
|
1111
|
+
appContext.shared.multiModelObjectUrlRef.current = objectUrl;
|
|
1112
|
+
const probed = await probeFootage(objectUrl, {
|
|
1113
|
+
createVideo: () => document.createElement("video"),
|
|
1114
|
+
// The bridge normalized the clip and DECLARED its rate; a probe
|
|
1115
|
+
// that re-guesses it would overrule a measurement with a guess.
|
|
1116
|
+
knownFps: Number.isFinite(source.fps) ? source.fps : null,
|
|
1117
|
+
});
|
|
1118
|
+
if (!live()) return;
|
|
1119
|
+
commandContext?.check();
|
|
1120
|
+
const footage = { ...probed, bytes, objectUrl, blob, bridgeId: source.bridgeId ?? null };
|
|
1121
|
+
setMultiModelFootage(footage);
|
|
1122
|
+
setMultiModelStage("ready");
|
|
1123
|
+
setMultiModelStatus("ready");
|
|
1124
|
+
setMultiModelProgress(1);
|
|
1125
|
+
// The timeline is the point: the playhead now spans the footage that
|
|
1126
|
+
// was actually decoded, at the rate that was actually measured.
|
|
1127
|
+
appContext.shared.setTlFps(footage.fps);
|
|
1128
|
+
appContext.shared.setTlFrameCount(footage.frames);
|
|
1129
|
+
appContext.shared.setTlFrame(0);
|
|
1130
|
+
appContext.shared.setTlPlaying(false);
|
|
1131
|
+
appContext.notify((isKo, ko) => isKo
|
|
1132
|
+
? `${source.name} 인제스트됨 — ${footage.frames}프레임 @ ${footage.fps} fps`
|
|
1133
|
+
: `Ingested ${source.name} — ${footage.frames} frames @ ${footage.fps} fps`);
|
|
1134
|
+
return footage;
|
|
1135
|
+
} catch (error) {
|
|
1136
|
+
if (commandContext && (commandContext.signal.aborted || error.code === "STALE_TARGET")) throw error;
|
|
1137
|
+
if (!live()) return;
|
|
1138
|
+
const code = error?.message ?? String(error);
|
|
1139
|
+
setMultiModelStage("error");
|
|
1140
|
+
setMultiModelStatus("error");
|
|
1141
|
+
setMultiModelProgress(null);
|
|
1142
|
+
setMultiModelError(MULTIMODEL_REASONS[code]?.[isKo ? 1 : 0] ?? code);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/** Extract motion from the ingested footage. With the bridge up this goes
|
|
1147
|
+
* to the GPU box (GVHMR: whole-clip temporal context, real 3D body
|
|
1148
|
+
* prior — previs-grade). If the bridge is unavailable or is configured for
|
|
1149
|
+
* another backend, extraction stops with a named error. */
|
|
1150
|
+
async function extractMultiModelMotion() {
|
|
1151
|
+
if (!bridge?.ok) {
|
|
1152
|
+
setMultiModelExtract("error");
|
|
1153
|
+
setMultiModelExtractError(MULTIMODEL_REASONS["extract-bridge-required"]?.[isKo ? 1 : 0] ?? "extract-bridge-required");
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
if (bridge.extractionBackend !== "gvhmr") {
|
|
1157
|
+
setMultiModelExtract("error");
|
|
1158
|
+
setMultiModelExtractError(MULTIMODEL_REASONS["extract-backend-unsupported"]?.[isKo ? 1 : 0] ?? "extract-backend-unsupported");
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
return extractMultiModelMotionGpu();
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
async function extractMultiModelMotionGpu() {
|
|
1165
|
+
const footage = multiModelFootage;
|
|
1166
|
+
if (!footage || multiModelExtract === "running") return;
|
|
1167
|
+
const run = appContext.shared.multiModelRunRef.current;
|
|
1168
|
+
const live = () => appContext.shared.multiModelRunRef.current === run;
|
|
1169
|
+
setMultiModelExtract("running");
|
|
1170
|
+
setMultiModelExtractProgress(null);
|
|
1171
|
+
setMultiModelExtractError("");
|
|
1172
|
+
setMultiModelTake(null);
|
|
1173
|
+
try {
|
|
1174
|
+
const done = await requestBridgeExtract(
|
|
1175
|
+
footage.bridgeId ? { footage: footage.bridgeId } : footage.blob,
|
|
1176
|
+
{
|
|
1177
|
+
onProgress: ({ ratio }) => {
|
|
1178
|
+
if (live()) setMultiModelExtractProgress(Number.isFinite(ratio) ? ratio : null);
|
|
1179
|
+
},
|
|
1180
|
+
}
|
|
1181
|
+
);
|
|
1182
|
+
if (!live()) return;
|
|
1183
|
+
if (done.quality && done.quality.pass === false) {
|
|
1184
|
+
const failed = Array.isArray(done.quality.checks)
|
|
1185
|
+
? done.quality.checks.filter((check) => check && check.pass === false).map((check) => check.name).join(", ")
|
|
1186
|
+
: "quality";
|
|
1187
|
+
appContext.notify(isKo
|
|
1188
|
+
? `모캡 품질 경고: ${failed || "검증 실패"} — 결과는 로드하지만 보정이 필요합니다`
|
|
1189
|
+
: `Mocap quality warning: ${failed || "validation failed"} — loaded for review, correction required`);
|
|
1190
|
+
}
|
|
1191
|
+
// One take per tracked performer. An older bridge sends a single
|
|
1192
|
+
// motionUrl and no list; that is the same thing with one entry.
|
|
1193
|
+
const takes = Array.isArray(done.takes) && done.takes.length
|
|
1194
|
+
? done.takes
|
|
1195
|
+
: [{ motionUrl: done.motionUrl, personScale: done.personScale, offsetX: 0, offsetZ: 0 }];
|
|
1196
|
+
const label = multiModelSource?.name ?? "extracted take";
|
|
1197
|
+
// The cast can change while the GPU works, so the destination is read
|
|
1198
|
+
// now, once, and every take is placed against THIS character.
|
|
1199
|
+
const active = appContext.live.characters.find((entry) => entry.id === appContext.shared.activeChar.id) ?? appContext.shared.activeChar;
|
|
1200
|
+
// Take 0 arrives as an ordinary motion npz; loadMotion decodes,
|
|
1201
|
+
// retimes to the 24 fps timeline, snapshots the rig baseline and
|
|
1202
|
+
// applies the stature stored in the take itself — the body matches
|
|
1203
|
+
// the FILMED person because the file carries the measurement, not
|
|
1204
|
+
// because this handler remembered to re-apply it afterwards.
|
|
1205
|
+
const fallbackScale = Number.isFinite(takes[0].personScale) ? takes[0].personScale : done.personScale;
|
|
1206
|
+
let personScale = await loadMotion(takes[0].motionUrl ?? done.motionUrl, label, active.rot, null, active.id, null, { personScaleFallback: fallbackScale });
|
|
1207
|
+
if (!live()) return;
|
|
1208
|
+
// loadMotion reports the stature it applied — 1 for a take that
|
|
1209
|
+
// stores none, nothing at all if the load failed. A failed load is
|
|
1210
|
+
// not a finished extraction: say so with the named reason instead
|
|
1211
|
+
// of a receipt for a take nobody can play.
|
|
1212
|
+
if (!Number.isFinite(personScale)) throw new Error("extract-convert-failed");
|
|
1213
|
+
// Only a take that stores NO stature gets the fallback for an npz
|
|
1214
|
+
// that predates person_scale (or an older bridge): the response
|
|
1215
|
+
// still carries the estimate, clamped the same way, because a bad
|
|
1216
|
+
// leg estimate must never produce a giant or a gnome.
|
|
1217
|
+
const declared = Number.isFinite(takes[0].personScale) ? takes[0].personScale : done.personScale;
|
|
1218
|
+
if (personScale === 1 && Number.isFinite(declared)) {
|
|
1219
|
+
personScale = characterScaleFor(null, declared);
|
|
1220
|
+
}
|
|
1221
|
+
// The clip itself is session-only; this reference is what a save
|
|
1222
|
+
// keeps and restoreMotionRefs re-fetches on the next session.
|
|
1223
|
+
const leadRef = {
|
|
1224
|
+
url: takes[0].motionUrl ?? done.motionUrl,
|
|
1225
|
+
prompt: label,
|
|
1226
|
+
rotationDeg: active.rot,
|
|
1227
|
+
anchorX: active.x,
|
|
1228
|
+
anchorZ: active.z,
|
|
1229
|
+
};
|
|
1230
|
+
if (!appContext.storeDomain('motion')) appContext.shared.publishStudioCharacters((list) => list.map((entry) => entry.id === active.id
|
|
1231
|
+
? { ...entry, scale: personScale, motionRef: leadRef }
|
|
1232
|
+
: entry));
|
|
1233
|
+
const placed = await deliverExtraTakes(takes.slice(1), active, label);
|
|
1234
|
+
if (!live()) return;
|
|
1235
|
+
const persons = 1 + placed;
|
|
1236
|
+
setMultiModelTake({ frames: done.frames, fps: done.fps, gpu: true, personScale, persons, trajectory: done.performance?.trajectory, segmentation: done.segmentation ?? done.performance?.segmentation ?? takes[0]?.segmentation ?? null, quality: done.quality ?? takes[0]?.quality ?? null });
|
|
1237
|
+
setMultiModelExtract("done");
|
|
1238
|
+
appContext.notify(isKo
|
|
1239
|
+
? `GPU 모션 추출됨 — ${done.frames}프레임 @ ${done.fps} fps${persons > 1 ? ` · ${persons}명` : ""} · 인물 스케일 ×${personScale.toFixed(2)}`
|
|
1240
|
+
: `GPU motion extracted — ${done.frames} frames @ ${done.fps} fps${persons > 1 ? ` · ${persons} performers` : ""} · person scale ×${personScale.toFixed(2)}`);
|
|
1241
|
+
} catch (error) {
|
|
1242
|
+
if (!live()) return;
|
|
1243
|
+
const code = error?.message ?? String(error);
|
|
1244
|
+
setMultiModelExtract("error");
|
|
1245
|
+
setMultiModelExtractError(MULTIMODEL_REASONS[code]?.[isKo ? 1 : 0] ?? code);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
/** Land takes 1..N-1 on the rest of the cast. Each extra take is its OWN
|
|
1250
|
+
* layer: it goes to that entry's sessionMotion, NOT through the editing
|
|
1251
|
+
* buffer, which holds the active character's clip alone. Returns how many
|
|
1252
|
+
* performers actually landed. */
|
|
1253
|
+
const authoredSupportDescriptors = () => appContext.shared.sceneObjects.map((object) => ({
|
|
1254
|
+
x: object.x,
|
|
1255
|
+
z: object.z,
|
|
1256
|
+
rotDeg: object.rot ?? 0,
|
|
1257
|
+
supportY: (object.y ?? 0) + supportHeightForObject(object) * (object.scaleY ?? 1),
|
|
1258
|
+
topY: (object.y ?? 0) + supportHeightForObject(object) * (object.scaleY ?? 1),
|
|
1259
|
+
width: (object.footprint?.width ?? 0) * (object.scaleX ?? 1),
|
|
1260
|
+
depth: (object.footprint?.depth ?? 0) * (object.scaleZ ?? 1),
|
|
1261
|
+
}));
|
|
1262
|
+
|
|
1263
|
+
const applyAuthoredSupportRise = (clip, anchor, rotationDeg, worldScale = characterScaleFor(clip)) => applySupportRise(clip, authoredSupportDescriptors(), {
|
|
1264
|
+
subjectX: anchor.x,
|
|
1265
|
+
subjectY: anchor.y ?? 0,
|
|
1266
|
+
subjectZ: anchor.z,
|
|
1267
|
+
rotationDeg,
|
|
1268
|
+
worldScale,
|
|
1269
|
+
});
|
|
1270
|
+
|
|
1271
|
+
async function deliverExtraTakes(extras, active, label) {
|
|
1272
|
+
const decoded = await Promise.all(extras.map(async (take, index) => {
|
|
1273
|
+
if (typeof take?.motionUrl !== "string" || !take.motionUrl) return null;
|
|
1274
|
+
try {
|
|
1275
|
+
// Inbound boundary, exactly like every other clip: decode, then
|
|
1276
|
+
// retime onto the production clock before anything counts frames.
|
|
1277
|
+
const anchor = takeAnchor(active, take.offsetX, take.offsetZ);
|
|
1278
|
+
const clip = retimeMotion(await loadMotionFromUrl(take.motionUrl), TIMELINE_FPS);
|
|
1279
|
+
const scale = characterScaleFor(clip, take.personScale);
|
|
1280
|
+
const raised = applyAuthoredSupportRise(clip, { ...anchor, y: active.y ?? 0 }, active.rot, scale);
|
|
1281
|
+
const staging = autoRoofDrop(
|
|
1282
|
+
raised,
|
|
1283
|
+
{ x: anchor.x, z: anchor.z, y: active.y ?? 0, rotationDeg: active.rot },
|
|
1284
|
+
authoredSupportDescriptors(),
|
|
1285
|
+
{ worldScale: scale },
|
|
1286
|
+
);
|
|
1287
|
+
const stagedClip = staging ? applyAutoFall(raised, staging, { worldScale: scale }) : raised;
|
|
1288
|
+
return {
|
|
1289
|
+
url: take.motionUrl,
|
|
1290
|
+
prompt: `${label} · ${index + 2}`,
|
|
1291
|
+
rotationDeg: active.rot,
|
|
1292
|
+
anchor,
|
|
1293
|
+
// A second performer is a DIFFERENT body: their take carries
|
|
1294
|
+
// their own stature, and the response estimate is only the
|
|
1295
|
+
// fallback for an npz that stores none.
|
|
1296
|
+
scale: characterScaleFor(clip, take.personScale),
|
|
1297
|
+
clip: stagedClip,
|
|
1298
|
+
};
|
|
1299
|
+
} catch {
|
|
1300
|
+
return null; // one unreadable take never voids the others
|
|
1301
|
+
}
|
|
1302
|
+
}));
|
|
1303
|
+
const usable = decoded.filter(Boolean);
|
|
1304
|
+
if (!usable.length) return 0;
|
|
1305
|
+
const owned = appContext.storeDomain('motion');
|
|
1306
|
+
// Plan against the cast as it stands, so ids are decided once and the
|
|
1307
|
+
// full-take map can be seeded with them.
|
|
1308
|
+
const list = appContext.live.characters;
|
|
1309
|
+
const taken = new Set([active.id]);
|
|
1310
|
+
let idPool = list;
|
|
1311
|
+
const assignments = usable.map((take) => {
|
|
1312
|
+
// Reuse a visible cast member with no clip of its own before adding
|
|
1313
|
+
// another body to the set.
|
|
1314
|
+
const reuse = list.find((entry) => !entry.hidden && !taken.has(entry.id) && !entry.sessionMotion && !entry.motionRef);
|
|
1315
|
+
const id = reuse ? reuse.id : nextCharacterId(idPool);
|
|
1316
|
+
if (!reuse) idPool = [...idPool, { id }];
|
|
1317
|
+
taken.add(id);
|
|
1318
|
+
return {
|
|
1319
|
+
id,
|
|
1320
|
+
spawn: !reuse,
|
|
1321
|
+
patch: {
|
|
1322
|
+
hidden: false,
|
|
1323
|
+
x: take.anchor.x,
|
|
1324
|
+
z: take.anchor.z,
|
|
1325
|
+
rot: take.rotationDeg,
|
|
1326
|
+
scale: take.scale,
|
|
1327
|
+
motionRef: {
|
|
1328
|
+
url: take.url,
|
|
1329
|
+
prompt: take.prompt,
|
|
1330
|
+
rotationDeg: take.rotationDeg,
|
|
1331
|
+
anchorX: take.anchor.x,
|
|
1332
|
+
anchorZ: take.anchor.z,
|
|
1333
|
+
},
|
|
1334
|
+
sessionMotion: {
|
|
1335
|
+
...take.clip,
|
|
1336
|
+
url: take.url,
|
|
1337
|
+
prompt: take.prompt,
|
|
1338
|
+
anchorX: take.anchor.x,
|
|
1339
|
+
anchorZ: take.anchor.z,
|
|
1340
|
+
anchorFrame: 0,
|
|
1341
|
+
rotationDeg: take.rotationDeg,
|
|
1342
|
+
editSegments: createMotionEdit(take.clip.frames),
|
|
1343
|
+
},
|
|
1344
|
+
},
|
|
1345
|
+
};
|
|
1346
|
+
});
|
|
1347
|
+
const place = current => {
|
|
1348
|
+
let next = current;
|
|
1349
|
+
for (const { id, spawn, patch } of assignments) {
|
|
1350
|
+
next = spawn && !next.some((entry) => entry.id === id)
|
|
1351
|
+
? [...next, { ...createCharacterEntry({ id, model: active.model, pose: DEFAULT_POSE, subject: "a person" }, next.length), ...patch }]
|
|
1352
|
+
: next.map((entry) => (entry.id === id ? { ...entry, ...patch } : entry));
|
|
1353
|
+
}
|
|
1354
|
+
return next;
|
|
1355
|
+
};
|
|
1356
|
+
if (owned) owned.runPrepared(active.id, () => {
|
|
1357
|
+
owned.castWrite(place);
|
|
1358
|
+
for (const { id, patch } of assignments) { owned.replace(id, patch.sessionMotion); owned.persistTake(patch.sessionMotion, patch.motionRef); }
|
|
1359
|
+
return { affectedIds: assignments.map(row => row.id) };
|
|
1360
|
+
});
|
|
1361
|
+
else {
|
|
1362
|
+
appContext.shared.publishStudioCharacters(place);
|
|
1363
|
+
// Their takes are trimmable the moment they become the active layer.
|
|
1364
|
+
for (const { id, patch } of assignments) appContext.shared.motionFullRef.current.set(id, patch.sessionMotion);
|
|
1365
|
+
}
|
|
1366
|
+
return assignments.length;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
function commitLoadedTake(characterId, take, { recipe = null, job = null, promptClips = null, scale = characterScaleFor(take) } = {}) {
|
|
1370
|
+
const owned = appContext.storeDomain('motion');
|
|
1371
|
+
const previous = owned.layer(characterId);
|
|
1372
|
+
const imported = recipe ?? (job ? previous.takeRecipe : { seed: null, blocks: [{ prompt: take.prompt, duration: take.frames / take.fps }], lineEdits: [] });
|
|
1373
|
+
const versions = recipe || job ? previous.takeVersions : pushTakeVersion(previous.takeVersions, {
|
|
1374
|
+
motionUrl: take.url, recipe: imported, savedAt: Date.now(), label: ko('Loaded', '불러옴'),
|
|
1375
|
+
}, TAKE_VERSIONS_MAX);
|
|
1376
|
+
owned.replace(characterId, take, { recipe: imported, versions });
|
|
1377
|
+
const motionRef = { url: take.url, prompt: take.prompt, rotationDeg: take.rotationDeg, anchorX: take.anchorX, anchorZ: take.anchorZ,
|
|
1378
|
+
calibration: take.sceneCalibration, ...(take.trailEdits ? { trailEdits: take.trailEdits } : {}), ...(take.motionId ? { motionId: take.motionId } : {}) };
|
|
1379
|
+
owned.castWrite(rows => rows.map(row => row.id === characterId ? { ...row, scale, motionRef,
|
|
1380
|
+
layer: promptClips ? { ...row.layer, promptClips } : row.layer } : row));
|
|
1381
|
+
if (job) {
|
|
1382
|
+
commitTakeRecipe(job, take.url);
|
|
1383
|
+
if (job.hasBlockEdits) owned.writeLayer(characterId, { committedIkEdits: job.committedEditKeys });
|
|
1384
|
+
}
|
|
1385
|
+
owned.persistTake(take, motionRef);
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
// Decoded motion + the world anchor: frame 0 always starts at Subject 1.
|
|
1389
|
+
// Authored root destinations are generated by ARDY as sparse constraints,
|
|
1390
|
+
// so playback consumes the returned trajectory without coordinate warping.
|
|
1391
|
+
async function loadMotion(
|
|
1392
|
+
url,
|
|
1393
|
+
prompt,
|
|
1394
|
+
rotationDeg = appContext.shared.charA.rot,
|
|
1395
|
+
drop = null,
|
|
1396
|
+
targetCharacterId = appContext.shared.activeChar.id,
|
|
1397
|
+
targetPromptClips = null,
|
|
1398
|
+
// `preview: true` means "put this clip on screen, do not treat it as a
|
|
1399
|
+
// new take". The line-edit preview loop swaps the viewport several times
|
|
1400
|
+
// a minute, and every announcement this function normally makes — the
|
|
1401
|
+
// load toast, the auto-drop toast, clearing the IK keys, snapping the
|
|
1402
|
+
// playhead back to 0 — is an announcement about a take CHANGING. A
|
|
1403
|
+
// preview is the same take seen a second time, so it makes none of them.
|
|
1404
|
+
{ preview = false, calibration = null, tutorialEpoch = null, commandContext = null, recipe = null, job = null, personScaleFallback = null } = {},
|
|
1405
|
+
) {
|
|
1406
|
+
setMotionBusy(true);
|
|
1407
|
+
setMotionError("");
|
|
1408
|
+
try {
|
|
1409
|
+
// Inbound boundary: an ARDY take (20 fps) or a filmed one (30/60)
|
|
1410
|
+
// becomes a production-clock clip here, once, before anything on
|
|
1411
|
+
// the timeline counts its frames. Same-rate input rides through.
|
|
1412
|
+
// A drop is staging applied to the clip itself, so it happens at
|
|
1413
|
+
// the same boundary — trims and IK then see the dropped take.
|
|
1414
|
+
const retimed = retimeMotion(await loadMotionFromUrl(url), TIMELINE_FPS);
|
|
1415
|
+
if (retimed.sourceBytes) retimed.motionId = await sha256Hex(retimed.sourceBytes);
|
|
1416
|
+
if (tutorialEpoch !== null && tutorialEpoch !== appContext.shared.tutorialProjectEpochRef.current) return null;
|
|
1417
|
+
const normalizedCalibration = normalizeMotionCalibration(calibration);
|
|
1418
|
+
// Scene yaw/XY translation belong to the character's scene transform.
|
|
1419
|
+
// Applying them to both the arrays and the Character group would rotate
|
|
1420
|
+
// the trajectory twice and leave rotMats facing the old direction.
|
|
1421
|
+
const playbackCalibration = { ...normalizedCalibration, yawDeg: 0, offsetX: 0, offsetZ: 0 };
|
|
1422
|
+
// Scene calibration is optional metadata from the capture boundary. It
|
|
1423
|
+
// runs before support/fall staging so every downstream measurement uses
|
|
1424
|
+
// the same scene-space coordinates.
|
|
1425
|
+
const raw = applyMotionCalibration(retimed, playbackCalibration).motion;
|
|
1426
|
+
// Staging descriptors are authored in scene metres while decoded
|
|
1427
|
+
// trajectories are canonical-body units. Resolve stature before any
|
|
1428
|
+
// support or fall math so a 0.8x/1.2x performer still lands exactly on
|
|
1429
|
+
// the same authored surface after playback multiplies the clip.
|
|
1430
|
+
const motionScale = characterScaleFor(raw);
|
|
1431
|
+
const targetCharacter = appContext.live.characters.find((entry) => entry.id === targetCharacterId);
|
|
1432
|
+
if (!targetCharacter) throw new Error(`Motion target ${targetCharacterId} no longer exists.`);
|
|
1433
|
+
const sceneAnchorX = targetCharacter.x + normalizedCalibration.offsetX;
|
|
1434
|
+
const sceneAnchorZ = targetCharacter.z + normalizedCalibration.offsetZ;
|
|
1435
|
+
const sceneRotationDeg = rotationDeg + normalizedCalibration.yawDeg;
|
|
1436
|
+
const rig = appContext.shared.rigs[targetCharacter.id] ?? await appContext.shared.waitForRig(targetCharacter.id);
|
|
1437
|
+
if (tutorialEpoch !== null && tutorialEpoch !== appContext.shared.tutorialProjectEpochRef.current) return null;
|
|
1438
|
+
// No explicit drop staged: a character standing on a raised object
|
|
1439
|
+
// whose take walks off the edge falls on its own — ARDY motion is
|
|
1440
|
+
// flat-ground, so the stage supplies the gravity.
|
|
1441
|
+
const supports = authoredSupportDescriptors();
|
|
1442
|
+
// A support's top is scene data, never guessed from the motion. Apply
|
|
1443
|
+
// only when the clip shows an upward root trend entering its footprint;
|
|
1444
|
+
// ordinary deck walks remain byte-for-byte unchanged.
|
|
1445
|
+
const raised = drop ? raw : applySupportRise(raw, supports, {
|
|
1446
|
+
subjectX: sceneAnchorX,
|
|
1447
|
+
subjectY: targetCharacter.y ?? 0,
|
|
1448
|
+
subjectZ: sceneAnchorZ,
|
|
1449
|
+
rotationDeg: sceneRotationDeg,
|
|
1450
|
+
worldScale: motionScale,
|
|
1451
|
+
});
|
|
1452
|
+
const staging = drop ?? autoRoofDrop(
|
|
1453
|
+
raised,
|
|
1454
|
+
{ x: sceneAnchorX, z: sceneAnchorZ, y: targetCharacter.y ?? 0, rotationDeg: sceneRotationDeg },
|
|
1455
|
+
supports,
|
|
1456
|
+
{ worldScale: motionScale },
|
|
1457
|
+
);
|
|
1458
|
+
const decoded = drop ? applyRootDrop(raised, staging, { worldScale: motionScale }) : applyAutoFall(raised, staging, { worldScale: motionScale });
|
|
1459
|
+
if (!drop && staging && !preview) {
|
|
1460
|
+
appContext.notify((isKo, ko) => ko(
|
|
1461
|
+
`Auto drop staged: the take leaves its support at ${staging.fromS.toFixed(1)}s and falls ${staging.meters.toFixed(1)}m`,
|
|
1462
|
+
`자동 낙하 적용: ${staging.fromS.toFixed(1)}초에 지지면을 벗어나 ${staging.meters.toFixed(1)}m 낙하`,
|
|
1463
|
+
));
|
|
1464
|
+
}
|
|
1465
|
+
const targetStillExists = appContext.live.characters.some((entry) => entry.id === targetCharacter.id);
|
|
1466
|
+
if (!targetStillExists) throw new Error(`Motion target ${targetCharacterId} no longer exists.`);
|
|
1467
|
+
const apply = () => {
|
|
1468
|
+
const bufferOwnsTarget = targetCharacter.id === appContext.shared.loadedLayerCharRef.current;
|
|
1469
|
+
beginPlaybackOn(rig);
|
|
1470
|
+
// THE INVARIANT: the take's travel assumes the character is scaled.
|
|
1471
|
+
// Extraction divided the root translation by the filmed person's
|
|
1472
|
+
// stature, so the clip and that stature have to be applied together
|
|
1473
|
+
// or every stride overshoots by the same factor and the feet skate.
|
|
1474
|
+
// The scale rides INSIDE the npz, so this one line covers every path
|
|
1475
|
+
// that loads a motion; an ARDY-generated take stores none and is
|
|
1476
|
+
// canonical, 1.
|
|
1477
|
+
const measuredScale = characterScaleFor(decoded);
|
|
1478
|
+
const scale = measuredScale === 1 && Number.isFinite(personScaleFallback) ? characterScaleFor(null, personScaleFallback) : measuredScale;
|
|
1479
|
+
const loaded = {
|
|
1480
|
+
// Identity calibration retains the legacy frame-zero anchorX: targetCharacter.x
|
|
1481
|
+
// and anchorZ: targetCharacter.z contract; calibrated takes use the scene anchor.
|
|
1482
|
+
// Capture the exact prompt this motion was generated from; the
|
|
1483
|
+
// timeline keeps showing it even if the input field is edited
|
|
1484
|
+
// afterwards.
|
|
1485
|
+
prompt: typeof prompt === "string" ? prompt : "",
|
|
1486
|
+
...decoded,
|
|
1487
|
+
url,
|
|
1488
|
+
anchorX: sceneAnchorX,
|
|
1489
|
+
anchorZ: sceneAnchorZ,
|
|
1490
|
+
anchorFrame: 0,
|
|
1491
|
+
rotationDeg: sceneRotationDeg,
|
|
1492
|
+
sceneCalibration: normalizedCalibration,
|
|
1493
|
+
editSegments: createMotionEdit(decoded.frames),
|
|
1494
|
+
};
|
|
1495
|
+
const owned = appContext.storeDomain('motion');
|
|
1496
|
+
if (preview) owned.preview(targetCharacter.id, url === owned.motionFor(targetCharacter.id)?.url ? null : loaded);
|
|
1497
|
+
else {
|
|
1498
|
+
commitLoadedTake(targetCharacter.id, loaded, { recipe, job, promptClips: targetPromptClips, scale });
|
|
1499
|
+
if (bufferOwnsTarget) { appContext.shared.setTlFrame(0); appContext.shared.setTlPlaying(false); }
|
|
1500
|
+
setMotionError('');
|
|
1501
|
+
}
|
|
1502
|
+
return scale;
|
|
1503
|
+
};
|
|
1504
|
+
return preview ? apply() : commandContext ? commandContext.commit(apply)
|
|
1505
|
+
: appContext.storeDomain('motion').runPrepared(targetCharacterId, apply);
|
|
1506
|
+
} catch (err) {
|
|
1507
|
+
if (tutorialEpoch !== null && tutorialEpoch !== appContext.shared.tutorialProjectEpochRef.current) return null;
|
|
1508
|
+
setMotionError(err?.message || String(err));
|
|
1509
|
+
throw err;
|
|
1510
|
+
} finally {
|
|
1511
|
+
setMotionBusy(false);
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
/** Drop the ACTIVE character's take, its IK corrections and the stature the
|
|
1516
|
+
* take imposed. One Ctrl+Z entry brings all three back; nothing to clear
|
|
1517
|
+
* records nothing, so the shortcut never becomes a dead press.
|
|
1518
|
+
*
|
|
1519
|
+
* The pose flows (studio Apply/Reset, pose tiles, photo pose) call this
|
|
1520
|
+
* first and then write the pose: the snapshot taken here predates both, so
|
|
1521
|
+
* one undo restores the take AND the pose it replaced. */
|
|
1522
|
+
function clearMotion() {
|
|
1523
|
+
return appContext.storeDomain('motion').clear(appContext.shared.activeChar.id);
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
/** Cut the ACTIVE character's take to [start, end] of the CURRENT view.
|
|
1527
|
+
* Offsets compose, so a second cut still slices the original take; a cut
|
|
1528
|
+
* take drops its bridge url — its frames no longer match the source npz,
|
|
1529
|
+
* and IK-edit regeneration must not pretend they do. Per-character layers
|
|
1530
|
+
* mean the cut lands on this layer only; nobody else's clip moves. */
|
|
1531
|
+
function applyMotionTrim(start, end) {
|
|
1532
|
+
return appContext.storeDomain('motion').edit('motion.trim', { characterId: appContext.shared.activeChar.id, start, end });
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
function resetMotionTrim() {
|
|
1536
|
+
return appContext.storeDomain('motion').run('motion.resetTrim', { characterId: appContext.shared.activeChar.id });
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
function cutMotionAtPlayhead() {
|
|
1540
|
+
return appContext.storeDomain('motion').run('motion.cut', { characterId: appContext.shared.activeChar.id, frame: appContext.shared.tlFrame });
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
function changeMotionSegmentSpeed(id, speed) {
|
|
1544
|
+
return appContext.storeDomain('motion').run('motion.setSegmentSpeed', { characterId: appContext.shared.activeChar.id, id, speed });
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
/** Drop one Full-Body segment from the take. The removal composes like a
|
|
1548
|
+
* trim: the source frames stay untouched, so the trim-reset path (right-click
|
|
1549
|
+
* an outer handle) still restores the whole take. */
|
|
1550
|
+
function removeMotionSegmentById(id) {
|
|
1551
|
+
return appContext.storeDomain('motion').run('motion.removeSegment', { characterId: appContext.shared.activeChar.id, id });
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
// Everyone EXCEPT the active character, posed at an absolute frame from
|
|
1555
|
+
// their own session clips and their STORED IK corrections (#77). Factored
|
|
1556
|
+
// out of the effect below because the whole-clip Fix Collisions pass needs
|
|
1557
|
+
// the same thing: the other bodies are static blockers, and a blocker
|
|
1558
|
+
// sampled from a rig still standing at the playhead would block the wrong
|
|
1559
|
+
// volume on every other frame of the walk.
|
|
1560
|
+
const poseOtherCastMembers = (frame) => {
|
|
1561
|
+
for (const entry of appContext.shared.characters) {
|
|
1562
|
+
if (entry.id === appContext.shared.activeChar.id) continue;
|
|
1563
|
+
appContext.shared.poseMemberAtFrame(appContext.shared.rigs[entry.id], entry.sessionMotion, appContext.shared.ikStatesRef.current.get(entry.id), frame, IK_CORRECTION_BLEND_FRAMES);
|
|
1564
|
+
}
|
|
1565
|
+
};
|
|
1566
|
+
|
|
1567
|
+
function toggleIkMode() {
|
|
1568
|
+
const next = !ikMode;
|
|
1569
|
+
// Authoring modes are mutually exclusive: IK mode swaps the main pane to
|
|
1570
|
+
// the poser camera, which would silently invalidate any pulled path
|
|
1571
|
+
// anyway. Leaving the line mode explicitly says so instead.
|
|
1572
|
+
if (next && appContext.shared.lineEditMode) appContext.shared.exitLineEditMode();
|
|
1573
|
+
if (next) {
|
|
1574
|
+
// Always enter on the safe, detailed IK tool. Trail editing is an
|
|
1575
|
+
// explicit second tool and must never leave the regular handles
|
|
1576
|
+
// locked when the user re-enters IK mode.
|
|
1577
|
+
setIkEditTool("ik");
|
|
1578
|
+
// With a motion loaded, IK edits ON TOP of it: the motion is the
|
|
1579
|
+
// rough base layer, the IK keys the correction layer. Every frame
|
|
1580
|
+
// applies the clip first and the keyed corrections after, so the
|
|
1581
|
+
// composite is what gets pinned for re-generation. Pause playback
|
|
1582
|
+
// so a running playhead cannot fight the drag.
|
|
1583
|
+
appContext.shared.setTlPlaying(false);
|
|
1584
|
+
// Self-heal the ref after a hot-reload with an older state shape:
|
|
1585
|
+
// a missing `tracked` set would throw on the first drag.
|
|
1586
|
+
if (!appContext.shared.ikStateRef.current.tracked) appContext.shared.ikStateRef.current = createIkState();
|
|
1587
|
+
appContext.shared.ikStateRef.current.chains = ikChains;
|
|
1588
|
+
appContext.shared.ikStateRef.current.fkJoints = ikFkJoints;
|
|
1589
|
+
appContext.shared.ikStateRef.current.rig = appContext.shared.activeRig;
|
|
1590
|
+
// Handles open exactly on the effectors of the CURRENT pose —
|
|
1591
|
+
// non-destructive entry. (The evaluate effect applies the keyed
|
|
1592
|
+
// pose at this frame right after ikMode flips, so re-seating on
|
|
1593
|
+
// frame changes is handled there.)
|
|
1594
|
+
if (ikChains) ikSeedTargets(ikChains, appContext.shared.ikStateRef.current);
|
|
1595
|
+
// The main view switches to the poser camera: start it exactly on
|
|
1596
|
+
// the shot camera's pose so nothing jumps, then navigation moves
|
|
1597
|
+
// the POSER only — the shot camera (inset) stays frozen.
|
|
1598
|
+
const shotCam = appContext.shared.shotCamRef.current;
|
|
1599
|
+
const editorCam = appContext.shared.editorCamRef?.current;
|
|
1600
|
+
const poserCam = appContext.shared.poserCamRef.current;
|
|
1601
|
+
const pose = chooseIkEntryPose({
|
|
1602
|
+
rememberedPose: ikCameraMemoryRef.current.get(appContext.shared.activeChar.id) ?? null,
|
|
1603
|
+
editorPose: editorCam ? { position: editorCam.position, quaternion: editorCam.quaternion, yaw: appContext.shared.editorLook?.current?.yaw, pitch: appContext.shared.editorLook?.current?.pitch } : null,
|
|
1604
|
+
shotPose: shotCam ? { position: shotCam.position, quaternion: shotCam.quaternion, yaw: shotCam.rotation.y, pitch: shotCam.rotation.x } : null,
|
|
1605
|
+
lookThroughShot: appContext.shared.lookThroughShot,
|
|
1606
|
+
});
|
|
1607
|
+
if (pose && poserCam) {
|
|
1608
|
+
poserCam.position.copy(pose.position);
|
|
1609
|
+
poserCam.quaternion.copy(pose.quaternion);
|
|
1610
|
+
poserCam.rotation.order = "YXZ";
|
|
1611
|
+
appContext.shared.poserLook.current = { yaw: pose.yaw ?? poserCam.rotation.y, pitch: pose.pitch ?? poserCam.rotation.x };
|
|
1612
|
+
}
|
|
1613
|
+
setIkMode(true);
|
|
1614
|
+
appContext.notify(motion
|
|
1615
|
+
? ko("Pose mode — correct the motion; drag end keys the fix at this frame", "포즈 모드 — 모션을 보정합니다. 드래그를 끝내면 이 프레임에 보정 키가 찍혀요")
|
|
1616
|
+
: ko("Pose mode — drag handles in the main view; the shot camera stays frozen in the inset", "포즈 모드 — 메인 뷰에서 핸들을 드래그하세요. 샷 카메라는 인셋에 고정됩니다"));
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
1619
|
+
// Exit: the keyed pose stays — the evaluate effect re-applies the
|
|
1620
|
+
// current frame's keyed rotations the moment ikMode flips, so nothing
|
|
1621
|
+
// the user authored is lost by toggling. Untracked/unkeyed parts keep
|
|
1622
|
+
// their current (FK) pose.
|
|
1623
|
+
leaveIkMode();
|
|
1624
|
+
appContext.notify(ko("Pose mode off — keyed poses keep playing", "포즈 모드 꺼짐 — 키로 찍은 포즈는 계속 재생됩니다"));
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
// Drag solve, routed by handle kind: chain targets solve the two-bone
|
|
1628
|
+
// chain toward the target; mid joints reposition the elbow/knee with both
|
|
1629
|
+
// ends pinned (the handle snaps to the clamped position); FK joints swing
|
|
1630
|
+
// toward the pointer. Keys are baked on drag END — see ikDragEnd.
|
|
1631
|
+
function ikSolve(kind, trackId, targetWorld) {
|
|
1632
|
+
ikDragRef.current = ikDragRecord(ikDragRef.current, appContext.shared.tlFrame);
|
|
1633
|
+
const touch = (id) => ikDragTouch(appContext.shared.ikStateRef.current, ikDragRef.current, id);
|
|
1634
|
+
if (kind === "chain") {
|
|
1635
|
+
const chain = appContext.shared.ikStateRef.current.chains?.get(trackId);
|
|
1636
|
+
if (!chain) return;
|
|
1637
|
+
touch(trackId);
|
|
1638
|
+
const clampedTarget = bodyContact ? clampIkTargetToFloor(trackId, targetWorld, 0, ikChains?.get(trackId)?.contactHeights ?? ikChains?.values().next().value?.contactHeights) : targetWorld;
|
|
1639
|
+
appContext.shared.ikStateRef.current.targets.set(trackId, clampedTarget.clone());
|
|
1640
|
+
solveIk(chain, clampedTarget);
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
if (kind === "mid") {
|
|
1644
|
+
// Mid tracks reference their parent chain through MID_TRACKS.
|
|
1645
|
+
const midDef = MID_TRACKS.find((t) => t.id === trackId);
|
|
1646
|
+
const chain = midDef ? appContext.shared.ikStateRef.current.chains?.get(midDef.chain) : null;
|
|
1647
|
+
if (!chain) return;
|
|
1648
|
+
touch(chain.track.id);
|
|
1649
|
+
solveMidJoint(chain, bodyContact ? clampIkTargetToFloor(trackId, targetWorld, 0, chain.contactHeights) : targetWorld);
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
// Effector swing: the rotation ring on a focused hand/foot. Rotates
|
|
1653
|
+
// only the end bone — the solved limb position is untouched — and the
|
|
1654
|
+
// bake on drag end now stores b2's quaternion with the chain's.
|
|
1655
|
+
if (kind === "swing") {
|
|
1656
|
+
const chain = appContext.shared.ikStateRef.current.chains?.get(trackId);
|
|
1657
|
+
if (!chain || !targetWorld?.axis) return;
|
|
1658
|
+
touch(trackId);
|
|
1659
|
+
solveEffectorSwing(chain, targetWorld.axis, targetWorld.angle, targetWorld.startQuat, targetWorld.startParentQuat);
|
|
1660
|
+
return;
|
|
1661
|
+
}
|
|
1662
|
+
// Body root (hips): arrow drags translate ({ worldDelta, startLocalPos
|
|
1663
|
+
// }), the centre sphere swings ({ axis, angle, startQuat, ... }). With
|
|
1664
|
+
// foot snap ON the feet stay at the positions captured when the drag
|
|
1665
|
+
// started — the legs re-solve after every hips transform so the knees
|
|
1666
|
+
// bend instead of the feet sinking through the floor.
|
|
1667
|
+
if (kind === "body") {
|
|
1668
|
+
const joint = ikFkJoints?.get(trackId);
|
|
1669
|
+
if (!joint) return;
|
|
1670
|
+
touch(trackId);
|
|
1671
|
+
if (footSnap && !appContext.shared.ikBodyDragRef.current && ikChains) {
|
|
1672
|
+
// Capture the plant points once, BEFORE the first hips move.
|
|
1673
|
+
ikPlantFeet(ikChains, appContext.shared.ikStateRef.current);
|
|
1674
|
+
appContext.shared.ikBodyDragRef.current = true;
|
|
1675
|
+
}
|
|
1676
|
+
if (targetWorld?.worldDelta && targetWorld?.startLocalPos) {
|
|
1677
|
+
if (bodyContact) solveHipsTranslateToFloor(joint, targetWorld.worldDelta, targetWorld.startLocalPos, 0, ikChains?.get("leftHand")?.contactHeights);
|
|
1678
|
+
else solveHipsTranslate(joint, targetWorld.worldDelta, targetWorld.startLocalPos);
|
|
1679
|
+
} else if (targetWorld?.axis) solveSwingAngle(joint, targetWorld.axis, targetWorld.angle, targetWorld.startQuat, targetWorld.startParentQuat);
|
|
1680
|
+
if (footSnap && ikChains) {
|
|
1681
|
+
ikSolvePlantedFeet(ikChains, appContext.shared.ikStateRef.current);
|
|
1682
|
+
// the planted re-solve wrote the leg bones — key them too
|
|
1683
|
+
touch("leftFoot");
|
|
1684
|
+
touch("rightFoot");
|
|
1685
|
+
}
|
|
1686
|
+
if (bodyContact && ikChains) {
|
|
1687
|
+
for (const id of chainsChangedBy(ikChains, () => applyBodyContact(ikChains, ikFkJoints, 0, { skipFeet: footSnap }))) touch(id);
|
|
1688
|
+
}
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1691
|
+
// FK swing: targetWorld is the trackball payload { axis, angle,
|
|
1692
|
+
// startQuat, startParentQuat } from the drag layer.
|
|
1693
|
+
const joint = ikFkJoints?.get(trackId);
|
|
1694
|
+
if (!joint || !targetWorld?.axis) return;
|
|
1695
|
+
touch(trackId);
|
|
1696
|
+
solveSwingAngle(joint, targetWorld.axis, targetWorld.angle, targetWorld.startQuat, targetWorld.startParentQuat);
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
// Drag end: key the dragged part's local rotations at the playhead, so a
|
|
1700
|
+
// scrub away and back restores the dragged pose exactly (slerp).
|
|
1701
|
+
function ikDragEnd() {
|
|
1702
|
+
appContext.shared.ikBodyDragRef.current = false;
|
|
1703
|
+
const drag = ikDragRef.current;
|
|
1704
|
+
ikDragRef.current = null;
|
|
1705
|
+
// One entry per drag: the pointermoves only moved bones, the keys map is
|
|
1706
|
+
// untouched until this bake — the key it sets records the pre-drag keys.
|
|
1707
|
+
if (appContext.storeDomain('motion') || ikChains) keyIkPoseAtPlayhead(
|
|
1708
|
+
motion ? (drag ? drag.ids : null) : null,
|
|
1709
|
+
);
|
|
1710
|
+
setIkTick((n) => n + 1);
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
/** Bake the current tracked rotations at the playhead into a scratch layer
|
|
1714
|
+
* and set them as a key through the shared registry. A bake only writes
|
|
1715
|
+
* TRACKED parts: with nothing dragged yet there is no key, nothing is
|
|
1716
|
+
* dispatched and Ctrl+Z never goes dead. */
|
|
1717
|
+
function keyIkPoseAtPlayhead(dragIds = null) {
|
|
1718
|
+
if (appContext.storeDomain('motion')) return appContext.storeDomain('motion').bakeCurrentKey(appContext.shared.activeChar.id, appContext.shared.tlFrame, dragIds);
|
|
1719
|
+
const scratch = { ...createIkState(), tracked: new Set(appContext.shared.ikStateRef.current.tracked) };
|
|
1720
|
+
ikBakeKeyframe(ikChains, scratch, appContext.shared.tlFrame, ikFkJoints);
|
|
1721
|
+
const baked = scratch.keys.get(appContext.shared.tlFrame);
|
|
1722
|
+
return baked ? appContext.shared.runStudioAction("character.setIkKey", { characterId: appContext.shared.activeChar.id, frame: appContext.shared.tlFrame, tracks: ikKeyJson(baked) }) : null;
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
// Manual key: bake the current tracked rotations at the playhead.
|
|
1726
|
+
function ikAddKeyframe() {
|
|
1727
|
+
if (!ikChains) return;
|
|
1728
|
+
if (!keyIkPoseAtPlayhead()) return;
|
|
1729
|
+
appContext.notify(isKo ? `${appContext.shared.tlFrame}프레임에 전신 IK 키를 추가했어요` : `Full-body IK key at frame ${appContext.shared.tlFrame}`);
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
// Self-collision cleanup: push interpenetrating body parts apart with the
|
|
1733
|
+
// IK solver, then bake the fix as an ordinary IK correction key so it
|
|
1734
|
+
// survives scrubs, undo, and blends back into the clip outside its range.
|
|
1735
|
+
//
|
|
1736
|
+
// The result has THREE outcomes, not two, and each gets its own sentence.
|
|
1737
|
+
// "supported: false" means the rig has no capsule proxies to build at all
|
|
1738
|
+
// — a non-Mixamo skeleton — and reporting that as "no collisions" would
|
|
1739
|
+
// be a lie the user cannot act on: they would keep clicking a button that
|
|
1740
|
+
// silently does nothing. The buttons below are disabled in that case, so
|
|
1741
|
+
// this branch is the belt to that suspenders (a rig can be swapped under
|
|
1742
|
+
// a stale render).
|
|
1743
|
+
/**
|
|
1744
|
+
* Everything OUTSIDE the active character that a limb has to stay out of:
|
|
1745
|
+
* the other cast members' bodies (capsules, built from the rigs AS THEY ARE
|
|
1746
|
+
* POSED at the moment of the call) and every scene object (an upright box
|
|
1747
|
+
* on its footprint). Ids are namespaced `char:<id>:<capsule>` / `obj:<id>`,
|
|
1748
|
+
* so a penetration label names the thing that was hit.
|
|
1749
|
+
*
|
|
1750
|
+
* `frame` re-samples travel paths — a prop walking a route stands somewhere
|
|
1751
|
+
* else on every frame — but the CAST is read live from the scene graph, so
|
|
1752
|
+
* a caller walking a clip must pose the others at that frame first (see
|
|
1753
|
+
* runFixCollisionsRange's blockersAt).
|
|
1754
|
+
*/
|
|
1755
|
+
const externalBlockers = (frame = appContext.shared.tlFrame) => collisionBlockers({
|
|
1756
|
+
rigs: appContext.shared.rigs,
|
|
1757
|
+
activeId: appContext.shared.activeChar.id,
|
|
1758
|
+
// The CAST is the authority on who is on stage, not the rig map: undo can
|
|
1759
|
+
// put the cast list back to one subject while the rig mounted for the
|
|
1760
|
+
// removed one is still in `rigs`, and a ghost body would go on blocking
|
|
1761
|
+
// limbs that pass through empty space.
|
|
1762
|
+
characterIds: appContext.shared.characters,
|
|
1763
|
+
sceneObjects: appContext.shared.sceneObjects,
|
|
1764
|
+
library: OBJECT_LIBRARY,
|
|
1765
|
+
frame,
|
|
1766
|
+
take: { frameCount: appContext.shared.tlFrameCount, fps: appContext.shared.tlFps },
|
|
1767
|
+
});
|
|
1768
|
+
|
|
1769
|
+
function runFixCollisions() {
|
|
1770
|
+
if (appContext.storeDomain('motion')) return appContext.storeDomain('motion').run('motion.fixCollisions', { characterId: appContext.shared.activeChar.id, scope: 'frame' });
|
|
1771
|
+
if (!ikChains || !appContext.shared.activeRig) return;
|
|
1772
|
+
// A rig swap leaves one render where ikChains still describes the old
|
|
1773
|
+
// skeleton; solving the new rig with them would push the wrong bones.
|
|
1774
|
+
if (appContext.shared.ikStateRef.current.rig !== appContext.shared.activeRig) return;
|
|
1775
|
+
// The set as it stands right now: the other bodies at this frame's pose
|
|
1776
|
+
// and the props at this frame's placement.
|
|
1777
|
+
const result = fixCollisions(appContext.shared.activeRig, ikChains, { ikState: appContext.shared.ikStateRef.current, fkJoints: ikFkJoints, blockers: externalBlockers(appContext.shared.tlFrame) });
|
|
1778
|
+
if (!result.supported) {
|
|
1779
|
+
appContext.notify(ko("This rig doesn't support collision cleanup", "이 리그는 신체 관통 정리를 지원하지 않아요"));
|
|
1780
|
+
return;
|
|
1781
|
+
}
|
|
1782
|
+
if (!result.changed) {
|
|
1783
|
+
appContext.notify(ko("No body collisions at this frame", "이 프레임에는 신체 관통이 없어요"));
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
editIkKeys(() => ikBakeKeyframe(ikChains, appContext.shared.ikStateRef.current, appContext.shared.tlFrame, ikFkJoints, result.touched, null, result.baseQuats));
|
|
1787
|
+
setIkTick((n) => n + 1);
|
|
1788
|
+
appContext.notify(result.residual > 1e-4
|
|
1789
|
+
? ko(`Collisions reduced (residual ${(result.residual * 100).toFixed(1)} cm)`, `관통을 줄였어요 (잔여 ${(result.residual * 100).toFixed(1)} cm)`)
|
|
1790
|
+
: ko(`Collisions fixed at frame ${appContext.shared.tlFrame}`, `프레임 ${appContext.shared.tlFrame}의 관통을 정리했어요`));
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
// Whole-clip variant: walk the motion frame by frame, clean each pose and
|
|
1794
|
+
// key ONLY the frames that changed, so a clean clip stays keyless.
|
|
1795
|
+
function runFixCollisionsRange() {
|
|
1796
|
+
if (appContext.storeDomain('motion')) return appContext.storeDomain('motion').run('motion.fixCollisions', { characterId: appContext.shared.activeChar.id, scope: 'clip' });
|
|
1797
|
+
if (!ikChains || !appContext.shared.activeRig || !motion) return;
|
|
1798
|
+
if (appContext.shared.ikStateRef.current.rig !== appContext.shared.activeRig) return;
|
|
1799
|
+
// Screened before the undo entry: an unsupported rig would record an
|
|
1800
|
+
// undo step for a walk that keys nothing, leaving a no-op in history.
|
|
1801
|
+
if (!appContext.shared.collisionCleanupSupported) {
|
|
1802
|
+
appContext.notify(ko("This rig doesn't support collision cleanup", "이 리그는 신체 관통 정리를 지원하지 않아요"));
|
|
1803
|
+
return;
|
|
1804
|
+
}
|
|
1805
|
+
const currentFrame = appContext.shared.tlFrame;
|
|
1806
|
+
const applyFrame = (frame) => {
|
|
1807
|
+
applyMotionFrame(appContext.shared.activeRig, motion, frame);
|
|
1808
|
+
ikEvaluate(ikChains, appContext.shared.ikStateRef.current, frame, ikFkJoints, IK_CORRECTION_BLEND_FRAMES);
|
|
1809
|
+
};
|
|
1810
|
+
// The other bodies move too. blockersAt runs AFTER applyFrame(frame), so
|
|
1811
|
+
// it poses the rest of the cast at that same frame — their own clips and
|
|
1812
|
+
// their own stored IK layers, the very pass the viewport renders with —
|
|
1813
|
+
// and only then samples their capsules. Without this the blockers would
|
|
1814
|
+
// describe everyone frozen at the playhead, which is a wrong obstacle on
|
|
1815
|
+
// every frame but one.
|
|
1816
|
+
const blockersAt = (frame) => {
|
|
1817
|
+
poseOtherCastMembers(frame);
|
|
1818
|
+
return externalBlockers(frame);
|
|
1819
|
+
};
|
|
1820
|
+
let keyed = [];
|
|
1821
|
+
let unresolved = [];
|
|
1822
|
+
try {
|
|
1823
|
+
const walked = editIkKeys(() => fixCollisionsRange({
|
|
1824
|
+
rig: appContext.shared.activeRig,
|
|
1825
|
+
chains: ikChains,
|
|
1826
|
+
ikState: appContext.shared.ikStateRef.current,
|
|
1827
|
+
fkJoints: ikFkJoints,
|
|
1828
|
+
startFrame: 0,
|
|
1829
|
+
endFrame: motion.frames - 1,
|
|
1830
|
+
applyFrame,
|
|
1831
|
+
blockersAt,
|
|
1832
|
+
}));
|
|
1833
|
+
// The frames the walk keyed. `unresolved` — the frames whose residual
|
|
1834
|
+
// survived every pass — is ADDITIVE: read it defensively off either
|
|
1835
|
+
// shape so this keeps working before and after the driver grows it.
|
|
1836
|
+
keyed = Array.isArray(walked) ? walked : walked?.keyed ?? [];
|
|
1837
|
+
unresolved = (Array.isArray(walked) ? walked.unresolved : walked?.unresolved) ?? [];
|
|
1838
|
+
} finally {
|
|
1839
|
+
// The restore is the pass's CLEANUP, not its epilogue: a throw mid-walk
|
|
1840
|
+
// would otherwise leave the active rig and the rest of the cast frozen
|
|
1841
|
+
// at whatever frame it died on, which is a wrong-looking set the user
|
|
1842
|
+
// cannot scrub out of without touching the playhead.
|
|
1843
|
+
applyFrame(currentFrame);
|
|
1844
|
+
poseOtherCastMembers(currentFrame);
|
|
1845
|
+
setIkTick((n) => n + 1);
|
|
1846
|
+
}
|
|
1847
|
+
// Residual is worth saying out loud: a limb pinned between two blockers
|
|
1848
|
+
// (another body and a prop, say) can come out of the walk still touching,
|
|
1849
|
+
// and silence would read as "all clean".
|
|
1850
|
+
const stillPenetrating = unresolved.length
|
|
1851
|
+
? ko(` · ${unresolved.length} frame(s) still penetrate`, ` · ${unresolved.length}개 프레임은 남아 있어요`)
|
|
1852
|
+
: "";
|
|
1853
|
+
// "No body collisions" must never share a sentence with "still
|
|
1854
|
+
// penetrate": a converged pass over an unfixable clip has nothing more
|
|
1855
|
+
// to do, which is a different statement from the clip being clean.
|
|
1856
|
+
appContext.notify((keyed.length
|
|
1857
|
+
? ko(`Fixed collisions on ${keyed.length} frame(s)`, `${keyed.length}개 프레임의 관통을 정리했어요`)
|
|
1858
|
+
: unresolved.length
|
|
1859
|
+
? ko("Nothing more to fix", "더 고칠 수 있는 게 없어요")
|
|
1860
|
+
: ko("No body collisions in the clip", "클립에 신체 관통이 없어요")) + stillPenetrating);
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
function changePhysicsOptions(next) {
|
|
1864
|
+
setPhysicsOptions(next); setPhysicsPreview(null); setIkTick((n) => n + 1);
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
function showPhysicsPreview(show) { setPhysicsShow(show); setIkTick((n) => n + 1); }
|
|
1868
|
+
|
|
1869
|
+
function cancelPhysicsPreview() { setPhysicsPreview(null); setIkTick((n) => n + 1); }
|
|
1870
|
+
|
|
1871
|
+
function applyPhysicsPreview() {
|
|
1872
|
+
if (appContext.storeDomain('motion')) return appContext.storeDomain('motion').run('motion.applyPhysics', { characterId: appContext.shared.activeChar.id });
|
|
1873
|
+
if (!physicsPreview || physicsPreview.sourceStamp !== physicsKeyStamp(appContext.shared.ikStateRef.current.keys)) return;
|
|
1874
|
+
editIkKeys(() => { appContext.shared.ikStateRef.current.keys = copyPhysicsKeys(physicsPreview.candidate.keys); });
|
|
1875
|
+
appContext.shared.ikStateRef.current.tracked = new Set(physicsPreview.candidate.tracked);
|
|
1876
|
+
appContext.shared.autoPhysicsRunRef.current = { motion, rig: appContext.shared.activeRig, stamp: physicsKeyStamp(appContext.shared.ikStateRef.current.keys) };
|
|
1877
|
+
setPhysicsPreview(null); setIkTick((n) => n + 1);
|
|
1878
|
+
appContext.notify(ko("AutoPhysics applied · Undo restores the original", "오토피직스를 적용했어요 · 실행 취소로 원본 복구"));
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
async function runAutoPhysics() {
|
|
1882
|
+
if (appContext.storeDomain('motion')) return appContext.storeDomain('motion').run('motion.autoPhysics', { characterId: appContext.shared.activeChar.id, ...physicsOptions, apply: false });
|
|
1883
|
+
if (autoPhysicsRunning || !ikChains || !appContext.shared.activeRig || !motion || appContext.shared.ikStateRef.current.rig !== appContext.shared.activeRig) return null;
|
|
1884
|
+
const previous = appContext.shared.autoPhysicsRunRef.current;
|
|
1885
|
+
if (previous?.motion === motion && previous.rig === appContext.shared.activeRig && previous.stamp === physicsKeyStamp(appContext.shared.ikStateRef.current.keys)) {
|
|
1886
|
+
appContext.notify(ko("Already applied. Undo to review this correction again.", "이미 적용했어요. 실행 취소 후 다시 비교할 수 있어요.")); return null;
|
|
1887
|
+
}
|
|
1888
|
+
const job = ++appContext.shared.physicsJobRef.current, frame = appContext.shared.tlFrame;
|
|
1889
|
+
const sourceKeys = copyPhysicsKeys(appContext.shared.ikStateRef.current.keys), stamp = physicsKeyStamp(sourceKeys);
|
|
1890
|
+
let lastYieldAt = Date.now(), yieldWaitMs = 0, yieldCount = 0;
|
|
1891
|
+
const restore = () => { appContext.shared.poseMemberAtFrame(appContext.shared.activeRig, motion, appContext.shared.ikStateRef.current, frame, IK_CORRECTION_BLEND_FRAMES); };
|
|
1892
|
+
appContext.shared.setTlPlaying(false); setAutoPhysicsRunning(true); setPhysicsProgress(0); setPhysicsPreview(null);
|
|
1893
|
+
try {
|
|
1894
|
+
const result = await reviewAutoPhysics({ rig: appContext.shared.activeRig, motion, chains: ikChains, fkJoints: ikFkJoints, sourceKeys,
|
|
1895
|
+
applyRaw: (f) => appContext.shared.poseMemberAtFrame(appContext.shared.activeRig, motion, null, f), sceneObjects: appContext.shared.sceneObjects, ...physicsOptions,
|
|
1896
|
+
cache: appContext.shared.physicsSourceCacheRef.current,
|
|
1897
|
+
onProgress: setPhysicsProgress,
|
|
1898
|
+
yieldFrame: async () => {
|
|
1899
|
+
if (appContext.shared.physicsJobRef.current !== job) throw new Error("Analysis cancelled after changing the character or motion");
|
|
1900
|
+
// A batch is a cancellation checkpoint, not necessarily a paint/
|
|
1901
|
+
// event-loop boundary. Yield on a time budget, not every 12 frames.
|
|
1902
|
+
if (Date.now() - lastYieldAt < 16) return;
|
|
1903
|
+
restore();
|
|
1904
|
+
const queuedAt = Date.now();
|
|
1905
|
+
// Yield CPU work without waiting for a paint. requestAnimationFrame
|
|
1906
|
+
// can be throttled/paused in an occluded tab, stretching a seconds-
|
|
1907
|
+
// long solve into minutes. MessageChannel also lets input run.
|
|
1908
|
+
await new Promise((resolve) => {
|
|
1909
|
+
const channel = new MessageChannel();
|
|
1910
|
+
channel.port1.onmessage = () => { channel.port1.close(); channel.port2.close(); resolve(); };
|
|
1911
|
+
channel.port2.postMessage(0);
|
|
1912
|
+
});
|
|
1913
|
+
lastYieldAt = Date.now(); yieldWaitMs += lastYieldAt - queuedAt; yieldCount += 1;
|
|
1914
|
+
if (appContext.shared.physicsJobRef.current !== job) throw new Error("Analysis cancelled after changing the character or motion");
|
|
1915
|
+
},
|
|
1916
|
+
});
|
|
1917
|
+
Object.assign(result.performance, { yieldWaitMs, yieldCount });
|
|
1918
|
+
if (appContext.shared.physicsJobRef.current !== job || physicsKeyStamp(appContext.shared.ikStateRef.current.keys) !== stamp) return null;
|
|
1919
|
+
setPhysicsPreview(result); setPhysicsShow(true);
|
|
1920
|
+
return { before: result.before, after: result.after, warnings: result.warnings, unresolved: result.unresolved, contacts: result.contacts.spans };
|
|
1921
|
+
} catch (error) {
|
|
1922
|
+
if (appContext.shared.physicsJobRef.current === job) appContext.notify(ko(`AutoPhysics: ${error.message}`, `오토피직스: ${error.message}`));
|
|
1923
|
+
return null;
|
|
1924
|
+
} finally {
|
|
1925
|
+
if (appContext.shared.physicsJobRef.current === job) { restore(); setAutoPhysicsRunning(false); setIkTick((n) => n + 1); }
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
async function platformFitRun(id, context, { removal = false, preview = false } = {}) {
|
|
1930
|
+
const rig = appContext.shared.rigs[id], resolved = rig && resolveIkRig(rig), take = domain.motionFor(id);
|
|
1931
|
+
if (!resolved || !take) throw new StudioProtocolError('TARGET_NOT_READY', 'Load a take and character rig first.');
|
|
1932
|
+
if (platformFitRunning) throw new StudioProtocolError('TARGET_BUSY', 'A platform fit is already running.');
|
|
1933
|
+
const job = ++platformFitJobRef.current;
|
|
1934
|
+
const sourceKeys = copyPhysicsKeys(decodeMotionKeys(domain.layer(id).ikKeys));
|
|
1935
|
+
const sourceTracked = new Set([...sourceKeys.values()].flatMap(entry => [...entry.keys()]));
|
|
1936
|
+
const stamp = physicsKeyStamp(sourceKeys);
|
|
1937
|
+
const prior = platformFitAppliedRef.current;
|
|
1938
|
+
const stacked = prior && prior.id === id && prior.stamp === stamp ? prior : null;
|
|
1939
|
+
const frame = appContext.shared.tlFrame;
|
|
1940
|
+
const restore = () => appContext.shared.poseMemberAtFrame(rig, take, appContext.shared.ikStatesRef.current.get(id), frame, 6);
|
|
1941
|
+
let lastYield = performance.now();
|
|
1942
|
+
const check = () => { context?.check?.(); if (platformFitJobRef.current !== job) throw new StudioProtocolError('STALE_TARGET', 'Platform fit was superseded.'); };
|
|
1943
|
+
setPlatformFitRunning(true); platformFitProgressSet(0); setPlatformFitLast(null);
|
|
1944
|
+
try {
|
|
1945
|
+
const result = await fitPlatforms({
|
|
1946
|
+
rig: rig, motion: take, chains: resolved.chains, fkJoints: resolved.fkJoints, sourceKeys,
|
|
1947
|
+
applyRaw: at => appContext.shared.poseMemberAtFrame(rig, take, null, at),
|
|
1948
|
+
sceneObjects: removal ? [] : (appContext.ports.read?.().objects ?? appContext.shared.sceneObjects),
|
|
1949
|
+
onProgress: platformFitProgressSet,
|
|
1950
|
+
yieldFrame: async () => {
|
|
1951
|
+
check(); if (performance.now() - lastYield < 16) return;
|
|
1952
|
+
restore();
|
|
1953
|
+
await new Promise(resolve => { const channel = new MessageChannel(); channel.port1.onmessage = () => { channel.port1.close(); channel.port2.close(); resolve(); }; channel.port2.postMessage(0); });
|
|
1954
|
+
lastYield = performance.now(); check();
|
|
1955
|
+
},
|
|
1956
|
+
});
|
|
1957
|
+
check();
|
|
1958
|
+
const last = { summary: result.summary, steps: result.steps, changedFrames: result.changedFrames };
|
|
1959
|
+
if (removal) {
|
|
1960
|
+
setPlatformFitLast(last);
|
|
1961
|
+
if (result.changedFrames.length) context.commit(() => domain.setKeys(id, result.candidate.keys));
|
|
1962
|
+
platformFitAppliedRef.current = null; setPlatformFitApplied(false); setPlatformFitLast(null);
|
|
1963
|
+
return { ...last, removed: result.changedFrames.length > 0 };
|
|
1964
|
+
}
|
|
1965
|
+
if (!preview) {
|
|
1966
|
+
if (result.changedFrames.length) context.commit(() => domain.setKeys(id, result.candidate.keys));
|
|
1967
|
+
platformFitAppliedRef.current = { id, before: stacked?.before ?? sourceKeys, tracked: stacked?.tracked ?? sourceTracked, stamp: physicsKeyStamp(decodeMotionKeys(domain.layer(id).ikKeys)) };
|
|
1968
|
+
setPlatformFitLast(last); setPlatformFitApplied(true);
|
|
1969
|
+
return last;
|
|
1970
|
+
}
|
|
1971
|
+
platformFitAppliedRef.current = { preview: { id, sourceStamp: stamp, sourceKeys, sourceTracked, candidate: result.candidate, last }, before: stacked?.before ?? sourceKeys, tracked: stacked?.tracked ?? sourceTracked, stamp };
|
|
1972
|
+
setPlatformFitLast({ ...last, candidate: result.candidate });
|
|
1973
|
+
setPlatformFitApplied(false);
|
|
1974
|
+
return last;
|
|
1975
|
+
} finally {
|
|
1976
|
+
if (platformFitJobRef.current === job) { restore(); setPlatformFitRunning(false); }
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
function platformFitApply(id, context) {
|
|
1981
|
+
const preview = platformFitAppliedRef.current?.preview;
|
|
1982
|
+
if (!preview || preview.id !== id || platformFitRunning) return { applied: false };
|
|
1983
|
+
const current = physicsKeyStamp(decodeMotionKeys(domain.layer(id).ikKeys));
|
|
1984
|
+
if (current !== preview.sourceStamp) throw new StudioProtocolError('STALE_TARGET', 'The platform preview is out of date. Analyse this take again before applying.');
|
|
1985
|
+
context.commit(() => domain.setKeys(id, copyPhysicsKeys(preview.candidate.keys)));
|
|
1986
|
+
platformFitAppliedRef.current = { id, before: preview.sourceKeys, tracked: preview.sourceTracked, stamp: physicsKeyStamp(decodeMotionKeys(domain.layer(id).ikKeys)) };
|
|
1987
|
+
setPlatformFitApplied(true);
|
|
1988
|
+
setPlatformFitLast({ ...preview.last, candidate: undefined });
|
|
1989
|
+
return { applied: true, changedFrames: preview.last.changedFrames };
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
function platformFitRemove(id, context, { apply = false } = {}) {
|
|
1993
|
+
if (apply) return platformFitApply(id, context);
|
|
1994
|
+
const applied = platformFitAppliedRef.current;
|
|
1995
|
+
if (applied?.preview?.id === id) {
|
|
1996
|
+
platformFitAppliedRef.current = null; setPlatformFitLast(null); setPlatformFitApplied(false);
|
|
1997
|
+
return { removed: false, cancelled: true };
|
|
1998
|
+
}
|
|
1999
|
+
if (!applied || applied.id !== id || platformFitRunning) return { removed: false };
|
|
2000
|
+
const current = physicsKeyStamp(decodeMotionKeys(domain.layer(id).ikKeys));
|
|
2001
|
+
if (current === applied.stamp) {
|
|
2002
|
+
context.commit(() => domain.setKeys(id, copyPhysicsKeys(applied.before)));
|
|
2003
|
+
platformFitAppliedRef.current = null; setPlatformFitApplied(false); setPlatformFitLast(null);
|
|
2004
|
+
return { removed: true };
|
|
2005
|
+
}
|
|
2006
|
+
return platformFitRun(id, context, { removal: true });
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
function ikDeleteKeyframe(frame) {
|
|
2010
|
+
if (!appContext.shared.ikStateRef.current.keys.has(frame)) return;
|
|
2011
|
+
appContext.shared.runStudioAction("character.removeIkKey", { characterId: appContext.shared.activeChar.id, frame });
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
/** With IK mode on over a loaded take, a pose pick is a CORRECTION, not a
|
|
2015
|
+
* replacement: write the saved pose onto the rig and bake every IK part
|
|
2016
|
+
* into a full-body key at the current frame. The take survives, and the
|
|
2017
|
+
* key blends back into the clip outside its window exactly like a dragged
|
|
2018
|
+
* key would. Returns false when there is nothing to key against so the
|
|
2019
|
+
* caller can fall through to the plain pose-apply path. */
|
|
2020
|
+
function ikApplyPoseAsKey(pose) {
|
|
2021
|
+
if (!appContext.shared.activeRig || !motion) return false;
|
|
2022
|
+
if (appContext.storeDomain('motion')) { appContext.storeDomain('motion').run('ik.applyPose', { characterId: appContext.shared.activeChar.id, frame: appContext.shared.tlFrame, pose }); return true; }
|
|
2023
|
+
if (!ikChains) return false;
|
|
2024
|
+
// The clip's positional skinning left per-bone translations the FK pose
|
|
2025
|
+
// math never produced. The bake below stores every FK joint's position
|
|
2026
|
+
// (p) as-is, so posing rotations over those clip translations would key
|
|
2027
|
+
// a torn-apart body — bind translations first, ALWAYS.
|
|
2028
|
+
restoreBindPositions(appContext.shared.activeRig);
|
|
2029
|
+
// Reset-then-pose, the same shape the Character effect applies: unlisted
|
|
2030
|
+
// joints return to rest instead of keeping stale limbs from the clip.
|
|
2031
|
+
applyPose(appContext.shared.activeRig, { ...REST_BONES, ...pose.bones });
|
|
2032
|
+
// The hips' measured height rides into the bake: the hips FK joint keys
|
|
2033
|
+
// its local position (p), so a crouched pose keys a crouched body.
|
|
2034
|
+
applyHipsOffset(appContext.shared.activeRig, pose.rootY ?? 0);
|
|
2035
|
+
// The pose authors the whole body, so every part is tracked — an
|
|
2036
|
+
// untracked chain would silently keep the clip's limb.
|
|
2037
|
+
for (const id of ikChains.keys()) ikTouch(appContext.shared.ikStateRef.current, id);
|
|
2038
|
+
if (ikFkJoints) for (const id of ikFkJoints.keys()) ikTouch(appContext.shared.ikStateRef.current, id);
|
|
2039
|
+
editIkKeys(() => ikBakeKeyframe(ikChains, appContext.shared.ikStateRef.current, appContext.shared.tlFrame, ikFkJoints));
|
|
2040
|
+
// Handles re-seat on the posed effectors, ready to drag into a refinement.
|
|
2041
|
+
ikSeedTargets(ikChains, appContext.shared.ikStateRef.current);
|
|
2042
|
+
setIkTick((n) => n + 1);
|
|
2043
|
+
appContext.notify(isKo
|
|
2044
|
+
? `${appContext.shared.tlFrame}프레임에 포즈를 전신 IK 보정 키로 추가했어요 — 모션은 그대로예요`
|
|
2045
|
+
: `Pose keyed as a full-body IK correction at frame ${appContext.shared.tlFrame} — the take stays`);
|
|
2046
|
+
return true;
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
function downloadArdyPose() {
|
|
2050
|
+
const rig = appContext.shared.posedRig();
|
|
2051
|
+
if (!rig) {
|
|
2052
|
+
appContext.notify(ko("Character not loaded yet", "캐릭터가 아직 로드되지 않았어요"));
|
|
2053
|
+
return;
|
|
2054
|
+
}
|
|
2055
|
+
trackFeature("export_pose");
|
|
2056
|
+
const pose = buildArdyPose({
|
|
2057
|
+
rig,
|
|
2058
|
+
camRef: appContext.shared.shotCamRef,
|
|
2059
|
+
look: appContext.shared.look,
|
|
2060
|
+
fovDeg: appContext.shared.fovDeg,
|
|
2061
|
+
slate: slateLine(appContext.shared.shot),
|
|
2062
|
+
// rigName follows the posed character's actual model below
|
|
2063
|
+
rigName: appContext.shared.posingChar?.model ?? appContext.shared.charA.model,
|
|
2064
|
+
root: captureArdyRoot(rig),
|
|
2065
|
+
});
|
|
2066
|
+
const blob = new Blob([JSON.stringify(pose, null, 2)], { type: "application/json" });
|
|
2067
|
+
const url = URL.createObjectURL(blob);
|
|
2068
|
+
const a = document.createElement("a");
|
|
2069
|
+
a.href = url;
|
|
2070
|
+
a.download = "cozyclay-pose.json";
|
|
2071
|
+
document.body.appendChild(a);
|
|
2072
|
+
a.click();
|
|
2073
|
+
a.remove();
|
|
2074
|
+
URL.revokeObjectURL(url);
|
|
2075
|
+
appContext.notify(ko("Pose exported", "포즈 내보내기 완료"));
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
function recheckMotionHealth() {
|
|
2079
|
+
setBridgeChecking(true);
|
|
2080
|
+
return appContext.shared.bridgeRefreshRef.current().finally(() => setBridgeChecking(false));
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
function requestMotionGeneration(surface, inputMode, body = {}) {
|
|
2084
|
+
const request = startMotionRequest({ surface, input_mode: inputMode });
|
|
2085
|
+
const options = { body, lineEditSupported: lineEditBackend };
|
|
2086
|
+
// Record known readiness refusals even if existing input validation returns
|
|
2087
|
+
// early. A pass waits for the fully packaged payload at the queue boundary.
|
|
2088
|
+
// The queue independently checks readiness; telemetry failure cannot
|
|
2089
|
+
// enable or disable generation.
|
|
2090
|
+
if (motionPreflightReason(bridge, options)) {
|
|
2091
|
+
request.preflight(bridge, options);
|
|
2092
|
+
const readiness = motionReadiness(bridge, options);
|
|
2093
|
+
appContext.notify(motionReadinessMessage(readiness), ({ loading: "Checking motion generation…", ready: "Ready for this motion request", not_configured: "No motion backend configured", unsupported_route: "This route cannot run the selected motion request" })[readiness] ?? "The motion backend is unavailable");
|
|
2094
|
+
}
|
|
2095
|
+
return request;
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
// Optional native-ARDY seed. Empty = request omits seed; the raw string
|
|
2099
|
+
// is kept as typed (trimmed only). runArdy validates the bridge contract
|
|
2100
|
+
// (integer in 0..2**31-1) right before the request and toasts on a
|
|
2101
|
+
// violation, so an invalid seed can never reach the bridge silently.
|
|
2102
|
+
function changeArdySeed(value) {
|
|
2103
|
+
setArdySeed(value.trim());
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
/** THE SEED RULE (contract C9), enforced in ONE place so no take-creating
|
|
2107
|
+
* call site can forget it: an empty field is rolled here, a typed one is
|
|
2108
|
+
* kept exactly as typed, and either way a concrete integer comes back to be
|
|
2109
|
+
* both SENT and RECORDED. A take whose seed was never written down cannot
|
|
2110
|
+
* be rebuilt from its recipe, which is the one promise the whole recipe
|
|
2111
|
+
* model rests on. Returns null after toasting when the typed value violates
|
|
2112
|
+
* the bridge contract — the caller must then abandon the request. */
|
|
2113
|
+
function takeSeed() {
|
|
2114
|
+
try {
|
|
2115
|
+
return resolveSeed(ardySeed, ARDY_SEED_MAX);
|
|
2116
|
+
} catch {
|
|
2117
|
+
appContext.notify((isKo, ko) => isKo ? `Seed는 0..${ARDY_SEED_MAX} 범위의 정수여야 해요. 비워 두면 자동으로 선택됩니다` : `Seed must be an integer in 0..${ARDY_SEED_MAX} — clear it to let the box pick one`);
|
|
2118
|
+
return null;
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
/** CONFIRM the pull: the full-quality run of exactly what the preview has
|
|
2123
|
+
* been showing. Its own run mode — the body carries lineEdit and NOTHING
|
|
2124
|
+
* else authored, because C6 makes it exclusive with preserve, waypoints,
|
|
2125
|
+
* segments, regenerateSegments and motionEdit — and it is the one path that
|
|
2126
|
+
* commits a recipe and a version. Same builder, same SESSION SEED and same
|
|
2127
|
+
* curve as the last preview; only `preview: true` is absent, which is what
|
|
2128
|
+
* buys the full step count. */
|
|
2129
|
+
function runLineEdit() {
|
|
2130
|
+
if (appContext.shared.generationPendingRef.current || appContext.shared.genRunningRef.current || ardyRunning) return;
|
|
2131
|
+
const generationRequest = requestMotionGeneration("line_edit", "edit", { lineEdit: true });
|
|
2132
|
+
if (!appContext.shared.takeSourceUrl) {
|
|
2133
|
+
appContext.notify(ko("The current take has no bridge source — generate it once before editing a path", "현재 테이크에 브리지 원본이 없어요 — 궤적을 편집하기 전에 한 번 생성하세요"));
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
// No curve object means no edit — an untouched path is the take's own
|
|
2137
|
+
// trajectory, and sending it would ask the box to spend eight seconds
|
|
2138
|
+
// reproducing what is already there.
|
|
2139
|
+
if (!appContext.shared.lineEditPayload) {
|
|
2140
|
+
appContext.notify(ko(
|
|
2141
|
+
"Draw along the path, pull a dot, or pin a moment first",
|
|
2142
|
+
"먼저 궤적을 따라 그리거나, 점을 잡아당기거나, 순간을 찍어 주세요",
|
|
2143
|
+
));
|
|
2144
|
+
return;
|
|
2145
|
+
}
|
|
2146
|
+
if (!lineEditBackend) {
|
|
2147
|
+
appContext.notify(ko(
|
|
2148
|
+
"The line-editing backend is not connected yet",
|
|
2149
|
+
"라인 편집 백엔드가 아직 연결 전이에요",
|
|
2150
|
+
));
|
|
2151
|
+
return;
|
|
2152
|
+
}
|
|
2153
|
+
const request = appContext.shared.buildLineEditRequest(appContext.shared.lineEditPayload);
|
|
2154
|
+
if (!request.ok) {
|
|
2155
|
+
if (request.message) appContext.notify(request.message);
|
|
2156
|
+
return;
|
|
2157
|
+
}
|
|
2158
|
+
const { body, lineEdit, seed } = request;
|
|
2159
|
+
// The take being edited, not the draft that may be on screen: a preview
|
|
2160
|
+
// is a picture and must never become anyone's lineage.
|
|
2161
|
+
const source = appContext.shared.linePreviewSource;
|
|
2162
|
+
const queued = enqueueMotionJob({
|
|
2163
|
+
request: generationRequest,
|
|
2164
|
+
charId: source?.charId ?? appContext.shared.activeChar.id,
|
|
2165
|
+
charIndex: appContext.shared.activeCharIndex,
|
|
2166
|
+
prompt: body.prompt,
|
|
2167
|
+
body,
|
|
2168
|
+
hasBlockEdits: false,
|
|
2169
|
+
committedEditKeys: [],
|
|
2170
|
+
rootRotationDeg: source?.rotationDeg ?? motion.rotationDeg ?? appContext.shared.activeChar.rot,
|
|
2171
|
+
anchor: { x: motion.anchorX ?? appContext.shared.activeChar.x, z: motion.anchorZ ?? appContext.shared.activeChar.z },
|
|
2172
|
+
ikState: null,
|
|
2173
|
+
recipeIntent: "lineEdit",
|
|
2174
|
+
recipeSeed: seed,
|
|
2175
|
+
// sourceMotion is dropped on the way into the recipe: replay rebinds
|
|
2176
|
+
// it to whatever take it is re-applied to.
|
|
2177
|
+
// (stripSourceMotion keeps only C10's replay keys, so `preview` — when
|
|
2178
|
+
// the object came back from a draft build — cannot leak into a recipe.)
|
|
2179
|
+
recipeLineEdit: stripSourceMotion({ ...lineEdit, sourceMotion: undefined, seed }),
|
|
2180
|
+
recipeLabel: isKo ? `다듬기 · ${lineTrackLabel(appContext.shared.lineTrack)}` : `Refine · ${lineTrackLabel(appContext.shared.lineTrack)}`,
|
|
2181
|
+
});
|
|
2182
|
+
if (!queued) return;
|
|
2183
|
+
appContext.shared.generationPendingRef.current = true;
|
|
2184
|
+
// The pull has left the building. The curve stays (it is the reference
|
|
2185
|
+
// the next edit starts from) but its deformation is released, so the
|
|
2186
|
+
// Generate button goes back to needing a fresh pull instead of inviting
|
|
2187
|
+
// a second identical run while the first is still queued. The undo
|
|
2188
|
+
// stack goes with it: restoring a pull that is already generating would
|
|
2189
|
+
// invite the identical run this reset exists to prevent.
|
|
2190
|
+
//
|
|
2191
|
+
// This also ENDS THE PREVIEW SESSION: the draft comes off the viewport
|
|
2192
|
+
// (the real result will land on it in a couple of seconds, and until it
|
|
2193
|
+
// does the take on screen should be the take that exists) and the seed
|
|
2194
|
+
// is released, so the next pull is a new piece of work with a new roll.
|
|
2195
|
+
appContext.shared.clearLineEdit();
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
function runAllPromptBlocks(commandContext = null) {
|
|
2199
|
+
const characterId = appContext.storeDomain('cast').activeId;
|
|
2200
|
+
return commandContext ? generateMotion({ characterId }, commandContext)
|
|
2201
|
+
: appContext.bus.run('motion.generate', { characterId });
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
// The UI's uncommitted prompt/fresh choice is read synchronously at admission,
|
|
2205
|
+
// just like its seed and pose controls. It never survives into another run.
|
|
2206
|
+
let uiGenerationIntent = null;
|
|
2207
|
+
function runArdy({ promptOverride = ardyPrompt, durationOverride = ardyDuration, promptClipsOverride = [], fresh = false } = {}) {
|
|
2208
|
+
uiGenerationIntent = { prompt: promptOverride, blocks: promptClipsOverride, fresh };
|
|
2209
|
+
try { return appContext.bus.run('motion.generate', { characterId: appContext.storeDomain('cast').activeId, durationSeconds: Math.round(Number(durationOverride)) || ARDY_DURATION_MIN }); }
|
|
2210
|
+
finally { uiGenerationIntent = null; }
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
async function generateMotion(args, commandContext) {
|
|
2214
|
+
if (appContext.shared.generationPendingRef.current || appContext.shared.genRunningRef.current || ardyRunning) throw generationRefusal('TARGET_BUSY', 'A motion generation is already running.');
|
|
2215
|
+
if (appContext.shared.linePreviewUrl) throw generationRefusal('TARGET_NOT_READY', previewBlockingReason(en => en), previewBlockingReason(ko));
|
|
2216
|
+
const character = appContext.storeDomain('cast').read().find(row => row.id === args.characterId);
|
|
2217
|
+
const active = character.id === appContext.shared.loadedLayerCharRef.current;
|
|
2218
|
+
const posing = active && appContext.shared.posing;
|
|
2219
|
+
const rig = posing ? appContext.shared.posedRig() : appContext.shared.rigs[character.id];
|
|
2220
|
+
if (!rig) throw generationRefusal('TARGET_NOT_READY', 'Character not loaded yet', ko('Character not loaded yet', '캐릭터가 아직 로드되지 않았어요'));
|
|
2221
|
+
const take = domain.motionFor(character.id), state = ikStateFor(character.id);
|
|
2222
|
+
const intent = commandContext.origin === 'ui' ? uiGenerationIntent : null;
|
|
2223
|
+
const blocks = (intent?.blocks ?? character.layer.promptClips).filter(clip => clip.text.trim()).sort((a, b) => a.startFrame - b.startFrame);
|
|
2224
|
+
const prompt = intent?.prompt ?? blocks[0]?.text ?? (active ? ardyPrompt : '');
|
|
2225
|
+
let seed;
|
|
2226
|
+
try { seed = resolveSeed(args.seed ?? (active ? ardySeed : ''), ARDY_SEED_MAX); }
|
|
2227
|
+
catch (error) { throw generationRefusal('INVALID_ARGUMENT', error.message); }
|
|
2228
|
+
const input = { character, prompt, blocks, seed, durationSeconds: args.durationSeconds ?? (blocks.length ? Math.max(ARDY_DURATION_MIN, Math.ceil(Math.max(...blocks.map(clip => clip.endFrame)) / TIMELINE_FPS)) : ardyDuration),
|
|
2229
|
+
waypoints: character.layer.waypoints, motion: take, ikKeys: state.keys,
|
|
2230
|
+
startFromPose: ardyStartFromPose, posePlacement: ardyPosePlacement, frame: appContext.shared.tlFrame,
|
|
2231
|
+
preserveStrength, recipe: domain.layer(character.id).takeRecipe, fresh: intent?.fresh ?? false };
|
|
2232
|
+
const plan = buildGenerationRequest(input), resolved = resolveIkRig(rig);
|
|
2233
|
+
const image = appContext.shared.snapshotExportRig(rig);
|
|
2234
|
+
let poses;
|
|
2235
|
+
try {
|
|
2236
|
+
poses = plan.constraintFrames.map(frame => {
|
|
2237
|
+
if (take) applyMotionFrame(rig, take, frame);
|
|
2238
|
+
if (resolved && state.keys.size) ikEvaluate(resolved.chains, state, frame, resolved.fkJoints, take ? IK_CORRECTION_BLEND_FRAMES : 0);
|
|
2239
|
+
return { frame, pose: buildArdyPose({ rig, camRef: appContext.shared.shotCamRef, look: appContext.shared.look,
|
|
2240
|
+
fovDeg: appContext.shared.fovDeg, slate: slateLine(appContext.shared.shot), rigName: posing ? appContext.shared.posingChar?.model ?? character.model : character.model, root: captureArdyRoot(rig) }) };
|
|
2241
|
+
});
|
|
2242
|
+
} finally { appContext.shared.restoreExportRig(image); }
|
|
2243
|
+
const built = buildGenerationRequest({ ...input, poses });
|
|
2244
|
+
for (const warning of built.warnings) appContext.notify(warning);
|
|
2245
|
+
const request = requestMotionGeneration('timeline', take?.url && state.keys.size ? 'edit' : ardyStartFromPose ? 'pose' : 'prompt');
|
|
2246
|
+
commandContext.check();
|
|
2247
|
+
return new Promise((resolve, reject) => {
|
|
2248
|
+
const job = { request, commandContext, commandCompletion: { resolve, reject }, charId: character.id,
|
|
2249
|
+
charIndex: appContext.live.characters.findIndex(row => row.id === character.id), prompt: built.body.prompt,
|
|
2250
|
+
body: built.body, hasBlockEdits: built.hasBlockEdits, committedEditKeys: built.committedEditKeys,
|
|
2251
|
+
rootRotationDeg: built.rootRotationDeg, anchor: { x: character.x, z: character.z }, ikState: built.hasBlockEdits ? state : null,
|
|
2252
|
+
recipeIntent: built.hasBlockEdits ? 'carry' : 'fresh', recipeSeed: seed,
|
|
2253
|
+
recipeLabel: built.hasBlockEdits ? ko('Block fix', '블록 수정') : built.hasPromptSchedule ? ko('Blocks', '블록 생성')
|
|
2254
|
+
: input.fresh ? ko('New', '새로 만들기') : take?.url ? ko('Again', '다시 뽑기') : ko('Generate', '생성') };
|
|
2255
|
+
appContext.shared.generationPendingRef.current = enqueueMotionJob(job) === true;
|
|
2256
|
+
if (!appContext.shared.generationPendingRef.current) reject(generationRefusal('TARGET_NOT_READY', 'The motion backend cannot run this request.'));
|
|
2257
|
+
});
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
/* --------------------- trail drag -> preview -> regen -------------------- */
|
|
2261
|
+
function onTrailDragStart({ grabFrame } = {}) {
|
|
2262
|
+
if (!motion) return;
|
|
2263
|
+
const frame = Math.round(grabFrame ?? appContext.shared.tlFrame);
|
|
2264
|
+
appContext.shared.setTlFrame(frame);
|
|
2265
|
+
// The pre-drag take is both the deformation base (repeated moves re-derive
|
|
2266
|
+
// from it, so deltas never accumulate) and the undo snapshot.
|
|
2267
|
+
appContext.shared.trailBaseMotionRef.current = motion;
|
|
2268
|
+
appContext.storeDomain('motion').beginGesture();
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
/** World drag delta -> clip delta, shedding the character's stature scale
|
|
2272
|
+
* (the trail is drawn scaled by it). */
|
|
2273
|
+
function trailClipDelta(base, delta, track = "hips", grabFrame = appContext.shared.tlFrame) {
|
|
2274
|
+
const statureScale = appContext.shared.activeChar.scale ?? 1;
|
|
2275
|
+
return worldDeltaToTrailClip(base, delta, { track, grabFrame, radiusFrames: trailFalloffFrames, rig: appContext.shared.activeRig, scale: statureScale });
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
/** Per-rAF drag preview. Deliberately React-free: the deformed take lands in
|
|
2279
|
+
* a ref and on the rig directly, so a drag never re-renders the app. The
|
|
2280
|
+
* trail/highlight lines are rewritten in place by MotionTrails itself. */
|
|
2281
|
+
function onTrailDragPreview({ track, grabFrame, delta }) {
|
|
2282
|
+
const base = appContext.shared.trailBaseMotionRef.current;
|
|
2283
|
+
if (!base) return;
|
|
2284
|
+
const deformed = applyTrailFalloffDelta(base, {
|
|
2285
|
+
grabFrame,
|
|
2286
|
+
radiusFrames: trailFalloffFrames,
|
|
2287
|
+
track, clipDelta: trailClipDelta(base, delta, track, grabFrame),
|
|
2288
|
+
});
|
|
2289
|
+
appContext.shared.trailPreviewMotionRef.current = deformed;
|
|
2290
|
+
const rig = appContext.shared.activeRig;
|
|
2291
|
+
if (!rig) return;
|
|
2292
|
+
applyMotionFrame(rig, deformed, Math.round(grabFrame));
|
|
2293
|
+
if (ikChains && appContext.shared.ikStateRef.current.keys.size > 0) {
|
|
2294
|
+
ikEvaluate(ikChains, appContext.shared.ikStateRef.current, Math.round(grabFrame), ikFkJoints, IK_CORRECTION_BLEND_FRAMES);
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
function onTrailDragEnd({ track, grabFrame, delta }) {
|
|
2299
|
+
const base = appContext.shared.trailBaseMotionRef.current;
|
|
2300
|
+
const deformed = appContext.shared.trailPreviewMotionRef.current;
|
|
2301
|
+
appContext.shared.trailBaseMotionRef.current = null;
|
|
2302
|
+
appContext.shared.trailPreviewMotionRef.current = null;
|
|
2303
|
+
const size = delta ? Math.hypot(delta.x, delta.y, delta.z) : 0;
|
|
2304
|
+
if (!base || !deformed || size < 0.01) {
|
|
2305
|
+
// A sub-centimetre nudge is a mis-grab, not an authored edit: the
|
|
2306
|
+
// motion state never changed, so only the rig pose needs restoring.
|
|
2307
|
+
if (base && appContext.shared.activeRig) {
|
|
2308
|
+
applyMotionFrame(appContext.shared.activeRig, base, appContext.shared.tlFrame);
|
|
2309
|
+
if (ikChains && appContext.shared.ikStateRef.current.keys.size > 0) {
|
|
2310
|
+
ikEvaluate(ikChains, appContext.shared.ikStateRef.current, appContext.shared.tlFrame, ikFkJoints, IK_CORRECTION_BLEND_FRAMES);
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
setTrailEdit(null);
|
|
2314
|
+
return;
|
|
2315
|
+
}
|
|
2316
|
+
const owned = appContext.storeDomain('motion');
|
|
2317
|
+
if (base !== owned.motionFor(appContext.shared.activeChar.id)) { owned.project(); throw new StudioProtocolError('STALE_TARGET', 'The take changed during the trail drag.'); }
|
|
2318
|
+
owned.run('motion.editTrail', { characterId: appContext.shared.activeChar.id, track, grabFrame, radiusFrames: trailFalloffFrames, delta });
|
|
2319
|
+
owned.finishGesture();
|
|
2320
|
+
setTrailEdit({ track, grabFrame, radiusFrames: trailFalloffFrames, clipDelta: trailClipDelta(base, delta, track, grabFrame) });
|
|
2321
|
+
const { startFrame, endFrame } = trailEditRange(base.frames, grabFrame, trailFalloffFrames);
|
|
2322
|
+
const conflictFrames = findAbsoluteIkKeyConflicts({ keys: appContext.shared.ikStateRef.current.keys, track, startFrame, endFrame });
|
|
2323
|
+
if (conflictFrames.length) appContext.notify(`${ko("Absolute IK keys in this range override the path edit — remove or re-key them", "이 구간의 절대 IK 키가 궤적 수정을 덮어써요 — 해당 키를 지우거나 다시 찍어주세요")} ${isKo ? `프레임 ${conflictFrames.join(", ")}` : `frames ${conflictFrames.join(", ")}`}`);
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
/** Send the pending trail edit through the existing motionEdit pipeline:
|
|
2327
|
+
* the regen window is auto-derived from the grab + falloff, explicit IK
|
|
2328
|
+
* keys inside the window ride as hard constraints (their tracks), and the
|
|
2329
|
+
* deformed line contributes the grab-frame pose as a root guide. */
|
|
2330
|
+
function runTrailRegeneration() {
|
|
2331
|
+
if (appContext.shared.generationPendingRef.current || appContext.shared.genRunningRef.current || ardyRunning) return;
|
|
2332
|
+
const request = requestMotionGeneration("trail", "edit", { motionEdit: true });
|
|
2333
|
+
if (!trailEdit) return;
|
|
2334
|
+
// Same rule as runArdy: motionEdit rewrites a span of THE take, and a
|
|
2335
|
+
// draft on the viewport is not it.
|
|
2336
|
+
if (appContext.shared.linePreviewUrl) {
|
|
2337
|
+
appContext.notify(previewBlockingReason());
|
|
2338
|
+
return;
|
|
2339
|
+
}
|
|
2340
|
+
if (!motion?.url) {
|
|
2341
|
+
appContext.notify(ko("The current motion has no bridge source; generate the prompt blocks once before regenerating a trail edit", "현재 모션에 브리지 원본이 없어요. 궤적 수정을 재생성하려면 프롬프트 블록을 먼저 한 번 생성하세요"));
|
|
2342
|
+
return;
|
|
2343
|
+
}
|
|
2344
|
+
const rig = appContext.shared.activeRig;
|
|
2345
|
+
if (!rig) {
|
|
2346
|
+
appContext.notify(ko("Character not loaded yet", "캐릭터가 아직 로드되지 않았어요"));
|
|
2347
|
+
return;
|
|
2348
|
+
}
|
|
2349
|
+
const { startFrame, endFrame } = trailEditRange(motion.frames, trailEdit.grabFrame, trailEdit.radiusFrames);
|
|
2350
|
+
const frames = [...new Set([
|
|
2351
|
+
trailEdit.grabFrame,
|
|
2352
|
+
...appContext.shared.ikFrames.filter((frame) => frame >= startFrame && frame < endFrame),
|
|
2353
|
+
])].sort((a, b) => a - b);
|
|
2354
|
+
const currentFrame = appContext.shared.tlFrame;
|
|
2355
|
+
const entries = [];
|
|
2356
|
+
for (const frame of frames) {
|
|
2357
|
+
applyMotionFrame(rig, motion, frame);
|
|
2358
|
+
if (ikChains && appContext.shared.ikStateRef.current.keys.size > 0) {
|
|
2359
|
+
ikEvaluate(ikChains, appContext.shared.ikStateRef.current, frame, ikFkJoints, IK_CORRECTION_BLEND_FRAMES);
|
|
2360
|
+
}
|
|
2361
|
+
const pose = buildArdyPose({
|
|
2362
|
+
rig,
|
|
2363
|
+
camRef: appContext.shared.shotCamRef,
|
|
2364
|
+
look: appContext.shared.look,
|
|
2365
|
+
fovDeg: appContext.shared.fovDeg,
|
|
2366
|
+
slate: slateLine(appContext.shared.shot),
|
|
2367
|
+
rigName: appContext.shared.activeChar.model,
|
|
2368
|
+
root: captureArdyRoot(rig),
|
|
2369
|
+
});
|
|
2370
|
+
const wireFrame = toArdyFrame(frame);
|
|
2371
|
+
if (entries.length && wireFrame <= entries[entries.length - 1].frame) continue;
|
|
2372
|
+
const ikTracks = [...(appContext.shared.ikStateRef.current.keys.get(frame)?.keys() || [])];
|
|
2373
|
+
if (frame === trailEdit.grabFrame && trailEdit.track && !ikTracks.includes(trailEdit.track)) ikTracks.push(trailEdit.track);
|
|
2374
|
+
entries.push({ frame: wireFrame, timelineFrame: frame, tracks: ikTracks.length ? ikTracks : ["hips"], pose });
|
|
2375
|
+
}
|
|
2376
|
+
applyMotionFrame(rig, motion, currentFrame);
|
|
2377
|
+
if (ikChains && appContext.shared.ikStateRef.current.keys.size > 0) {
|
|
2378
|
+
ikEvaluate(ikChains, appContext.shared.ikStateRef.current, currentFrame, ikFkJoints, IK_CORRECTION_BLEND_FRAMES);
|
|
2379
|
+
}
|
|
2380
|
+
const prompt = (motion.prompt || "").trim() || "A person continues the motion naturally.";
|
|
2381
|
+
const body = {
|
|
2382
|
+
prompt,
|
|
2383
|
+
duration: motion.frames / TIMELINE_FPS,
|
|
2384
|
+
posePin: true,
|
|
2385
|
+
motionEdit: {
|
|
2386
|
+
sourceMotion: motion.url,
|
|
2387
|
+
startFrame: toArdyFrame(startFrame),
|
|
2388
|
+
endFrame: toArdyFrame(endFrame),
|
|
2389
|
+
contextBefore: 40,
|
|
2390
|
+
contextAfter: 20,
|
|
2391
|
+
edits: entries.map(({ frame, tracks, pose }) => ({ frame, tracks, pose })),
|
|
2392
|
+
},
|
|
2393
|
+
};
|
|
2394
|
+
// THE SEED RULE (C9) — a trail regeneration writes a new take too, so its
|
|
2395
|
+
// seed is rolled, sent and recorded like every other take-creating run.
|
|
2396
|
+
const seed = takeSeed();
|
|
2397
|
+
if (seed === null) return;
|
|
2398
|
+
body.seed = seed;
|
|
2399
|
+
const queued = enqueueMotionJob({
|
|
2400
|
+
request,
|
|
2401
|
+
charId: appContext.shared.activeChar.id,
|
|
2402
|
+
charIndex: appContext.shared.activeCharIndex,
|
|
2403
|
+
prompt,
|
|
2404
|
+
body,
|
|
2405
|
+
hasBlockEdits: true,
|
|
2406
|
+
committedEditKeys: entries.map(({ timelineFrame, tracks }) => ({ frame: timelineFrame, tracks })),
|
|
2407
|
+
rootRotationDeg: motion.rotationDeg ?? appContext.shared.activeChar.rot,
|
|
2408
|
+
anchor: { x: motion.anchorX ?? appContext.shared.activeChar.x, z: motion.anchorZ ?? appContext.shared.activeChar.z },
|
|
2409
|
+
ikState: appContext.shared.ikStateRef.current,
|
|
2410
|
+
// motionEdit rewrites a span of the loaded take; the recipe travels
|
|
2411
|
+
// forward unchanged because there is no recipe field that could
|
|
2412
|
+
// describe the splice (C10 excludes motionEdit from replay outright).
|
|
2413
|
+
recipeIntent: "carry",
|
|
2414
|
+
recipeSeed: seed,
|
|
2415
|
+
recipeLabel: ko("Trail fix", "궤적 수정"),
|
|
2416
|
+
});
|
|
2417
|
+
if (!queued) return;
|
|
2418
|
+
appContext.shared.generationPendingRef.current = true;
|
|
2419
|
+
setTrailEdit(null);
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
/* ------------------------- motion job queue ---------------------------
|
|
2423
|
+
* One box, one job at a time: explicit entry points suppress duplicate
|
|
2424
|
+
* requests through queueing and execution. The
|
|
2425
|
+
* payload is frozen at enqueue time; completion delivers the clip to the
|
|
2426
|
+
* REQUESTING character's layer, not whoever happens to be selected then. */
|
|
2427
|
+
const [genQueue, setGenQueue] = useState([]);
|
|
2428
|
+
|
|
2429
|
+
function enqueueMotionJob(spec) {
|
|
2430
|
+
const options = { body: spec.body, lineEditSupported: lineEditBackend };
|
|
2431
|
+
spec.request.preflight(bridge, options);
|
|
2432
|
+
if (motionPreflightReason(bridge, options)) return;
|
|
2433
|
+
const id = `gen-${++appContext.shared.genJobSeq.current}`;
|
|
2434
|
+
setGenQueue((queue) => [...queue, { id, status: "queued", ...spec }]);
|
|
2435
|
+
appContext.notify((isKo, ko) => isKo ? `인물 ${spec.charIndex + 1} 모션 생성을 대기열에 넣었어요` : `Queued motion generation for Subject ${spec.charIndex + 1}`);
|
|
2436
|
+
return true;
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
async function executeMotionJob(job) {
|
|
2440
|
+
job.commandContext?.check();
|
|
2441
|
+
const controller = new AbortController();
|
|
2442
|
+
const abort = () => controller.abort(job.commandContext.signal.reason);
|
|
2443
|
+
job.commandContext?.signal.addEventListener("abort", abort, { once: true });
|
|
2444
|
+
appContext.shared.ardyAbortRef.current = controller;
|
|
2445
|
+
setArdyRunning(true);
|
|
2446
|
+
reportArdyStatus(ko("connecting…", "연결 중…"));
|
|
2447
|
+
setArdyReport(null);
|
|
2448
|
+
setArdyOutcome(null);
|
|
2449
|
+
// Replay notices belong to ONE run; the next run re-earns them.
|
|
2450
|
+
setReplayNotices([]);
|
|
2451
|
+
const request = job.request;
|
|
2452
|
+
request.start();
|
|
2453
|
+
controller.signal.addEventListener("abort", () => request.fail(new DOMException("", "AbortError")), { once: true });
|
|
2454
|
+
let editCommitReport = null;
|
|
2455
|
+
try {
|
|
2456
|
+
const done = await ardyGenerate(
|
|
2457
|
+
job.body,
|
|
2458
|
+
(event) => {
|
|
2459
|
+
if (event.event === "status") reportArdyStatus(event.message);
|
|
2460
|
+
else if (event.event === "report") {
|
|
2461
|
+
setArdyReport(event.report);
|
|
2462
|
+
if (job.hasBlockEdits) editCommitReport = event.report;
|
|
2463
|
+
// C10's per-entry replay report. Only the entries worth
|
|
2464
|
+
// acting on are kept: a refinement that failed outright,
|
|
2465
|
+
// or one whose range straddles an internal block
|
|
2466
|
+
// boundary (where block N+1 was conditioned on N's
|
|
2467
|
+
// PRE-edit tail, so the replay is approximate rather
|
|
2468
|
+
// than exact). Both are non-blocking — the take exists.
|
|
2469
|
+
if (Array.isArray(event.report.replay)) {
|
|
2470
|
+
setReplayNotices(event.report.replay.filter((entry) => entry?.ok === false || entry?.boundaryWarning === true));
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
},
|
|
2474
|
+
{ signal: controller.signal },
|
|
2475
|
+
);
|
|
2476
|
+
if (
|
|
2477
|
+
job.hasBlockEdits &&
|
|
2478
|
+
(
|
|
2479
|
+
editCommitReport?.commit_verified !== true ||
|
|
2480
|
+
!job.body.motionEdit.edits.every((entry) =>
|
|
2481
|
+
editCommitReport.committed_keys?.includes(entry.frame)
|
|
2482
|
+
)
|
|
2483
|
+
)
|
|
2484
|
+
) {
|
|
2485
|
+
throw new Error(ko("The generated motion came back without verified authored pose keys", "생성된 모션에 검증된 수동 포즈 키가 없어요"));
|
|
2486
|
+
}
|
|
2487
|
+
job.commandContext?.check();
|
|
2488
|
+
if (!done.motionUrl) throw generationRefusal('TARGET_NOT_READY', 'The generator finished without a motion artifact.');
|
|
2489
|
+
setArdyOutcome({ ok: true, output: done.output, bytes: done.bytes, motionUrl: done.motionUrl, rotationDeg: job.rootRotationDeg });
|
|
2490
|
+
request.succeed();
|
|
2491
|
+
trackActivation("motion");
|
|
2492
|
+
// Fetch and decode the real npz right away; decode errors are shown
|
|
2493
|
+
// in the card, playback is never faked. The clip lands on the
|
|
2494
|
+
// REQUESTING character, not whoever is selected now.
|
|
2495
|
+
if (done.motionUrl) {
|
|
2496
|
+
await deliverMotion(job, done.motionUrl);
|
|
2497
|
+
if (!controller.signal.aborted && appContext.live.characters.some((entry) => entry.id === job.charId)) request.apply();
|
|
2498
|
+
if (!appContext.storeDomain('motion')) commitTakeRecipe(job, done.motionUrl);
|
|
2499
|
+
}
|
|
2500
|
+
if (!appContext.storeDomain('motion') && job.hasBlockEdits && job.ikState) {
|
|
2501
|
+
// Timeline frames, not the wire frames in body.motionEdit.edits:
|
|
2502
|
+
// these light up the IK markers on the production clock.
|
|
2503
|
+
setCommittedIkEdits((current) => [...current, ...job.committedEditKeys]);
|
|
2504
|
+
job.ikState.keys.clear();
|
|
2505
|
+
job.ikState.tracked.clear();
|
|
2506
|
+
job.ikState.plants.clear();
|
|
2507
|
+
setIkTick((value) => value + 1);
|
|
2508
|
+
}
|
|
2509
|
+
appContext.notify((isKo, ko) => isKo ? `인물 ${job.charIndex + 1} 모션 생성됨` : `Motion generated for Subject ${job.charIndex + 1}`);
|
|
2510
|
+
} catch (err) {
|
|
2511
|
+
// Wave-2 gate, second line of defence. The capability preflight
|
|
2512
|
+
// normally stops a line edit before it is sent, but a bridge that
|
|
2513
|
+
// advertises the route and then 400s on the field (a half-landed
|
|
2514
|
+
// wave 2, an older sidecar behind the proxy) must read as "not
|
|
2515
|
+
// connected yet", not as a red generation failure the user could
|
|
2516
|
+
// act on.
|
|
2517
|
+
if (job.body.lineEdit && isLineEditUnsupported(err?.message)) {
|
|
2518
|
+
appContext.notify(ko(
|
|
2519
|
+
"The line-editing backend is not connected yet",
|
|
2520
|
+
"라인 편집 백엔드가 아직 연결 전이에요",
|
|
2521
|
+
));
|
|
2522
|
+
}
|
|
2523
|
+
setArdyOutcome({
|
|
2524
|
+
ok: false,
|
|
2525
|
+
message: err?.name === "AbortError" ? ko("Cancelled", "취소됨") : err?.message || String(err),
|
|
2526
|
+
});
|
|
2527
|
+
request.fail(err, job.body.lineEdit && isLineEditUnsupported(err?.message) ? "unsupported_route" : undefined);
|
|
2528
|
+
throw err;
|
|
2529
|
+
} finally {
|
|
2530
|
+
setArdyRunning(false);
|
|
2531
|
+
appContext.shared.ardyAbortRef.current = null;
|
|
2532
|
+
job.commandContext?.signal.removeEventListener("abort", abort);
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
/* ------------------- recipe + version bookkeeping (C9/C12) ----------------
|
|
2537
|
+
* ONE writer for both. Every take that reaches the app came out of a job,
|
|
2538
|
+
* so a job's completion is the only place where "what is this take made of"
|
|
2539
|
+
* can be answered honestly, and the answer is checkpointed next to the
|
|
2540
|
+
* motionUrl in the same breath. Nothing else may write takeRecipeRef except
|
|
2541
|
+
* loadTakeVersion, which restores a checkpoint rather than authoring one. */
|
|
2542
|
+
function commitTakeRecipe(job, motionUrl) {
|
|
2543
|
+
const owned = appContext.storeDomain('motion');
|
|
2544
|
+
const base = owned ? owned.layer(job.charId).takeRecipe : appContext.shared.takeRecipeRef.current;
|
|
2545
|
+
let next = base;
|
|
2546
|
+
if (job.recipeIntent === "fresh") {
|
|
2547
|
+
// A regeneration that carried `replay` produced a take that ALREADY
|
|
2548
|
+
// contains those edits, so they stay on the recipe. Resetting
|
|
2549
|
+
// lineEdits to [] here would make the second regeneration lose what
|
|
2550
|
+
// the first one preserved — the exact failure replay exists to fix.
|
|
2551
|
+
next = freshRecipe({
|
|
2552
|
+
seed: job.recipeSeed,
|
|
2553
|
+
blocks: blocksFromRequest(job.body, ARDY_FPS),
|
|
2554
|
+
lineEdits: job.body.replay ?? [],
|
|
2555
|
+
});
|
|
2556
|
+
} else if (job.recipeIntent === "lineEdit") {
|
|
2557
|
+
// A take imported by url (?motion=, a reload) has no recipe of its own.
|
|
2558
|
+
// The edit's body carries the take's prompt and length, so a
|
|
2559
|
+
// best-effort single block is recorded rather than dropping the
|
|
2560
|
+
// refinement on the floor; only the SEED is a guess, and it is the
|
|
2561
|
+
// one this edit actually ran with. The seedless placeholder recipe an
|
|
2562
|
+
// imported take is given (seedLoadedTake) counts as "no recipe" for
|
|
2563
|
+
// the seed specifically: adopting this edit's seed is what turns it
|
|
2564
|
+
// into something that can replay at all.
|
|
2565
|
+
const seeded = Number.isInteger(base?.seed)
|
|
2566
|
+
? base
|
|
2567
|
+
: freshRecipe({
|
|
2568
|
+
seed: job.recipeSeed,
|
|
2569
|
+
blocks: base?.blocks?.length ? base.blocks : blocksFromRequest(job.body, ARDY_FPS),
|
|
2570
|
+
lineEdits: base?.lineEdits ?? [],
|
|
2571
|
+
});
|
|
2572
|
+
next = withLineEdit(seeded, job.recipeLineEdit);
|
|
2573
|
+
}
|
|
2574
|
+
if (owned) {
|
|
2575
|
+
owned.writeLayer(job.charId, { takeRecipe: next, takeVersions: pushTakeVersion(owned.layer(job.charId).takeVersions, {
|
|
2576
|
+
motionUrl, recipe: next, savedAt: Date.now(), label: job.recipeLabel ?? '',
|
|
2577
|
+
}, TAKE_VERSIONS_MAX) });
|
|
2578
|
+
return;
|
|
2579
|
+
}
|
|
2580
|
+
appContext.shared.takeRecipeRef.current = next;
|
|
2581
|
+
setTakeRecipe(next);
|
|
2582
|
+
setTakeVersions((list) => pushTakeVersion(list, {
|
|
2583
|
+
motionUrl,
|
|
2584
|
+
recipe: next,
|
|
2585
|
+
savedAt: Date.now(),
|
|
2586
|
+
label: job.recipeLabel ?? "",
|
|
2587
|
+
}, TAKE_VERSIONS_MAX));
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
/* A take can also arrive WITHOUT a job behind it — ?motion=<url>, the shipped
|
|
2591
|
+
* demo clip, a scene reload that re-fetches a stored motionRef. Those takes
|
|
2592
|
+
* used to leave the version strip empty, so the first refinement had nothing
|
|
2593
|
+
* to walk back to and the artist's only checkpoint was the thing they had
|
|
2594
|
+
* just overwritten. They get a v1 like everything else.
|
|
2595
|
+
*
|
|
2596
|
+
* WHAT IS HONESTLY KNOWN is the take's url, its prompt and its length —
|
|
2597
|
+
* NOT its seed, which was rolled on the box in a session nobody here
|
|
2598
|
+
* witnessed. So the placeholder recipe carries `seed: null` and every reader
|
|
2599
|
+
* treats that as "this take cannot be rebuilt": no request may attach it as
|
|
2600
|
+
* C10 `replay` (a replay whose base is a different random take re-applies
|
|
2601
|
+
* refinements to a stranger), and the first real edit adopts its own seed in
|
|
2602
|
+
* commitTakeRecipe. A checkpoint you can return to beats a recipe you can
|
|
2603
|
+
* replay, and this gets the first without pretending to the second. */
|
|
2604
|
+
function seedLoadedTake(url, prompt, frames) {
|
|
2605
|
+
if (appContext.storeDomain('motion')) return;
|
|
2606
|
+
if (!url || appContext.shared.takeRecipeRef.current) return;
|
|
2607
|
+
const recipe = Object.freeze({
|
|
2608
|
+
seed: null,
|
|
2609
|
+
blocks: Object.freeze([Object.freeze({
|
|
2610
|
+
prompt: typeof prompt === "string" ? prompt : "",
|
|
2611
|
+
duration: frames > 0 ? frames / TIMELINE_FPS : 0,
|
|
2612
|
+
})]),
|
|
2613
|
+
lineEdits: Object.freeze([]),
|
|
2614
|
+
});
|
|
2615
|
+
appContext.shared.takeRecipeRef.current = recipe;
|
|
2616
|
+
setTakeRecipe(recipe);
|
|
2617
|
+
setTakeVersions((list) => pushTakeVersion(list, {
|
|
2618
|
+
motionUrl: url,
|
|
2619
|
+
recipe,
|
|
2620
|
+
savedAt: Date.now(),
|
|
2621
|
+
label: ko("Loaded", "불러옴"),
|
|
2622
|
+
}, TAKE_VERSIONS_MAX));
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
/** Click a chip: that motionUrl becomes the active take through the SAME
|
|
2626
|
+
* delivery path a fresh generation result travels, and the recipe saved
|
|
2627
|
+
* beside it becomes the current one. Nothing is truncated — editing from an
|
|
2628
|
+
* old version pushes a NEW version on top, so no click can destroy work. */
|
|
2629
|
+
async function loadTakeVersion(entry) {
|
|
2630
|
+
if (!entry?.motionUrl || motionBusy) return;
|
|
2631
|
+
if (appContext.storeDomain('motion')) return appContext.storeDomain('motion').run('motion.loadVersion', { characterId: appContext.shared.activeChar.id, motionUrl: entry.motionUrl });
|
|
2632
|
+
if (entry.motionUrl === appContext.shared.takeSourceUrl) return;
|
|
2633
|
+
// A pull in hand was authored against the take that is leaving. The
|
|
2634
|
+
// preview is dropped WITHOUT reverting: this call is already loading a
|
|
2635
|
+
// different take, and a revert would race it with a reload of the one
|
|
2636
|
+
// being left behind.
|
|
2637
|
+
appContext.shared.cancelLinePreview({ revert: false });
|
|
2638
|
+
if (appContext.shared.lineEditMode) appContext.shared.clearLineEdit();
|
|
2639
|
+
setReplayNotices([]);
|
|
2640
|
+
appContext.shared.takeRecipeRef.current = entry.recipe ?? null;
|
|
2641
|
+
setTakeRecipe(entry.recipe ?? null);
|
|
2642
|
+
try {
|
|
2643
|
+
await deliverMotion({
|
|
2644
|
+
charId: appContext.shared.activeChar.id,
|
|
2645
|
+
prompt: entry.recipe?.blocks?.[0]?.prompt ?? motion?.prompt ?? "",
|
|
2646
|
+
rootRotationDeg: motion?.rotationDeg ?? appContext.shared.activeChar.rot,
|
|
2647
|
+
anchor: { x: motion?.anchorX ?? appContext.shared.activeChar.x, z: motion?.anchorZ ?? appContext.shared.activeChar.z },
|
|
2648
|
+
calibration: motion?.sceneCalibration ?? null,
|
|
2649
|
+
}, entry.motionUrl);
|
|
2650
|
+
} catch {
|
|
2651
|
+
/* loadMotion already surfaced the decode failure in the panel */
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
/* ---------------------- the two edit entries (C12) ------------------------
|
|
2656
|
+
* Scene blocks the shot with Kimodo; Refine pulls one joint's path with
|
|
2657
|
+
* ProjFlow. Everything else the pipeline can do is one of those two said
|
|
2658
|
+
* more precisely, and both are reachable from the take itself instead of
|
|
2659
|
+
* from a foldout the artist has to remember to open.
|
|
2660
|
+
*
|
|
2661
|
+
* WHY THE REASONS ARE FUNCTIONS, not toasts. An action the artist cannot
|
|
2662
|
+
* take must say so BEFORE the click, in place, next to the button. A toast
|
|
2663
|
+
* fired after the click teaches nothing: it arrives once, scrolls away, and
|
|
2664
|
+
* leaves the button looking identical to the ones that work. Each reason
|
|
2665
|
+
* below is rendered as a line under its entry AND as data-disabled-reason,
|
|
2666
|
+
* which is also what the CDP surface gate reads. */
|
|
2667
|
+
function selectedMotionReadiness({ fresh = false, clips = appContext.shared.promptClips } = {}) {
|
|
2668
|
+
const authored = clips.filter((clip) => clip.text.trim()).sort((a, b) => a.startFrame - b.startFrame);
|
|
2669
|
+
const prompt = authored[0]?.text.trim() || ardyPrompt.trim();
|
|
2670
|
+
const duration = motion && appContext.shared.ikFrames.length > 0 ? motion.frames / motion.fps
|
|
2671
|
+
: authored.length ? Math.max(ARDY_DURATION_MIN, Math.ceil(Math.max(...authored.map((clip) => clip.endFrame)) / TIMELINE_FPS))
|
|
2672
|
+
: Math.round(Number(ardyDuration)) || ARDY_DURATION_MIN;
|
|
2673
|
+
const clipFrames = duration * TIMELINE_FPS;
|
|
2674
|
+
const segments = buildPromptSchedule(authored, clipFrames, prompt);
|
|
2675
|
+
const hasPromptSchedule = segments.length > 1;
|
|
2676
|
+
const editedSegments = motion?.url && hasPromptSchedule
|
|
2677
|
+
? segments.filter((segment) => appContext.shared.ikFrames.some((frame) => frame >= segment.startFrame && frame < segment.endFrame))
|
|
2678
|
+
: [];
|
|
2679
|
+
const hasBlockEdits = editedSegments.length > 0;
|
|
2680
|
+
const pinPlan = planPosePin({
|
|
2681
|
+
startFromPose: ardyStartFromPose,
|
|
2682
|
+
poseFrame: posePlacementFrame(ardyPosePlacement, clipFrames, appContext.shared.tlFrame),
|
|
2683
|
+
hasPromptSchedule, hasBlockEdits, waypointMode: appContext.shared.waypointMode, ikFrames: appContext.shared.ikFrames, clipFrames, segments, editedSegments,
|
|
2684
|
+
});
|
|
2685
|
+
// Only the capability-bearing fields are needed here; the queue still
|
|
2686
|
+
// preflights the actual frozen request before any generation HTTP call.
|
|
2687
|
+
const body = { prompt, duration, posePin: pinPlan.pin };
|
|
2688
|
+
if (hasBlockEdits) body.motionEdit = {};
|
|
2689
|
+
else if (hasPromptSchedule) body.segments = toArdySegments(segments);
|
|
2690
|
+
if (appContext.shared.waypointMode) body.waypoints = [{}];
|
|
2691
|
+
const recipe = appContext.shared.takeRecipeRef.current;
|
|
2692
|
+
if (!fresh && !hasBlockEdits && Number.isInteger(recipe?.seed)) body.replay = replayPayload(recipe);
|
|
2693
|
+
if (!fresh && !hasBlockEdits && !hasPromptSchedule && motion?.url && preserveStrength > 0
|
|
2694
|
+
&& Math.abs(motion.frames / TIMELINE_FPS - duration) <= 1 / ARDY_FPS + 1e-9) {
|
|
2695
|
+
const blocks = blocksFromRequest(body, ARDY_FPS);
|
|
2696
|
+
if (recipe?.blocks?.length === blocks.length
|
|
2697
|
+
&& recipe.blocks.every((block, index) => block.prompt.trim() === blocks[index].prompt.trim())) body.preserve = {};
|
|
2698
|
+
}
|
|
2699
|
+
return motionReadiness(bridge, { body, lineEditSupported: lineEditBackend });
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
const generationBusy = ardyRunning || genQueue.some((job) => job.status === "queued" || job.status === "running");
|
|
2703
|
+
|
|
2704
|
+
function openMotionSetup(kind = "prompt") {
|
|
2705
|
+
setMotionSetupKind(kind);
|
|
2706
|
+
setMotionSetupReveal((value) => value + 1);
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2709
|
+
function refineDisabledReason() {
|
|
2710
|
+
if (!motion) return ko("No take yet — block a scene first", "아직 테이크가 없어요 — 먼저 장면을 만들어 주세요");
|
|
2711
|
+
if (!motion.url) return ko("This take has no bridge source — generate it once before refining", "이 테이크에는 브리지 원본이 없어요 — 한 번 생성해야 다듬을 수 있어요");
|
|
2712
|
+
return "";
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
function sceneDisabledReason() {
|
|
2716
|
+
if (bridge === null || bridgeChecking) return motionReadinessMessage("loading");
|
|
2717
|
+
if (generationBusy) return ko("A generation is already running", "이미 생성이 돌고 있어요");
|
|
2718
|
+
// NOT a line-edit preview, deliberately. Every other reason here is a
|
|
2719
|
+
// standing capability the entry should be greyed for; a draft on the
|
|
2720
|
+
// viewport lasts a second and a half, and a reason line appearing and
|
|
2721
|
+
// vanishing under the Scene button RESIZES THE TAKE BAR — which shortens
|
|
2722
|
+
// the stage, which changes the camera aspect, which the drift watcher
|
|
2723
|
+
// reads as "the view moved".
|
|
2724
|
+
//
|
|
2725
|
+
// THE TEETH ARE OUT OF THAT TRAP: drift no longer discards anything, so a
|
|
2726
|
+
// take-bar resize now costs at most a flicker of the ghosted paint and the
|
|
2727
|
+
// hint while the aspect settles — it used to kill the pull ~400 ms after
|
|
2728
|
+
// every draft landed. The refusal still lives in runArdy /
|
|
2729
|
+
// runTrailRegeneration rather than here, because a reason line that
|
|
2730
|
+
// appears and vanishes under the pointer is its own small nuisance and
|
|
2731
|
+
// nothing is gained by moving it back.
|
|
2732
|
+
return "";
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
/** The one sentence every take-consuming action says while a draft is up. */
|
|
2736
|
+
function previewBlockingReason(localize = ko) {
|
|
2737
|
+
return localize(
|
|
2738
|
+
"A line-edit preview is on the viewport — press Generate to keep it, or undo (Ctrl/Cmd+Z) to drop it",
|
|
2739
|
+
"라인 편집 미리보기가 떠 있어요 — 생성으로 확정하거나 Ctrl/Cmd+Z로 되돌린 뒤에 쓰세요",
|
|
2740
|
+
);
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
function sceneGenerateDisabledReason() {
|
|
2744
|
+
return sceneDisabledReason()
|
|
2745
|
+
|| (ardyPrompt.trim() || appContext.shared.promptClips.some((clip) => clip.text.trim())
|
|
2746
|
+
? ""
|
|
2747
|
+
: ko("Describe the motion first — press 4 and add a prompt with + on the Prompt lane", "먼저 어떤 동작인지 적어 주세요 — 4를 누르고 프롬프트 레인의 +로 프롬프트를 추가하세요"));
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
/** Taking it AGAIN needs no fresh wording: the loaded take already knows what
|
|
2751
|
+
* it was asked for, so its own prompt is the fallback (the same fallback
|
|
2752
|
+
* runLineEdit uses). What it does need is a take to re-take. */
|
|
2753
|
+
function sceneAgainPrompt() {
|
|
2754
|
+
return ardyPrompt.trim() || (motion?.prompt ?? "").trim();
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
function sceneAgainDisabledReason() {
|
|
2758
|
+
return sceneDisabledReason()
|
|
2759
|
+
|| (motion?.url ? "" : ko("Nothing to redo yet — make a take first", "다시 뽑을 테이크가 없어요 — 먼저 한 번 만들어 주세요"))
|
|
2760
|
+
|| (sceneAgainPrompt() || appContext.shared.promptClips.some((clip) => clip.text.trim())
|
|
2761
|
+
? ""
|
|
2762
|
+
: ko("This take carries no prompt — add a block and describe it", "이 테이크에는 프롬프트가 없어요 — 블록을 추가하고 동작을 적어 주세요"));
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
/** ONE CLICK from a loaded take into drag mode. The pull itself is authored
|
|
2766
|
+
* on the viewport, but its controls live under the character's Inspector, so
|
|
2767
|
+
* selecting that character and revealing the panel happen HERE rather than
|
|
2768
|
+
* being three clicks the artist has to find first. */
|
|
2769
|
+
function enterRefineMode() {
|
|
2770
|
+
const reason = refineDisabledReason();
|
|
2771
|
+
if (reason) {
|
|
2772
|
+
appContext.notify(reason);
|
|
2773
|
+
return;
|
|
2774
|
+
}
|
|
2775
|
+
setSceneMenuOpen(false);
|
|
2776
|
+
appContext.shared.selectActiveCharacterInHierarchy();
|
|
2777
|
+
appContext.shared.revealPromptBlocks();
|
|
2778
|
+
appContext.shared.toggleLineEditMode();
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
/** Take it again — same blocks, same lineage, refinements replayed. Authored
|
|
2782
|
+
* blocks go through the batch path so the schedule survives; a single-prompt
|
|
2783
|
+
* take goes straight through runArdy. */
|
|
2784
|
+
function runSceneAgain() {
|
|
2785
|
+
if (appContext.shared.promptClips.some((clip) => clip.text.trim())) runAllPromptBlocks();
|
|
2786
|
+
else runArdy({ promptOverride: sceneAgainPrompt() });
|
|
2787
|
+
}
|
|
2788
|
+
|
|
2789
|
+
/** Add a block — the timeline's own add-block gesture, said as a button. */
|
|
2790
|
+
function addSceneBlock() {
|
|
2791
|
+
appContext.shared.addPromptClip(appContext.shared.tlFrame);
|
|
2792
|
+
appContext.shared.selectActiveCharacterInHierarchy();
|
|
2793
|
+
appContext.shared.revealPromptBlocks();
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
/** Hand a finished clip to the layer that asked for it: the buffer when
|
|
2797
|
+
* the requester is still active, its stored session motion otherwise. A
|
|
2798
|
+
* lightweight motionRef is persisted with the entry either way, so the
|
|
2799
|
+
* clip can be re-fetched after a reload. */
|
|
2800
|
+
async function deliverMotion(job, motionUrl) {
|
|
2801
|
+
const calibration = job.calibration ?? job.sceneCalibration ?? null;
|
|
2802
|
+
const normalizedCalibration = normalizeMotionCalibration(calibration);
|
|
2803
|
+
const sceneAnchorX = job.anchor.x + normalizedCalibration.offsetX;
|
|
2804
|
+
const sceneAnchorZ = job.anchor.z + normalizedCalibration.offsetZ;
|
|
2805
|
+
const sceneRotationDeg = job.rootRotationDeg + normalizedCalibration.yawDeg;
|
|
2806
|
+
const motionRef = {
|
|
2807
|
+
url: motionUrl,
|
|
2808
|
+
prompt: job.prompt,
|
|
2809
|
+
rotationDeg: sceneRotationDeg,
|
|
2810
|
+
anchorX: sceneAnchorX,
|
|
2811
|
+
anchorZ: sceneAnchorZ,
|
|
2812
|
+
};
|
|
2813
|
+
if (calibration && typeof calibration === "object") motionRef.calibration = normalizedCalibration;
|
|
2814
|
+
if (!appContext.storeDomain('motion') && !job.commandContext) appContext.shared.publishStudioCharacters((list) => list.map((entry) => entry.id === job.charId ? { ...entry, motionRef } : entry));
|
|
2815
|
+
if (job.charId === appContext.shared.loadedLayerCharRef.current) {
|
|
2816
|
+
await loadMotion(motionUrl, job.prompt, job.rootRotationDeg, null, job.charId, null, { calibration, commandContext: job.commandContext, job });
|
|
2817
|
+
if (!appContext.storeDomain('motion') && job.commandContext) appContext.shared.publishStudioCharacters(appContext.live.characters.map(entry => entry.id === job.charId ? { ...entry, motionRef } : entry));
|
|
2818
|
+
return;
|
|
2819
|
+
}
|
|
2820
|
+
// Inbound boundary for a clip delivered to a non-active layer.
|
|
2821
|
+
const retimed = retimeMotion(await loadMotionFromUrl(motionUrl), TIMELINE_FPS);
|
|
2822
|
+
if (retimed.sourceBytes) retimed.motionId = await sha256Hex(retimed.sourceBytes);
|
|
2823
|
+
const decoded = applyMotionCalibration(retimed, { ...normalizedCalibration, yawDeg: 0, offsetX: 0, offsetZ: 0 }).motion;
|
|
2824
|
+
const clip = {
|
|
2825
|
+
...decoded,
|
|
2826
|
+
url: motionUrl,
|
|
2827
|
+
prompt: job.prompt,
|
|
2828
|
+
anchorX: sceneAnchorX,
|
|
2829
|
+
anchorZ: sceneAnchorZ,
|
|
2830
|
+
anchorFrame: 0,
|
|
2831
|
+
rotationDeg: sceneRotationDeg,
|
|
2832
|
+
sceneCalibration: normalizedCalibration,
|
|
2833
|
+
editSegments: createMotionEdit(decoded.frames),
|
|
2834
|
+
};
|
|
2835
|
+
if (calibration && typeof calibration === "object") clip.sceneCalibration = normalizedCalibration;
|
|
2836
|
+
// Same stature rule as loadMotion, on the layer that asked for the clip.
|
|
2837
|
+
const scale = characterScaleFor(decoded);
|
|
2838
|
+
const apply = () => {
|
|
2839
|
+
if (appContext.storeDomain('motion')) return commitLoadedTake(job.charId, clip, { job });
|
|
2840
|
+
appContext.shared.motionFullRef.current.set(job.charId, clip);
|
|
2841
|
+
const next = appContext.live.characters.map(entry => entry.id === job.charId ? { ...entry, scale, sessionMotion: clip, motionRef } : entry);
|
|
2842
|
+
appContext.shared.publishStudioCharacters(next);
|
|
2843
|
+
};
|
|
2844
|
+
if (job.commandContext) job.commandContext.commit(apply);
|
|
2845
|
+
else if (appContext.storeDomain('motion')) appContext.storeDomain('motion').runPrepared(job.charId, apply);
|
|
2846
|
+
else apply();
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
/** After a scene (re)load, re-fetch every persisted clip reference and
|
|
2850
|
+
* rebuild the session motions. The bridge may be gone — failures just
|
|
2851
|
+
* leave the character posed, never an error the user must act on. */
|
|
2852
|
+
async function restoreMotionRefs(list) {
|
|
2853
|
+
const epoch = ++appContext.shared.restoreEpochRef.current;
|
|
2854
|
+
const motions = appContext.shared.projectMotionsRef.current;
|
|
2855
|
+
try { const db = await openMotionDb(); const ids = [...new Set(list.map((entry) => entry.motionRef?.motionId?.toLowerCase()).filter(Boolean))]; const cached = await Promise.all(ids.map((id) => getMotion(db, id))); cached.filter(Boolean).forEach((record) => motions.set(record.motionId.toLowerCase(), record)); db.close(); } catch (error) { console.warn("[cozyclay] could not restore motion cache", error); }
|
|
2856
|
+
for (const entry of list) {
|
|
2857
|
+
const source = resolveMotionSource(entry.motionRef, motions);
|
|
2858
|
+
if (!entry.motionRef) continue;
|
|
2859
|
+
if (source.kind === "missing") {
|
|
2860
|
+
appContext.notify(isKo ? `저장된 모션이 누락되었습니다 (${entry.subject || entry.id})` : `Saved motion is missing for ${entry.subject || entry.id}`);
|
|
2861
|
+
continue;
|
|
2862
|
+
}
|
|
2863
|
+
const load = source.kind === "embedded" ? decodeMotionResource(source.record) : loadMotionFromUrl(source.url);
|
|
2864
|
+
load.then((raw) => {
|
|
2865
|
+
if (epoch !== appContext.shared.restoreEpochRef.current) return;
|
|
2866
|
+
// Inbound boundary: a re-fetched clip is retimed exactly like a
|
|
2867
|
+
// freshly generated one, so a reload cannot resurrect 20 fps frames.
|
|
2868
|
+
const sourceUrl = source.kind === "url" ? source.url : entry.motionRef?.url;
|
|
2869
|
+
const retimed = retimeMotion(raw, TIMELINE_FPS);
|
|
2870
|
+
const normalizedCalibration = normalizeMotionCalibration(entry.motionRef.calibration);
|
|
2871
|
+
const decoded = applyMotionCalibration(retimed, { ...normalizedCalibration, yawDeg: 0, offsetX: 0, offsetZ: 0 }).motion;
|
|
2872
|
+
const clip = {
|
|
2873
|
+
...decoded,
|
|
2874
|
+
url: sourceUrl,
|
|
2875
|
+
sourceBytes: raw.sourceBytes,
|
|
2876
|
+
prompt: entry.motionRef.prompt,
|
|
2877
|
+
anchorX: entry.motionRef.anchorX,
|
|
2878
|
+
anchorZ: entry.motionRef.anchorZ,
|
|
2879
|
+
anchorFrame: 0,
|
|
2880
|
+
rotationDeg: entry.motionRef.rotationDeg,
|
|
2881
|
+
sceneCalibration: normalizedCalibration,
|
|
2882
|
+
editSegments: createMotionEdit(decoded.frames),
|
|
2883
|
+
};
|
|
2884
|
+
if (entry.motionRef.calibration) clip.sceneCalibration = entry.motionRef.calibration;
|
|
2885
|
+
if (entry.motionRef.trailEdits) Object.assign(clip, restoreTrailEdits(clip, entry.motionRef.trailEdits));
|
|
2886
|
+
if (entry.motionRef.studioTakeId) clip.studioTakeId = entry.motionRef.studioTakeId;
|
|
2887
|
+
appContext.storeDomain('motion').hydrate(entry.id, clip, entry.motionRef);
|
|
2888
|
+
}).catch((error) => {
|
|
2889
|
+
if (epoch !== appContext.shared.restoreEpochRef.current) return;
|
|
2890
|
+
appContext.shared.setProjectManifest((current) => {
|
|
2891
|
+
const id = entry.motionRef?.motionId?.toLowerCase();
|
|
2892
|
+
if (!id || !current?.items?.some((item) => item.kind === "motion" && item.id === id)) return current;
|
|
2893
|
+
const items = current.items.map((item) => item.kind === "motion" && item.id === id
|
|
2894
|
+
? { ...item, status: "missing", url: undefined }
|
|
2895
|
+
: item);
|
|
2896
|
+
const totals = { embedded: 0, external: 0, missing: 0, bytes: 0 };
|
|
2897
|
+
for (const item of items) {
|
|
2898
|
+
totals[item.status] += 1;
|
|
2899
|
+
if (Number.isFinite(item.bytes)) totals.bytes += item.bytes;
|
|
2900
|
+
}
|
|
2901
|
+
return { items, totals, missing: items.filter((item) => item.status === "missing") };
|
|
2902
|
+
});
|
|
2903
|
+
// A saved take that fails to refetch used to vanish silently — the
|
|
2904
|
+
// user would find a merely posed character and assume their motion
|
|
2905
|
+
// was lost. Name it and offer the reload path.
|
|
2906
|
+
const subject = entry.subject || entry.id;
|
|
2907
|
+
appContext.notify(isKo
|
|
2908
|
+
? `저장된 모션을 다시 불러오지 못했어요 (${subject}) [${error?.code || "decode"}]`
|
|
2909
|
+
: `Saved motion could not be restored for ${subject} [${error?.code || "decode"}]`);
|
|
2910
|
+
});
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2914
|
+
function cancelArdy() {
|
|
2915
|
+
appContext.shared.ardyAbortRef.current?.abort();
|
|
2916
|
+
}
|
|
2917
|
+
function captureI2vStill() {
|
|
2918
|
+
// H3 480P renders 832x480. The still is captured at exactly that canvas
|
|
2919
|
+
// (x2) regardless of the Studio's shot ratio; markI2vPose also switches
|
|
2920
|
+
// the viewport to the matching ratio so what the user framed is what
|
|
2921
|
+
// gets sent.
|
|
2922
|
+
const captured = appContext.shared.captureLiveFraming({ output: I2V_MOTION_STILL_OUTPUT });
|
|
2923
|
+
if (!captured?.dataUrl?.startsWith("data:image/")) throw new Error(ko("The shot renderer is not ready.", "렌더러가 준비되지 않았어요."));
|
|
2924
|
+
if (captured.width !== I2V_MOTION_STILL_OUTPUT.width || captured.height !== I2V_MOTION_STILL_OUTPUT.height) {
|
|
2925
|
+
throw new Error(ko(`The H3 480P reference must be captured at ${I2V_MOTION_STILL_OUTPUT.width}×${I2V_MOTION_STILL_OUTPUT.height}.`, `H3 480P 참조 캡처는 ${I2V_MOTION_STILL_OUTPUT.width}×${I2V_MOTION_STILL_OUTPUT.height}이어야 해요.`));
|
|
2926
|
+
}
|
|
2927
|
+
// H3 must see the same complete subject in both endpoints. A clipped
|
|
2928
|
+
// foot or head makes the model invent the missing geometry during the
|
|
2929
|
+
// transition, which is exactly the bad motion this flow is meant to avoid.
|
|
2930
|
+
const rig = appContext.live.state.rigs?.[appContext.shared.activeChar.id];
|
|
2931
|
+
const cam = appContext.shared.shotCamRef.current;
|
|
2932
|
+
if (!rig || !cam) throw new Error(ko("The full-body frame is not ready yet. Wait a moment and try the reference capture again.", "전신 프레임을 확인할 수 없어 참조를 캡처할 수 없어요. 잠시 후 다시 시도하세요."));
|
|
2933
|
+
rig.updateWorldMatrix(true, true);
|
|
2934
|
+
cam.updateMatrixWorld(true);
|
|
2935
|
+
const bounds = new THREE.Box3().setFromObject(rig);
|
|
2936
|
+
const corners = [
|
|
2937
|
+
new THREE.Vector3(bounds.min.x, bounds.min.y, bounds.min.z),
|
|
2938
|
+
new THREE.Vector3(bounds.min.x, bounds.min.y, bounds.max.z),
|
|
2939
|
+
new THREE.Vector3(bounds.min.x, bounds.max.y, bounds.min.z),
|
|
2940
|
+
new THREE.Vector3(bounds.min.x, bounds.max.y, bounds.max.z),
|
|
2941
|
+
new THREE.Vector3(bounds.max.x, bounds.min.y, bounds.min.z),
|
|
2942
|
+
new THREE.Vector3(bounds.max.x, bounds.min.y, bounds.max.z),
|
|
2943
|
+
new THREE.Vector3(bounds.max.x, bounds.max.y, bounds.min.z),
|
|
2944
|
+
new THREE.Vector3(bounds.max.x, bounds.max.y, bounds.max.z),
|
|
2945
|
+
].map((corner) => corner.project(cam));
|
|
2946
|
+
const margin = 0.94;
|
|
2947
|
+
const clipped = corners.some((corner) =>
|
|
2948
|
+
corner.z < -1 || corner.z > 1 || Math.abs(corner.x) > margin || Math.abs(corner.y) > margin
|
|
2949
|
+
);
|
|
2950
|
+
if (clipped) {
|
|
2951
|
+
throw new Error(ko(
|
|
2952
|
+
"The full character is not inside the A/B reference. In the shot view, pull the camera back until the head and both feet are visible, then capture again.",
|
|
2953
|
+
"A/B 참조에 캐릭터 전신이 다 안 들어왔어요. 샷 시점에서 머리와 양발이 화면 안에 들어오도록 카메라를 뒤로 빼고 다시 캡처하세요."
|
|
2954
|
+
));
|
|
2955
|
+
}
|
|
2956
|
+
if (!appContext.shared.i2vMotionSegmentationReady || !Array.isArray(captured.partColours) || captured.partColours.length === 0) {
|
|
2957
|
+
throw new Error(ko("Enable View → Body part colours → Shaded before capturing an A/B reference.", "A/B 참조는 View에서 부위 색상 → 음영을 켜야 캡처할 수 있어요."));
|
|
2958
|
+
}
|
|
2959
|
+
return { ...captured, framing: appContext.shared.captureCurrentFraming() };
|
|
2960
|
+
}
|
|
2961
|
+
/** Put the viewport on the Fal canvas and hand the fly controls to the
|
|
2962
|
+
* shot camera, so the user composes the A/B reference on exactly the
|
|
2963
|
+
* 832x480 frame the clip will have. Idempotent; capture does not need it. */
|
|
2964
|
+
function enterI2vFraming() {
|
|
2965
|
+
appContext.shared.runStudioAction("stage.setFilmback", { shotAspect: I2V_MOTION_SHOT_ASPECT });
|
|
2966
|
+
if (!appContext.shared.lookThroughShot) appContext.shared.enterShotLook();
|
|
2967
|
+
}
|
|
2968
|
+
function markI2vPose(slot) {
|
|
2969
|
+
try {
|
|
2970
|
+
if (slot === "b" && i2vMotion.a && framingDistance(i2vMotion.a.framing, appContext.shared.captureCurrentFraming()) > 0.001) {
|
|
2971
|
+
throw new Error(ko("The camera moved between A and B. Capture both refs with the same camera framing.", "A와 B 사이에서 카메라가 이동했어요. 같은 카메라 프레이밍으로 다시 캡처하세요."));
|
|
2972
|
+
}
|
|
2973
|
+
const still = captureI2vStill();
|
|
2974
|
+
// The capture is already on the Fal canvas; make the viewport agree so
|
|
2975
|
+
// the user sees the frame that was just sent.
|
|
2976
|
+
appContext.shared.runStudioAction("stage.setFilmback", { shotAspect: I2V_MOTION_SHOT_ASPECT });
|
|
2977
|
+
setI2vMotion((current) => ({ ...current, [slot]: still, status: "idle", error: "" }));
|
|
2978
|
+
if (slot === "a") appContext.shared.setI2vMotionCameraUnlocked(false);
|
|
2979
|
+
appContext.notify(isKo ? `포즈 ${slot.toUpperCase()} 캡처됨 · ${still.width}×${still.height}` : `Pose ${slot.toUpperCase()} captured · ${still.width}×${still.height}`);
|
|
2980
|
+
} catch (error) {
|
|
2981
|
+
setI2vMotion((current) => ({ ...current, error: error.message, status: "error" }));
|
|
2982
|
+
}
|
|
2983
|
+
}
|
|
2984
|
+
function clearI2vPose(slot) {
|
|
2985
|
+
setI2vMotion((current) => ({ ...current, [slot]: null, status: "idle", error: "", job: null }));
|
|
2986
|
+
if (slot === "a") appContext.shared.setI2vMotionCameraUnlocked(false);
|
|
2987
|
+
}
|
|
2988
|
+
function clearI2vMotion() {
|
|
2989
|
+
setI2vMotion({ a: null, b: null, job: null, status: "idle", error: "", instruction: "", promptOverride: "", duration: I2V_MOTION_MIN_DURATION, dailyRemaining: null });
|
|
2990
|
+
appContext.shared.setI2vMotionCameraUnlocked(false);
|
|
2991
|
+
}
|
|
2992
|
+
function restoreI2vCamera() {
|
|
2993
|
+
const framing = i2vMotion.a?.framing;
|
|
2994
|
+
const camera = appContext.shared.shotCamRef.current;
|
|
2995
|
+
if (!framing || !camera) return;
|
|
2996
|
+
camera.position.set(framing.pos.x, framing.pos.y, framing.pos.z);
|
|
2997
|
+
camera.rotation.order = "YXZ";
|
|
2998
|
+
camera.rotation.set(framing.pitch, framing.yaw, 0);
|
|
2999
|
+
camera.fov = framing.fovDeg;
|
|
3000
|
+
camera.updateProjectionMatrix();
|
|
3001
|
+
appContext.shared.look.current.yaw = framing.yaw;
|
|
3002
|
+
appContext.shared.look.current.pitch = framing.pitch;
|
|
3003
|
+
appContext.shared.shotCameraPosRef.current = { ...framing.pos };
|
|
3004
|
+
appContext.shared.setCameraPos({ ...framing.pos });
|
|
3005
|
+
appContext.shared.setFovDeg(framing.fovDeg);
|
|
3006
|
+
appContext.shared.setI2vMotionCameraUnlocked(false);
|
|
3007
|
+
setI2vMotion((current) => ({ ...current, error: "", status: "idle" }));
|
|
3008
|
+
appContext.notify(isKo ? "A 캡처 카메라로 복원했어요." : "Restored the camera used for A.");
|
|
3009
|
+
}
|
|
3010
|
+
function framingDistance(a, b) {
|
|
3011
|
+
if (!a || !b) return Infinity;
|
|
3012
|
+
return Math.max(
|
|
3013
|
+
Math.abs(a.pos.x - b.pos.x), Math.abs(a.pos.y - b.pos.y), Math.abs(a.pos.z - b.pos.z),
|
|
3014
|
+
Math.abs(a.yaw - b.yaw), Math.abs(a.pitch - b.pitch), Math.abs(a.fovDeg - b.fovDeg),
|
|
3015
|
+
);
|
|
3016
|
+
}
|
|
3017
|
+
/** The AI motion card's lock line: AI video motion is not enabled for this account. */
|
|
3018
|
+
function showI2vMotionLock() {
|
|
3019
|
+
setI2vMotion((current) => ({ ...current, error: ko("AI motion generation is locked during QA.", "AI 모션 생성은 QA 중 잠겨 있어요."), status: "error" }));
|
|
3020
|
+
}
|
|
3021
|
+
/** The AI motion card shows every failure itself. For motion.generateFromVideo the
|
|
3022
|
+
* answer says what happened: `{ failed }` with the reason in English, or the
|
|
3023
|
+
* finished job, the footage it was ingested as (null when ingest failed) and
|
|
3024
|
+
* the account's daily generations left. */
|
|
3025
|
+
async function generateI2vMotion(kind = "interpolate", instructionOverride = null, commandContext = null) {
|
|
3026
|
+
// One explicit request on the motion:* contract (#466). While the route is
|
|
3027
|
+
// gated, the lock itself is the demand signal. Telemetry never decides.
|
|
3028
|
+
const motionRequest = startMotionRequest({
|
|
3029
|
+
surface: !commandContext ? "fal_card" : commandContext.origin === "mcp" || commandContext.origin === "live" ? "mcp" : "agent",
|
|
3030
|
+
input_mode: kind === "interpolate" ? "a_to_b" : "still",
|
|
3031
|
+
});
|
|
3032
|
+
if (!appContext.shared.i2vMotionEnabled) {
|
|
3033
|
+
motionRequest.block("locked");
|
|
3034
|
+
showI2vMotionLock();
|
|
3035
|
+
return { failed: "AI video motion (Fal) is not enabled for this account." };
|
|
3036
|
+
}
|
|
3037
|
+
let source = i2vMotion;
|
|
3038
|
+
if (kind === "act" && !source.a) {
|
|
3039
|
+
try { source = { ...source, a: captureI2vStill() }; setI2vMotion((current) => ({ ...current, a: source.a })); }
|
|
3040
|
+
catch (error) {
|
|
3041
|
+
motionRequest.block("missing_input");
|
|
3042
|
+
setI2vMotion((current) => ({ ...current, error: error.message, status: "error" }));
|
|
3043
|
+
return { failed: "Could not capture the character's pose frame: the full body must be inside the shot frame, shaded part colours must be on (view.setPartColours { mode: \"shaded\" }), and the renderer and rig must be ready." };
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
if (kind === "interpolate" && (!source.a || !source.b)) {
|
|
3047
|
+
motionRequest.block("missing_input");
|
|
3048
|
+
setI2vMotion((current) => ({ ...current, error: ko("Capture both A and B poses first.", "A와 B 포즈를 먼저 캡처하세요."), status: "error" }));
|
|
3049
|
+
return { failed: "Capture both A and B poses first." };
|
|
3050
|
+
}
|
|
3051
|
+
if (!source.a?.partColours || (kind === "interpolate" && !source.b?.partColours)) {
|
|
3052
|
+
motionRequest.block("missing_input");
|
|
3053
|
+
setI2vMotion((current) => ({ ...current, error: ko("Recapture A/B refs with shaded body-part segmentation enabled.", "색 세그멘테이션이 포함된 음영 A/B 참조를 다시 캡처하세요."), status: "error" }));
|
|
3054
|
+
return { failed: "The captured pose frame has no shaded body-part segmentation; the user must recapture it in the Fal card with shaded part colours on." };
|
|
3055
|
+
}
|
|
3056
|
+
if (kind === "interpolate" && framingDistance(source.a.framing, source.b.framing) > 0.001) {
|
|
3057
|
+
motionRequest.block("missing_input");
|
|
3058
|
+
setI2vMotion((current) => ({ ...current, error: ko("The camera changed between A and B. Capture both poses with the same camera.", "A와 B 사이에서 카메라가 바뀌었어요. 같은 카메라로 다시 캡처하세요."), status: "error" }));
|
|
3059
|
+
return { failed: "The camera changed between poses A and B; capture both with the same camera." };
|
|
3060
|
+
}
|
|
3061
|
+
// A hand-edited prompt wins verbatim; otherwise build from the description.
|
|
3062
|
+
// Interpolate now honours the description too (#380): the bare pose
|
|
3063
|
+
// difference lets the model invent the transition and produces junk.
|
|
3064
|
+
const description = instructionOverride || source.instruction || "";
|
|
3065
|
+
const prompt = source.promptOverride?.trim()
|
|
3066
|
+
? source.promptOverride.trim()
|
|
3067
|
+
: buildH3MotionPrompt(description || (kind === "interpolate" ? "" : "Make the character perform the requested action."), { interpolate: kind === "interpolate" });
|
|
3068
|
+
setI2vMotion((current) => ({ ...current, status: "submitting", error: "", job: null }));
|
|
3069
|
+
motionRequest.pass("fal");
|
|
3070
|
+
motionRequest.start();
|
|
3071
|
+
try {
|
|
3072
|
+
const fetchImpl = commandContext ? (url, options) => fetch(url, { ...options, signal: commandContext.signal }) : undefined;
|
|
3073
|
+
const submitted = await submitMotionJob({
|
|
3074
|
+
kind,
|
|
3075
|
+
stillA: source.a?.dataUrl,
|
|
3076
|
+
stillB: source.b?.dataUrl,
|
|
3077
|
+
still: source.a?.dataUrl,
|
|
3078
|
+
prompt,
|
|
3079
|
+
duration: source.duration ?? I2V_MOTION_MIN_DURATION,
|
|
3080
|
+
}, fetchImpl);
|
|
3081
|
+
const id = submitted?.job?.id;
|
|
3082
|
+
if (!id) throw Object.assign(new Error(ko("The server did not return a motion job ID.", "생성 작업 ID를 받지 못했어요.")), { reason: "The motion server did not return a job id." });
|
|
3083
|
+
setI2vMotion((current) => ({ ...current, status: "queued", job: submitted.job, dailyRemaining: submitted.dailyRemaining }));
|
|
3084
|
+
const finished = await waitForMotionJob(id, {
|
|
3085
|
+
fetchImpl,
|
|
3086
|
+
onUpdate: (job) => setI2vMotion((current) => ({ ...current, job, status: job?.status ?? current.status })),
|
|
3087
|
+
});
|
|
3088
|
+
commandContext?.check();
|
|
3089
|
+
const job = finished?.job;
|
|
3090
|
+
if (job?.status !== "done") throw Object.assign(new Error(job?.error || ko("AI motion generation failed.", "AI 모션 생성에 실패했어요.")), job?.error ? {} : { reason: "The AI video generation failed." });
|
|
3091
|
+
motionRequest.succeed();
|
|
3092
|
+
setI2vMotion((current) => ({ ...current, job, status: "done", dailyRemaining: finished.dailyRemaining }));
|
|
3093
|
+
let footage = null;
|
|
3094
|
+
if (job.video?.url) {
|
|
3095
|
+
const motionSource = { kind: "url", url: job.video.url, name: `Fal H3 Max Turbo · ${job.resolution}` };
|
|
3096
|
+
setMultiModelSource(motionSource);
|
|
3097
|
+
// Put the completed clip through the same probe/ingest path as a
|
|
3098
|
+
// manually supplied URL so GVHMR sees measured fps, duration and
|
|
3099
|
+
// a ready extraction card without another generation request.
|
|
3100
|
+
footage = (await ingestFootage(motionSource, commandContext)) ?? null;
|
|
3101
|
+
appContext.shared.setResult({
|
|
3102
|
+
mode: "video",
|
|
3103
|
+
modelLabel: "Fal H3 Max Turbo",
|
|
3104
|
+
prompt,
|
|
3105
|
+
frame: source.a?.dataUrl ?? null,
|
|
3106
|
+
videoUrl: job.video.url,
|
|
3107
|
+
motion: {
|
|
3108
|
+
videoUrl: job.video.url,
|
|
3109
|
+
resolution: job.resolution,
|
|
3110
|
+
width: job.width,
|
|
3111
|
+
height: job.height,
|
|
3112
|
+
fps: job.fps,
|
|
3113
|
+
duration: job.resultDuration ?? job.duration,
|
|
3114
|
+
cost: job.cost,
|
|
3115
|
+
},
|
|
3116
|
+
});
|
|
3117
|
+
appContext.shared.setResultOpen(true);
|
|
3118
|
+
// Applied = the clip is in the Studio, ready for GVHMR extraction.
|
|
3119
|
+
motionRequest.apply();
|
|
3120
|
+
// The result modal and the studio modal are both z-30; never stack them.
|
|
3121
|
+
appContext.shared.setI2vMotionStudioOpen(false);
|
|
3122
|
+
appContext.notify((isKo, ko) => isKo ? "AI 영상이 준비됐어요 · 추출 패널에서 GVHMR을 실행하세요" : "AI video is ready · run GVHMR from the extraction panel");
|
|
3123
|
+
}
|
|
3124
|
+
return { job, footage, dailyRemaining: finished.dailyRemaining ?? null };
|
|
3125
|
+
} catch (error) {
|
|
3126
|
+
const cancelled = Boolean(commandContext && (commandContext.signal.aborted || error?.code === "STALE_TARGET"));
|
|
3127
|
+
// 429 is the account's daily cap (workers/api daily_cap).
|
|
3128
|
+
motionRequest.fail(error, cancelled ? "aborted" : error?.status === 429 ? "quota" : "generation_failed");
|
|
3129
|
+
if (cancelled) throw error;
|
|
3130
|
+
setI2vMotion((current) => ({ ...current, status: "error", error: error.message || String(error) }));
|
|
3131
|
+
return { failed: error.reason ?? `The AI video generation failed: ${error.message || error}` };
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
/** Why the chip cannot start an AI video motion now, as the user reads it:
|
|
3135
|
+
* one already running, or none left today. The lock has its own AI motion card line. */
|
|
3136
|
+
function i2vMotionUnavailable() {
|
|
3137
|
+
if (!["idle", "done", "error", "failed"].includes(i2vMotion.status)) return ko("A generation is already running", "이미 생성이 돌고 있어요");
|
|
3138
|
+
if (i2vMotion.dailyRemaining === 0) return ko("No AI video motion generations left today", "오늘 남은 AI 영상 모션 생성이 없어요");
|
|
3139
|
+
return null;
|
|
3140
|
+
}
|
|
3141
|
+
function generateI2vMotionFromUi(instruction) {
|
|
3142
|
+
if (!appContext.shared.i2vMotionEnabled) {
|
|
3143
|
+
// Locked, the action's availability check refuses before
|
|
3144
|
+
// generateI2vMotion runs, so the chip records its own lock (#466).
|
|
3145
|
+
startMotionRequest({ surface: "agent", input_mode: "still" }).block("locked");
|
|
3146
|
+
showI2vMotionLock();
|
|
3147
|
+
} else {
|
|
3148
|
+
// The chip clears the typed instruction when clicked, so a refusal says why.
|
|
3149
|
+
const reason = i2vMotionUnavailable();
|
|
3150
|
+
if (reason) { appContext.notify(reason); return null; }
|
|
3151
|
+
}
|
|
3152
|
+
return appContext.shared.runStudioAction("motion.generateFromVideo", { instruction });
|
|
3153
|
+
}
|
|
3154
|
+
function updateI2vMotionQuota(dailyRemaining) { setI2vMotion((current) => ({ ...current, dailyRemaining })); }
|
|
3155
|
+
domain.setVideoDraft = patch => setI2vMotion(current => ({ ...current, ...patch }));
|
|
3156
|
+
domain.requestLineEdit = runLineEdit;
|
|
3157
|
+
domain.requestTrailRegeneration = runTrailRegeneration;
|
|
3158
|
+
domain.generate = generateMotion;
|
|
3159
|
+
domain.isGenerating = () => appContext.shared.generationPendingRef.current || appContext.shared.genRunningRef.current || ardyRunning;
|
|
3160
|
+
domain.onPhysicsRunning = setAutoPhysicsRunning;
|
|
3161
|
+
domain.onPhysicsProgress = setPhysicsProgress;
|
|
3162
|
+
domain.onPhysicsPreview = result => { setPhysicsPreview(result); setPhysicsShow(true); setIkTick(value => value + 1); };
|
|
3163
|
+
domain.platformFitRun = platformFitRun;
|
|
3164
|
+
domain.platformFitRemove = platformFitRemove;
|
|
3165
|
+
domain.beginPlayback = id => { const rig = appContext.shared.rigs[id]; if (rig) beginPlaybackOn(rig); };
|
|
3166
|
+
domain.loadRemote = async (args, context) => {
|
|
3167
|
+
const entry = domain.layer(args.characterId).takeVersions.find(version => version.motionUrl === args.motionUrl);
|
|
3168
|
+
if (args.motionUrl && !entry) throw new StudioProtocolError('TARGET_NOT_READY', 'This take version is no longer available.');
|
|
3169
|
+
if (args.drop != null && !normalizeRootDrop(args.drop)) throw new StudioProtocolError('INVALID_ARGUMENT', 'Invalid drop.');
|
|
3170
|
+
const toTimeline = frame => Math.round(frame * TIMELINE_FPS / ARDY_FPS);
|
|
3171
|
+
const clips = args.blocks?.length ? args.blocks.map(block => {
|
|
3172
|
+
if (block.endFrame <= block.startFrame) throw new StudioProtocolError('INVALID_RANGE', 'Prompt blocks must have a positive range.');
|
|
3173
|
+
return { id: crypto.randomUUID(), startFrame: toTimeline(block.startFrame), endFrame: toTimeline(block.endFrame), text: block.prompt };
|
|
3174
|
+
}) : null;
|
|
3175
|
+
const character = appContext.storeDomain('cast').read().find(row => row.id === args.characterId);
|
|
3176
|
+
const commandContext = !clips ? context : { ...context, commit: apply => context.commit(() => {
|
|
3177
|
+
const result = apply();
|
|
3178
|
+
appContext.recordAction('cast', () => appContext.storeDomain('cast').extendTimeline(Math.max(...clips.map(clip => clip.endFrame))), null, true);
|
|
3179
|
+
return result;
|
|
3180
|
+
}) };
|
|
3181
|
+
await loadMotion(args.url ?? args.motionUrl, args.prompt ?? entry?.recipe?.blocks?.map(block => block.prompt).join(' ') ?? domain.motionFor(args.characterId)?.prompt ?? '', character.rot,
|
|
3182
|
+
args.drop ?? null, args.characterId, clips, { commandContext, recipe: entry?.recipe });
|
|
3183
|
+
};
|
|
3184
|
+
appContext.updateActionPorts({ clearMotionNative: clearMotion, setCharacterIkKey, removeCharacterIkKey, clearCharacterIkKeys });
|
|
3185
|
+
return {
|
|
3186
|
+
...domain,
|
|
3187
|
+
i2vMotion, setI2vMotion, captureI2vStill, enterI2vFraming, markI2vPose, clearI2vPose, clearI2vMotion, restoreI2vCamera, framingDistance, showI2vMotionLock, generateI2vMotion, i2vMotionUnavailable, generateI2vMotionFromUi, updateI2vMotionQuota,
|
|
3188
|
+
ikMode, ikChains, setIkChains, ikFkJoints, setIkFkJoints, ikFocus, setIkFocus, footSnap, setFootSnap,
|
|
3189
|
+
bodyContact, setBodyContact, IK_CORRECTION_BLEND_FRAMES, autoPhysicsRunning, setAutoPhysicsRunning,
|
|
3190
|
+
physicsPreview, setPhysicsPreview, physicsShow, physicsProgress, physicsOptions, setPhysicsOptions,
|
|
3191
|
+
ikTick, setIkTick, committedIkEdits, setCommittedIkEdits, trailFalloffS, setTrailFalloffS, showTrails,
|
|
3192
|
+
setShowTrails, ikEditTool, setIkEditTool, trailEdit, trailFalloffFrames, focusIkHandle, snapshotIkKeys,
|
|
3193
|
+
rangePinApplySpec: (pin) => {
|
|
3194
|
+
domain.run('motion.rangePin.apply', { characterId: appContext.shared.activeChar.id, pin });
|
|
3195
|
+
return { residuals: decodeRangePinResiduals(domain.layer(appContext.shared.activeChar.id).ikPinResiduals).get(pin.id) };
|
|
3196
|
+
},
|
|
3197
|
+
rangePins, rangePinResiduals, rangePinSelection, setRangePinSelection, rangePinPartPick, setRangePinPartPick, rangePinPreview, previewRangePinDraft, applyRangePinDraft, deleteRangePin,
|
|
3198
|
+
setCharacterIkKey, removeCharacterIkKey, clearCharacterIkKeys, bridge, setBridge, bridgeChecking,
|
|
3199
|
+
motionSetupReveal, motionSetupKind, setArdyPrompt, setArdyDuration, ardySeed, preserveStrength,
|
|
3200
|
+
setPreserveStrength, takeRecipe, takeVersions, replayNotices, sceneMenuOpen, setSceneMenuOpen,
|
|
3201
|
+
ardyRunning, ardyStatus, ardyOutcome, lineEditBackend, setLineEditBackend, motion, motionBusy,
|
|
3202
|
+
multiModelUrl, setMultiModelUrl, multiModelSource, setMultiModelSource, multiModelStatus,
|
|
3203
|
+
multiModelStage, multiModelProgress, multiModelFootage, multiModelError, multiModelTake,
|
|
3204
|
+
multiModelExtract, multiModelExtractProgress, multiModelExtractError, advanceFrame, stepFrame,
|
|
3205
|
+
leaveIkMode, beginPlaybackOn, chooseMultiModelFile, pasteMultiModelUrl, useMultiModelUrl, ingestFootage,
|
|
3206
|
+
extractMultiModelMotion, deliverExtraTakes, loadMotion, clearMotion: () => appContext.bus.run('motion.clear', { characterId: appContext.shared.activeChar.id }), clearMotionNative: clearMotion, applyMotionTrim, resetMotionTrim, cutMotionAtPlayhead,
|
|
3207
|
+
changeMotionSegmentSpeed, removeMotionSegmentById, poseOtherCastMembers, toggleIkMode, ikSolve,
|
|
3208
|
+
ikDragEnd, ikAddKeyframe, externalBlockers, runFixCollisions, runFixCollisionsRange,
|
|
3209
|
+
changePhysicsOptions, showPhysicsPreview, cancelPhysicsPreview, applyPhysicsPreview, runAutoPhysics,
|
|
3210
|
+
platformFitRun, platformFitApply, platformFitRemove, platformFitRunning, platformFitLast, platformFitProgress, platformFitApplied,
|
|
3211
|
+
ikDeleteKeyframe, ikApplyPoseAsKey, recheckMotionHealth, changeArdySeed, takeSeed,
|
|
3212
|
+
runLineEdit: () => domain.run('motion.commitLineEdit', { characterId: appContext.shared.activeChar.id }),
|
|
3213
|
+
runAllPromptBlocks, runArdy, onTrailDragStart, onTrailDragPreview, onTrailDragEnd,
|
|
3214
|
+
runTrailRegeneration: () => domain.run('motion.regenerateTrail', { characterId: appContext.shared.activeChar.id }),
|
|
3215
|
+
genQueue, setGenQueue, executeMotionJob, seedLoadedTake, loadTakeVersion, selectedMotionReadiness,
|
|
3216
|
+
generationBusy, openMotionSetup, refineDisabledReason, sceneDisabledReason, sceneGenerateDisabledReason,
|
|
3217
|
+
sceneAgainDisabledReason, enterRefineMode, runSceneAgain, addSceneBlock, restoreMotionRefs, cancelArdy,
|
|
3218
|
+
};
|
|
3219
|
+
}
|