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
package/src/motion-trail.js
CHANGED
|
@@ -9,9 +9,23 @@
|
|
|
9
9
|
* objects with cloned arrays and never touch the caller's take.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { CSKEL27_JOINTS } from "./ardy/cskel27.js";
|
|
12
|
+
import { CSKEL27_JOINTS, CSKEL27_PARENTS } from "./ardy/cskel27.js";
|
|
13
|
+
import { motionJointPositions, motionJointPositionsAt } from "./ardy/playback.js";
|
|
14
|
+
import { renderMotionEdit } from "./ardy/motion-edit.js";
|
|
15
|
+
|
|
16
|
+
/** Metres per rig unit: Character scales the Mixamo centimetre rig by 0.01 * stature. */
|
|
17
|
+
const RIG_UNIT_METRES = 0.01;
|
|
13
18
|
|
|
14
19
|
const JOINTS = CSKEL27_JOINTS.length;
|
|
20
|
+
const jointIndex = (name) => CSKEL27_JOINTS.indexOf(name);
|
|
21
|
+
|
|
22
|
+
/** Limb trail track -> two-bone chain [root, mid, effector] its drag bends. */
|
|
23
|
+
const LIMB_CHAINS = {
|
|
24
|
+
leftHand: ["LeftArm", "LeftForeArm", "LeftHand"],
|
|
25
|
+
rightHand: ["RightArm", "RightForeArm", "RightHand"],
|
|
26
|
+
leftFoot: ["LeftUpLeg", "LeftLeg", "LeftFoot"],
|
|
27
|
+
rightFoot: ["RightUpLeg", "RightLeg", "RightFoot"],
|
|
28
|
+
};
|
|
15
29
|
|
|
16
30
|
/**
|
|
17
31
|
* The always-drawn trail tracks: root plus every IK chain endpoint and the
|
|
@@ -74,17 +88,42 @@ function toWorldXZ(basis, x, z) {
|
|
|
74
88
|
];
|
|
75
89
|
}
|
|
76
90
|
|
|
91
|
+
/** Rendered rig-space samples of one joint, or null when the rig drives no bone for it. */
|
|
92
|
+
function renderedSamples(motion, joint, rig) {
|
|
93
|
+
if (!rig) return null;
|
|
94
|
+
const samples = motionJointPositions(rig, motion);
|
|
95
|
+
return samples && Number.isFinite(samples.positions[joint * 3]) ? samples : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
77
98
|
/**
|
|
78
|
-
* World-space polyline of one cskel27 joint
|
|
99
|
+
* World-space polyline of one cskel27 joint across the take.
|
|
79
100
|
* Returns a flat [x0,y0,z0, x1,y1,z1, ...] array of length frames*3.
|
|
80
101
|
* `baseY` is the character entry's stage height (roof scenes ride above 0).
|
|
102
|
+
* With `rig`, the points are where playback RENDERS that joint's bone on this
|
|
103
|
+
* rig (rig proportions, prep scale, bind offsets); without it, the clip's
|
|
104
|
+
* own posedJoints (the space pins and the wire use).
|
|
81
105
|
*/
|
|
82
|
-
export function jointTrailPoints(motion, jointName = "Hips", { baseY = 0, scale = 1 } = {}) {
|
|
106
|
+
export function jointTrailPoints(motion, jointName = "Hips", { baseY = 0, scale = 1, rig = null } = {}) {
|
|
83
107
|
if (!motion?.posedJoints || !(motion.frames > 0)) return null;
|
|
84
108
|
const joint = CSKEL27_JOINTS.indexOf(jointName);
|
|
85
109
|
if (joint < 0) return null;
|
|
86
110
|
const basis = anchorBasis(motion);
|
|
87
111
|
const out = new Float32Array(motion.frames * 3);
|
|
112
|
+
const rendered = renderedSamples(motion, joint, rig);
|
|
113
|
+
if (rendered) {
|
|
114
|
+
// The Character group: anchor position, clip yaw, 0.01 * stature scale.
|
|
115
|
+
// Rig space is already anchor-relative (applyMotionFrame rebases).
|
|
116
|
+
const unit = RIG_UNIT_METRES * scale;
|
|
117
|
+
for (let f = 0; f < motion.frames; f += 1) {
|
|
118
|
+
const po = (f * JOINTS + joint) * 3;
|
|
119
|
+
const x = rendered.positions[po] * unit;
|
|
120
|
+
const z = rendered.positions[po + 2] * unit;
|
|
121
|
+
out[f * 3] = basis.anchorX + x * basis.cos + z * basis.sin;
|
|
122
|
+
out[f * 3 + 1] = baseY + rendered.positions[po + 1] * unit;
|
|
123
|
+
out[f * 3 + 2] = basis.anchorZ - x * basis.sin + z * basis.cos;
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
88
127
|
for (let f = 0; f < motion.frames; f += 1) {
|
|
89
128
|
const po = (f * JOINTS + joint) * 3;
|
|
90
129
|
const [wx, wz] = toWorldXZ(basis, motion.posedJoints[po], motion.posedJoints[po + 2]);
|
|
@@ -152,6 +191,63 @@ export function worldDeltaToClip(motion, delta) {
|
|
|
152
191
|
};
|
|
153
192
|
}
|
|
154
193
|
|
|
194
|
+
/**
|
|
195
|
+
* World drag delta on a drawn (rendered) trail -> the clip delta
|
|
196
|
+
* applyTrailFalloffDelta takes, so the RENDERED effector follows the pointer.
|
|
197
|
+
*
|
|
198
|
+
* Playback turns the rig's own bones by the clip's rotations and adds rotated
|
|
199
|
+
* bind offsets, so a clip move is not a rendered move (the y-bot forearm is
|
|
200
|
+
* 27.6 cm against the clip's 23.3; the Mixamo head bone sits near the neck
|
|
201
|
+
* pivot). Rather than model that, keep the clip delta along the pointer (as
|
|
202
|
+
* before) and solve its LENGTH through real playback: the gain that brings
|
|
203
|
+
* the rendered effector closest to the pointer (least squares, so it never
|
|
204
|
+
* overshoots). A full 3D inverse was rejected: limb reach makes some
|
|
205
|
+
* directions unreachable and chasing them sends the clip delta far off.
|
|
206
|
+
* The gain is capped at 1.5: rig/clip limb ratios sit well inside it, and a
|
|
207
|
+
* bone that needs more (the Mixamo head bone rides ~11 cm from the neck
|
|
208
|
+
* pivot, 17 degrees per rendered cm) is a lever the edit cannot drive 1:1
|
|
209
|
+
* without spinning the part. Without a rig: yaw + stature only.
|
|
210
|
+
*/
|
|
211
|
+
export function worldDeltaToTrailClip(motion, delta, { track = "hips", grabFrame = 0, radiusFrames = 0, rig = null, scale = 1 } = {}) {
|
|
212
|
+
const stature = Number.isFinite(scale) && scale > 1e-9 ? scale : 1;
|
|
213
|
+
const plain = worldDeltaToClip(motion, { x: delta.x / stature, y: delta.y / stature, z: delta.z / stature });
|
|
214
|
+
const distance = Math.hypot(delta.x, delta.y, delta.z);
|
|
215
|
+
const joint = jointIndex(TRAIL_TRACKS.find((item) => item.id === track)?.joint ?? "");
|
|
216
|
+
const samples = rig && joint >= 0 && motion?.rotMats && distance > 1e-6 ? motionJointPositions(rig, motion) : null;
|
|
217
|
+
const frame = Math.max(0, Math.min((motion?.frames ?? 1) - 1, Math.round(grabFrame) || 0));
|
|
218
|
+
const base = samples?.positions.subarray((frame * JOINTS + joint) * 3, (frame * JOINTS + joint) * 3 + 3);
|
|
219
|
+
if (!base || !Number.isFinite(base[0])) return plain;
|
|
220
|
+
const basis = anchorBasis(motion);
|
|
221
|
+
const unit = RIG_UNIT_METRES * stature;
|
|
222
|
+
const target = [delta.x, delta.y, delta.z];
|
|
223
|
+
const scaled = (gain) => ({ x: plain.x * gain, y: plain.y * gain, z: plain.z * gain });
|
|
224
|
+
// Rendered world displacement of the effector at the grab for a gain.
|
|
225
|
+
const reached = (gain) => {
|
|
226
|
+
const edited = applyTrailFalloffDelta(motion, { track, grabFrame: frame, radiusFrames, clipDelta: scaled(gain) });
|
|
227
|
+
const p = motionJointPositionsAt(rig, edited, frame).subarray(joint * 3, joint * 3 + 3);
|
|
228
|
+
const x = (p[0] - base[0]) * unit;
|
|
229
|
+
const z = (p[2] - base[2]) * unit;
|
|
230
|
+
return [x * basis.cos + z * basis.sin, (p[1] - base[1]) * unit, -x * basis.sin + z * basis.cos];
|
|
231
|
+
};
|
|
232
|
+
const MIN_GAIN = 0.1;
|
|
233
|
+
const MAX_GAIN = 1.5;
|
|
234
|
+
// Locally reached ~ gain * v, so the best gain is gain * (D.r)/(r.r);
|
|
235
|
+
// iterate that (the reach limit bends the curve) and keep the closest.
|
|
236
|
+
let gain = 1;
|
|
237
|
+
let best = { gain: 1, miss: Infinity };
|
|
238
|
+
for (let i = 0; i < 4; i += 1) {
|
|
239
|
+
const r = reached(gain);
|
|
240
|
+
const miss = len(sub(target, r));
|
|
241
|
+
if (miss < best.miss) best = { gain, miss };
|
|
242
|
+
const rr = dot(r, r);
|
|
243
|
+
if (!(rr > 1e-12) || miss < 1e-4) break;
|
|
244
|
+
const next = Math.max(MIN_GAIN, Math.min(MAX_GAIN, (gain * dot(target, r)) / rr));
|
|
245
|
+
if (Math.abs(next - gain) < 1e-4) break;
|
|
246
|
+
gain = next;
|
|
247
|
+
}
|
|
248
|
+
return scaled(best.gain);
|
|
249
|
+
}
|
|
250
|
+
|
|
155
251
|
/** Smoothstep falloff: 1 at the grab frame, 0 at/beyond the radius. */
|
|
156
252
|
export function falloffWeight(distanceFrames, radiusFrames) {
|
|
157
253
|
if (!(radiusFrames > 0)) return distanceFrames === 0 ? 1 : 0;
|
|
@@ -172,17 +268,278 @@ export function trailEditRange(frameCount, grabFrame, radiusFrames) {
|
|
|
172
268
|
};
|
|
173
269
|
}
|
|
174
270
|
|
|
271
|
+
/* --- small vector / row-major 3x3 helpers for the limb and head solves --- */
|
|
272
|
+
|
|
273
|
+
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
274
|
+
const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
|
275
|
+
const scale3 = (a, s) => [a[0] * s, a[1] * s, a[2] * s];
|
|
276
|
+
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
277
|
+
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
|
278
|
+
const len = (a) => Math.hypot(a[0], a[1], a[2]);
|
|
279
|
+
function unit(a) {
|
|
280
|
+
const l = len(a);
|
|
281
|
+
return l > 1e-9 ? scale3(a, 1 / l) : null;
|
|
282
|
+
}
|
|
283
|
+
function matMul(a, b) {
|
|
284
|
+
const out = new Array(9);
|
|
285
|
+
for (let r = 0; r < 3; r += 1) {
|
|
286
|
+
for (let c = 0; c < 3; c += 1) {
|
|
287
|
+
out[r * 3 + c] = a[r * 3] * b[c] + a[r * 3 + 1] * b[3 + c] + a[r * 3 + 2] * b[6 + c];
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return out;
|
|
291
|
+
}
|
|
292
|
+
const matT = (m) => [m[0], m[3], m[6], m[1], m[4], m[7], m[2], m[5], m[8]];
|
|
293
|
+
const matVec = (m, v) => [
|
|
294
|
+
m[0] * v[0] + m[1] * v[1] + m[2] * v[2],
|
|
295
|
+
m[3] * v[0] + m[4] * v[1] + m[5] * v[2],
|
|
296
|
+
m[6] * v[0] + m[7] * v[1] + m[8] * v[2],
|
|
297
|
+
];
|
|
298
|
+
/** Inverse of a row-major 3x3 (callers keep it well conditioned). */
|
|
299
|
+
function inverse3(m) {
|
|
300
|
+
const [a, b, c, d, e, f, g, h, i] = m;
|
|
301
|
+
const A = e * i - f * h, B = f * g - d * i, C = d * h - e * g;
|
|
302
|
+
const det = a * A + b * B + c * C;
|
|
303
|
+
return [A, c * h - b * i, b * f - c * e, B, a * i - c * g, c * d - a * f, C, b * g - a * h, a * e - b * d].map((v) => v / det);
|
|
304
|
+
}
|
|
305
|
+
/** Shortest-arc rotation taking unit `a` onto unit `b` (Rodrigues). */
|
|
306
|
+
function arcRotation(a, b) {
|
|
307
|
+
const c = dot(a, b);
|
|
308
|
+
if (c < -1 + 1e-9) {
|
|
309
|
+
const axis = unit(cross(a, Math.abs(a[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]));
|
|
310
|
+
const [x, y, z] = axis;
|
|
311
|
+
return [2 * x * x - 1, 2 * x * y, 2 * x * z, 2 * x * y, 2 * y * y - 1, 2 * y * z, 2 * x * z, 2 * y * z, 2 * z * z - 1];
|
|
312
|
+
}
|
|
313
|
+
const [x, y, z] = cross(a, b);
|
|
314
|
+
const k = 1 / (1 + c);
|
|
315
|
+
return [
|
|
316
|
+
1 - k * (y * y + z * z), -z + k * x * y, y + k * x * z,
|
|
317
|
+
z + k * x * y, 1 - k * (x * x + z * z), -x + k * y * z,
|
|
318
|
+
-y + k * x * z, x + k * y * z, 1 - k * (x * x + y * y),
|
|
319
|
+
];
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Joint `root` and everything below it in the cskel27 tree. */
|
|
323
|
+
function subtreeOf(root) {
|
|
324
|
+
return CSKEL27_JOINTS.map((_, j) => j).filter((j) => {
|
|
325
|
+
for (let k = j; k !== null; k = CSKEL27_PARENTS[k]) if (k === root) return true;
|
|
326
|
+
return false;
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Per-frame accessors over a take's flat posedJoints / rotMats (locals). */
|
|
331
|
+
function frameAccess(posedJoints, rotMats, f) {
|
|
332
|
+
const globals = new Map();
|
|
333
|
+
const local = (j) => Array.from(rotMats.subarray((f * JOINTS + j) * 9, (f * JOINTS + j) * 9 + 9));
|
|
334
|
+
const global = (j) => {
|
|
335
|
+
if (!globals.has(j)) {
|
|
336
|
+
const parent = CSKEL27_PARENTS[j];
|
|
337
|
+
globals.set(j, parent === null ? local(j) : matMul(global(parent), local(j)));
|
|
338
|
+
}
|
|
339
|
+
return globals.get(j);
|
|
340
|
+
};
|
|
341
|
+
return {
|
|
342
|
+
pos: (j) => {
|
|
343
|
+
const po = (f * JOINTS + j) * 3;
|
|
344
|
+
return [posedJoints[po], posedJoints[po + 1], posedJoints[po + 2]];
|
|
345
|
+
},
|
|
346
|
+
setPos: (j, p) => posedJoints.set(p, (f * JOINTS + j) * 3),
|
|
347
|
+
global,
|
|
348
|
+
/** Write joint j's LOCAL rotation so its global becomes `g` (parent untouched). */
|
|
349
|
+
setGlobal: (j, g, parentGlobal = CSKEL27_PARENTS[j] === null ? null : global(CSKEL27_PARENTS[j])) => {
|
|
350
|
+
rotMats.set(parentGlobal ? matMul(matT(parentGlobal), g) : g, (f * JOINTS + j) * 9);
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** One bend-side guide from the source window, never from the dragged target. */
|
|
356
|
+
function limbWindowNormal(motion, [a, b, c], startFrame, endFrame, grabFrame, radiusFrames) {
|
|
357
|
+
let sum = [0, 0, 0];
|
|
358
|
+
for (let f = startFrame; f < endFrame; f += 1) {
|
|
359
|
+
const access = frameAccess(motion.posedJoints, null, f);
|
|
360
|
+
const normal = cross(sub(access.pos(b), access.pos(a)), sub(access.pos(c), access.pos(a)));
|
|
361
|
+
// Area weighting gives nearly straight (ill-conditioned) frames no vote.
|
|
362
|
+
sum = add(sum, scale3(normal, falloffWeight(f - grabFrame, radiusFrames)));
|
|
363
|
+
}
|
|
364
|
+
return unit(sum);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Beyond reach, the bend plane turns at most this far from the source frame's plane. */
|
|
368
|
+
const OVERREACH_PLANE_LIMIT = Math.PI / 6;
|
|
369
|
+
/** Excess distance, as a fraction of reach, over which that limit eases in. */
|
|
370
|
+
const OVERREACH_EASE = 0.25;
|
|
371
|
+
|
|
372
|
+
/** Rotate unit `from` toward unit `to` by at most `radians`. */
|
|
373
|
+
function limitNormal(from, to, radians) {
|
|
374
|
+
const angle = Math.acos(Math.max(-1, Math.min(1, dot(from, to))));
|
|
375
|
+
if (angle <= radians) return to;
|
|
376
|
+
const tangent = unit(sub(to, scale3(from, dot(from, to))));
|
|
377
|
+
return tangent ? add(scale3(from, Math.cos(radians)), scale3(tangent, Math.sin(radians))) : from;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Two-bone solve: follow a reachable target exactly, using one window guide.
|
|
382
|
+
* An unreachable target cannot be met anyway, so there the bend plane is
|
|
383
|
+
* held within OVERREACH_PLANE_LIMIT of its pre-drag plane, easing in from the
|
|
384
|
+
* reach boundary so crossing it does not pop.
|
|
385
|
+
*/
|
|
386
|
+
function bendLimbFrame(access, [a, b, c], descendants, offset, guide) {
|
|
387
|
+
const A = access.pos(a);
|
|
388
|
+
const B = access.pos(b);
|
|
389
|
+
const C = access.pos(c);
|
|
390
|
+
const l1 = len(sub(B, A));
|
|
391
|
+
const l2 = len(sub(C, B));
|
|
392
|
+
if (len(offset) < 1e-9) return;
|
|
393
|
+
const AB = sub(B, A);
|
|
394
|
+
const AC = sub(C, A);
|
|
395
|
+
const oldNormal = unit(cross(AB, AC));
|
|
396
|
+
let toTarget = sub(add(C, offset), A);
|
|
397
|
+
let dir = unit(toTarget) ?? unit(AC);
|
|
398
|
+
if (!dir) return;
|
|
399
|
+
const reference = guide ?? oldNormal ?? unit(cross(dir, Math.abs(dir[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]));
|
|
400
|
+
let normal = unit(sub(reference, scale3(dir, dot(reference, dir))))
|
|
401
|
+
?? unit(cross(dir, Math.abs(dir[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]));
|
|
402
|
+
const excess = (len(toTarget) - (l1 + l2)) / ((l1 + l2) * OVERREACH_EASE);
|
|
403
|
+
const turn = oldNormal ? Math.acos(Math.max(-1, Math.min(1, dot(oldNormal, normal)))) : 0;
|
|
404
|
+
if (excess > 0 && turn > OVERREACH_PLANE_LIMIT) {
|
|
405
|
+
const t = Math.min(1, excess);
|
|
406
|
+
const capped = limitNormal(oldNormal, normal, turn - (turn - OVERREACH_PLANE_LIMIT) * t * t * (3 - 2 * t));
|
|
407
|
+
// Aim the straightened limb at the target's projection into that plane.
|
|
408
|
+
const projected = sub(toTarget, scale3(capped, dot(toTarget, capped)));
|
|
409
|
+
if (unit(projected)) {
|
|
410
|
+
normal = capped;
|
|
411
|
+
toTarget = projected;
|
|
412
|
+
dir = unit(projected);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
// Leave a sub-millimetre bend at full reach so its side stays defined.
|
|
416
|
+
const d = Math.min(l1 + l2 - 1e-6, Math.max(Math.abs(l1 - l2) + 1e-6, len(toTarget)));
|
|
417
|
+
if (!(d > 1e-9)) return;
|
|
418
|
+
const along = (l1 * l1 - l2 * l2 + d * d) / (2 * d);
|
|
419
|
+
const h = Math.sqrt(Math.max(0, l1 * l1 - along * along));
|
|
420
|
+
const pole = unit(cross(dir, normal));
|
|
421
|
+
const B2 = add(A, add(scale3(dir, along), scale3(pole, h)));
|
|
422
|
+
const C2 = add(A, scale3(dir, d));
|
|
423
|
+
const move = sub(C2, C);
|
|
424
|
+
for (const j of descendants) access.setPos(j, add(access.pos(j), move));
|
|
425
|
+
access.setPos(b, B2);
|
|
426
|
+
if (!access.rotMats) return;
|
|
427
|
+
// Transport each segment by its shortest arc, not by the bend frame:
|
|
428
|
+
// rotating the frame added axial twist even when the elbow hardly moved.
|
|
429
|
+
const swing = (from, to) => arcRotation(unit(from), unit(to));
|
|
430
|
+
const ga = matMul(swing(AB, sub(B2, A)), access.global(a));
|
|
431
|
+
const gb = matMul(swing(sub(C, B), sub(C2, B2)), access.global(b));
|
|
432
|
+
const gc = access.global(c);
|
|
433
|
+
access.setGlobal(a, ga);
|
|
434
|
+
access.setGlobal(b, gb, ga);
|
|
435
|
+
access.setGlobal(c, gc, gb);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Head drag for one frame: swing Neck->Head about the neck toward the target. */
|
|
439
|
+
function swingHeadFrame(access, neck, head, subtree, offset) {
|
|
440
|
+
const N = access.pos(neck);
|
|
441
|
+
const H = access.pos(head);
|
|
442
|
+
const from = unit(sub(H, N));
|
|
443
|
+
const to = unit(sub(add(H, offset), N));
|
|
444
|
+
if (!from || !to) return;
|
|
445
|
+
const q = arcRotation(from, to);
|
|
446
|
+
for (const j of subtree) access.setPos(j, add(N, matVec(q, sub(access.pos(j), N))));
|
|
447
|
+
if (access.rotMats) access.setGlobal(neck, matMul(q, access.global(neck)));
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Validate and own the small JSON recipe stored beside the source motion ID. */
|
|
451
|
+
export function normalizeTrailEdits(value) {
|
|
452
|
+
if (value == null) return null;
|
|
453
|
+
const fail = () => { throw new TypeError("Invalid motion trail edit recipe"); };
|
|
454
|
+
if (value.version !== 1 || !Number.isInteger(value.frames) || value.frames < 1
|
|
455
|
+
|| !(value.fps > 0) || !Number.isFinite(value.fps)
|
|
456
|
+
|| !Array.isArray(value.edits) || value.edits.length > 4096) fail();
|
|
457
|
+
const edits = value.edits.map((edit) => {
|
|
458
|
+
if (!TRAIL_TRACKS.some(({ id }) => id === edit?.track)
|
|
459
|
+
|| !Number.isInteger(edit.grabFrame) || edit.grabFrame < 0 || edit.grabFrame >= value.frames
|
|
460
|
+
|| !Number.isInteger(edit.radiusFrames) || edit.radiusFrames < 0
|
|
461
|
+
|| !["x", "y", "z"].every((axis) => Number.isFinite(edit.clipDelta?.[axis]))) fail();
|
|
462
|
+
return { track: edit.track, grabFrame: edit.grabFrame, radiusFrames: edit.radiusFrames,
|
|
463
|
+
clipDelta: { x: edit.clipDelta.x, y: edit.clipDelta.y, z: edit.clipDelta.z } };
|
|
464
|
+
});
|
|
465
|
+
let segments = null;
|
|
466
|
+
if (value.segments != null) {
|
|
467
|
+
if (!Array.isArray(value.segments) || !value.segments.length || value.segments.length > 4096) fail();
|
|
468
|
+
segments = value.segments.map((segment) => {
|
|
469
|
+
if (typeof segment?.id !== "string" || !Number.isInteger(segment.sourceStart) || segment.sourceStart < 0
|
|
470
|
+
|| !Number.isInteger(segment.sourceEnd) || segment.sourceEnd < segment.sourceStart
|
|
471
|
+
|| !Number.isFinite(segment.speed) || segment.speed < 0.1 || segment.speed > 4) fail();
|
|
472
|
+
return { id: segment.id, sourceStart: segment.sourceStart, sourceEnd: segment.sourceEnd, speed: segment.speed };
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
return { version: 1, frames: value.frames, fps: value.fps, segments, edits };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function rememberTrailEdit(source, edited, edit) {
|
|
479
|
+
const previous = source.trailEdits;
|
|
480
|
+
return { ...edited, trailEdits: {
|
|
481
|
+
version: 1, frames: source.frames, fps: source.fps,
|
|
482
|
+
segments: previous?.segments ?? source.editSegments?.map((segment) => ({ ...segment })) ?? null,
|
|
483
|
+
edits: [...(previous?.edits ?? []), { ...edit, clipDelta: { ...edit.clipDelta } }],
|
|
484
|
+
} };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Rebuild the arrays AND the retarget reference after project/cache decoding. */
|
|
488
|
+
export function restoreTrailEdits(motion, stored) {
|
|
489
|
+
const recipe = normalizeTrailEdits(stored);
|
|
490
|
+
if (!recipe) return motion;
|
|
491
|
+
if (recipe.segments?.some((segment) => segment.sourceEnd >= motion.frames)) {
|
|
492
|
+
throw new RangeError("Motion trail source segment exceeds the take");
|
|
493
|
+
}
|
|
494
|
+
let restored = recipe.segments ? renderMotionEdit(motion, recipe.segments) : motion;
|
|
495
|
+
if (restored.frames !== recipe.frames || restored.fps !== recipe.fps) {
|
|
496
|
+
throw new RangeError("Motion trail recipe does not match the take clock");
|
|
497
|
+
}
|
|
498
|
+
for (const edit of recipe.edits) restored = applyTrailFalloffDelta(restored, edit);
|
|
499
|
+
return restored;
|
|
500
|
+
}
|
|
501
|
+
|
|
175
502
|
/**
|
|
176
|
-
* Deform a take by a clip-space delta centred on `grabFrame
|
|
177
|
-
*
|
|
178
|
-
* (
|
|
179
|
-
*
|
|
503
|
+
* Deform a take by a clip-space delta centred on `grabFrame`, weighted per
|
|
504
|
+
* frame by the smoothstep falloff. `track` is the dragged TRAIL_TRACKS id:
|
|
505
|
+
* - "hips" (default): the whole body shifts by weight(f) * delta on rootPos
|
|
506
|
+
* AND posedJoints (applyMotionFrame skins from posedJoints; rootPos feeds
|
|
507
|
+
* camera/subject sampling — both must agree or the preview tears).
|
|
508
|
+
* - a hand/foot: only that limb bends (bendLimbFrame).
|
|
509
|
+
* - "head": only the head swings about the neck (swingHeadFrame).
|
|
510
|
+
* Limb and head edits rewrite rotMats too, since playback reads rotations.
|
|
511
|
+
* Returns a new motion; the caller's arrays are never written.
|
|
180
512
|
*/
|
|
181
|
-
export function applyTrailFalloffDelta(motion, { grabFrame, radiusFrames, clipDelta }) {
|
|
513
|
+
export function applyTrailFalloffDelta(motion, { track = "hips", grabFrame, radiusFrames, clipDelta }) {
|
|
182
514
|
if (!motion?.posedJoints || !motion?.rootPos || !(motion.frames > 0)) return motion;
|
|
183
515
|
const { startFrame, endFrame } = trailEditRange(motion.frames, grabFrame, radiusFrames);
|
|
184
516
|
const rootPos = motion.rootPos.slice();
|
|
185
517
|
const posedJoints = motion.posedJoints.slice();
|
|
518
|
+
const chain = LIMB_CHAINS[track]?.map(jointIndex);
|
|
519
|
+
if (chain || track === "head") {
|
|
520
|
+
const rotMats = motion.rotMats ? motion.rotMats.slice() : null;
|
|
521
|
+
const neck = jointIndex("Neck");
|
|
522
|
+
const head = jointIndex("Head");
|
|
523
|
+
const moved = subtreeOf(chain ? chain[2] : head).filter((j) => !chain || j !== chain[2]);
|
|
524
|
+
const guide = chain ? limbWindowNormal(motion, chain, startFrame, endFrame, grabFrame, radiusFrames) : null;
|
|
525
|
+
for (let f = startFrame; f < endFrame; f += 1) {
|
|
526
|
+
const w = falloffWeight(f - grabFrame, radiusFrames);
|
|
527
|
+
if (w <= 0) continue;
|
|
528
|
+
const access = { ...frameAccess(posedJoints, rotMats, f), rotMats };
|
|
529
|
+
const offset = [clipDelta.x * w, clipDelta.y * w, clipDelta.z * w];
|
|
530
|
+
if (chain) bendLimbFrame(access, chain, [chain[2], ...moved], offset, guide);
|
|
531
|
+
else swingHeadFrame(access, neck, head, moved, offset);
|
|
532
|
+
}
|
|
533
|
+
const edited = rotMats ? { ...motion, rootPos, posedJoints, rotMats } : { ...motion, rootPos, posedJoints };
|
|
534
|
+
if (chain && rotMats) {
|
|
535
|
+
// One immutable reference, not a chain of preview/drag snapshots.
|
|
536
|
+
edited.trailRetarget = {
|
|
537
|
+
base: motion.trailRetarget?.base ?? motion,
|
|
538
|
+
chains: [...new Set([...(motion.trailRetarget?.chains ?? []), track])],
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
return rememberTrailEdit(motion, edited, { track, grabFrame, radiusFrames, clipDelta });
|
|
542
|
+
}
|
|
186
543
|
for (let f = startFrame; f < endFrame; f += 1) {
|
|
187
544
|
const w = falloffWeight(f - grabFrame, radiusFrames);
|
|
188
545
|
if (w <= 0) continue;
|
|
@@ -199,7 +556,7 @@ export function applyTrailFalloffDelta(motion, { grabFrame, radiusFrames, clipDe
|
|
|
199
556
|
posedJoints[po + 2] += dz;
|
|
200
557
|
}
|
|
201
558
|
}
|
|
202
|
-
return { ...motion, rootPos, posedJoints };
|
|
559
|
+
return rememberTrailEdit(motion, { ...motion, rootPos, posedJoints }, { track, grabFrame, radiusFrames, clipDelta });
|
|
203
560
|
}
|
|
204
561
|
|
|
205
562
|
/**
|
package/src/object-gizmo.jsx
CHANGED
|
@@ -461,7 +461,13 @@ export default function ObjectGizmo({ object, objects = [], mode = "move", snap
|
|
|
461
461
|
* Escape-clears-selection handler; with no drag open it returns without
|
|
462
462
|
* touching the event, so App still gets its deselect (§7). */
|
|
463
463
|
const onKeyDown = (event) => {
|
|
464
|
-
if (
|
|
464
|
+
if (!dragRef.current) return;
|
|
465
|
+
// Settle before App chooses between owned and native undo histories.
|
|
466
|
+
if ((event.ctrlKey || event.metaKey) && (event.code === "KeyZ" || event.code === "KeyY")) {
|
|
467
|
+
endDrag(true);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (event.code !== "Escape" && event.key !== "Escape") return;
|
|
465
471
|
endDrag(false);
|
|
466
472
|
event.preventDefault();
|
|
467
473
|
event.stopPropagation();
|
package/src/otio.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// OpenTimelineIO cut-list export. OTIO is JSON, so this module deliberately
|
|
2
2
|
// has no OTIO dependency: the schema names below are the wire contract.
|
|
3
3
|
|
|
4
|
+
import { isEffectivelyHidden } from "./scene-objects.js";
|
|
4
5
|
import { sampleAt } from "./sample-at.js";
|
|
5
6
|
import { TIMELINE_FRAME_FPS } from "./scenes.js";
|
|
6
7
|
import { usedSensorHeightMm } from "./shot.js";
|
|
@@ -71,7 +72,7 @@ function blockingAt(scene, frame) {
|
|
|
71
72
|
...(Array.isArray(characters) ? characters : [])
|
|
72
73
|
.filter((character) => character && character.hidden !== true)
|
|
73
74
|
.map((character, index) => characterBlocking(scene, character, index, frame)),
|
|
74
|
-
...objects.filter(
|
|
75
|
+
...objects.filter((object) => object && !isEffectivelyHidden(object, objects, Array.isArray(characters) ? characters : [])).map(objectBlocking),
|
|
75
76
|
];
|
|
76
77
|
}
|
|
77
78
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import Foldout from "./Foldout.jsx";
|
|
2
|
+
import { ko, isKo } from "../locale.js";
|
|
3
|
+
import { Field } from "../ui.jsx";
|
|
4
|
+
import { VIDEO_MODEL_PRESETS } from "../model-presets.js";
|
|
5
|
+
import { useBus } from "../app-context.js";
|
|
6
|
+
|
|
7
|
+
export default function CameraPanel({ isCameraSelection, shot, moveSequence, cameraKeys, activeShot }) {
|
|
8
|
+
const { run } = useBus();
|
|
9
|
+
return (
|
|
10
|
+
<Foldout hidden={!isCameraSelection} title={ko("Camera", "카메라")}>
|
|
11
|
+
<div className="readout v2-details-readout">
|
|
12
|
+
<span title={ko("camera to subject", "카메라와 피사체 거리")}>{shot.distance.toFixed(2)} m</span>
|
|
13
|
+
<span title={ko("nearest prime on the cropped filmback", "크롭된 필름백 기준 가장 가까운 단렌즈")}>{shot.focalMm} mm</span>
|
|
14
|
+
<span title={ko("angle relative to the subject's eyes", "피사체 눈높이 기준 각도")}>{shot.elevationDeg.toFixed(0)}°</span>
|
|
15
|
+
</div>
|
|
16
|
+
<h3 className="move-head v2-details-subsection-title">{ko("Move keys", "움직임 키")}</h3>
|
|
17
|
+
{moveSequence ? (
|
|
18
|
+
<div className="move-slate v2-details-value-box" title={ko("derived from the keyframings, not chosen from a list", "목록에서 고른 값이 아니라 키프레임에서 계산된 움직임입니다")}>
|
|
19
|
+
{moveSequence.displaySlate} · {moveSequence.spanS}{ko("s", "초")}
|
|
20
|
+
</div>
|
|
21
|
+
) : (
|
|
22
|
+
<div className="move-slate v2-details-value-box">
|
|
23
|
+
{cameraKeys.length === 1
|
|
24
|
+
? (isKo ? `프레임 ${cameraKeys[0].frame}부터 고정 샷 — 샷 블록 아래 빈 줄을 클릭해 움직임을 추가하세요` : `locked-off hold from frame ${cameraKeys[0].frame} — click the empty lower strip in a Shot block to add a move`)
|
|
25
|
+
: ko("click a Shot block's lower strip to key the current framing at that frame", "샷 블록 아래 빈 줄을 클릭하면 해당 프레임에 현재 프레이밍을 저장합니다")}
|
|
26
|
+
</div>
|
|
27
|
+
)}
|
|
28
|
+
|
|
29
|
+
<h3 className="move-head v2-details-subsection-title">{ko("Follow cam", "팔로우 카메라")}</h3>
|
|
30
|
+
<p className="camera-editor-pointer">
|
|
31
|
+
{activeShot
|
|
32
|
+
? ko(`Editing ${activeShot.name} in the timeline camera bar below.`, `아래 타임라인 카메라 바에서 ${activeShot.name}을 편집합니다.`)
|
|
33
|
+
: ko("Select a Shot block below to edit its camera.", "아래에서 샷 블록을 선택하면 카메라를 편집할 수 있습니다.")}
|
|
34
|
+
</p>
|
|
35
|
+
|
|
36
|
+
{/* Which generator this cut is being made FOR. Nothing here
|
|
37
|
+
re-times or re-crops the shot — the timeline simply warns
|
|
38
|
+
when the cut runs past the target's clip length or leaves
|
|
39
|
+
its delivery aspects. */}
|
|
40
|
+
<h3 className="move-head v2-details-subsection-title">{ko("Target model", "타깃 모델")}</h3>
|
|
41
|
+
<p className="inspector-hint v2-details-hint">
|
|
42
|
+
{ko("The timeline flags this shot when the cut runs past the model's clip length or leaves its delivery ratios. Nothing is re-timed or re-cropped.", "컷 길이나 화면 비율이 모델 한계를 벗어나면 타임라인이 표시해줘요. 자동으로 재조정하지는 않습니다.")}
|
|
43
|
+
</p>
|
|
44
|
+
<Field label={ko("Cut for", "맞출 모델")}>
|
|
45
|
+
<select
|
|
46
|
+
data-shot-target-model
|
|
47
|
+
aria-label={ko("Target video model", "타깃 영상 모델")}
|
|
48
|
+
disabled={!activeShot}
|
|
49
|
+
value={activeShot?.targetModel ?? ""}
|
|
50
|
+
onChange={(event) => run('shot.set', { id: activeShot.id, set: { targetModel: event.target.value || null } })}
|
|
51
|
+
>
|
|
52
|
+
<option value="">{ko("None", "없음")}</option>
|
|
53
|
+
{VIDEO_MODEL_PRESETS.map((entry) => (
|
|
54
|
+
<option key={entry.id} value={entry.id}>{entry.name}</option>
|
|
55
|
+
))}
|
|
56
|
+
</select>
|
|
57
|
+
</Field>
|
|
58
|
+
</Foldout>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import Foldout from "./Foldout.jsx";
|
|
2
|
+
import { ko } from "../locale.js";
|
|
3
|
+
import { Vector3Row, Slider } from "../ui.jsx";
|
|
4
|
+
import { useCastTransaction } from '../domains/cast.js';
|
|
5
|
+
|
|
6
|
+
export default function CharacterTransformPanel({ workflowMode, isCharacterSelection, activeChar }) {
|
|
7
|
+
const { run, begin, commit } = useCastTransaction();
|
|
8
|
+
return (
|
|
9
|
+
<Foldout
|
|
10
|
+
key={workflowMode === "motion" ? "placement" : "transform"}
|
|
11
|
+
hidden={!isCharacterSelection}
|
|
12
|
+
defaultOpen={workflowMode === "motion"}
|
|
13
|
+
title={workflowMode === "motion" ? ko("Placement", "배치") : ko("Transform", "변환")}
|
|
14
|
+
>
|
|
15
|
+
{workflowMode === "motion" ? (
|
|
16
|
+
<div className="placement-fields v2-details-panel">
|
|
17
|
+
<p className="inspector-hint">
|
|
18
|
+
{ko("Stage position — does not change the take", "무대 위치 — 테이크는 바꾸지 않습니다")}
|
|
19
|
+
</p>
|
|
20
|
+
<Vector3Row
|
|
21
|
+
label={ko("Position", "위치")}
|
|
22
|
+
fields={[
|
|
23
|
+
{ axis: "X", value: activeChar.x, step: 0.05, precision: 2, scrubRange: 5, onChange: (x) => run('character.update', { characterId: activeChar.id, patch: { x } }), onScrubStart: begin, onScrubEnd: commit },
|
|
24
|
+
{ axis: "Z", value: activeChar.z, step: 0.05, precision: 2, scrubRange: 5, onChange: (z) => run('character.update', { characterId: activeChar.id, patch: { z } }), onScrubStart: begin, onScrubEnd: commit },
|
|
25
|
+
]}
|
|
26
|
+
/>
|
|
27
|
+
<Slider compact label={ko("Rotation", "회전")} min={-180} max={180} step={1} value={activeChar.rot ?? 0} unit="°" onChange={(rot) => run('character.update', { characterId: activeChar.id, patch: { rot } })} />
|
|
28
|
+
</div>
|
|
29
|
+
) : (
|
|
30
|
+
<>
|
|
31
|
+
<p className="inspector-hint">
|
|
32
|
+
{ko("Edit the selected subject's placement, turn and size. Drag the Transform tool in the viewport for direct manipulation.", "선택한 인물의 위치·회전·크기를 편집합니다. 뷰포트의 변환 도구를 드래그해 바로 조작할 수도 있어요.")}
|
|
33
|
+
</p>
|
|
34
|
+
<Vector3Row
|
|
35
|
+
label={ko("Position", "위치")}
|
|
36
|
+
fields={[
|
|
37
|
+
{ axis: "X", value: activeChar.x, step: 0.05, precision: 2, scrubRange: 5, onChange: (x) => run('character.update', { characterId: activeChar.id, patch: { x } }), onScrubStart: begin, onScrubEnd: commit },
|
|
38
|
+
{ axis: "Y", value: activeChar.y ?? 0, step: 0.05, precision: 2, scrubRange: 5, onChange: (y) => run('character.update', { characterId: activeChar.id, patch: { y } }), onScrubStart: begin, onScrubEnd: commit },
|
|
39
|
+
{ axis: "Z", value: activeChar.z, step: 0.05, precision: 2, scrubRange: 5, onChange: (z) => run('character.update', { characterId: activeChar.id, patch: { z } }), onScrubStart: begin, onScrubEnd: commit },
|
|
40
|
+
]}
|
|
41
|
+
/>
|
|
42
|
+
<Slider compact label={ko("Rotation", "회전")} min={-180} max={180} step={1} value={activeChar.rot ?? 0} unit="°" onChange={(rot) => run('character.update', { characterId: activeChar.id, patch: { rot } })} />
|
|
43
|
+
<Slider compact label={ko("Scale", "크기")} min={0.2} max={3} step={0.05} value={activeChar.scale ?? 1} unit="×" onChange={(scale) => run('character.update', { characterId: activeChar.id, patch: { scale } })} />
|
|
44
|
+
</>
|
|
45
|
+
)}
|
|
46
|
+
</Foldout>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { ko } from "../locale.js";
|
|
2
|
+
import { useStageTransaction } from "../domains/stage.js";
|
|
3
|
+
import Foldout from "./Foldout.jsx";
|
|
4
|
+
import { Field } from "../ui.jsx";
|
|
5
|
+
import ReferenceImageField from "./ReferenceImageField.jsx";
|
|
6
|
+
|
|
7
|
+
export default function EnvironmentPanel(props) {
|
|
8
|
+
const { run, begin, commit } = useStageTransaction();
|
|
9
|
+
return (
|
|
10
|
+
<Foldout hidden={props.selectedHierarchyId !== "environment"} title={ko("Environment", "환경")}>
|
|
11
|
+
<label className="check details-toggle-row">
|
|
12
|
+
<span>{ko("I have an environment sheet", "환경 시트가 있어요")}</span>
|
|
13
|
+
<input type="checkbox" checked={props.hasEnvSheet} onChange={(event) => run("stage.setEnvironment", { hasEnvSheet: event.target.checked })} />
|
|
14
|
+
</label>
|
|
15
|
+
{!props.hasEnvSheet && (
|
|
16
|
+
<Field label={ko("Environment description", "환경 설명")}>
|
|
17
|
+
<input type="text" value={props.environment} onChange={(event) => run("run.update", { txId: begin("stage.setEnvironment"), args: { environment: event.target.value } })} onBlur={commit} />
|
|
18
|
+
</Field>
|
|
19
|
+
)}
|
|
20
|
+
<Field label={ko("Look / style", "룩 / 스타일")}>
|
|
21
|
+
<input type="text" value={props.style} onChange={(event) => run("run.update", { txId: begin("stage.setStyle"), args: { style: event.target.value } })} onBlur={commit} />
|
|
22
|
+
</Field>
|
|
23
|
+
<ReferenceImageField
|
|
24
|
+
label={ko("Environment reference", "환경 참고 이미지")}
|
|
25
|
+
hint={ko(
|
|
26
|
+
"A picture of this location. It travels with every framing capture so a render takes its materials, palette and lighting from the real place.",
|
|
27
|
+
"이 장소의 사진입니다. 모든 프레이밍 캐프처에 함께 실려 재질·색감·조명을 실제 장소에서 가져옵니다.",
|
|
28
|
+
)}
|
|
29
|
+
value={props.environmentImage}
|
|
30
|
+
alt={ko("Environment reference", "환경 참고 이미지")}
|
|
31
|
+
inputProps={{ "data-environment-image-input": "" }}
|
|
32
|
+
onPick={(dataUrl) => {
|
|
33
|
+
run("stage.setEnvironment", { environmentImage: dataUrl });
|
|
34
|
+
props.setToast(ko("Environment reference set", "환경 참고 이미지를 설정했어요"));
|
|
35
|
+
}}
|
|
36
|
+
onClear={() => run("stage.setEnvironment", { environmentImage: null })}
|
|
37
|
+
/>
|
|
38
|
+
</Foldout>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useState, useRef, useEffect } from "react";
|
|
2
|
+
import "./details.css";
|
|
3
|
+
|
|
4
|
+
export default function Foldout({ title, hidden, defaultOpen = true, openSignal = 0, children }) {
|
|
5
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
6
|
+
const cardRef = useRef(null);
|
|
7
|
+
// A collapsed panel must still be reachable from elsewhere: selecting a
|
|
8
|
+
// prompt block on the timeline has to reveal the panel that edits it, or
|
|
9
|
+
// the click looks like it did nothing. Opening alone is not enough — the
|
|
10
|
+
// panel can sit below the fold of a long Inspector — so it is scrolled into
|
|
11
|
+
// view as well. The signal only ever opens; it never closes a panel.
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (openSignal <= 0) return undefined;
|
|
14
|
+
setOpen(true);
|
|
15
|
+
// One frame later: the body has to exist before it can be scrolled to.
|
|
16
|
+
const raf = requestAnimationFrame(() => {
|
|
17
|
+
cardRef.current?.scrollIntoView({ block: "nearest" });
|
|
18
|
+
});
|
|
19
|
+
return () => cancelAnimationFrame(raf);
|
|
20
|
+
}, [openSignal]);
|
|
21
|
+
return (
|
|
22
|
+
<section ref={cardRef} className={"card foldout v2-details-section" + (open ? " open" : "")} hidden={hidden}>
|
|
23
|
+
<h3>
|
|
24
|
+
<button type="button" className="foldout-head v2-details-foldout-head" aria-expanded={open} onClick={() => setOpen((v) => !v)}>
|
|
25
|
+
<span className="foldout-arrow v2-details-foldout-arrow" data-open={open ? "true" : "false"} aria-hidden="true" />
|
|
26
|
+
<span className="foldout-title v2-details-foldout-title">{title}</span>
|
|
27
|
+
</button>
|
|
28
|
+
</h3>
|
|
29
|
+
{open && <div className="foldout-body v2-details-foldout-body">{children}</div>}
|
|
30
|
+
</section>
|
|
31
|
+
);
|
|
32
|
+
}
|