cozyclay 2.0.2 → 2.1.1
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 +61 -0
- package/README.md +17 -2
- package/THIRD_PARTY_NOTICES.md +8 -0
- package/bin/agent/agent-routes.mjs +211 -36
- package/bin/agent/agent-runner.mjs +98 -4
- package/bin/agent/codex-client.mjs +2 -1
- package/bin/agent/fal-contract.mjs +0 -17
- package/bin/agent/h3-preservation.mjs +6 -8
- package/bin/agent/provider-keys.mjs +1 -1
- package/bin/agent/providers.mjs +124 -3
- package/bin/agent/script-tool.mjs +74 -0
- package/bin/agent/session-store.mjs +5 -5
- package/bin/agent/studio-prompt.mjs +2 -1
- package/bin/agent/studio-rebase.mjs +314 -0
- package/bin/agent/studio-tools.mjs +21 -14
- package/bin/agent/supervisor.mjs +311 -0
- package/bin/agent/video-adapters.mjs +0 -7
- package/bin/codex-auth.mjs +4 -4
- package/bin/live/cli.mjs +23 -8
- package/bin/live/client.mjs +1 -1
- package/bin/live/studio.mjs +5 -2
- package/bin/mcp-runtime.mjs +2 -2
- package/bin/telemetry-state.mjs +3 -3
- package/dist/app/index.html +6 -6
- package/dist/assets/analytics-C9pMpW7s.js +1 -0
- package/dist/assets/app-B_2XB9rw.css +1 -0
- package/dist/assets/app-DAuvKPP8.js +4920 -0
- package/dist/assets/{demo-ByeYp-dY.js → demo-CvKuBgy5.js} +1 -1
- package/dist/assets/{first-shot-handoff-DgT28ZiV.js → first-shot-handoff-msLI71g1.js} +1 -1
- package/dist/assets/{landing-BaXkpFip.js → landing-ChUvB_zL.js} +1 -1
- package/dist/assets/scene-asset-sync-DKkpjKBV.js +19 -0
- package/dist/assets/{shot-prompt-CbqWcWYi.css → scene-asset-sync-DjJ_cJ2R.css} +1 -1
- package/dist/assets/{ticket-BcmsnHn5.js → ticket-BPk2Ni95.js} +1 -1
- package/dist/assets/workflow-jJRBR94Z.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +328 -222
- package/dist/llms.txt +14 -0
- package/dist/sitemap.xml +1 -1
- package/dist/workflow/index.html +4 -4
- package/mcp/LIVE-PROTOCOL.md +23 -2
- package/mcp/README.md +37 -2
- package/mcp/ardy-prompts.mjs +1 -1
- package/mcp/image-file.mjs +132 -0
- package/mcp/live-hub.mjs +2 -2
- package/mcp/package.json +1 -0
- package/mcp/runtime/package-lock.json +7 -0
- package/mcp/runtime/package.json +1 -0
- package/mcp/server.mjs +0 -6
- package/mcp/tool-handlers.mjs +289 -87
- package/package.json +11 -2
- package/src/App.jsx +813 -278
- package/src/agent-image-client.js +94 -0
- package/src/analytics.js +47 -3
- package/src/app-stage.jsx +129 -2745
- package/src/ardy/auto-physics.js +6 -16
- package/src/ardy/collision-blockers.js +3 -7
- package/src/ardy/fix-collisions.js +5 -35
- package/src/ardy/ik-drag.js +86 -5
- package/src/ardy/ik-key-json.js +3 -1
- package/src/ardy/ik.js +15 -13
- package/src/ardy/motion-calibration.js +1 -1
- package/src/ardy/npz.js +0 -8
- package/src/ardy/physics-panel.jsx +3 -2
- package/src/ardy/physics-review.js +3 -2
- package/src/ardy/physics-support.js +1 -1
- package/src/ardy/physics-surface.js +1 -1
- package/src/ardy/platform-fit-panel.jsx +2 -1
- package/src/ardy/playback.js +1 -5
- package/src/ardy/range-pin.js +2 -2
- package/src/ardy/root-drop.js +0 -1
- package/src/ardy/timeline.css +184 -6
- package/src/ardy/timeline.jsx +121 -36
- package/src/ardy/to-cskel27.js +0 -2
- package/src/ardy/waypoints.js +0 -3
- package/src/ardy-bridge-contract.js +281 -0
- package/src/asset-pane.jsx +19 -9
- package/src/auto-color.js +1 -1
- package/src/burn-in.js +1 -1
- package/src/camera-block.js +1 -1
- package/src/camera-follow.js +75 -32
- package/src/camera-move.js +0 -6
- package/src/camera-tutorial.jsx +6 -7
- package/src/character-kind.js +79 -0
- package/src/command-bus.js +76 -17
- package/src/commands/cast.js +31 -7
- package/src/commands/elements/object.js +1 -1
- package/src/commands/elements/shot.js +1 -1
- package/src/commands/elements.js +1 -1
- package/src/commands/export.js +3 -3
- package/src/commands/motion.js +2 -2
- package/src/commands/objects.js +105 -10
- package/src/commands/project.js +15 -2
- package/src/commands/sculpt-images.js +53 -0
- package/src/commands/shot.js +88 -10
- package/src/commands/view.js +10 -4
- package/src/coplanar-depth.js +216 -0
- package/src/cuts.js +7 -6
- package/src/domains/cast.js +60 -43
- package/src/domains/motion/motion-intent-codec.js +50 -0
- package/src/domains/motion/motion-range-pin-rules.js +23 -0
- package/src/domains/motion/motion-video-capture.js +664 -0
- package/src/domains/motion.js +253 -742
- package/src/domains/objects.js +106 -5
- package/src/domains/scenes.js +79 -28
- package/src/domains/shots.js +33 -7
- package/src/error-boundary.jsx +1 -1
- package/src/execution-telemetry.js +5 -5
- package/src/facing-marks.js +14 -1
- package/src/first-success-guide.jsx +35 -4
- package/src/gizmo-claim.js +3 -1
- package/src/grid-floor.jsx +1 -4
- package/src/hierarchy-model.js +48 -35
- package/src/hierarchy-panel.css +1 -0
- package/src/hierarchy-panel.jsx +54 -102
- package/src/history.js +0 -2
- package/src/i2v-motion-client.js +1 -4
- package/src/ime.js +6 -0
- package/src/keyframe-pack.js +15 -6
- package/src/line-edit.js +4 -4
- package/src/live-control.js +6 -6
- package/src/matte-editor.js +2 -8
- package/src/matte.js +0 -19
- package/src/mesh-graph-clone.js +16 -2
- package/src/mesh-sniff.js +0 -1
- package/src/monotone-cubic.js +40 -0
- package/src/motion-store.js +4 -4
- package/src/motion-trail.js +5 -11
- package/src/multimodel-ingest.js +1 -3
- package/src/object-catalog.jsx +7 -5
- package/src/object-gizmo.jsx +45 -31
- package/src/object-path.js +310 -67
- package/src/object-travel.js +224 -0
- package/src/otio.js +8 -3
- package/src/panels/CharacterTransformPanel.jsx +24 -6
- package/src/panels/ObjectTransformPanel.jsx +4 -3
- package/src/panels/PoseImagePanel.jsx +205 -0
- package/src/panels/PromptBlocksPanel.jsx +3 -1
- package/src/panels/ProxyFigurePanel.jsx +56 -0
- package/src/panels/ReferenceImageField.jsx +1 -1
- package/src/panels/RigControlPanel.jsx +24 -3
- package/src/panels/RigPanel.jsx +2 -1
- package/src/panels/SculptPanel.jsx +405 -0
- package/src/panels/SubjectBox.jsx +1 -1
- package/src/panels/SubjectsPanel.jsx +0 -2
- package/src/panels/VideoCapturePanel.jsx +2 -1
- package/src/panels/pose-image.css +42 -0
- package/src/panels/pose.css +3 -0
- package/src/panels/sculpt.css +162 -0
- package/src/plan-diagram.js +184 -0
- package/src/planview.jsx +127 -80
- package/src/playground.js +3 -5
- package/src/pose-fade.js +77 -0
- package/src/pose-gizmo.css +23 -0
- package/src/pose-gizmo.js +96 -0
- package/src/posestudio.jsx +134 -83
- package/src/project-browser.css +53 -0
- package/src/project-browser.jsx +49 -4
- package/src/project.js +55 -24
- package/src/props.jsx +177 -36
- package/src/proxy-figure.jsx +69 -0
- package/src/range-pin-panel.jsx +0 -1
- package/src/receipt-findings.js +20 -0
- package/src/render-passes.js +0 -1
- package/src/resource-status.jsx +1 -1
- package/src/root-path.js +48 -0
- package/src/route-owner.js +52 -0
- package/src/scene-asset-cache.js +1 -2
- package/src/scene-assets.js +12 -4
- package/src/scene-fbx.js +1 -1
- package/src/scene-mesh-cache.js +0 -1
- package/src/scene-objects.js +319 -19
- package/src/scenes.js +58 -9
- package/src/sculpt-blueprint.js +65 -0
- package/src/sculpt-compare.js +384 -0
- package/src/sculpt-geometry.js +141 -0
- package/src/sculpt-mode.js +168 -0
- package/src/sculpt-recipe.js +385 -0
- package/src/sculpt-run.js +351 -0
- package/src/sculpt-stage.jsx +221 -0
- package/src/settings-menu.jsx +1 -1
- package/src/shell/BoardSlot.jsx +341 -0
- package/src/shell/BottomDock.jsx +60 -16
- package/src/shell/CompositionGuideButton.jsx +20 -0
- package/src/shell/DetailsSlot.jsx +26 -6
- package/src/shell/MenuBar.jsx +78 -6
- package/src/shell/OutlinerSlot.jsx +3 -1
- package/src/shell/ShotPreviewHeader.jsx +17 -0
- package/src/shell/TopBar.jsx +18 -3
- package/src/shell/ViewportToolbar.jsx +36 -31
- package/src/shell/dock.css +239 -0
- package/src/shell/glass-regions.css +3 -2
- package/src/shell/log-store.js +4 -4
- package/src/shell/mode.css +20 -0
- package/src/shell/studio-shell-context.js +4 -1
- package/src/shell/viewport.css +12 -0
- package/src/shot-authoring.js +69 -6
- package/src/stable-items.js +1 -1
- package/src/stage-attach-frames.js +169 -0
- package/src/stage-canvas-rigs.jsx +402 -0
- package/src/stage-capture.jsx +216 -0
- package/src/stage-characters.jsx +302 -0
- package/src/stage-editor-gizmos.jsx +1229 -0
- package/src/stage-labels-ko.js +204 -0
- package/src/stage-startup-defaults.js +182 -0
- package/src/starter-scenes.js +1 -1
- package/src/storyboard.js +24 -5
- package/src/studio-actions.js +42 -9
- package/src/studio-agent-commands.js +213 -23
- package/src/studio-agent-context.js +11 -9
- package/src/studio-agent-motion.js +2 -1
- package/src/studio-agent-protocol.js +60 -24
- package/src/studio-app-binding.js +56 -23
- package/src/studio-contact-sheet.js +3 -3
- package/src/studio-elements.js +11 -2
- package/src/studio-geometry-facts.js +114 -0
- package/src/styles.css +92 -9
- package/src/ui-scrub.js +4 -4
- package/src/ui.jsx +6 -4
- package/src/use-asset-shelf.js +60 -0
- package/src/use-camera-tutorial.js +50 -0
- package/src/use-embed-bridge.js +12 -0
- package/src/use-first-success-prompt.js +7 -0
- package/src/use-i2v-capture-modal.js +15 -0
- package/src/use-sculpt-mode.js +95 -0
- package/src/workflow/AgentPanel.jsx +106 -18
- package/src/workflow/WorkflowBuilder.jsx +1 -1
- package/src/workflow/agent-client.js +113 -45
- package/src/workflow/agent-panel.css +35 -0
- package/src/workflow/attachment-image.js +1 -1
- package/src/workflow/canvas-commands.js +3 -3
- package/src/workflow/cozy-scene-node.js +3 -3
- package/src/workflow/local-workflow.js +2 -2
- package/src/workflow/motion-input.js +3 -3
- package/src/workflow/node-schema.js +1 -1
- package/src/workflow/scene-asset-sync.js +2 -9
- package/src/workflow/shot-prompt-node.js +1 -1
- package/src/workflow/video-contract.js +1 -1
- package/src/workflow/workflow-resources.js +0 -8
- package/src/workflow-send.js +87 -0
- package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
- package/tools/check-eslint.mjs +57 -0
- package/tools/check-knip.mjs +60 -0
- package/tools/check-loc.mjs +54 -0
- package/tools/demo-worker/api-client.mjs +7 -13
- package/tools/demo-worker/generate.mjs +2 -4
- package/tools/eslint-baseline.json +23 -0
- package/tools/gt-render/browser.mjs +23 -0
- package/tools/headless-studio.mjs +85 -0
- package/tools/kimodo/effector-constraints.mjs +4 -4
- package/tools/kimodo/generate.mjs +1 -1
- package/tools/knip-baseline.json +331 -0
- package/tools/loc-baseline.json +126 -0
- package/tools/process-supervisor.mjs +1 -1
- package/tools/projflow/replay.mjs +4 -4
- package/tools/projflow/runner.mjs +3 -3
- package/tools/qa/studio-control-count.mjs +2 -0
- package/tools/run-tests.mjs +95 -7
- package/dist/assets/analytics-MVZOEW8O.js +0 -1
- package/dist/assets/app-BxBLC6GT.js +0 -4876
- package/dist/assets/app-CJdxvnrl.css +0 -1
- package/dist/assets/shot-prompt-hN0j_O-p.js +0 -17
- package/dist/assets/workflow-B5XZwVfe.js +0 -179
package/src/domains/motion.js
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
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
1
|
import { useState, useEffect, useSyncExternalStore, useContext, useRef, useMemo } from "react";
|
|
6
2
|
import { AppContext } from '../app-context.js';
|
|
7
3
|
import { createDocumentStore } from '../document-store.js';
|
|
@@ -11,14 +7,11 @@ import {
|
|
|
11
7
|
TIMELINE_FPS,
|
|
12
8
|
hierarchyIdForIkFocus,
|
|
13
9
|
ARDY_PRESERVE_DEFAULT,
|
|
14
|
-
MULTIMODEL_REASONS,
|
|
15
|
-
nextCharacterId,
|
|
16
10
|
REST_BONES,
|
|
17
11
|
ARDY_SEED_MAX,
|
|
18
12
|
lineTrackLabel,
|
|
19
13
|
ARDY_DURATION_MIN,
|
|
20
14
|
buildPromptSchedule,
|
|
21
|
-
toArdyFrameEntries,
|
|
22
15
|
posePlacementFrame,
|
|
23
16
|
toArdySegments,
|
|
24
17
|
ARDY_FPS,
|
|
@@ -51,22 +44,15 @@ import * as THREE from "three";
|
|
|
51
44
|
import { StudioProtocolError } from "../studio-agent-protocol.js";
|
|
52
45
|
import { restorePlaybackBones, snapshotPlaybackBones, applyMotionFrame, captureArdyRoot } from "../ardy/playback.js";
|
|
53
46
|
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
47
|
import { characterScaleFor, loadMotionFromUrl } from "../ardy/npz.js";
|
|
64
48
|
import { supportHeightForObject, OBJECT_LIBRARY } from "../scene-objects.js";
|
|
65
49
|
import { applySupportRise, autoRoofDrop, applyAutoFall, applyRootDrop, normalizeRootDrop } from "../ardy/root-drop.js";
|
|
66
|
-
import {
|
|
50
|
+
import { isProxyFigure } from "../scenes.js";
|
|
51
|
+
import { characterCapabilities, kindRefusal, refuseRigOnly } from "../character-kind.js";
|
|
67
52
|
import { retimeMotion } from "../ardy/retime.js";
|
|
68
53
|
import {
|
|
69
54
|
createMotionEdit,
|
|
55
|
+
motionEditLayout,
|
|
70
56
|
renderMotionEdit,
|
|
71
57
|
remapFrameKeyMap,
|
|
72
58
|
remapTimelineFrame,
|
|
@@ -92,8 +78,8 @@ import {
|
|
|
92
78
|
} from "../take-recipe.js";
|
|
93
79
|
import { buildGenerationRequest, generationRefusal } from '../motion/generation.js';
|
|
94
80
|
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";
|
|
81
|
+
import { TRAIL_TRACKS, applyTrailFalloffDelta, restoreTrailEdits, trailEditRange, worldDeltaToTrailClip } from "../motion-trail.js";
|
|
82
|
+
import { bakeIkDragKey, bakeIkDragRange, chainsChangedBy, ikDragRecord, ikDragTouch } from "../ardy/ik-drag.js";
|
|
97
83
|
import { ikKeyJson, ikTrackKeyFromJson } from "../ardy/ik-key-json.js";
|
|
98
84
|
import { findAbsoluteIkKeyConflicts } from "../trail-key-conflicts.js";
|
|
99
85
|
import { chooseIkEntryPose } from "../ik-camera.js";
|
|
@@ -103,52 +89,10 @@ import { openMotionDb, getMotion, putMotion } from "../motion-store.js";
|
|
|
103
89
|
import { resolveMotionSource, decodeMotionResource, encodeMotionResource, sha256Hex } from "../motion-resources.js";
|
|
104
90
|
import { applyRangePin, captureRangePinTarget, normalizeRangePin, rangePinTargetWorld, rangePinTracks, removeRangePinKeys } from "../ardy/range-pin.js";
|
|
105
91
|
import { rangePinObjectMatrixAt } from "../range-pin-object-transform.js";
|
|
92
|
+
import { createMotionVideoCapture } from "./motion/motion-video-capture.js";
|
|
93
|
+
import { ensureRangePinState, overlappingRangePin, rangePinConflictFrames, rangePinOverlapMessage } from "./motion/motion-range-pin-rules.js";
|
|
106
94
|
|
|
107
|
-
|
|
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
|
-
|
|
95
|
+
import { sameMotionIntent, emptyMotionLayer, encodeMotionKeys, decodeMotionKeys, cloneRangePin, encodeRangePins, decodeRangePins, encodeRangePinResiduals, decodeRangePinResiduals } from "./motion/motion-intent-codec.js";
|
|
152
96
|
// Plain intent owns take identities and JSON keys. Decoded buffers and rig
|
|
153
97
|
// preimages are copy-on-record runtime resources, never mutable store values.
|
|
154
98
|
export function createMotionDomain(appContext, characters) {
|
|
@@ -158,7 +102,10 @@ export function createMotionDomain(appContext, characters) {
|
|
|
158
102
|
const ref = entry.motionRef;
|
|
159
103
|
const take = ref ? { resourceId: `restore:${entry.id}:${ref.motionId ?? ref.url}`, url: ref.url ?? null, anchorX: ref.anchorX, anchorZ: ref.anchorZ,
|
|
160
104
|
rotationDeg: ref.rotationDeg, prompt: ref.prompt ?? '', ...(ref.trailEdits ? { trailEdits: ref.trailEdits } : {}), ...(ref.studioTakeId ? { studioTakeId: ref.studioTakeId } : {}) } : null;
|
|
161
|
-
|
|
105
|
+
// The saved Pose-mode / IK edits come back with the scene (scenes.js ikEdits).
|
|
106
|
+
const edits = entry.ikEdits;
|
|
107
|
+
return { id: entry.id, take, fullTake: take, takeVersions: ref?.url ? [{ motionUrl: ref.url, recipe: null, savedAt: Date.now(), label: ko('Loaded', '불러옴') }] : [],
|
|
108
|
+
ikKeys: edits?.keys ?? [], ikPins: edits?.pins ?? [], ikPinResiduals: edits?.pinResiduals ?? [] };
|
|
162
109
|
});
|
|
163
110
|
function snapshotTake(take) {
|
|
164
111
|
if (!take) return null;
|
|
@@ -192,6 +139,8 @@ export function createMotionDomain(appContext, characters) {
|
|
|
192
139
|
const visibleMotion = id => previews.get(id) ?? motionFor(id);
|
|
193
140
|
const frame = () => appContext.live.state?.timeline.currentFrame ?? 0;
|
|
194
141
|
const rigFor = id => appContext.shared.rigs[id];
|
|
142
|
+
// Rig-only tools refuse a capsule figure by name before looking for its rig.
|
|
143
|
+
const rigOnly = (id, feature) => refuseRigOnly(appContext.storeDomain('cast')?.read().find(row => row.id === id), feature);
|
|
195
144
|
function rigSnapshots() {
|
|
196
145
|
return new Map(Object.entries(appContext.shared.rigs).map(([id, rig]) => [id, appContext.shared.snapshotExportRig(rig)]));
|
|
197
146
|
}
|
|
@@ -245,8 +194,10 @@ export function createMotionDomain(appContext, characters) {
|
|
|
245
194
|
.flatMap(rows => rows.flatMap(row => [row.take?.resourceId, row.fullTake?.resourceId])).filter(Boolean));
|
|
246
195
|
for (const id of takes.keys()) if (!ids.has(id)) takes.delete(id);
|
|
247
196
|
}
|
|
248
|
-
function beginAction() {
|
|
249
|
-
|
|
197
|
+
function beginAction(preimages = null) {
|
|
198
|
+
// The bus passes a target id; an explicit preview commit can override
|
|
199
|
+
// only its rig's preimage without retaining the preview in Undo.
|
|
200
|
+
rigImages.set(read(), new Map([...(gesture?.rigs ?? rigSnapshots()), ...(preimages instanceof Map ? preimages : [])]));
|
|
250
201
|
const session = documentStore.beginAction('motion');
|
|
251
202
|
return { ...session,
|
|
252
203
|
run(fn) { return session.run(() => { running++; try { return fn(); } finally { running--; } }); },
|
|
@@ -267,6 +218,7 @@ export function createMotionDomain(appContext, characters) {
|
|
|
267
218
|
setRangePinState(id, state);
|
|
268
219
|
}
|
|
269
220
|
function bakeRangePin(id, spec, replaceExisting = false, projectionOnly = false) {
|
|
221
|
+
rigOnly(id, 'ik');
|
|
270
222
|
const motion = motionFor(id), rig = rigFor(id), resolved = rig && resolveIkRig(rig);
|
|
271
223
|
if (!motion || !resolved) throw new StudioProtocolError('TARGET_NOT_READY', 'Load a take and rig for this character first.');
|
|
272
224
|
const pin = normalizeRangePin(spec, { clipFrames: motion.frames }), state = rangePinState(id);
|
|
@@ -331,7 +283,8 @@ export function createMotionDomain(appContext, characters) {
|
|
|
331
283
|
function clear(id) {
|
|
332
284
|
previews.delete(id);
|
|
333
285
|
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
|
|
286
|
+
castWrite(rows => rows.map(row => row.id === id ? { ...row, scale: 1, motionRef: null,
|
|
287
|
+
layer: { ...row.layer, promptClips: [] } } : row));
|
|
335
288
|
synchronizeTimeline();
|
|
336
289
|
}
|
|
337
290
|
function editSegments(id, segments) {
|
|
@@ -351,6 +304,7 @@ export function createMotionDomain(appContext, characters) {
|
|
|
351
304
|
synchronizeTimeline();
|
|
352
305
|
}
|
|
353
306
|
function fix(id, scope = 'frame') {
|
|
307
|
+
rigOnly(id, 'collision');
|
|
354
308
|
const rig = rigFor(id), resolved = rig && resolveIkRig(rig);
|
|
355
309
|
if (!resolved) throw new StudioProtocolError('TARGET_NOT_READY', 'The character rig is not loaded.');
|
|
356
310
|
const state = appContext.shared.ikStatesRef.current.get(id) ?? { ...createIkState(), ...resolved };
|
|
@@ -362,7 +316,7 @@ export function createMotionDomain(appContext, characters) {
|
|
|
362
316
|
try {
|
|
363
317
|
fixCollisionsRange({ rig, ...resolved, ikState: state, startFrame: 0, endFrame: take.frames - 1,
|
|
364
318
|
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); } });
|
|
319
|
+
blockersAt(at) { for (const row of read()) if (row.id !== id && rigFor(row.id)) appContext.shared.poseMemberAtFrame(rigFor(row.id), motionFor(row.id), appContext.shared.ikStatesRef.current.get(row.id), at, 6); return blockers(at); } });
|
|
366
320
|
} finally { for (const snapshot of originals.values()) appContext.shared.restoreExportRig(snapshot); }
|
|
367
321
|
} else {
|
|
368
322
|
const result = fixCollisions(rig, resolved.chains, { ikState: state, fkJoints: resolved.fkJoints, blockers: blockers(frame()) });
|
|
@@ -388,6 +342,7 @@ export function createMotionDomain(appContext, characters) {
|
|
|
388
342
|
return keyed ? run('ik.setKey', { characterId: id, frame: at, tracks: keyed.tracks }) : null;
|
|
389
343
|
}
|
|
390
344
|
function keyPose(id, at, pose) {
|
|
345
|
+
rigOnly(id, 'pose');
|
|
391
346
|
const rig = rigFor(id), resolved = rig && resolveIkRig(rig);
|
|
392
347
|
if (!resolved) throw new StudioProtocolError('TARGET_NOT_READY', 'The character rig is not loaded.');
|
|
393
348
|
restoreBindPositions(rig); applyPose(rig, { ...REST_BONES, ...pose.bones }); applyHipsOffset(rig, pose.rootY ?? 0);
|
|
@@ -397,6 +352,7 @@ export function createMotionDomain(appContext, characters) {
|
|
|
397
352
|
});
|
|
398
353
|
}
|
|
399
354
|
function editTrail(id, { track = "hips", grabFrame, radiusFrames, delta }) {
|
|
355
|
+
rigOnly(id, 'trails');
|
|
400
356
|
const take = motionFor(id), character = appContext.storeDomain('cast').read().find(row => row.id === id);
|
|
401
357
|
if (!take) throw new StudioProtocolError('TARGET_NOT_READY', 'Load a take first.');
|
|
402
358
|
const scale = character.scale ?? 1;
|
|
@@ -410,12 +366,14 @@ export function createMotionDomain(appContext, characters) {
|
|
|
410
366
|
let physicsJob = 0;
|
|
411
367
|
const physicsStamp = id => JSON.stringify([layer(id), appContext.storeDomain('cast').read().find(row => row.id === id)]);
|
|
412
368
|
function applyPhysics(id) {
|
|
369
|
+
rigOnly(id, 'physics');
|
|
413
370
|
const preview = physicsReviews.get(id);
|
|
414
371
|
if (!preview || preview.stamp !== physicsStamp(id)) throw new StudioProtocolError('STALE_TARGET', 'Analyse this take again before applying corrections.');
|
|
415
372
|
setKeys(id, preview.result.candidate.keys);
|
|
416
373
|
domain.onPhysicsPreview?.(null);
|
|
417
374
|
}
|
|
418
375
|
async function autoPhysics(id, options, context) {
|
|
376
|
+
rigOnly(id, 'physics');
|
|
419
377
|
const rig = rigFor(id), resolved = rig && resolveIkRig(rig), take = motionFor(id);
|
|
420
378
|
if (!resolved || !take) throw new StudioProtocolError('TARGET_NOT_READY', 'Load a take and character rig first.');
|
|
421
379
|
const job = ++physicsJob, stamp = physicsStamp(id), original = appContext.shared.snapshotExportRig(rig);
|
|
@@ -541,7 +499,6 @@ export function useMotion(appContext) {
|
|
|
541
499
|
return () => { window.removeEventListener('pointerdown', start, true); domain.finishGesture(true); };
|
|
542
500
|
}, [domain, appContext.shared.activeChar.id]);
|
|
543
501
|
const [i2vMotion, setI2vMotion] = useState({ a: null, b: null, job: null, status: "idle", error: "", instruction: "", dailyRemaining: null });
|
|
544
|
-
/* ------------------------------ IK layer ------------------------------ */
|
|
545
502
|
// IK posing for Subject 1: dragging a wrist/ankle handle FOCUSES that
|
|
546
503
|
// joint and solves its chain backward (two-bone analytic IK) on top of
|
|
547
504
|
// the current pose; joints never dragged stay purely on the FK pose.
|
|
@@ -554,6 +511,8 @@ export function useMotion(appContext) {
|
|
|
554
511
|
// outside React renders so pointer moves stay cheap and the drag-end bake
|
|
555
512
|
// can create a delta key over the raw clip.
|
|
556
513
|
const ikDragRef = useRef(null);
|
|
514
|
+
const ikPreDragRef = useRef(null);
|
|
515
|
+
const pendingIkEditRef = useRef(null);
|
|
557
516
|
const ikCameraMemoryRef = useRef(new Map());
|
|
558
517
|
|
|
559
518
|
const [ikChains, setIkChains] = useState(null);
|
|
@@ -561,6 +520,7 @@ export function useMotion(appContext) {
|
|
|
561
520
|
const [ikFkJoints, setIkFkJoints] = useState(null);
|
|
562
521
|
|
|
563
522
|
const [ikFocus, setIkFocus] = useState(null);
|
|
523
|
+
const [pendingIkEdit, setPendingIkEdit] = useState(null);
|
|
564
524
|
|
|
565
525
|
// Foot snap (ground plant): while ON, body (hips) drags keep the feet at
|
|
566
526
|
// the positions captured when the drag started — the knees bend instead
|
|
@@ -600,8 +560,7 @@ export function useMotion(appContext) {
|
|
|
600
560
|
const setCommittedIkEdits = update => domain.writeLayer(appContext.shared.activeChar.id, { committedIkEdits: typeof update === 'function' ? update(domain.layer(appContext.shared.activeChar.id).committedIkEdits) : update });
|
|
601
561
|
domain.onProject = () => setIkTick(value => value + 1);
|
|
602
562
|
|
|
603
|
-
/*
|
|
604
|
-
* Grabbing the viewport trajectory line deforms the loaded take with a
|
|
563
|
+
/* Grabbing the viewport trajectory line deforms the loaded take with a
|
|
605
564
|
* smoothstep falloff around the grab frame (pure local preview). The last
|
|
606
565
|
* finished drag stays pending so "Regenerate from trail edit" can send the
|
|
607
566
|
* auto-derived window through the existing motionEdit pipeline. */
|
|
@@ -612,6 +571,7 @@ export function useMotion(appContext) {
|
|
|
612
571
|
const [ikEditTool, setIkEditTool] = useState("ik");
|
|
613
572
|
|
|
614
573
|
const [trailEdit, setTrailEdit] = useState(null);
|
|
574
|
+
const [trailTrackFocus, setTrailTrackFocus] = useState("hips");
|
|
615
575
|
const [rangePinSelection, setRangePinSelection] = useState(null);
|
|
616
576
|
const [rangePinPartPick, setRangePinPartPick] = useState(null);
|
|
617
577
|
const [rangePinPreview, setRangePinPreview] = useState(null);
|
|
@@ -629,12 +589,89 @@ export function useMotion(appContext) {
|
|
|
629
589
|
}
|
|
630
590
|
}
|
|
631
591
|
|
|
632
|
-
function
|
|
633
|
-
|
|
634
|
-
if (!
|
|
635
|
-
|
|
636
|
-
|
|
592
|
+
function selectedIkRange(frame = appContext.shared.tlFrame) {
|
|
593
|
+
const count = Math.max(1, motion?.frames ?? appContext.shared.tlFrameCount ?? 1);
|
|
594
|
+
if (!motion) return { startFrame: 0, endFrame: count - 1 };
|
|
595
|
+
const edit = motion.editSegments ?? createMotionEdit(count);
|
|
596
|
+
const layout = motionEditLayout(edit);
|
|
597
|
+
const at = Math.max(0, Math.min(count - 1, Math.round(frame) || 0));
|
|
598
|
+
const selected = layout.find((segment) => at >= segment.timelineStart && at <= segment.timelineEnd) ?? layout[0];
|
|
599
|
+
return {
|
|
600
|
+
startFrame: Math.max(0, selected?.timelineStart ?? 0),
|
|
601
|
+
endFrame: Math.min(count - 1, selected?.timelineEnd ?? count - 1),
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function setPendingIkEditValue(value) {
|
|
606
|
+
pendingIkEditRef.current = value;
|
|
607
|
+
setPendingIkEdit(value);
|
|
637
608
|
}
|
|
609
|
+
|
|
610
|
+
function restorePendingIkState(pending) {
|
|
611
|
+
// A replaced take or another authored edit owns the newer state.
|
|
612
|
+
if (domain.layer(pending.characterId) !== pending.sourceLayer) return;
|
|
613
|
+
const state = pending.state;
|
|
614
|
+
state.keys = copyPhysicsKeys(pending.beforeKeys);
|
|
615
|
+
state.tracked = new Set(pending.beforeTracked);
|
|
616
|
+
state.targets = new Map([...pending.beforeTargets].map(([id, target]) => [id, target.clone()]));
|
|
617
|
+
state.plants = new Map([...pending.beforePlants].map(([id, target]) => [id, target.clone()]));
|
|
618
|
+
if (pending.rig) {
|
|
619
|
+
if (pending.beforeFrame === appContext.shared.tlFrame) appContext.shared.restoreExportRig(pending.beforeRig);
|
|
620
|
+
else appContext.shared.poseMemberAtFrame(pending.rig, pending.motion, state, appContext.shared.tlFrame, IK_CORRECTION_BLEND_FRAMES);
|
|
621
|
+
if (state.chains) ikSeedTargets(state.chains, state);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function cancelPendingIkEdit() {
|
|
626
|
+
const pending = pendingIkEditRef.current;
|
|
627
|
+
if (!pending) return false;
|
|
628
|
+
restorePendingIkState(pending);
|
|
629
|
+
setPendingIkEditValue(null);
|
|
630
|
+
ikDragRef.current = null;
|
|
631
|
+
ikPreDragRef.current = null;
|
|
632
|
+
setIkTick((value) => value + 1);
|
|
633
|
+
return true;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function applyPendingIkEdit() {
|
|
637
|
+
const pending = pendingIkEditRef.current;
|
|
638
|
+
if (!pending) return false;
|
|
639
|
+
if (pending.characterId !== appContext.shared.activeChar.id || domain.layer(pending.characterId) !== pending.sourceLayer) {
|
|
640
|
+
cancelPendingIkEdit();
|
|
641
|
+
return false;
|
|
642
|
+
}
|
|
643
|
+
const session = domain.beginAction(new Map([[pending.characterId, pending.beforeRig]]));
|
|
644
|
+
try {
|
|
645
|
+
session.run(() => domain.setKeys(pending.characterId, pending.candidate.keys));
|
|
646
|
+
session.commit();
|
|
647
|
+
} catch (error) {
|
|
648
|
+
session.cancel();
|
|
649
|
+
throw error;
|
|
650
|
+
}
|
|
651
|
+
setPendingIkEditValue(null);
|
|
652
|
+
ikDragRef.current = null;
|
|
653
|
+
ikPreDragRef.current = null;
|
|
654
|
+
if (pending.rig) appContext.shared.poseMemberAtFrame(pending.rig, motion, pending.state, appContext.shared.tlFrame, IK_CORRECTION_BLEND_FRAMES);
|
|
655
|
+
setIkTick((value) => value + 1);
|
|
656
|
+
appContext.notify(isKo
|
|
657
|
+
? `IK 보정을 ${pending.startFrame}–${pending.endFrame}프레임에 적용했어요`
|
|
658
|
+
: `Applied IK correction across frames ${pending.startFrame}–${pending.endFrame}`);
|
|
659
|
+
return true;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function clearTrailTrackFocus() {
|
|
663
|
+
setTrailTrackFocus(null);
|
|
664
|
+
setIkFocus(null);
|
|
665
|
+
}
|
|
666
|
+
function selectTrailTrack(track) {
|
|
667
|
+
setTrailTrackFocus(track);
|
|
668
|
+
focusIkHandle(track);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
useEffect(() => {
|
|
672
|
+
if (!ikMode || ikEditTool !== "trail") setTrailTrackFocus(TRAIL_TRACKS.some(track => track.id === ikFocus) ? ikFocus : "hips");
|
|
673
|
+
}, [ikMode, ikEditTool, ikFocus]);
|
|
674
|
+
|
|
638
675
|
function rangePinObjectWorldMatrix(objectId, frame) {
|
|
639
676
|
const object = appContext.shared.sceneObjects.find((entry) => entry.id === objectId);
|
|
640
677
|
if (!object) return null;
|
|
@@ -649,20 +686,6 @@ export function useMotion(appContext) {
|
|
|
649
686
|
appContext.shared.poseMemberAtFrame(appContext.shared.activeRig, motion, null, frame);
|
|
650
687
|
if (ikChains && state.keys.size > 0) ikEvaluate(ikChains, state, frame, ikFkJoints, IK_CORRECTION_BLEND_FRAMES);
|
|
651
688
|
}
|
|
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
689
|
function captureRangePinTargetForDraft(draft) {
|
|
667
690
|
if (!motion || !appContext.shared.activeRig || !ikChains) return null;
|
|
668
691
|
const objectWorldMatrix = draft.targetKind === "object" ? (objectId, frame) => rangePinObjectWorldMatrix(objectId, frame) : null;
|
|
@@ -758,7 +781,7 @@ export function useMotion(appContext) {
|
|
|
758
781
|
/** Write one key from its JSON form (studio-actions.js character.setIkKey):
|
|
759
782
|
* each named track replaces its key at `frame` and joins the tracked set. */
|
|
760
783
|
function setCharacterIkKey(characterId, frame, tracks) {
|
|
761
|
-
appContext.shared.castMemberOf(characterId);
|
|
784
|
+
refuseRigOnly(appContext.shared.castMemberOf(characterId), 'ik');
|
|
762
785
|
if (appContext.storeDomain('motion')) return appContext.storeDomain('motion').setKey(characterId, frame, tracks);
|
|
763
786
|
editCharacterIkKeys(characterId, (state) => {
|
|
764
787
|
let entry = state.keys.get(frame);
|
|
@@ -794,11 +817,6 @@ export function useMotion(appContext) {
|
|
|
794
817
|
return count;
|
|
795
818
|
}
|
|
796
819
|
|
|
797
|
-
/** A baked key entry in the JSON form character.setIkKey takes. */
|
|
798
|
-
function ikKeyJson(entry) {
|
|
799
|
-
return ikKeyJson(entry);
|
|
800
|
-
}
|
|
801
|
-
|
|
802
820
|
const [bridge, setBridge] = useState(null);
|
|
803
821
|
|
|
804
822
|
const [bridgeChecking, setBridgeChecking] = useState(false);
|
|
@@ -833,8 +851,7 @@ export function useMotion(appContext) {
|
|
|
833
851
|
// operator scrubbed to — the box takes any destination frame.
|
|
834
852
|
const [ardyPosePlacement, setArdyPosePlacement] = useState("start");
|
|
835
853
|
|
|
836
|
-
/*
|
|
837
|
-
* The recipe of the take that is loaded RIGHT NOW: seed + prompt blocks +
|
|
854
|
+
/* The recipe of the take that is loaded RIGHT NOW: seed + prompt blocks +
|
|
838
855
|
* the line edits pulled on top of it. It is written in exactly one place
|
|
839
856
|
* (commitTakeRecipe, on a successful run) and read in two: the request
|
|
840
857
|
* assembly, which attaches it as C10 `replay`, and the version strip, which
|
|
@@ -873,9 +890,6 @@ export function useMotion(appContext) {
|
|
|
873
890
|
|
|
874
891
|
const [ardyOutcome, setArdyOutcome] = useState(null);
|
|
875
892
|
|
|
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
893
|
// Wave-2 gate: the bridge only routes lineEdit once M4's routing lands.
|
|
880
894
|
// Until the /ardy/health payload says so, the request is never sent —
|
|
881
895
|
// today's bridge ignores unknown fields, so an ungated POST would quietly
|
|
@@ -884,6 +898,13 @@ export function useMotion(appContext) {
|
|
|
884
898
|
|
|
885
899
|
// Loaded motion: decoded arrays plus the world anchor captured at load.
|
|
886
900
|
const motion = domain.visibleMotion(appContext.shared.activeChar.id);
|
|
901
|
+
useEffect(() => {
|
|
902
|
+
const pending = pendingIkEditRef.current;
|
|
903
|
+
if (pending && (pending.characterId !== appContext.shared.activeChar.id
|
|
904
|
+
|| pending.rig !== appContext.shared.activeRig || pending.motion !== motion
|
|
905
|
+
|| pending.sourceLayer !== domain.layer(pending.characterId)
|
|
906
|
+
|| !ikMode || ikEditTool !== "ik")) cancelPendingIkEdit();
|
|
907
|
+
}, [appContext.shared.activeChar.id, appContext.shared.activeRig, motion, pinLayer, ikMode, ikEditTool]);
|
|
887
908
|
const rangePinRebuildRef = useRef(null);
|
|
888
909
|
const rangePinSourceStamp = JSON.stringify([pinLayer.take, appContext.shared.activeChar.id]);
|
|
889
910
|
// A fit is invalidated by a different take, trim or preview, not by key
|
|
@@ -924,7 +945,6 @@ export function useMotion(appContext) {
|
|
|
924
945
|
|
|
925
946
|
const [motionError, setMotionError] = useState("");
|
|
926
947
|
|
|
927
|
-
/* ------------------------- video capture (ingest) ---------------------- */
|
|
928
948
|
const [multiModelUrl, setMultiModelUrl] = useState("");
|
|
929
949
|
|
|
930
950
|
const [multiModelSource, setMultiModelSource] = useState(null);
|
|
@@ -965,8 +985,8 @@ export function useMotion(appContext) {
|
|
|
965
985
|
appContext.shared.setTlFrame((f) => Math.max(0, Math.min(f + delta, appContext.shared.frameCountRef.current - 1)));
|
|
966
986
|
}
|
|
967
987
|
|
|
968
|
-
/* --------------------------- motion playback ---------------------------- */
|
|
969
988
|
function leaveIkMode() {
|
|
989
|
+
cancelPendingIkEdit();
|
|
970
990
|
const poserCam = appContext.shared.poserCamRef?.current;
|
|
971
991
|
if (ikMode && poserCam && appContext.shared.activeChar?.id) {
|
|
972
992
|
const look = appContext.shared.poserLook?.current ?? {};
|
|
@@ -995,257 +1015,6 @@ export function useMotion(appContext) {
|
|
|
995
1015
|
appContext.shared.restoreRef.current = { rig, bones: snapshotPlaybackBones(rig) };
|
|
996
1016
|
}
|
|
997
1017
|
|
|
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
1018
|
/** Land takes 1..N-1 on the rest of the cast. Each extra take is its OWN
|
|
1250
1019
|
* layer: it goes to that entry's sessionMotion, NOT through the editing
|
|
1251
1020
|
* buffer, which holds the active character's clip alone. Returns how many
|
|
@@ -1268,104 +1037,6 @@ export function useMotion(appContext) {
|
|
|
1268
1037
|
worldScale,
|
|
1269
1038
|
});
|
|
1270
1039
|
|
|
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
1040
|
function commitLoadedTake(characterId, take, { recipe = null, job = null, promptClips = null, scale = characterScaleFor(take) } = {}) {
|
|
1370
1041
|
const owned = appContext.storeDomain('motion');
|
|
1371
1042
|
const previous = owned.layer(characterId);
|
|
@@ -1406,6 +1077,7 @@ export function useMotion(appContext) {
|
|
|
1406
1077
|
setMotionBusy(true);
|
|
1407
1078
|
setMotionError("");
|
|
1408
1079
|
try {
|
|
1080
|
+
refuseRigOnly(appContext.live.characters.find((entry) => entry.id === targetCharacterId), "take");
|
|
1409
1081
|
// Inbound boundary: an ARDY take (20 fps) or a filmed one (30/60)
|
|
1410
1082
|
// becomes a production-clock clip here, once, before anything on
|
|
1411
1083
|
// the timeline counts its frames. Same-rate input rides through.
|
|
@@ -1512,8 +1184,8 @@ export function useMotion(appContext) {
|
|
|
1512
1184
|
}
|
|
1513
1185
|
}
|
|
1514
1186
|
|
|
1515
|
-
/** Drop the ACTIVE character's take, its IK corrections
|
|
1516
|
-
* take imposed. One Ctrl+Z entry brings all
|
|
1187
|
+
/** Drop the ACTIVE character's take, its IK corrections, prompt blocks and
|
|
1188
|
+
* the stature the take imposed. One Ctrl+Z entry brings all four back; nothing to clear
|
|
1517
1189
|
* records nothing, so the shortcut never becomes a dead press.
|
|
1518
1190
|
*
|
|
1519
1191
|
* The pose flows (studio Apply/Reset, pose tiles, photo pose) call this
|
|
@@ -1559,7 +1231,7 @@ export function useMotion(appContext) {
|
|
|
1559
1231
|
// volume on every other frame of the walk.
|
|
1560
1232
|
const poseOtherCastMembers = (frame) => {
|
|
1561
1233
|
for (const entry of appContext.shared.characters) {
|
|
1562
|
-
if (entry.id === appContext.shared.activeChar.id) continue;
|
|
1234
|
+
if (entry.id === appContext.shared.activeChar.id || isProxyFigure(entry)) continue;
|
|
1563
1235
|
appContext.shared.poseMemberAtFrame(appContext.shared.rigs[entry.id], entry.sessionMotion, appContext.shared.ikStatesRef.current.get(entry.id), frame, IK_CORRECTION_BLEND_FRAMES);
|
|
1564
1236
|
}
|
|
1565
1237
|
};
|
|
@@ -1612,7 +1284,7 @@ export function useMotion(appContext) {
|
|
|
1612
1284
|
}
|
|
1613
1285
|
setIkMode(true);
|
|
1614
1286
|
appContext.notify(motion
|
|
1615
|
-
? ko("Pose mode —
|
|
1287
|
+
? ko("Pose mode — preview the correction, then apply it to the selected motion block", "포즈 모드 — 보정을 미리 본 뒤 선택한 모션 구간에 적용하세요")
|
|
1616
1288
|
: ko("Pose mode — drag handles in the main view; the shot camera stays frozen in the inset", "포즈 모드 — 메인 뷰에서 핸들을 드래그하세요. 샷 카메라는 인셋에 고정됩니다"));
|
|
1617
1289
|
return;
|
|
1618
1290
|
}
|
|
@@ -1627,8 +1299,26 @@ export function useMotion(appContext) {
|
|
|
1627
1299
|
// Drag solve, routed by handle kind: chain targets solve the two-bone
|
|
1628
1300
|
// chain toward the target; mid joints reposition the elbow/knee with both
|
|
1629
1301
|
// ends pinned (the handle snaps to the clamped position); FK joints swing
|
|
1630
|
-
// toward the pointer.
|
|
1302
|
+
// toward the pointer. The solved pose stays a preview until Apply.
|
|
1631
1303
|
function ikSolve(kind, trackId, targetWorld) {
|
|
1304
|
+
const state = appContext.shared.ikStateRef.current;
|
|
1305
|
+
const ikChains = state.chains;
|
|
1306
|
+
const ikFkJoints = state.fkJoints;
|
|
1307
|
+
if (!ikDragRef.current) {
|
|
1308
|
+
const pending = pendingIkEditRef.current;
|
|
1309
|
+
ikPreDragRef.current = pending ?? {
|
|
1310
|
+
characterId: appContext.shared.activeChar.id,
|
|
1311
|
+
beforeFrame: appContext.shared.tlFrame,
|
|
1312
|
+
sourceLayer: domain.layer(appContext.shared.activeChar.id),
|
|
1313
|
+
state, rig: appContext.shared.activeRig, motion,
|
|
1314
|
+
beforeRig: appContext.shared.snapshotExportRig(appContext.shared.activeRig),
|
|
1315
|
+
beforeKeys: snapshotIkKeys(state),
|
|
1316
|
+
beforeTracked: new Set(state.tracked),
|
|
1317
|
+
beforeTargets: new Map([...state.targets].map(([id, target]) => [id, target.clone()])),
|
|
1318
|
+
beforePlants: new Map([...state.plants].map(([id, target]) => [id, target.clone()])),
|
|
1319
|
+
};
|
|
1320
|
+
ikDragRef.current = { frame: appContext.shared.tlFrame, ids: new Set(pending?.ids ?? []) };
|
|
1321
|
+
}
|
|
1632
1322
|
ikDragRef.current = ikDragRecord(ikDragRef.current, appContext.shared.tlFrame);
|
|
1633
1323
|
const touch = (id) => ikDragTouch(appContext.shared.ikStateRef.current, ikDragRef.current, id);
|
|
1634
1324
|
if (kind === "chain") {
|
|
@@ -1696,18 +1386,47 @@ export function useMotion(appContext) {
|
|
|
1696
1386
|
solveSwingAngle(joint, targetWorld.axis, targetWorld.angle, targetWorld.startQuat, targetWorld.startParentQuat);
|
|
1697
1387
|
}
|
|
1698
1388
|
|
|
1699
|
-
// Drag end:
|
|
1700
|
-
//
|
|
1389
|
+
// Drag end: retain the solved pose as a preview. Apply creates keys at both
|
|
1390
|
+
// boundaries of the selected timeline motion segment.
|
|
1701
1391
|
function ikDragEnd() {
|
|
1702
1392
|
appContext.shared.ikBodyDragRef.current = false;
|
|
1703
1393
|
const drag = ikDragRef.current;
|
|
1704
1394
|
ikDragRef.current = null;
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1395
|
+
if (!motion) {
|
|
1396
|
+
keyIkPoseAtPlayhead();
|
|
1397
|
+
ikPreDragRef.current = null;
|
|
1398
|
+
setIkTick((n) => n + 1);
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
if (!drag?.ids?.size) {
|
|
1402
|
+
ikPreDragRef.current = null;
|
|
1403
|
+
return;
|
|
1404
|
+
}
|
|
1405
|
+
const state = appContext.shared.ikStateRef.current;
|
|
1406
|
+
const raw = (rawRig, at) => appContext.shared.poseMemberAtFrame(rawRig, motion, null, at);
|
|
1407
|
+
const keyed = bakeIkDragKey(state.chains, state.fkJoints, drag.frame, drag.ids, raw);
|
|
1408
|
+
if (!keyed) {
|
|
1409
|
+
ikPreDragRef.current = null;
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
const before = ikPreDragRef.current;
|
|
1413
|
+
const range = pendingIkEditRef.current ?? selectedIkRange(drag.frame);
|
|
1414
|
+
const next = copyPhysicsKeys(before.beforeKeys);
|
|
1415
|
+
const boundaries = bakeIkDragRange(state.chains, state.fkJoints, keyed, range.startFrame, range.endFrame, drag.ids, raw);
|
|
1416
|
+
for (const [at, entry] of boundaries) {
|
|
1417
|
+
if (!entry) continue;
|
|
1418
|
+
let frameEntry = next.get(at);
|
|
1419
|
+
if (!frameEntry) next.set(at, (frameEntry = new Map()));
|
|
1420
|
+
for (const [track, key] of entry) frameEntry.set(track, { ...key, blend: IK_CORRECTION_BLEND_FRAMES });
|
|
1421
|
+
}
|
|
1422
|
+
setPendingIkEditValue({
|
|
1423
|
+
...before,
|
|
1424
|
+
frame: drag.frame,
|
|
1425
|
+
startFrame: range.startFrame,
|
|
1426
|
+
endFrame: range.endFrame,
|
|
1427
|
+
ids: [...drag.ids],
|
|
1428
|
+
candidate: { ...state, keys: next, tracked: new Set([...state.tracked, ...drag.ids]) },
|
|
1429
|
+
});
|
|
1711
1430
|
}
|
|
1712
1431
|
|
|
1713
1432
|
/** Bake the current tracked rotations at the playhead into a scratch layer
|
|
@@ -2046,35 +1765,6 @@ export function useMotion(appContext) {
|
|
|
2046
1765
|
return true;
|
|
2047
1766
|
}
|
|
2048
1767
|
|
|
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
1768
|
function recheckMotionHealth() {
|
|
2079
1769
|
setBridgeChecking(true);
|
|
2080
1770
|
return appContext.shared.bridgeRefreshRef.current().finally(() => setBridgeChecking(false));
|
|
@@ -2128,6 +1818,10 @@ export function useMotion(appContext) {
|
|
|
2128
1818
|
* buys the full step count. */
|
|
2129
1819
|
function runLineEdit() {
|
|
2130
1820
|
if (appContext.shared.generationPendingRef.current || appContext.shared.genRunningRef.current || ardyRunning) return;
|
|
1821
|
+
if (isProxyFigure(appContext.shared.activeChar)) {
|
|
1822
|
+
appContext.notify(kindRefusal("lineEdit", isKo));
|
|
1823
|
+
return;
|
|
1824
|
+
}
|
|
2131
1825
|
const generationRequest = requestMotionGeneration("line_edit", "edit", { lineEdit: true });
|
|
2132
1826
|
if (!appContext.shared.takeSourceUrl) {
|
|
2133
1827
|
appContext.notify(ko("The current take has no bridge source — generate it once before editing a path", "현재 테이크에 브리지 원본이 없어요 — 궤적을 편집하기 전에 한 번 생성하세요"));
|
|
@@ -2214,6 +1908,7 @@ export function useMotion(appContext) {
|
|
|
2214
1908
|
if (appContext.shared.generationPendingRef.current || appContext.shared.genRunningRef.current || ardyRunning) throw generationRefusal('TARGET_BUSY', 'A motion generation is already running.');
|
|
2215
1909
|
if (appContext.shared.linePreviewUrl) throw generationRefusal('TARGET_NOT_READY', previewBlockingReason(en => en), previewBlockingReason(ko));
|
|
2216
1910
|
const character = appContext.storeDomain('cast').read().find(row => row.id === args.characterId);
|
|
1911
|
+
refuseRigOnly(character, 'motion');
|
|
2217
1912
|
const active = character.id === appContext.shared.loadedLayerCharRef.current;
|
|
2218
1913
|
const posing = active && appContext.shared.posing;
|
|
2219
1914
|
const rig = posing ? appContext.shared.posedRig() : appContext.shared.rigs[character.id];
|
|
@@ -2257,9 +1952,12 @@ export function useMotion(appContext) {
|
|
|
2257
1952
|
});
|
|
2258
1953
|
}
|
|
2259
1954
|
|
|
2260
|
-
|
|
2261
|
-
function onTrailDragStart({ grabFrame } = {}) {
|
|
1955
|
+
function onTrailDragStart({ track, grabFrame } = {}) {
|
|
2262
1956
|
if (!motion) return;
|
|
1957
|
+
if (track) {
|
|
1958
|
+
setTrailTrackFocus(track);
|
|
1959
|
+
focusIkHandle(track);
|
|
1960
|
+
}
|
|
2263
1961
|
const frame = Math.round(grabFrame ?? appContext.shared.tlFrame);
|
|
2264
1962
|
appContext.shared.setTlFrame(frame);
|
|
2265
1963
|
// The pre-drag take is both the deformation base (repeated moves re-derive
|
|
@@ -2329,6 +2027,10 @@ export function useMotion(appContext) {
|
|
|
2329
2027
|
* deformed line contributes the grab-frame pose as a root guide. */
|
|
2330
2028
|
function runTrailRegeneration() {
|
|
2331
2029
|
if (appContext.shared.generationPendingRef.current || appContext.shared.genRunningRef.current || ardyRunning) return;
|
|
2030
|
+
if (isProxyFigure(appContext.shared.activeChar)) {
|
|
2031
|
+
appContext.notify(kindRefusal("trails", isKo));
|
|
2032
|
+
return;
|
|
2033
|
+
}
|
|
2332
2034
|
const request = requestMotionGeneration("trail", "edit", { motionEdit: true });
|
|
2333
2035
|
if (!trailEdit) return;
|
|
2334
2036
|
// Same rule as runArdy: motionEdit rewrites a span of THE take, and a
|
|
@@ -2419,8 +2121,7 @@ export function useMotion(appContext) {
|
|
|
2419
2121
|
setTrailEdit(null);
|
|
2420
2122
|
}
|
|
2421
2123
|
|
|
2422
|
-
/*
|
|
2423
|
-
* One box, one job at a time: explicit entry points suppress duplicate
|
|
2124
|
+
/* One box, one job at a time: explicit entry points suppress duplicate
|
|
2424
2125
|
* requests through queueing and execution. The
|
|
2425
2126
|
* payload is frozen at enqueue time; completion delivers the clip to the
|
|
2426
2127
|
* REQUESTING character's layer, not whoever happens to be selected then. */
|
|
@@ -2533,8 +2234,7 @@ export function useMotion(appContext) {
|
|
|
2533
2234
|
}
|
|
2534
2235
|
}
|
|
2535
2236
|
|
|
2536
|
-
/*
|
|
2537
|
-
* ONE writer for both. Every take that reaches the app came out of a job,
|
|
2237
|
+
/* ONE writer for both. Every take that reaches the app came out of a job,
|
|
2538
2238
|
* so a job's completion is the only place where "what is this take made of"
|
|
2539
2239
|
* can be answered honestly, and the answer is checkpointed next to the
|
|
2540
2240
|
* motionUrl in the same breath. Nothing else may write takeRecipeRef except
|
|
@@ -2652,8 +2352,7 @@ export function useMotion(appContext) {
|
|
|
2652
2352
|
}
|
|
2653
2353
|
}
|
|
2654
2354
|
|
|
2655
|
-
/*
|
|
2656
|
-
* Scene blocks the shot with Kimodo; Refine pulls one joint's path with
|
|
2355
|
+
/* Scene blocks the shot with Kimodo; Refine pulls one joint's path with
|
|
2657
2356
|
* ProjFlow. Everything else the pipeline can do is one of those two said
|
|
2658
2357
|
* more precisely, and both are reachable from the take itself instead of
|
|
2659
2358
|
* from a foldout the artist has to remember to open.
|
|
@@ -2707,12 +2406,16 @@ export function useMotion(appContext) {
|
|
|
2707
2406
|
}
|
|
2708
2407
|
|
|
2709
2408
|
function refineDisabledReason() {
|
|
2409
|
+
if (!characterCapabilities(appContext.shared.activeChar).lineEdit) return kindRefusal("lineEdit", isKo);
|
|
2710
2410
|
if (!motion) return ko("No take yet — block a scene first", "아직 테이크가 없어요 — 먼저 장면을 만들어 주세요");
|
|
2711
2411
|
if (!motion.url) return ko("This take has no bridge source — generate it once before refining", "이 테이크에는 브리지 원본이 없어요 — 한 번 생성해야 다듬을 수 있어요");
|
|
2712
2412
|
return "";
|
|
2713
2413
|
}
|
|
2714
2414
|
|
|
2715
2415
|
function sceneDisabledReason() {
|
|
2416
|
+
// A standing capability, so it outranks the bridge probe: a capsule figure
|
|
2417
|
+
// cannot generate whatever the backend says.
|
|
2418
|
+
if (!characterCapabilities(appContext.shared.activeChar).motion) return kindRefusal("motion", isKo);
|
|
2716
2419
|
if (bridge === null || bridgeChecking) return motionReadinessMessage("loading");
|
|
2717
2420
|
if (generationBusy) return ko("A generation is already running", "이미 생성이 돌고 있어요");
|
|
2718
2421
|
// NOT a line-edit preview, deliberately. Every other reason here is a
|
|
@@ -2914,244 +2617,51 @@ export function useMotion(appContext) {
|
|
|
2914
2617
|
function cancelArdy() {
|
|
2915
2618
|
appContext.shared.ardyAbortRef.current?.abort();
|
|
2916
2619
|
}
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
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 })); }
|
|
2620
|
+
const {
|
|
2621
|
+
chooseMultiModelFile,
|
|
2622
|
+
pasteMultiModelUrl,
|
|
2623
|
+
useMultiModelUrl,
|
|
2624
|
+
ingestPlatformFootage,
|
|
2625
|
+
ingestFootage,
|
|
2626
|
+
extractMultiModelMotion,
|
|
2627
|
+
extractMultiModelMotionGpu,
|
|
2628
|
+
deliverExtraTakes,
|
|
2629
|
+
captureI2vStill,
|
|
2630
|
+
enterI2vFraming,
|
|
2631
|
+
markI2vPose,
|
|
2632
|
+
clearI2vPose,
|
|
2633
|
+
clearI2vMotion,
|
|
2634
|
+
restoreI2vCamera,
|
|
2635
|
+
framingDistance,
|
|
2636
|
+
showI2vMotionLock,
|
|
2637
|
+
generateI2vMotion,
|
|
2638
|
+
i2vMotionUnavailable,
|
|
2639
|
+
generateI2vMotionFromUi,
|
|
2640
|
+
updateI2vMotionQuota,
|
|
2641
|
+
} = createMotionVideoCapture({
|
|
2642
|
+
appContext,
|
|
2643
|
+
bridge,
|
|
2644
|
+
i2vMotion,
|
|
2645
|
+
setI2vMotion,
|
|
2646
|
+
multiModelUrl,
|
|
2647
|
+
multiModelSource,
|
|
2648
|
+
multiModelFootage,
|
|
2649
|
+
multiModelExtract,
|
|
2650
|
+
setMultiModelUrl,
|
|
2651
|
+
setMultiModelSource,
|
|
2652
|
+
setMultiModelStatus,
|
|
2653
|
+
setMultiModelStage,
|
|
2654
|
+
setMultiModelProgress,
|
|
2655
|
+
setMultiModelFootage,
|
|
2656
|
+
setMultiModelError,
|
|
2657
|
+
setMultiModelTake,
|
|
2658
|
+
setMultiModelExtract,
|
|
2659
|
+
setMultiModelExtractProgress,
|
|
2660
|
+
setMultiModelExtractError,
|
|
2661
|
+
loadMotion,
|
|
2662
|
+
authoredSupportDescriptors,
|
|
2663
|
+
applyAuthoredSupportRise,
|
|
2664
|
+
});
|
|
3155
2665
|
domain.setVideoDraft = patch => setI2vMotion(current => ({ ...current, ...patch }));
|
|
3156
2666
|
domain.requestLineEdit = runLineEdit;
|
|
3157
2667
|
domain.requestTrailRegeneration = runTrailRegeneration;
|
|
@@ -3189,7 +2699,8 @@ export function useMotion(appContext) {
|
|
|
3189
2699
|
bodyContact, setBodyContact, IK_CORRECTION_BLEND_FRAMES, autoPhysicsRunning, setAutoPhysicsRunning,
|
|
3190
2700
|
physicsPreview, setPhysicsPreview, physicsShow, physicsProgress, physicsOptions, setPhysicsOptions,
|
|
3191
2701
|
ikTick, setIkTick, committedIkEdits, setCommittedIkEdits, trailFalloffS, setTrailFalloffS, showTrails,
|
|
3192
|
-
setShowTrails, ikEditTool, setIkEditTool, trailEdit, trailFalloffFrames, focusIkHandle, snapshotIkKeys,
|
|
2702
|
+
setShowTrails, ikEditTool, setIkEditTool, trailEdit, trailTrackFocus, selectTrailTrack, clearTrailTrackFocus, trailFalloffFrames, focusIkHandle, snapshotIkKeys,
|
|
2703
|
+
pendingIkEdit, applyPendingIkEdit, cancelPendingIkEdit,
|
|
3193
2704
|
rangePinApplySpec: (pin) => {
|
|
3194
2705
|
domain.run('motion.rangePin.apply', { characterId: appContext.shared.activeChar.id, pin });
|
|
3195
2706
|
return { residuals: decodeRangePinResiduals(domain.layer(appContext.shared.activeChar.id).ikPinResiduals).get(pin.id) };
|