diff --git a/frontend/libs/components/pagescene/CLAUDE.md b/frontend/libs/components/pagescene/CLAUDE.md index 7680354b3f..6748901fcc 100644 --- a/frontend/libs/components/pagescene/CLAUDE.md +++ b/frontend/libs/components/pagescene/CLAUDE.md @@ -219,8 +219,14 @@ then one row per character is the right model. - **Overlap guard (single row)**: `TimelineController.resolveFreeStart` snaps add/move to the nearest gap so a character's strips never overlap; `nextStartAfter` bounds trim (`resizeClipLane`) and auto-length (`resolveClipDuration`) against the following clip. `evaluateAt` then has at most one - active clip at any playhead, so playback is an unambiguous sequence (a gap between strips falls - back to the bind/T-pose — position strips edge-to-edge to avoid it). + active clip at any playhead, so playback is an unambiguous sequence. +- **Bind-pose in gaps**: a disabled three.js action leaves the skeleton frozen on its last frame, so + `evaluateAt` resets any character with no clip under the playhead to its **bind (T) pose** via + `resetToBindPose` → `Skeleton.pose()` (skeletons cached per character in `getMixer`). Gaps between + strips, before the first, and after the last therefore show the default T-pose, not a held frame. +- **Drag preview**: dragging a clip drives the shared `DragGhost` (tilt card) by setting + `dragItem`/`dragPosition`/`assetDraggingUnder` on the store in the drawer's native-DnD handlers, + and suppresses the browser's default drag image — matching object/character pickup motion. - **Real clip length (fix, done)**: a fresh drop seeds `strip.duration` with a placeholder and flags `autoDuration`; when the GLB loads, `CharacterAnimationManager` calls `TimelineController.resolveClipDuration(laneId, clip.duration)` to adopt the clip's true length diff --git a/frontend/libs/components/pagescene/src/lib/comps/AnimationsDrawer/AnimationsDrawer.tsx b/frontend/libs/components/pagescene/src/lib/comps/AnimationsDrawer/AnimationsDrawer.tsx index deb8ab7013..5c45dd634b 100644 --- a/frontend/libs/components/pagescene/src/lib/comps/AnimationsDrawer/AnimationsDrawer.tsx +++ b/frontend/libs/components/pagescene/src/lib/comps/AnimationsDrawer/AnimationsDrawer.tsx @@ -1,10 +1,8 @@ // Animations drawer: when a character is selected, lists the curated Mixamo -// clips. Clicking one drops it as a stacked clip lane on that character's -// timeline at the current playhead (the character then animates on scrub/play). -// -// This is the first verifiable slice of the Mixamo-drawer feature — click to -// add. Drag-and-drop onto specific timeline lanes and the stacked strip UI come -// once retargeting/playback is confirmed correct in the viewport. +// clips. Click a clip to drop it on the character's timeline at the playhead, +// or drag it onto a character's timeline row (drop time follows the cursor). +// While dragging, the shared DragGhost (tilt card) previews the clip's +// thumbnail — the same pickup motion as object/character drags. import { useContext } from "react"; import { EngineContext } from "../../contexts/EngineContext"; @@ -13,6 +11,16 @@ import { addClipToCharacter } from "../../actions"; import { demoAnimationItems } from "../../signals/demoAssets/demoAnimationItems"; import { ANIMATION_CLIP_MIME } from "../Timeline/timelineUtils"; +// 1×1 transparent GIF used to suppress the browser's default drag image so only +// the DragGhost tilt card shows. Created once at module load so it's decoded by +// the time a drag starts. +const TRANSPARENT_DRAG_IMAGE = + typeof Image !== "undefined" ? new Image() : null; +if (TRANSPARENT_DRAG_IMAGE) { + TRANSPARENT_DRAG_IMAGE.src = + "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; +} + export const AnimationsDrawer = () => { const editor = useContext(EngineContext); const selectedObject = usePageSceneStore((s) => s.selectedObject); @@ -42,6 +50,28 @@ export const AnimationsDrawer = () => { JSON.stringify({ media_id: item.media_id, name: item.name }), ); e.dataTransfer.effectAllowed = "copy"; + // Hide the native drag image; the DragGhost renders the preview. + if (TRANSPARENT_DRAG_IMAGE) { + e.dataTransfer.setDragImage(TRANSPARENT_DRAG_IMAGE, 0, 0); + } + const store = usePageSceneStore.getState(); + store.setDragItem(item); + store.setDragPosition({ currX: e.pageX, currY: e.pageY }); + store.setAssetDraggingUnder(true); + }} + onDrag={(e) => { + // The final native drag event can report (0,0) — ignore it so the + // ghost doesn't snap to the corner on release. + if (e.pageX === 0 && e.pageY === 0) return; + usePageSceneStore + .getState() + .setDragPosition({ currX: e.pageX, currY: e.pageY }); + }} + onDragEnd={() => { + const store = usePageSceneStore.getState(); + store.setDragItem(null); + store.setAssetDraggingUnder(false); + store.setDragPosition({ currX: 0, currY: 0 }); }} onClick={() => editor && addClipToCharacter(editor, character.id, item) diff --git a/frontend/libs/components/pagescene/src/lib/engine/animation/CharacterAnimationManager.ts b/frontend/libs/components/pagescene/src/lib/engine/animation/CharacterAnimationManager.ts index 9e51b7d023..0e5d358f54 100644 --- a/frontend/libs/components/pagescene/src/lib/engine/animation/CharacterAnimationManager.ts +++ b/frontend/libs/components/pagescene/src/lib/engine/animation/CharacterAnimationManager.ts @@ -35,6 +35,8 @@ export class CharacterAnimationManager { private lanes = new Map(); // laneId → monotonic token, so a superseded async load can't clobber state. private loadTokens = new Map(); + // characterUuid → its skeleton(s), cached so bind-pose resets don't re-traverse. + private skeletons = new Map(); constructor(private readonly editor: Editor) {} @@ -61,6 +63,9 @@ export class CharacterAnimationManager { // Pose every character at `playhead` (seconds). Deterministic: sets each // action's absolute time then applies via mixer.update(0). evaluateAt(playhead: number): void { + // Characters that have a clip covering this playhead. Everyone else is + // reset to their bind (T) pose below. + const posed = new Set(); for (const rt of this.lanes.values()) { const action = rt.action; if (!action) continue; @@ -77,6 +82,14 @@ export class CharacterAnimationManager { action.paused = true; // we drive time ourselves; don't auto-advance action.time = local; action.setEffectiveWeight(1); + posed.add(rt.characterUuid); + } + // Gaps between strips (and before the first / after the last) have no active + // clip. A disabled action leaves the skeleton frozen on its last evaluated + // frame, so explicitly restore the bind pose for those characters — the + // "default state" when nothing is under the scrubber. + for (const uuid of this.mixers.keys()) { + if (!posed.has(uuid)) this.resetToBindPose(uuid); } for (const mixer of this.mixers.values()) mixer.update(0); } @@ -85,6 +98,7 @@ export class CharacterAnimationManager { for (const laneId of [...this.lanes.keys()]) this.disposeLane(laneId); for (const mixer of this.mixers.values()) mixer.stopAllAction(); this.mixers.clear(); + this.skeletons.clear(); } // ─── internals ──────────────────────────────────────────────────────── @@ -170,6 +184,7 @@ export class CharacterAnimationManager { if (!live.has(uuid)) { this.mixers.get(uuid)?.stopAllAction(); this.mixers.delete(uuid); + this.skeletons.delete(uuid); } } } @@ -179,10 +194,29 @@ export class CharacterAnimationManager { if (!mixer) { mixer = new THREE.AnimationMixer(root); this.mixers.set(uuid, mixer); + this.skeletons.set(uuid, this.collectSkeletons(root)); } return mixer; } + // Reset a character's skeleton(s) to their bind (rest) pose. For Mixamo rigs + // the bind pose is the T-pose — the "default state" shown wherever no clip + // covers the playhead. + private resetToBindPose(characterUuid: string): void { + const skeletons = this.skeletons.get(characterUuid); + if (!skeletons) return; + for (const skeleton of skeletons) skeleton.pose(); + } + + private collectSkeletons(root: THREE.Object3D): THREE.Skeleton[] { + const skeletons: THREE.Skeleton[] = []; + root.traverse((o) => { + const sm = o as THREE.SkinnedMesh; + if (sm.isSkinnedMesh && sm.skeleton) skeletons.push(sm.skeleton); + }); + return skeletons; + } + // True if at least one of the clip's tracks names a node that exists under // `character`. Mixamo clips address bones as "mixamorig:Hips.position"; we // strip the ".property" suffix and look the node up by name.