# PageScene Animations — Branch Handoff Branch: `frontend/pagescene-animations` (forked from `main` @ `6329045350`, 2026-08-04). Everything below is TypeScript-typechecked per-lib (`tsc -p /tsconfig.lib.json`); runtime verification status is tracked in the checklist at the bottom. Drag-and-drop has had one manual pass (confirmed working); most other surfaces have not been launched. ## What this branch does Makes skeletal animations a first-class asset in the 3D editor (pagescene): browse them in the asset drawer, drag them onto characters or the timeline with live rig-compatibility feedback, play clips baked into uploaded models, preview animations everywhere models are previewed, and upload Mixamo FBX files (with or without mesh) end to end. ## Commit map | Commit | What | |--------------|-------------------------------------------------------------------------| | `7af3fb9856` | Animations tab in AssetModal; AnimationsDrawer retired | | `1fcbc55780` | Pointer drag-and-drop with live rig-compatibility badge | | `79d7ade8bc` | Baked-in clips on the timeline (+ clip-row eligibility widened) | | `142279414e` | Animation dropdown in Viewer3D + upload preview | | `913905308e` | Clip fixes: vanishing character, 1 s strips, select-to-delete, undo | | `60251104e3` | FBX accepted at the picker, converted to GLB in-browser | | `0942ec4804` | Nx tsconfig references for the new ui-select dependency | | `d0c7b65a6e` | FBX conversion moved into a Web Worker (main-thread fallback) | | `8e51b6c524` | Explicit "Upload as Animation" toggle + required duration_millis | | `00ebeae10c` | User's uploaded animations merged into the Animations tab | | `e1c7a0c7b0` | Persistent skeleton-helper toggle in the outliner | | `30f40c0ddb` | Previewer bone toggles + mesh-less framing/mixer-binding fixes | | `5f7ba7bbc5` | AnimationsModal (All/Presets/Uploaded) + timeline quality-of-life | | `d55735620a` | Timeline ruler tick marks + strip button spacing | | `79875d8c76` | Upload animations from the AnimationsModal (initialCategory) | | *(this)* | NodeHierarchyHelper: bone toggle works for converted mesh-less GLBs | ## Features ### 1. Animations tab in the asset drawer (`AssetModal`) - New left-rail section fed by `GET /v1/media_files/list_featured?filter_engine_categories=animation` (GLB/GLTF only — the clip loader is GLTF-based). Featured results REPLACE the 37 hardcoded `demoAnimationItems`; demos are only the empty-API fallback. - The session user's own uploaded animations (`list/user`, same filter) render AHEAD of the featured set. Fetched on every modal open and after each upload; fails silently for anonymous users (`suppressErrorToast` on the user-media fetch path). - Card click appends the clip to the selected character's row at the earliest free slot (toast when no character is selected or the row is full). The old right-docked `AnimationsDrawer` is deleted; the old HTML5-DnD path (`ANIMATION_CLIP_MIME`) is gone with it. ### 2. Drag-and-drop with rig-compatibility badge (`DndAsset`) - Animation cards ride the same pointer-drag pipeline as every other asset. On drag start the clip's track node names are preloaded (`loadRawGlb`, cached per media id); hovering a character (or any skinned object) checks those names against its nodes — the exact direct-bind rule `CharacterAnimationManager` plays by. No retargeting (explicitly out of scope). - The shared `DragGhost` shows the verdict: green check (binds) / red slash (no target or rig mismatch); nothing while the clip is still loading. Incompatible or slot-less drops are rejected with a toast. - Drop targets: a character in the 3D scene (earliest free slot) or a timeline row (`data-clip-drop-uuid`; pointer-x → time via the `data-timeline-ruler` rect). ### 3. Timeline clips: behavior + UX - **Strips default to 1 s** and the strip width is the authoritative play window (`LaneRuntime.stripDuration`): trimming genuinely cuts playback; loop repeats inside the strip; a non-loop strip wider than its clip freezes on the last frame. On load a strip only shrinks (clip shorter than default) — never grows. - **Rest-pose in gaps**: `Skeleton.pose()` was replaced with a rest-pose snapshot captured at mixer creation (all nodes BELOW the object root; root excluded so gizmo/keyframes never snap back). `pose()` rebuilds bone locals from inverse bind matrices and mis-scales cm-rigs — that was the "character vanishes when moving a strip" bug. - **Selection UX**: strips are click-to-select; × renders only on the selected strip; Del/Backspace deletes; removal is individually undoable (`RemoveClipLaneAction` — the one exception to clip edits riding the timeline Save/Cancel session). - **Full rows reject adds**: `resolveFreeStart` returns null instead of accepting an overlapping clamp. ### 4. Baked-in clips - `ClipStrip.bakedClipIndex` marks a strip sourced from the object's own `animations[]` (resolved synchronously — no media fetch). Objects with baked clips get a timeline clip row with a "+" picker listing them. - Clip-row eligibility is wider than characters: any skinned object (creatures, rigged uploads) accepts animation drags (bind check gates); baked-only objects get a row for the picker. - INVARIANT: baked clips are never removed from the THREE model. Removing a baked strip only unschedules it; the clip stays on `object.animations` and in the picker. Library strips stay fully removable. - Outliner metadata (`hasSkeleton`, `bakedClips`) is computed in `convert_object` and refreshed after every GLB load (`SceneDeps.onGlbLoaded` → `refreshOutliner`). ### 5. Previewers (Viewer3D + upload preview) - Both play baked clips: autoplay the first, dropdown with every clip + "T-pose (none)", hidden entirely for animation-less models. - Both have a bone icon toggling a skeleton overlay — default ON when the model has no mesh (Mixamo "without skin" exports), so animation-only files are visible. The upload thumbnail snapshot then captures the skeleton render. - The overlay adapts to the rig kind: real `THREE.Bone` joints get `THREE.SkeletonHelper`; mesh-less models whose joints re-imported as plain nodes get `NodeHierarchyHelper` (exported from `viewer-3d`) — GLTF only round-trips bone-ness through a *skin*, and a mesh-less export has no skin, so converted FBX animations lose `Bone`-ness entirely. The toggle shows for `hasBones || !hasMesh`. - Latent bugs fixed: upload preview discarded `gltf.animations`; its child re-parenting loop skipped every other child (`scene.add` mutates the array mid-forEach); its mixer bound before the model entered the scene (silently played nothing); camera framing NaN'd on empty bounding boxes (now falls back to bone world positions); camera now fits once, not per-child. ### 6. FBX upload support - The 3D uploader accepts `.fbx` and normalizes it at the door: `FBXLoader` → `GLTFExporter` → binary GLB with clips preserved. Runs in a dedicated Web Worker (transferred buffers, jobs naturally serialized); a broken worker environment falls back to the main thread. The raw FBX can never reach the backend — retry of a failed conversion re-converts. - Caveats: embedded textures may not survive; units are exported as parsed (cm-scaled Mixamo rigs are not rescaled). ### 7. Upload as Animation - Mutually exclusive checkbox next to "Upload as Character" → `engine_category=animation`. Mesh-less files preselect it (preview loader reports `hasMesh:false`); a user click always wins. Rig-type dropdown defaults to Mixamo (characters keep Mixamo ArKit first). - The backend-required `maybe_duration_millis` is read from the longest clip at submit time; clip-less files are flagged and excluded client-side. ### 8. Outliner skeleton toggle (`SkeletonHelperController`) - Bone icon on skinned rows toggles a persistent `SkeletonHelper` on LAYER 1 (editor-only — the render camera renders layer 0, so skeletons never appear in captures/recordings). - Persists as `userData.skeletonVisible` through scene save/load (explicit restore in the proxy load path). Reconciliation rides `OutlinerRefreshedEvent` — add/delete/load/new-scene and the post-GLB-load refresh all funnel there. - FK/pose-mode compatible: `PoseModeChangedEvent("pose")` suppresses the persistent helper for the posed character (FKHelper draws its own rig); exit restores it. The toggle state itself is never touched. ## Manual test checklist (outstanding) - [ ] Prod data: does `list_featured?filter_engine_categories=animation` return rows? If yes they replace the demo clips in the tab; if empty, the 37 demos show. - [ ] Clip→rig bind smoke test on shipped characters (runtime-unverified `mixamorig:*` assumption; a "bound 0 tracks" console warning means retargeting becomes the real task). - [ ] Creature experiment: drag a clip onto a creature — badge should reflect bind result. - [ ] Baked clips: "+" picker on an animated object's row; add/remove/re-add; save/reload persistence (the `object.animations` stash on load is gated on `auto_add` — statically unconfirmed for every load path). - [ ] Clip UX round: strips at 1 s; trim semantics; select → ×/Del; undo/redo of removal; character stays visible when a strip moves off the playhead. - [ ] FBX round trip: pick `.fbx` → converts (worker; check console for worker load failures — falls back to main thread) → skeleton visible + clip dropdown → Upload as Animation preselected → appears at front of Animations tab → drag onto character → plays. - [ ] Converted-FBX scale: if placed objects come in ~100× too big, the converter needs a normalize-scale step. - [ ] Outliner bone toggle: on/off, FK-mode enter/exit restore, save/reload persistence, and absence from Capture/Record output. - [ ] Previewer dropdown + bone toggle across lightbox, media page, lightbox-modal, ImageTo3DExperience, upload modal. ## Debug pass + hardening round (post-review) A four-agent review of the combined branch produced 30 findings (5 high / 12 medium / 13 low); ALL have been fixed or explicitly closed, one commit per finding (range `72cb218e..b21732d1`). Highlights, roughly in commit order: - Highs: baked clips cloned per lane (shared-AnimationAction playback/removal corruption); runtime revalidation for objects recreated under the same uuid (delete→undo, reloads) — later hardened into LOOP-FREE terminal lane states (bound / missing / unbindable, reassessed only on root-instance change; the pre-fetch rig gate is sound because every load path stamps the target uuid only post-load); skeleton-helper sync self-heals across in-session reloads; upload modal can no longer report success for empty/partial submissions; ref-guarded double-submit. - Mediums: strip removal mirrored into the Save/Cancel `saved` snapshot (Cancel can't resurrect, no phantom undo); stale strip/keyframe selections pruned in the bridge + live-checked before swallowing Del/Backspace; one winning lane per character at flush boundaries (blend-glitch frame was being ENCODED into recordings); orphaned tracks/lanes filtered from scene JSON at save; per-clip queued-drop tokens; animation raycasts gated to the canvas element + honest front-most-surface occlusion; virtualized-grid thumbnail state keyed by item; one shared lazily-fetched animation-library store (was double-fetch + double-toast + divergent lists); previewer rest-pose snapshots replace `Skeleton.pose()` everywhere; all-node camera-framing fallback for plain-node rigs; upload-preview races (superseded-load cancellation, snapshot after helper creation, File-identity row statuses, retry re-entry). - Lows: duration-shrink re-fits strips; baked-clip picker portals out of the scrolling row list; timeline resets on scene switch/new scene instead of leaking across; click-to-add accepts every drag-eligible target; queued drops bail on a torn-down editor (`Editor.isLive`); top-level root lookups unified; flick-click race killed via `wasDragGesture`; converting-overlay control reset; conversion completions reload only the previewed entry; worker `onmessageerror` recovery + corrupt-vs-clip-less error messages + explicit `initialCategory` wins over mesh-less auto-detect; Uploaded tab pages past the 100-item cap. - Closed without code: the "compat cache stales on streaming characters" finding (invalidated — uuid-resolvable roots are always fully loaded) and the Tauri `promptSignup` gap (desktop is always signed in by adapter contract). ## Landed since the original handoff - **AnimationsModal** (was deferred — now built, `5f7ba7bbc5`/`79875d8c76`): standalone modal with All / Presets / Uploaded tabs, opened from an "Add Animation" button next to Enter Pose Mode and from the "+" add-asset menu. Adding a clip expands the timeline and scrolls to the character (`timelineRevealObjectUuid`); the modal closes after an add unless "Reopen after adding" is on. Its "Upload animation" button opens `UploadModal3D` with `initialCategory="animation"` (anonymous users get the signup prompt), landing on the Uploaded tab afterwards. `AssetModal`'s animations tab was folded into this. - **Timeline QoL**: ruler tick marks (major/second/sub-tick), selected-object-only track filter, directional expand/collapse chevrons, thumbnail-less animation cards get an icon placeholder. ## Deferred (explicitly out of scope for this branch) - **Split-screen preview window** (based on the `?output=` `DemoOutputOverlay`) — parked until explicit approval. - **Retargeting** (`SkeletonUtils.retargetClip`) — too complex for now; mismatched rigs are simply rejected by the bind check. - **Clip blending/layering** — one non-overlapping row per object, by design. ## Key files | Area | Files | |---------------------|-------------------------------------------------------------------------------------------| | Drawer tab | `pagescene/src/lib/comps/AssetMenu/AssetModal.tsx`, `shared/ItemElement.tsx` | | Drag-and-drop | `pagescene/src/lib/DragAndDrop/DndAsset.ts`, `gallery-modal/src/lib/DragGhost.tsx` | | Timeline data/engine| `pagescene/src/lib/engine/timeline/types.ts`, `engine/editor/TimelineController.ts`, | | | `engine/animation/CharacterAnimationManager.ts`, `engine/editor/actions/RemoveClipLaneAction.ts` | | Timeline UI | `pagescene/src/lib/comps/Timeline/TimelineEditor.tsx`, `TimelineClipRow.tsx` | | Skeleton toggle | `pagescene/src/lib/engine/editor/SkeletonHelperController.ts`, `comps/Outliner/Outliner.tsx` | | Previewers | `viewer-3d/src/lib/viewer-3d.tsx`, `upload-modal/.../utilities/loadPreviewOnCanvas.ts` | | FBX + upload | `upload-modal/.../utilities/convertFbxToGlb.ts` (+ `.worker.ts`), `UploadModal3D.tsx`, | | | `UploadFiles3D.tsx`, `utilities/readGlbAnimationDuration.ts` | The pagescene `CLAUDE.md` "Mixamo animation clips" section has been kept current with these changes and carries the same invariants in engine-doc form.