cozyclay 1.2.0 → 1.3.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 +85 -0
- package/README.md +31 -0
- package/THIRD_PARTY_NOTICES.md +37 -1
- package/bin/cozyclay.mjs +51 -2
- package/dist/ai-camera-control/index.html +406 -0
- package/dist/app/index.html +5 -5
- package/dist/assets/app-B3U5aut1.js +4811 -0
- package/dist/assets/app-BrRF0wso.css +1 -0
- package/dist/assets/vision_bundle-jFkh-fIS.js +41 -0
- package/dist/fonts/InstrumentSerif-OFL.txt +93 -0
- package/dist/fonts/Inter-OFL.txt +92 -0
- package/dist/fonts/README.md +15 -0
- package/dist/index.html +60 -16
- package/dist/sitemap.xml +7 -1
- package/mcp/LIVE-PROTOCOL.md +63 -0
- package/mcp/README.md +142 -0
- package/mcp/ardy-prompts.mjs +170 -0
- package/mcp/live-hub.mjs +105 -0
- package/mcp/package.json +24 -0
- package/mcp/server.mjs +1394 -0
- package/package.json +122 -90
- package/src/App.jsx +2957 -514
- package/src/ardy/cskel27.js +7 -2
- package/src/ardy/ik.js +25 -15
- package/src/ardy/npz.js +64 -3
- package/src/ardy/playback.js +31 -1
- package/src/ardy/prompt-clips.js +7 -2
- package/src/ardy/retime.js +211 -0
- package/src/ardy/timeline-coordinates.js +13 -0
- package/src/ardy/timeline.jsx +124 -5
- package/src/ardy/to-cskel27.js +34 -12
- package/src/ardy/trim.js +33 -0
- package/src/asset-pane.jsx +36 -0
- package/src/dualview.jsx +14 -8
- package/src/hierarchy-model.js +95 -13
- package/src/hierarchy-panel.jsx +139 -6
- package/src/live-control.js +122 -0
- package/src/matte-editor.js +543 -0
- package/src/matte.js +503 -0
- package/src/multimodel-ingest.js +344 -0
- package/src/object-gizmo.jsx +43 -15
- package/src/planview.jsx +43 -32
- package/src/pose-extract/detector.js +75 -0
- package/src/pose-extract/index.js +3 -0
- package/src/pose-extract/take.js +87 -0
- package/src/pose-extract/video-frames.js +91 -0
- package/src/pose-thumbs.js +152 -0
- package/src/posestudio.jsx +361 -11
- package/src/project-browser.jsx +135 -0
- package/src/project.js +289 -0
- package/src/props.jsx +69 -3
- package/src/room.jsx +14 -35
- package/src/scene-asset-cache.js +125 -0
- package/src/scene-assets.js +288 -0
- package/src/scene-objects.js +245 -7
- package/src/scenes.js +207 -26
- package/src/shot-authoring.js +55 -13
- package/src/styles.css +1296 -129
- package/tools/ardy/BRIDGE.md +3 -2
- package/tools/ardy/README.md +9 -5
- package/tools/ardy/bridge.mjs +57 -1
- package/tools/ardy/bvh-cskel27.mjs +1209 -0
- package/tools/ardy/cclay_constrained_generate.py +123 -11
- package/tools/ardy/cclay_sequence_generate.py +49 -0
- package/tools/ardy/extract.mjs +367 -0
- package/tools/ardy/footage.mjs +462 -0
- package/tools/ardy/npz.mjs +74 -9
- package/tools/ardy/run-on-box.sh +25 -0
- package/tools/ardy/run-sequence-on-box.sh +15 -0
- package/tools/ardy/runners/remote.mjs +8 -2
- package/dist/assets/app-Cgpk2hwX.js +0 -4803
- package/dist/assets/app-DgZvaAE1.css +0 -1
package/src/ardy/timeline.jsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
2
|
-
import { frameFromClientX, promptMoveStartFrame, shotBlockGeometry } from "./timeline-coordinates.js";
|
|
2
|
+
import { frameFromClientX, motionTrimRange, promptMoveStartFrame, shotBlockGeometry } from "./timeline-coordinates.js";
|
|
3
3
|
import { promptResizeFrame } from "./timeline-resize.js";
|
|
4
4
|
import { ko, isKo } from "../locale.js";
|
|
5
5
|
|
|
@@ -18,8 +18,8 @@ import { ko, isKo } from "../locale.js";
|
|
|
18
18
|
* and PlayView ride the keys segment by segment.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
const DEFAULT_FRAME_COUNT =
|
|
22
|
-
const DEFAULT_FPS =
|
|
21
|
+
const DEFAULT_FRAME_COUNT = 360; // 15 s @ 24 fps, the production clock the app timeline runs on
|
|
22
|
+
const DEFAULT_FPS = 24;
|
|
23
23
|
// Trackpad/wheel zoom over the FRAME ruler: the gesture changes only the
|
|
24
24
|
// horizontal visual scale of the time surface — never frameCount, fps,
|
|
25
25
|
// duration, waypoints or the generation request.
|
|
@@ -168,15 +168,24 @@ export default function Timeline({
|
|
|
168
168
|
fps = DEFAULT_FPS,
|
|
169
169
|
playbackSpeed = 1,
|
|
170
170
|
playing,
|
|
171
|
+
// Which cast member's animation layer these tracks edit ("S2", …).
|
|
172
|
+
trackOwner = null,
|
|
173
|
+
// Read-only previews of the OTHER cast members' layers: their prompt
|
|
174
|
+
// blocks and root pins render dimmed with an owner chip, so the whole
|
|
175
|
+
// cast's schedule is visible while only the active layer is editable.
|
|
176
|
+
ghostLayers = [], // [{ owner, promptClips: [], waypointFrames: [] }]
|
|
171
177
|
waypointMode,
|
|
172
178
|
waypointFrames = [],
|
|
173
|
-
pathSpeed = null,
|
|
179
|
+
pathSpeed = null, // { min, max, warn } in m/s, shown on the 2D Root label
|
|
174
180
|
badge,
|
|
175
181
|
promptClips = [],
|
|
176
182
|
selectedPromptId,
|
|
177
183
|
pendingWaypointFrame = null,
|
|
178
184
|
ikMode = false,
|
|
179
185
|
ikDisabled = false, // a loaded motion owns the rig
|
|
186
|
+
// The ACTIVE cast member's loaded take, drawn as a passive strip on the
|
|
187
|
+
// Full-Body lane. Frames are already on the timeline's 24 fps clock.
|
|
188
|
+
motion = null, // { frames, label } | null
|
|
180
189
|
ikFrames = [], // sorted full-body key frames
|
|
181
190
|
footSnap = true, // feet stay planted while the body moves
|
|
182
191
|
shots = [],
|
|
@@ -224,6 +233,8 @@ export default function Timeline({
|
|
|
224
233
|
onShotCut,
|
|
225
234
|
onShotSplit,
|
|
226
235
|
onShotMove,
|
|
236
|
+
onMotionTrim,
|
|
237
|
+
onMotionTrimReset,
|
|
227
238
|
}) {
|
|
228
239
|
const [expanded, setExpanded] = useState(true);
|
|
229
240
|
const [zoom, setZoom] = useState(ZOOM_DEFAULT);
|
|
@@ -244,7 +255,7 @@ export default function Timeline({
|
|
|
244
255
|
// The window key/interval handlers register once; the latest callbacks
|
|
245
256
|
// are read through a ref so they never go stale mid-playback.
|
|
246
257
|
const handlers = useRef({});
|
|
247
|
-
handlers.current = { onScrub, onAdvance, onStep, onPlayToggle, onWaypointToggle, onMarkerSelect, onMarkerRemove, onRootKeyframeAdd, onPromptAdd, onPromptSelect, onPromptChange, onPromptResize, onPromptMove, onPromptRemove, onIkToggle, onIkKeyframeAdd, onIkKeyframeRemove, onFootSnapToggle, onCameraMoveSelect, onCameraKeyframeAdd, onCameraKeyframeMove, onCameraKeyframeRemove, onCameraBlockSelect, onCameraBlockChange, onCameraPreview, onCameraRailDrawToggle, onRailSelect, onRailMove, onRailRangeChange, onRailRemove, onShotSelect, onShotBoundaryMove, onShotRename, onShotRemove, onShotDuplicate, onShotCut, onShotSplit, onShotMove };
|
|
258
|
+
handlers.current = { onScrub, onAdvance, onStep, onPlayToggle, onWaypointToggle, onMarkerSelect, onMarkerRemove, onRootKeyframeAdd, onPromptAdd, onPromptSelect, onPromptChange, onPromptResize, onPromptMove, onPromptRemove, onIkToggle, onIkKeyframeAdd, onIkKeyframeRemove, onFootSnapToggle, onCameraMoveSelect, onCameraKeyframeAdd, onCameraKeyframeMove, onCameraKeyframeRemove, onCameraBlockSelect, onCameraBlockChange, onCameraPreview, onCameraRailDrawToggle, onRailSelect, onRailMove, onRailRangeChange, onRailRemove, onShotSelect, onShotBoundaryMove, onShotRename, onShotRemove, onShotDuplicate, onShotCut, onShotSplit, onShotMove, onMotionTrim, onMotionTrimReset };
|
|
248
259
|
|
|
249
260
|
// Trackpad/wheel zoom over the FRAME ruler lane only. React registers
|
|
250
261
|
// onWheel as passive, so a synthetic onWheel could never preventDefault —
|
|
@@ -484,6 +495,47 @@ export default function Timeline({
|
|
|
484
495
|
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
|
485
496
|
}
|
|
486
497
|
|
|
498
|
+
// Motion trim: frame-accurate drag of the loaded take's in/out point on
|
|
499
|
+
// the Full-Body strip. No block snapping — a cut lands on the exact frame
|
|
500
|
+
// the hand releases. The preview lives here; the CUT itself is App's.
|
|
501
|
+
const motionTrimRef = useRef(null);
|
|
502
|
+
const [trimPreview, setTrimPreview] = useState(null);
|
|
503
|
+
|
|
504
|
+
function beginMotionTrim(e, edge) {
|
|
505
|
+
if (e.button !== 0 || !motion) return;
|
|
506
|
+
e.preventDefault();
|
|
507
|
+
e.stopPropagation();
|
|
508
|
+
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
509
|
+
const rect = e.currentTarget.closest(".tl-lane")?.getBoundingClientRect();
|
|
510
|
+
if (!rect || rect.width < 2) return;
|
|
511
|
+
const max = Math.min(motion.frames, displayFrameCount) - 1;
|
|
512
|
+
// The preview always restarts from the FULL take: App composes the cut
|
|
513
|
+
// as trimOffset + start, so an end-only drag must still report start 0.
|
|
514
|
+
motionTrimRef.current = { edge, rect, max, displayFrameCount, preview: { start: 0, end: max } };
|
|
515
|
+
setTrimPreview({ start: 0, end: max });
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function moveMotionTrim(e) {
|
|
519
|
+
const active = motionTrimRef.current;
|
|
520
|
+
if (!active) return;
|
|
521
|
+
// Same pixel→frame transform as every other lane, off the pointer-down
|
|
522
|
+
// geometry, so the handle stays under the cursor at any zoom.
|
|
523
|
+
const frame = Math.min(active.max, frameFromClientX(e.clientX, active.rect.left, active.rect.width, active.displayFrameCount, frameCount));
|
|
524
|
+
const next = motionTrimRange(active.edge, frame, active.preview, active.max);
|
|
525
|
+
active.preview = next;
|
|
526
|
+
setTrimPreview(next);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function endMotionTrim(e) {
|
|
530
|
+
const active = motionTrimRef.current;
|
|
531
|
+
motionTrimRef.current = null;
|
|
532
|
+
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
|
533
|
+
setTrimPreview(null);
|
|
534
|
+
if (active && (active.preview.start > 0 || active.preview.end < active.max)) {
|
|
535
|
+
handlers.current.onMotionTrim?.(active.preview.start, active.preview.end);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
487
539
|
// Camera key dots re-time by dragging, like prompt clips move: the frame
|
|
488
540
|
// delta comes from the pointer-down geometry so a growing timeline cannot
|
|
489
541
|
// feed back into the next pointermove.
|
|
@@ -917,6 +969,17 @@ export default function Timeline({
|
|
|
917
969
|
<div className={"tl-track" + (name === "Prompts" ? " prompts" : "") + (name === IK_LANE ? " ik" : "") + (name === SHOTS_LANE ? " shots" : "")} key={name}>
|
|
918
970
|
<span className="tl-track-label">
|
|
919
971
|
{TRACK_LABELS_KO[name]}
|
|
972
|
+
{trackOwner && (name === "Prompts" || name === "2D Root") && <em className="tl-track-owner">{trackOwner}</em>}
|
|
973
|
+
{name === "2D Root" && pathSpeed && (
|
|
974
|
+
<em
|
|
975
|
+
className={"tl-path-speed" + (pathSpeed.warn ? " warn" : "")}
|
|
976
|
+
title={isKo ? `핀 구간 속도 ${pathSpeed.min.toFixed(1)}~${pathSpeed.max.toFixed(1)} m/s — 자연 보행은 0.8~1.2 m/s` : `Leg speeds ${pathSpeed.min.toFixed(1)}–${pathSpeed.max.toFixed(1)} m/s — natural gait is 0.8–1.2 m/s`}
|
|
977
|
+
>
|
|
978
|
+
{pathSpeed.min === pathSpeed.max
|
|
979
|
+
? `${pathSpeed.min.toFixed(1)} m/s`
|
|
980
|
+
: `${pathSpeed.min.toFixed(1)}–${pathSpeed.max.toFixed(1)} m/s`}
|
|
981
|
+
</em>
|
|
982
|
+
)}
|
|
920
983
|
{name === "Prompts" && <button className="tl-track-add" type="button" title={ko("Add 2 second prompt clip", "2초 프롬프트 클립 추가")} onClick={() => handlers.current.onPromptAdd?.(frame)}>+</button>}
|
|
921
984
|
{name === SHOTS_LANE && (
|
|
922
985
|
<button
|
|
@@ -1079,6 +1142,16 @@ export default function Timeline({
|
|
|
1079
1142
|
</div>
|
|
1080
1143
|
);
|
|
1081
1144
|
})}
|
|
1145
|
+
{name === "Prompts" && ghostLayers.map((layer) => layer.promptClips.map((clip) => (
|
|
1146
|
+
<div
|
|
1147
|
+
key={`${layer.owner}:${clip.id}`}
|
|
1148
|
+
className="tl-chip ghost"
|
|
1149
|
+
style={{ "--tl-f-start": clipPct(clip.startFrame), "--tl-f-end": clipPct(clip.endFrame) }}
|
|
1150
|
+
title={`${layer.owner} · ${clip.text}`}
|
|
1151
|
+
>
|
|
1152
|
+
<span className="tl-chip-ghost-label">{layer.owner} · {clip.text || "…"}</span>
|
|
1153
|
+
</div>
|
|
1154
|
+
)))}
|
|
1082
1155
|
{name === "Prompts" && promptClips.map((clip) => {
|
|
1083
1156
|
const duration = ((clip.endFrame - clip.startFrame) / Math.max(1, fps)).toFixed(1);
|
|
1084
1157
|
return (
|
|
@@ -1089,6 +1162,44 @@ export default function Timeline({
|
|
|
1089
1162
|
</div>
|
|
1090
1163
|
);
|
|
1091
1164
|
})}
|
|
1165
|
+
{name === IK_LANE && motion && (
|
|
1166
|
+
<div
|
|
1167
|
+
className={"tl-motion-clip" + (trimPreview ? " trimming" : "")}
|
|
1168
|
+
style={{
|
|
1169
|
+
"--tl-f-start": clipPct(trimPreview ? trimPreview.start : 0),
|
|
1170
|
+
"--tl-f-end": clipPct(trimPreview ? trimPreview.end + 1 : Math.min(motion.frames, displayFrameCount)),
|
|
1171
|
+
}}
|
|
1172
|
+
title={isKo
|
|
1173
|
+
? `불러온 테이크 — ${motion.frames}프레임. 양끝 핸들로 잘라내고, 핸들 우클릭으로 전체 길이 복원`
|
|
1174
|
+
: `Loaded take — ${motion.frames} frames. Drag the end handles to cut; right-click a handle to restore the full take`}
|
|
1175
|
+
>
|
|
1176
|
+
<button
|
|
1177
|
+
className="tl-motion-clip-handle start"
|
|
1178
|
+
type="button"
|
|
1179
|
+
aria-label={ko("Trim take start", "테이크 시작점 자르기")}
|
|
1180
|
+
onPointerDown={(e) => beginMotionTrim(e, "start")}
|
|
1181
|
+
onPointerMove={moveMotionTrim}
|
|
1182
|
+
onPointerUp={endMotionTrim}
|
|
1183
|
+
onPointerCancel={endMotionTrim}
|
|
1184
|
+
onContextMenu={(e) => { e.preventDefault(); handlers.current.onMotionTrimReset?.(); }}
|
|
1185
|
+
/>
|
|
1186
|
+
<span className="tl-motion-clip-label">
|
|
1187
|
+
{trimPreview
|
|
1188
|
+
? `${trimPreview.start}–${trimPreview.end} (${trimPreview.end - trimPreview.start + 1}f)`
|
|
1189
|
+
: motion.label}
|
|
1190
|
+
</span>
|
|
1191
|
+
<button
|
|
1192
|
+
className="tl-motion-clip-handle end"
|
|
1193
|
+
type="button"
|
|
1194
|
+
aria-label={ko("Trim take end", "테이크 끝점 자르기")}
|
|
1195
|
+
onPointerDown={(e) => beginMotionTrim(e, "end")}
|
|
1196
|
+
onPointerMove={moveMotionTrim}
|
|
1197
|
+
onPointerUp={endMotionTrim}
|
|
1198
|
+
onPointerCancel={endMotionTrim}
|
|
1199
|
+
onContextMenu={(e) => { e.preventDefault(); handlers.current.onMotionTrimReset?.(); }}
|
|
1200
|
+
/>
|
|
1201
|
+
</div>
|
|
1202
|
+
)}
|
|
1092
1203
|
{name === IK_LANE &&
|
|
1093
1204
|
ikFrames.map((f) => (
|
|
1094
1205
|
<span
|
|
@@ -1108,6 +1219,14 @@ export default function Timeline({
|
|
|
1108
1219
|
}}
|
|
1109
1220
|
/>
|
|
1110
1221
|
))}
|
|
1222
|
+
{name === "2D Root" && ghostLayers.map((layer) => layer.waypointFrames.map((f) => (
|
|
1223
|
+
<span
|
|
1224
|
+
key={`${layer.owner}:${f}`}
|
|
1225
|
+
className="tl-marker wp ghost"
|
|
1226
|
+
style={{ "--tl-f": framePct(f, displayFrameCount) }}
|
|
1227
|
+
title={`${layer.owner} · frame ${f}`}
|
|
1228
|
+
/>
|
|
1229
|
+
)))}
|
|
1111
1230
|
{name === "2D Root" &&
|
|
1112
1231
|
[...waypointFrames, ...(pendingWaypointFrame == null || waypointFrames.includes(pendingWaypointFrame) ? [] : [pendingWaypointFrame])].map((f) => (
|
|
1113
1232
|
<span
|
package/src/ardy/to-cskel27.js
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
* reads (local_rot_mats 27x3x3, posed_joints 27x3; the generator then runs its
|
|
4
4
|
* own FK, consulting only posed_joints[0] as the root).
|
|
5
5
|
*
|
|
6
|
-
* The 19 joints CozyClay authors map through each bone's armature-space
|
|
7
|
-
* rotation Rb (cskel27-rest.json): L = Rb @ basis @ Rb^T.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* The 19 joints CozyClay authors map through each Mixamo bone's armature-space
|
|
7
|
+
* rest rotation Rb (cskel27-rest.json): L = Rb @ basis @ Rb^T. ARDY has four
|
|
8
|
+
* torso segments where Mixamo has three, so Mixamo Spine/Spine1 target ARDY
|
|
9
|
+
* Spine1/Spine2, matching playback's map. The remaining joints get identity;
|
|
10
|
+
* `filled_identity` records them and `rotation_constraint_indices` identifies
|
|
11
|
+
* the rotations that are genuinely authored.
|
|
11
12
|
*
|
|
12
13
|
* Proportions are never hardcoded: bone offsets come from the reference frame
|
|
13
14
|
* of the same base clip the pose will constrain (mirroring
|
|
@@ -16,7 +17,11 @@
|
|
|
16
17
|
* correct by construction.
|
|
17
18
|
*/
|
|
18
19
|
|
|
19
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
COZYCLAY_BONES,
|
|
22
|
+
COZYCLAY_TO_CSKEL27,
|
|
23
|
+
CSKEL27_JOINTS,
|
|
24
|
+
} from "./cskel27.js";
|
|
20
25
|
import { CSKEL27_NEUTRAL } from "./cskel27-neutral.js";
|
|
21
26
|
import {
|
|
22
27
|
basisQuaternionToLocalRotation,
|
|
@@ -36,6 +41,13 @@ const IDENTITY = [
|
|
|
36
41
|
[0, 0, 1],
|
|
37
42
|
];
|
|
38
43
|
|
|
44
|
+
const SOURCE_BY_TARGET = new Map(
|
|
45
|
+
COZYCLAY_BONES.map((source) => [
|
|
46
|
+
CSKEL27_JOINTS[COZYCLAY_TO_CSKEL27[source]],
|
|
47
|
+
source,
|
|
48
|
+
])
|
|
49
|
+
);
|
|
50
|
+
|
|
39
51
|
const CANONICAL_ROOT_Y = -Math.min(...CSKEL27_NEUTRAL.map((joint) => joint[1]));
|
|
40
52
|
|
|
41
53
|
/** Floor-aligned CoreSkeleton27 reference used when no clip reference is
|
|
@@ -160,23 +172,28 @@ export function poseToCskel27({ pose, rest, reference }) {
|
|
|
160
172
|
restByJoint.set(entry.name, entry.rest);
|
|
161
173
|
}
|
|
162
174
|
|
|
163
|
-
const authored = new Set(COZYCLAY_BONES);
|
|
164
175
|
const local_rot_mats = new Array(CSKEL27_JOINTS.length);
|
|
165
176
|
const filled_identity = [];
|
|
177
|
+
const rotation_constraint_indices = [];
|
|
166
178
|
for (let index = 0; index < CSKEL27_JOINTS.length; index += 1) {
|
|
167
179
|
const name = CSKEL27_JOINTS[index];
|
|
168
|
-
const
|
|
169
|
-
|
|
180
|
+
const sourceName = SOURCE_BY_TARGET.get(name);
|
|
181
|
+
const rb = sourceName ? restByJoint.get(sourceName) : null;
|
|
182
|
+
if (sourceName && rb !== null && rb !== undefined) {
|
|
170
183
|
// L = Rb @ basis @ Rb^T; basisQuaternionToLocalRotation is the
|
|
171
184
|
// quatToMat + basisToLocal composition from convert.js.
|
|
172
|
-
local_rot_mats[index] = basisQuaternionToLocalRotation(
|
|
185
|
+
local_rot_mats[index] = basisQuaternionToLocalRotation(
|
|
186
|
+
pose.bones[sourceName],
|
|
187
|
+
rb
|
|
188
|
+
);
|
|
189
|
+
rotation_constraint_indices.push(index);
|
|
173
190
|
} else {
|
|
174
191
|
// Fresh identity per joint so callers can never alias a shared
|
|
175
192
|
// constant by mutating one row.
|
|
176
193
|
local_rot_mats[index] = IDENTITY.map((row) => row.slice());
|
|
177
194
|
filled_identity.push({
|
|
178
195
|
joint: name,
|
|
179
|
-
reason:
|
|
196
|
+
reason: sourceName ? "no rest rotation" : "not authored",
|
|
180
197
|
});
|
|
181
198
|
}
|
|
182
199
|
}
|
|
@@ -201,5 +218,10 @@ export function poseToCskel27({ pose, rest, reference }) {
|
|
|
201
218
|
const canonicalRoot = canonicalCskel27Reference().posed_joints[0];
|
|
202
219
|
const posed_joints = forwardKinematics(local_rot_mats, offsets, pose.root ?? canonicalRoot);
|
|
203
220
|
|
|
204
|
-
return {
|
|
221
|
+
return {
|
|
222
|
+
local_rot_mats,
|
|
223
|
+
posed_joints,
|
|
224
|
+
filled_identity,
|
|
225
|
+
rotation_constraint_indices,
|
|
226
|
+
};
|
|
205
227
|
}
|
package/src/ardy/trim.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// trim.js — cut a loaded take to a frame range. The slice is a NEW motion
|
|
2
|
+
// object in the exact npz-decoded shape; the caller keeps the original, so
|
|
3
|
+
// trimming is non-destructive and re-trimming always cuts from the full
|
|
4
|
+
// take. Frame 0 of the slice becomes the playback anchor: applyMotionFrame
|
|
5
|
+
// re-anchors X/Z on the anchor frame, so a mid-clip cut plays at the
|
|
6
|
+
// character's placement instead of teleporting to the original frame 0.
|
|
7
|
+
// The spread carries every non-array field with the slice — `personScale`
|
|
8
|
+
// included: a cut is the same performer, and a take that lost its stature
|
|
9
|
+
// mid-edit would replay the remaining frames at the wrong stride length.
|
|
10
|
+
|
|
11
|
+
import { CSKEL27_JOINTS } from "./cskel27.js";
|
|
12
|
+
|
|
13
|
+
export function sliceMotion(motion, startFrame, endFrame) {
|
|
14
|
+
if (!motion || !Number.isInteger(motion.frames) || motion.frames < 1) {
|
|
15
|
+
throw new Error("sliceMotion: motion.frames must be a positive integer");
|
|
16
|
+
}
|
|
17
|
+
if (!Number.isInteger(startFrame) || !Number.isInteger(endFrame)) {
|
|
18
|
+
throw new Error("sliceMotion: startFrame and endFrame must be integers");
|
|
19
|
+
}
|
|
20
|
+
if (startFrame < 0 || endFrame >= motion.frames || startFrame > endFrame) {
|
|
21
|
+
throw new Error(`sliceMotion: range ${startFrame}..${endFrame} is outside 0..${motion.frames - 1}`);
|
|
22
|
+
}
|
|
23
|
+
const joints = CSKEL27_JOINTS.length;
|
|
24
|
+
const frames = endFrame - startFrame + 1;
|
|
25
|
+
return {
|
|
26
|
+
...motion,
|
|
27
|
+
frames,
|
|
28
|
+
rotMats: motion.rotMats.slice(startFrame * joints * 9, (endFrame + 1) * joints * 9),
|
|
29
|
+
rootPos: motion.rootPos.slice(startFrame * 3, (endFrame + 1) * 3),
|
|
30
|
+
posedJoints: motion.posedJoints.slice(startFrame * joints * 3, (endFrame + 1) * joints * 3),
|
|
31
|
+
anchorFrame: 0,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { ko } from "./locale.js";
|
|
2
|
+
import { CHARACTER_MODEL_IDS } from "./scenes.js";
|
|
3
|
+
|
|
4
|
+
/** Casting assets offered in the bottom Assets tab. `id` doubles as the FBX
|
|
5
|
+
* file stem and the ARDY wire rig name (see scenes.js). */
|
|
6
|
+
export const CHARACTER_ASSETS = CHARACTER_MODEL_IDS.map((id) => ({
|
|
7
|
+
id,
|
|
8
|
+
label: id === "y-bot-tpose" ? "Y Bot" : "X Bot",
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
/** Bottom-window asset shelf: character cards the operator drags into the
|
|
12
|
+
* shot pane to spawn. The drag itself is owned by App (ghost overlay +
|
|
13
|
+
* ground raycast on drop); the pane only reports the grab. */
|
|
14
|
+
export default function AssetPane({ onAssetGrab }) {
|
|
15
|
+
return (
|
|
16
|
+
<div className="assets-grid">
|
|
17
|
+
{CHARACTER_ASSETS.map((asset) => (
|
|
18
|
+
<button
|
|
19
|
+
type="button"
|
|
20
|
+
className="asset-card"
|
|
21
|
+
key={asset.id}
|
|
22
|
+
title={ko(`Drag ${asset.label} into the scene`, `${asset.label}을(를) 씬에 드래그하세요`)}
|
|
23
|
+
onPointerDown={(event) => {
|
|
24
|
+
if (event.button !== 0) return;
|
|
25
|
+
event.preventDefault();
|
|
26
|
+
onAssetGrab?.(asset, event);
|
|
27
|
+
}}
|
|
28
|
+
>
|
|
29
|
+
<span className="asset-card-swatch" data-model={asset.id} aria-hidden="true" />
|
|
30
|
+
<span className="asset-card-label">{asset.label}</span>
|
|
31
|
+
<span className="asset-card-kind">{ko("Character", "인물")}</span>
|
|
32
|
+
</button>
|
|
33
|
+
))}
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
package/src/dualview.jsx
CHANGED
|
@@ -53,9 +53,15 @@ const EDGE_FRAGMENT = `
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
void main() {
|
|
56
|
+
float centerZ = linearDepth(texture2D(tDepth, vUv).x);
|
|
57
|
+
// Keep the stroke kernel stable, but soften its opacity past the 3 m
|
|
58
|
+
// reference distance. A minimum 42% opacity keeps distant silhouettes
|
|
59
|
+
// readable without letting ink become the only visible part of the body.
|
|
60
|
+
float distanceFade = orthographic > 0.5
|
|
61
|
+
? 1.0
|
|
62
|
+
: mix(0.42, 1.0, clamp(3.0 / max(centerZ, 0.001), 0.0, 1.0));
|
|
56
63
|
vec2 x = vec2(texel.x * 0.8, 0.0);
|
|
57
64
|
vec2 y = vec2(0.0, texel.y * 0.8);
|
|
58
|
-
float centerZ = linearDepth(texture2D(tDepth, vUv).x);
|
|
59
65
|
float nearestZ = min(
|
|
60
66
|
min(linearDepth(texture2D(tDepth, vUv - x).x), linearDepth(texture2D(tDepth, vUv + x).x)),
|
|
61
67
|
min(linearDepth(texture2D(tDepth, vUv - y).x), linearDepth(texture2D(tDepth, vUv + y).x))
|
|
@@ -88,7 +94,7 @@ const EDGE_FRAGMENT = `
|
|
|
88
94
|
float depthLine = smoothstep(0.055, 0.16, depthEdge);
|
|
89
95
|
float normalLine = smoothstep(0.7, 1.7, normalEdge);
|
|
90
96
|
float edge = max(depthLine * foregroundOwner, normalLine * 0.68);
|
|
91
|
-
gl_FragColor = vec4(edgeColor, edge * 0.76);
|
|
97
|
+
gl_FragColor = vec4(edgeColor, edge * 0.76 * distanceFade);
|
|
92
98
|
}
|
|
93
99
|
`;
|
|
94
100
|
|
|
@@ -103,7 +109,7 @@ export function fitAspect(rect, aspect) {
|
|
|
103
109
|
return { x: rect.x + (rect.w - w) / 2, y: rect.y + (rect.h - h) / 2, w, h };
|
|
104
110
|
}
|
|
105
111
|
|
|
106
|
-
export function DualRender({ stageRef, mainRef, insetRef, shotCamRef, planCamRef, poserCamRef, ikMode = false, planIsMain, playMode = false, insetCollapsed = false, planZoom = 1 }) {
|
|
112
|
+
export function DualRender({ stageRef, mainRef, insetRef, shotCamRef, planCamRef, poserCamRef, ikMode = false, planIsMain, playMode = false, insetCollapsed = false, planZoom = 1, shotAspect = SHOT_ASPECT }) {
|
|
107
113
|
const invalidate = useThree((state) => state.invalidate);
|
|
108
114
|
// Demand mode draws nothing by itself: the first frame can land before the
|
|
109
115
|
// layout settles (the inset reads 0x0), and async scene content commits
|
|
@@ -253,7 +259,7 @@ export function DualRender({ stageRef, mainRef, insetRef, shotCamRef, planCamRef
|
|
|
253
259
|
};
|
|
254
260
|
|
|
255
261
|
gl.autoClear = true;
|
|
256
|
-
shotCam.aspect =
|
|
262
|
+
shotCam.aspect = shotAspect;
|
|
257
263
|
shotCam.updateProjectionMatrix();
|
|
258
264
|
// An orthographic plan keeps every puck the same size and the floor
|
|
259
265
|
// undistorted; a perspective one skews the pucks near the edges and reads
|
|
@@ -279,7 +285,7 @@ export function DualRender({ stageRef, mainRef, insetRef, shotCamRef, planCamRef
|
|
|
279
285
|
// GIZMO_LAYER and is dropped for this draw, mask restored after.
|
|
280
286
|
const playMask = shotCam.layers.mask;
|
|
281
287
|
shotCam.layers.disable(GIZMO_LAYER);
|
|
282
|
-
draw(shotCam, mainRect, fitAspect(mainRect,
|
|
288
|
+
draw(shotCam, mainRect, fitAspect(mainRect, shotAspect));
|
|
283
289
|
shotCam.layers.mask = playMask;
|
|
284
290
|
} else if (ikMode && poserCam) {
|
|
285
291
|
// IK mode: the main pane is the poser working view (free navigation,
|
|
@@ -288,12 +294,12 @@ export function DualRender({ stageRef, mainRef, insetRef, shotCamRef, planCamRef
|
|
|
288
294
|
poserCam.aspect = mainRect.w / mainRect.h;
|
|
289
295
|
poserCam.updateProjectionMatrix();
|
|
290
296
|
draw(poserCam, mainRect);
|
|
291
|
-
if (!insetCollapsed) draw(shotCam, insetRect, fitAspect(insetRect,
|
|
297
|
+
if (!insetCollapsed) draw(shotCam, insetRect, fitAspect(insetRect, shotAspect));
|
|
292
298
|
} else if (planIsMain) {
|
|
293
299
|
draw(planCam, planPane, null, false);
|
|
294
|
-
if (!insetCollapsed) draw(shotCam, shotPane, fitAspect(shotPane,
|
|
300
|
+
if (!insetCollapsed) draw(shotCam, shotPane, fitAspect(shotPane, shotAspect));
|
|
295
301
|
} else {
|
|
296
|
-
draw(shotCam, shotPane, fitAspect(shotPane,
|
|
302
|
+
draw(shotCam, shotPane, fitAspect(shotPane, shotAspect));
|
|
297
303
|
if (!insetCollapsed) draw(planCam, planPane, null, false);
|
|
298
304
|
}
|
|
299
305
|
|
package/src/hierarchy-model.js
CHANGED
|
@@ -27,12 +27,56 @@ export const HIERARCHY_NODES = [
|
|
|
27
27
|
label: "Rig",
|
|
28
28
|
kind: "rig",
|
|
29
29
|
children: [
|
|
30
|
-
{
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
30
|
+
{
|
|
31
|
+
id: "rig.torso",
|
|
32
|
+
label: "Torso",
|
|
33
|
+
kind: "rig",
|
|
34
|
+
children: [
|
|
35
|
+
{ id: "rig.hips", label: "Root / Hips", kind: "bone" },
|
|
36
|
+
{ id: "rig.spine", label: "Spine", kind: "bone" },
|
|
37
|
+
{ id: "rig.chest", label: "Chest", kind: "bone" },
|
|
38
|
+
{ id: "rig.neck", label: "Neck", kind: "bone" },
|
|
39
|
+
{ id: "rig.head", label: "Head", kind: "bone" },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: "rig.leftArm",
|
|
44
|
+
label: "Left Arm",
|
|
45
|
+
kind: "rig",
|
|
46
|
+
children: [
|
|
47
|
+
{ id: "rig.leftShoulder", label: "Left Shoulder", kind: "bone" },
|
|
48
|
+
{ id: "rig.leftElbow", label: "Left Elbow", kind: "bone" },
|
|
49
|
+
{ id: "rig.leftHand", label: "Left Hand", kind: "bone" },
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: "rig.rightArm",
|
|
54
|
+
label: "Right Arm",
|
|
55
|
+
kind: "rig",
|
|
56
|
+
children: [
|
|
57
|
+
{ id: "rig.rightShoulder", label: "Right Shoulder", kind: "bone" },
|
|
58
|
+
{ id: "rig.rightElbow", label: "Right Elbow", kind: "bone" },
|
|
59
|
+
{ id: "rig.rightHand", label: "Right Hand", kind: "bone" },
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: "rig.leftLeg",
|
|
64
|
+
label: "Left Leg",
|
|
65
|
+
kind: "rig",
|
|
66
|
+
children: [
|
|
67
|
+
{ id: "rig.leftKnee", label: "Left Knee", kind: "bone" },
|
|
68
|
+
{ id: "rig.leftFoot", label: "Left Foot", kind: "bone" },
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: "rig.rightLeg",
|
|
73
|
+
label: "Right Leg",
|
|
74
|
+
kind: "rig",
|
|
75
|
+
children: [
|
|
76
|
+
{ id: "rig.rightKnee", label: "Right Knee", kind: "bone" },
|
|
77
|
+
{ id: "rig.rightFoot", label: "Right Foot", kind: "bone" },
|
|
78
|
+
],
|
|
79
|
+
},
|
|
36
80
|
],
|
|
37
81
|
},
|
|
38
82
|
],
|
|
@@ -51,19 +95,57 @@ export const HIERARCHY_NODES = [
|
|
|
51
95
|
},
|
|
52
96
|
];
|
|
53
97
|
|
|
54
|
-
export function buildHierarchyNodes(sceneObjects = []) {
|
|
98
|
+
export function buildHierarchyNodes(sceneObjects = [], characters = null) {
|
|
55
99
|
const clone = (node) => {
|
|
56
100
|
const next = { ...node };
|
|
57
101
|
if (node.id === "props") {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
102
|
+
// Grouped props nest under their parent, so a rocket built from ten
|
|
103
|
+
// primitives reads as one thing in the tree instead of ten siblings.
|
|
104
|
+
const childrenOf = (parentId) =>
|
|
105
|
+
sceneObjects
|
|
106
|
+
.filter((object) => (object.parent ?? null) === parentId)
|
|
107
|
+
.map((object) => {
|
|
108
|
+
const row = { id: `object:${object.id}`, label: object.name, kind: "object" };
|
|
109
|
+
const nested = childrenOf(object.id);
|
|
110
|
+
if (nested.length) row.children = nested;
|
|
111
|
+
return row;
|
|
112
|
+
});
|
|
113
|
+
// An object whose parent is missing still has to appear somewhere, so
|
|
114
|
+
// anything unreachable from the top level is shown at the top level.
|
|
115
|
+
const ids = new Set(sceneObjects.map((object) => object.id));
|
|
116
|
+
const rooted = sceneObjects.filter(
|
|
117
|
+
(object) => (object.parent ?? null) === null || !ids.has(object.parent),
|
|
118
|
+
);
|
|
119
|
+
next.children = rooted.map((object) => {
|
|
120
|
+
const row = { id: `object:${object.id}`, label: object.name, kind: "object" };
|
|
121
|
+
const nested = childrenOf(object.id);
|
|
122
|
+
if (nested.length) row.children = nested;
|
|
123
|
+
return row;
|
|
124
|
+
});
|
|
63
125
|
} else if (node.children) {
|
|
64
126
|
next.children = node.children.map(clone);
|
|
65
127
|
}
|
|
66
128
|
return next;
|
|
67
129
|
};
|
|
68
|
-
|
|
130
|
+
const nodes = HIERARCHY_NODES.map(clone);
|
|
131
|
+
// The cast is list-driven: one row per visible character. The first two
|
|
132
|
+
// keep the legacy row ids (characterA/characterB) that selection, IK and
|
|
133
|
+
// the inspector already route to; extras carry their character id.
|
|
134
|
+
if (Array.isArray(characters)) {
|
|
135
|
+
const group = nodes[0]?.children?.find((node) => node.id === "characters");
|
|
136
|
+
if (group) {
|
|
137
|
+
const rigChildren = group.children.find((node) => node.id === "characterA")?.children;
|
|
138
|
+
// Row ids follow the entry's LIST index so they match the viewport
|
|
139
|
+
// pickId (A/B/charId) stamped by the Character renderer in App.jsx.
|
|
140
|
+
group.children = characters.flatMap((entry, listIndex) => entry.hidden ? [] : [{
|
|
141
|
+
id: listIndex === 0 ? "characterA" : listIndex === 1 ? "characterB" : `character:${entry.id}`,
|
|
142
|
+
label: `Character ${listIndex + 1}`,
|
|
143
|
+
kind: "character",
|
|
144
|
+
// Only the primary carries the rig subtree: IK and viewport pose
|
|
145
|
+
// handles are scoped to it this phase.
|
|
146
|
+
...(listIndex === 0 && rigChildren ? { children: rigChildren } : {}),
|
|
147
|
+
}]);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return nodes;
|
|
69
151
|
}
|