3D: Animations support + UI (#1856)

Co-authored-by: BFlat <bombayapirat@gmail.com>
This commit is contained in:
Tanish Manku
2026-08-06 22:48:35 -07:00
committed by GitHub
co-authored by BFlat
parent a7a32a3862
commit a1435df07d
62 changed files with 4032 additions and 524 deletions
+227
View File
@@ -0,0 +1,227 @@
# PageScene Animations — Branch Handoff
Branch: `frontend/pagescene-animations` (forked from `main` @ `6329045350`, 2026-08-04).
Everything below is TypeScript-typechecked per-lib (`tsc -p <lib>/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.
@@ -438,6 +438,7 @@ export const useWebAppPageSceneAdapter = (
onSuccess={props.onSuccess}
title={props.title}
titleIcon={props.titleIcon}
initialCategory={props.initialCategory}
/>
),
renderImageUploader: (props) => (
@@ -338,6 +338,7 @@ export const useTauriPageSceneAdapter = (
onSuccess={props.onSuccess}
title={props.title}
titleIcon={props.titleIcon}
initialCategory={props.initialCategory}
/>
),
@@ -24,6 +24,13 @@ export interface DragGhostState {
thumbnail: string;
/** Optional caption rendered under the thumbnail (asset library shows names). */
label?: string;
/**
* Optional drop-compatibility badge on the ghost's corner: "ok" (green
* check — the hovered target accepts this item) or "blocked" (red slash —
* incompatible target or nothing droppable under the cursor). Omit for
* drags that don't validate their target.
*/
indicator?: "ok" | "blocked";
}
interface DragGhostProps {
@@ -134,6 +141,43 @@ export function DragGhost({
crossOrigin={crossOrigin}
className="pointer-events-none min-h-0 w-full flex-1 select-none object-cover"
/>
{state.indicator && (
<div
className={`absolute left-1.5 top-1.5 flex h-5 w-5 items-center justify-center rounded-full shadow-md ring-1 ring-black/30 ${
state.indicator === "ok" ? "bg-emerald-500" : "bg-red-500"
}`}
>
{state.indicator === "ok" ? (
<svg viewBox="0 0 16 16" className="h-3 w-3" aria-hidden="true">
<path
d="M3.5 8.5 6.5 11.5 12.5 5"
fill="none"
stroke="white"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : (
<svg viewBox="0 0 16 16" className="h-3 w-3" aria-hidden="true">
<circle
cx="8"
cy="8"
r="5.4"
fill="none"
stroke="white"
strokeWidth="2"
/>
<path
d="M4.2 4.2 11.8 11.8"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
)}
</div>
)}
{state.label && (
<div className="w-full select-none truncate bg-ui-controls px-2 py-1 text-center text-[12px]">
{state.label}
+105 -19
View File
@@ -205,33 +205,119 @@ then one row per character is the right model.
relies on the shared `mixorig:*` naming so a clip's tracks resolve against the character's bones
directly (direct-bind first; `SkeletonUtils.retargetClip` is the documented fallback if a rig uses
different bone names — **not yet wired**, pending the runtime spike).
- **Trigger UI (done)**: `comps/AnimationsDrawer/` — right-docked, only when a character is
selected (build mode; hidden via CSS in record). Clips are **click-to-add** (drops at the playhead)
**and draggable** onto the timeline (`ANIMATION_CLIP_MIME` payload `{media_id, name}`).
- **Trigger UI (done — moved)**: the old right-docked `comps/AnimationsDrawer/` is retired. The
clip source is now the **"Animations" tab in `comps/AssetMenu/AssetModal.tsx`**: server-curated
animations (`list_featured` with `engine_category=animation`, GLB/GLTF only) REPLACE the 37
`demoAnimationItems` when the API returns any; the demos are only the empty-API fallback.
Animation cards are **click-to-add** (earliest free slot on the selected character's row; toast
if no character is selected or the row is full) **and pointer-draggable** via the shared
`DndAsset` singleton (`ANIMATION` branch): drop on a character in the 3D scene (earliest free
slot) or on a character's timeline row (pointer-x → time via the `data-timeline-ruler` rect;
rows tag `data-clip-drop-uuid`). While dragging, the shared `DragGhost` shows a green/red
**compatibility badge** (`PageSceneStore.animationDropState`): DndAsset preloads the clip's
track node names (`loadRawGlb`, cached per media id) and checks them against the hovered
character's nodes — the same direct-bind rule `CharacterAnimationManager` plays by; no
retargeting. Incompatible or slot-less drops are rejected with a toast. A drop that beats the
clip's name-inspection fetch is **queued** and completes (fully validated) when the names
resolve — never punted back to the user (`completeAnimationDrop`; a re-drop of the SAME clip
supersedes its earlier queued drop via a per-clip token, while queued drops of other clips
complete independently). The old HTML5 DnD path (`ANIMATION_CLIP_MIME`) is removed. A full row
(`resolveFreeStart` → null) **rejects** the add instead of overlapping.
- **Standalone AnimationsModal (done)**: `comps/AssetMenu/AnimationsModal.tsx` — an
animations-only sibling of `AssetModal` (same `@storyteller/ui-modal` floating-panel props,
same `ItemElements` grid, same drag-under semantics) with All / Presets / Uploaded tabs.
Opened by the **"Add Animation" button** next to "Enter Pose Mode" (`PoseModeSelector`,
visible when a character is selected and not posing) and by the **"Animations" entry in the
Controls3D "+" popover** (not auth-gated, like "presets"), both via
`actions/openAnimationsModal.ts`.
The two library modals are **mutually exclusive** (each open action closes the other), so
`DndAsset.endDrag` and `ItemElement`'s click-add close whichever is visible (reopen-off
path). Animations data (uploads-first + featured-else-demos fallback) lives in the shared
`comps/AssetMenu/hooks/useAnimationLibrary.tsx`, consumed by both modals.
**Upload from the modal**: an "Upload animation" button (sidebar bottom, above "Reopen
after adding") plus an empty-state CTA on the Uploaded tab open the host uploader through
the same `renderAssetUploader` adapter slot AssetModal uses. The slot (and `UploadModal3D`)
gained an optional `initialCategory?: "animation" | "character"` (literal strings — the
pagescene and upload-modal `FilterEngineCategories` enums aren't cross-assignable): it
pre-checks the matching "Upload as ..." toggle and suppresses the My-Library popup on
success (the panel shows the result itself). On success the modal refetches user
animations and jumps to the Uploaded tab (only if the category stayed Animation).
Anonymous users get `promptSignup("upload-3d")` instead, same as the Controls3D entries.
- **Timeline reveal on add (done)**: a successful `addClipToCharacter` (click or drop, either
modal) sets `timelineExpanded` + `timelineRevealObjectUuid`; an effect in `TimelineEditor`
scrolls the row list to `[data-timeline-row-uuid]` (every row is tagged) then clears the
field. A store field because `TimelineEditor` is unmounted while collapsed — expand + scroll
can't happen in the caller's tick.
- **Timeline focus toggle (done)**: crosshair button in the `TimelineEditor` transport bar
(`timelineFocusSelected`, store-held so it survives the collapse/expand remount) narrows the
row list to the selected object's track. Falls back to all rows when nothing is selected (or
the selection has no outliner row), and the reveal effect switches focus off when a clip
lands on a non-selected object so the new strip is never hidden.
- **Timeline clip UI (phase C, done)**: `comps/Timeline/TimelineClipRow.tsx` renders **one row per
character** (rendered even when empty, as a drop hint) holding all that character's strips
end-to-end under its keyframe row in `TimelineEditor`. Strip body drags to **move**
(`moveClipLane`), the right edge drags to **trim** length (`resizeClipLane`), a loop chip toggles
repeat (`setClipLoop`), the × removes it (`removeClipLane`). Each character row is a **drop target**
for clip drags (`dropClipOnCharacter` → `addClipToCharacter(…, atTime)`, time from the ruler rect).
Non-character rows ignore clip drags. No per-op undo by design — clip edits ride the timeline
**Save/Cancel** session exactly like keyframes (`cancel()` restores + re-syncs).
repeat (`setClipLoop`), the × (selected strip only) removes it (`removeClipLane`, undoable — see
the selection bullet below). Clip-eligible rows are drop targets via `data-clip-drop-uuid` (see
the DnD bullet). Other clip edits (move/trim/loop) ride the timeline **Save/Cancel** session
exactly like keyframes (`cancel()` restores + re-syncs).
- **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.
- **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
(and re-clamp start). A user trim clears `autoDuration` so the natural length never clobbers a
hand-set duration on reload.
- **Runtime revalidation (loop-free)**: mixers/rest poses hold object-INSTANCE refs, but
delete→undo and in-session scene reloads recreate objects under the same uuids. Every lane
runtime lands in a TERMINAL state — bound, missing-object (`boundRoot: null`), or unbindable
(rig-less/clip-less target; one warn, no fetch) — and `CharacterAnimationManager.revalidate()`
(subscribed to `OutlinerRefreshedEvent` alongside the skeleton-helper sync) reassesses a lane
ONLY when the live top-level root instance for its uuid differs from `boundRoot` (object
appeared/vanished/replaced). One map lookup per lane per refresh; no traverses, timers, fetch
retries, or per-refresh re-adds. A uuid-resolvable root is always fully loaded (all load paths
stamp the uuid post-load), which is what makes rig-lessness a terminal verdict. Skeleton-helper `sync()` self-heals the same two cases: it purges and
recreates helpers that are orphaned (scene reloads strip children; the map survives) or whose
`helper.root` no longer matches the live object for that uuid.
- **Rest-pose in gaps** (was bind-pose): 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 a **rest
pose captured at mixer creation** (`captureRestPose` — local transforms of every node BELOW the
root; the root is excluded so gizmo/keyframe placement never snaps back). `Skeleton.pose()` was
abandoned: it rebuilds bone locals from inverse bind matrices and mis-scales rigs whose armature
carries a baked scale (mixamo cm-rigs), which made characters visually VANISH when a strip moved
off the playhead. `pruneMixers` also restores the rest pose before dropping a character's mixer.
- **Drag preview**: animation drags ride the same pointer-based `DndAsset` pipeline as every
other asset card, so the shared `DragGhost` (tilt card) works unchanged — plus the
compatibility badge described above.
- **Clip-row eligibility (widened)**: rows are no longer characters-only. Any **skinned** object
(creatures, rigged uploads — `OutlinerItem.hasSkeleton`, computed in `convert_object`) accepts
animation drags (bind check still gates), and any object with **baked clips**
(`OutlinerItem.bakedClips`, from `object.animations`) gets a clip row even without a skeleton.
- **Baked-in clips**: `ClipStrip.bakedClipIndex` marks a strip sourced from the object's own
`animations[]` (with `sourceMediaId: ""`); `CharacterAnimationManager.addLane` resolves it
synchronously from the model instead of `loadRawGlb`, and **CLONES the clip per lane** —
`mixer.clipAction` caches one action per (clip, root), so two strips of the same raw baked
clip would fight over a single action and `uncacheClip` on removal would kill the sibling's. Added via the **"+" picker** on the
object's clip row (`TimelineClipRow`, `addBakedClipToObject`, earliest free slot, real length
known up front so no `autoDuration`). INVARIANT: baked clips are **never removed from the THREE
model** — removing a baked strip only unschedules it (mixer uncache is mixer-state only); the
clip stays on `object.animations` and in the picker. Library-sourced strips remain fully
removable. ⚠️ Runtime-unverified: whether `object.animations` survives the save/load roundtrip
(the stash in `scene.ts` is gated on `auto_add`), and that the outliner refresh timing surfaces
`bakedClips` after async GLB loads.
- **Strip width = play window (revised)**: fresh strips default to a compact
`DEFAULT_CLIP_DURATION` (1 s) instead of adopting the clip's natural length — long clips were
eating the whole row. The strip's on-timeline width is the **authoritative play window**
(`LaneRuntime.stripDuration` in `evaluateAt`); the clip's natural length only drives the loop
modulo / final-frame clamp inside it, so trimming a strip shorter genuinely cuts playback and a
non-loop strip trimmed longer than the clip freezes on the last frame. `resolveClipDuration` now
only SHRINKS an `autoDuration` strip when the clip is shorter than the default.
- **Strip selection + delete UX**: strips are click-to-select
(`timelineSelectedClipLaneId` store field, `data-clip-strip` exempts the pointerdown from the
click-away deselect). The **× renders only on the selected strip** (always-visible was too easy
to hit); **Del/Backspace** removes the selected strip. Removal (either path) is **individually
undoable** via `RemoveClipLaneAction` — the one exception to "clip edits ride Save/Cancel" —
and to keep that honest it is **mirrored into the `saved` snapshot** (removeClipLane strips the
lane from `saved` too, so Cancel can't resurrect a deleted strip and Save doesn't double-record
the removal); the action captures both the live lane and its saved-snapshot entry so undo
restores both worlds exactly (`restoreClipLane(lane, savedLane?)`).
- **Rig-mismatch diagnostic**: `CharacterAnimationManager.clipBindsToCharacter` warns (console) when
a clip's tracks resolve to **0** nodes on the character — the signal that `SkeletonUtils.retargetClip`
is needed. Retarget itself is still **not wired** (direct-bind first).
@@ -1,11 +1,18 @@
import React from "react";
import * as THREE from "three";
import { MediaItem } from "../models";
import { usePageSceneStore } from "../PageSceneStore";
import { AssetType } from "../enums";
import { AssetType, ToastTypes } from "../enums";
import type Editor from "../engine/editor";
import { pickDropPosition } from "../engine/pickDropPosition";
import { DEFAULT_TIMELINE_FPS } from "../engine/timeline/types";
import {
fractionToTime,
quantizeToFrame,
} from "../comps/Timeline/timelineUtils";
import {
addCharacter,
addClipToCharacter,
addObject,
addShape,
} from "../actions";
@@ -16,6 +23,23 @@ import {
// dropTop deps callback. Today it's a constant.
const TOP_BAR_PX = 69;
// Timeline character rows tag themselves with this attribute so an animation
// drag can drop a clip at a precise time on a specific character's row.
const CLIP_DROP_ATTR = "data-clip-drop-uuid";
// The timeline ruler carries this tag; its rect maps pointer-x → time.
const TIMELINE_RULER_SELECTOR = "[data-timeline-ruler]";
// Hover-validation throttle for animation drags (raycast + DOM hit-test).
const HOVER_CHECK_MS = 33;
// Where an animation drag would land: a character row in the timeline (with a
// pointer-derived time) or a character in the 3D scene (time 0 → the clip
// snaps to the earliest free slot on the row).
interface AnimationDropTarget {
characterUuid: string;
time: number;
}
class DndAsset {
public dropId: string = "";
public overElement: DOMRect | null = null;
@@ -25,8 +49,23 @@ class DndAsset {
public notDropText = "";
public isDragging: boolean = false;
public dragThreshold: number = 5;
// True once the CURRENT pointer gesture crossed the drag threshold, until
// the next pointer-down. Unlike isDragging (reset by endDrag before the
// browser synthesizes the trailing click), this survives long enough for
// click handlers to tell "click" from "aborted drag" without depending on
// React having committed the modal's pointer-transparency mid-gesture.
public wasDragGesture: boolean = false;
private editor: Editor | null = null;
// Animation-drag state. The node names a clip's tracks address are cached
// across drags (they never change for a media id); compatibility verdicts
// are cached per hovered character for the current drag only.
private clipNodeNamesCache = new Map<string, Promise<string[] | null>>();
// undefined = still loading; null = clip unreadable/has no animation.
private activeClipNames: string[] | null | undefined = undefined;
private compatByCharacter = new Map<string, boolean>();
private lastHoverCheck = 0;
constructor() {
this.onPointerMove = this.onPointerMove.bind(this);
this.onPointerUp = this.onPointerUp.bind(this);
@@ -45,8 +84,13 @@ class DndAsset {
this.initX = event.pageX;
this.initY = event.pageY;
this.isDragging = false;
this.wasDragGesture = false;
store.setCanDrop(false);
store.setAnimationDropState("none");
this.notDropText = "";
if (item.type === AssetType.ANIMATION && editor) {
this.beginClipInspection(editor, item.media_id);
}
window.addEventListener("pointerup", this.onPointerUp);
window.addEventListener("pointermove", this.onPointerMove);
}
@@ -60,14 +104,19 @@ class DndAsset {
this.overElement = null;
this.notDropText = "";
}
store.setAnimationDropState("none");
this.activeClipNames = undefined;
this.compatByCharacter.clear();
if (store.reopenAfterDrag) {
// Refocus the library (un-dim, restore pointer events).
store.setAssetDraggingUnder(false);
} else {
// Close it after adding. Keep `assetDraggingUnder` true so the panel stays
// faded-out through the close instead of flashing back up; it's reset when
// the library is reopened.
store.setAssetModalVisible(false);
// Close whichever library panel sourced the drag (at most one is open;
// see openAssetModal/openAnimationsModal). Keep `assetDraggingUnder`
// true so the panel stays faded-out through the close instead of
// flashing back up; it's reset when the library is reopened.
if (store.assetModalVisible) store.setAssetModalVisible(false);
if (store.animationsModalVisible) store.setAnimationsModalVisible(false);
}
this.isDragging = false;
this.editor = null;
@@ -105,6 +154,15 @@ class DndAsset {
const editor = this.editor;
const mediaItem = store.dragItem;
if (mediaItem && editor && mediaItem.type === AssetType.ANIMATION) {
// Animations don't land at a world position — they land on a character
// (scene hover) or on a character's timeline row. Validated against the
// clip's bone names; a mismatch is rejected with a toast, mirroring the
// red badge shown during the drag.
this.dropAnimation(event, mediaItem, editor);
this.endDrag();
return;
}
if (mediaItem && editor) {
const positionX = event.pageX;
const positionY = event.pageY;
@@ -150,6 +208,7 @@ class DndAsset {
Math.abs(deltaY) > this.dragThreshold)
) {
this.isDragging = true;
this.wasDragGesture = true;
// Let pointer events pass through the library the moment the drag begins.
// The panel eases to translucent (reopen on) or all the way out (reopen
// off) via AssetModal's contentDimmed/contentHidden — never abrupt.
@@ -159,6 +218,9 @@ class DndAsset {
currX: this.initX + deltaX,
currY: this.initY + deltaY,
});
if (this.isDragging && store.dragItem.type === AssetType.ANIMATION) {
this.updateAnimationDropState(event);
}
if (this.overElement) {
const pos = this.overElement;
const eventY = event.pageY;
@@ -176,6 +238,275 @@ class DndAsset {
}
}
}
// ─── animation-clip drags ─────────────────────────────────────────────
// Per-clip monotonic tokens: a re-drop of the SAME clip while its
// inspection is still loading supersedes the earlier queued drop (an
// impatient retry must not double-add), while queued drops of OTHER
// clips complete independently — a single global token silently
// cancelled unrelated pending drops.
private pendingDropTokens = new Map<string, number>();
// Resolve + complete an animation drop. Silently cancels when nothing
// droppable is under the cursor; toasts when the target is incompatible or
// its clip row has no free slot (mirrors the ghost's red badge).
//
// If the clip's node names are still loading (a quick drag beats the
// cold-cache GLB fetch that started on pointer-down), the drop is QUEUED
// and completes — fully validated — when the inspection resolves. Punting
// back to the user with a "try again" toast for losing that race was a
// bug: click-to-add never waits, so drops shouldn't either.
private dropAnimation(
event: PointerEvent,
item: MediaItem,
editor: Editor,
): void {
const target = this.findAnimationDropTarget(event);
if (!target) return;
// Bumped on every drop of this clip — including the synchronous path,
// so a resolved re-drop also invalidates any stale queued one.
const token = (this.pendingDropTokens.get(item.media_id) ?? 0) + 1;
this.pendingDropTokens.set(item.media_id, token);
if (this.activeClipNames !== undefined) {
this.completeAnimationDrop(editor, item, target, this.activeClipNames);
return;
}
const inspection = this.clipNodeNamesCache.get(item.media_id);
if (!inspection) {
// No inspection in flight (shouldn't happen — pointer-down starts
// one): add unvalidated, matching the ungated click-to-add path.
this.completeAnimationDrop(editor, item, target, undefined);
return;
}
void inspection.then((names) => {
// Superseded by a newer drop of this same clip.
if (this.pendingDropTokens.get(item.media_id) !== token) return;
this.completeAnimationDrop(editor, item, target, names);
});
}
// Final validation + insert, decoupled from per-drag state so it can run
// after endDrag() when the drop was queued. `names`: string[] = validate
// the bind, null = clip unreadable, undefined = skip validation.
private completeAnimationDrop(
editor: Editor,
item: MediaItem,
target: AnimationDropTarget,
names: string[] | null | undefined,
): void {
// A queued drop can resolve after the EngineProvider teardown/recreate
// footgun replaced the whole Editor: adding the lane to the dead
// controller would silently lose the clip while the store writes
// (timeline expand + reveal) fire on the NEW editor's UI.
if (!editor.isLive) return;
const root = editor.activeScene.scene.children.find(
(child) => child.uuid === target.characterUuid,
);
if (!root) return; // target left the scene while the clip loaded
if (names === null) {
editor.adapter.showToast(
ToastTypes.ERROR,
`Couldn't read an animation from "${item.name ?? item.media_id}".`,
);
return;
}
if (names && !names.some((name) => root.getObjectByName(name))) {
editor.adapter.showToast(
ToastTypes.WARNING,
"This animation doesn't match the character's skeleton.",
);
return;
}
const laneId = addClipToCharacter(
editor,
target.characterUuid,
item,
target.time,
);
if (!laneId) {
editor.adapter.showToast(
ToastTypes.WARNING,
"No room left on the character's timeline for this clip.",
);
}
}
// Recompute the ghost's compatibility badge for the cursor position.
// Throttled: it runs a DOM hit-test plus (over the canvas) a raycast.
private updateAnimationDropState(event: MouseEvent): void {
const now = performance.now();
if (now - this.lastHoverCheck < HOVER_CHECK_MS) return;
this.lastHoverCheck = now;
const store = usePageSceneStore.getState();
const target = this.findAnimationDropTarget(event);
let state: "checking" | "ok" | "blocked";
if (!target) {
state = "blocked";
} else if (this.activeClipNames === undefined) {
state = "checking";
} else if (
this.activeClipNames !== null &&
this.characterAcceptsClip(target.characterUuid)
) {
state = "ok";
} else {
state = "blocked";
}
if (store.animationDropState !== state) {
store.setAnimationDropState(state);
}
}
// Timeline character row under the cursor first (precise time), then a
// character hit in the 3D scene (time 0 → earliest free slot on the row).
private findAnimationDropTarget(
event: MouseEvent | PointerEvent,
): AnimationDropTarget | null {
const rowTarget = this.timelineRowTarget(event.clientX, event.clientY);
if (rowTarget) return rowTarget;
const characterUuid = this.characterUnderPointer(
event.clientX,
event.clientY,
);
return characterUuid ? { characterUuid, time: 0 } : null;
}
private timelineRowTarget(
clientX: number,
clientY: number,
): AnimationDropTarget | null {
const editor = this.editor;
if (!editor) return null;
const row = document
.elementFromPoint(clientX, clientY)
?.closest?.(`[${CLIP_DROP_ATTR}]`);
const characterUuid = row?.getAttribute(CLIP_DROP_ATTR);
if (!characterUuid) return null;
const timeline = editor.timelineController.getTimeline();
const ruler = document.querySelector(TIMELINE_RULER_SELECTOR);
if (!timeline || !ruler) return { characterUuid, time: 0 };
const rect = ruler.getBoundingClientRect();
const time = quantizeToFrame(
fractionToTime((clientX - rect.left) / rect.width, timeline.duration),
timeline.fps || DEFAULT_TIMELINE_FPS,
);
return { characterUuid, time };
}
// Find the clip-eligible object the pointer is REALLY on. Two honesty
// rules the old roots-only raycast violated:
// - Only raycast when the viewport canvas itself is the hit-tested element
// under the pointer — over UI chrome (outliner, timeline, top bar) the
// badge must not go green and a release must not land "through" the
// panel. (The drag ghost and the dragged-from modal are
// pointer-transparent during a drag, so they never mask the canvas.)
// - Raycast against all CONTENT objects (anything with a media id or the
// shape marker — editor chrome like gizmos, skeleton helpers, grid and
// ground never participate), then accept only when the FIRST hit's root
// is clip-eligible: a character hidden behind a wall or prop is not a
// target, matching the regular drop path's front-most-surface semantics.
private characterUnderPointer(
clientX: number,
clientY: number,
): string | null {
const editor = this.editor;
if (!editor) return null;
const camera = editor.cameraController.camera;
const canvas = editor.renderer?.domElement;
if (!camera || !canvas) return null;
if (document.elementFromPoint(clientX, clientY) !== canvas) return null;
const store = usePageSceneStore.getState();
const scene = editor.activeScene.scene;
const candidateIds = new Set<string>();
for (const character of store.characters) candidateIds.add(character.id);
for (const item of store.outlinerItems) {
if (item.hasSkeleton) candidateIds.add(item.id);
}
if (candidateIds.size === 0) return null;
const contentRoots = scene.children.filter(
(child) =>
child.userData?.["media_id"] !== undefined ||
child.userData?.["isShape"] === true,
);
if (contentRoots.length === 0) return null;
const rect = canvas.getBoundingClientRect();
const ndc = new THREE.Vector2(
((clientX - rect.left) / rect.width) * 2 - 1,
-((clientY - rect.top) / rect.height) * 2 + 1,
);
const raycaster = new THREE.Raycaster();
raycaster.layers.enable(0);
raycaster.layers.enable(1);
raycaster.setFromCamera(ndc, camera);
const hits = raycaster.intersectObjects(contentRoots, true);
if (hits.length === 0) return null;
// Ascend the first (closest) hit to its top-level root; eligibility of
// THAT root decides — anything else in front means "occluded".
let node: THREE.Object3D | null = hits[0].object;
while (node) {
if (node.parent === scene) {
return candidateIds.has(node.uuid) ? node.uuid : null;
}
node = node.parent;
}
return null;
}
// Kick off (or reuse) the clip's node-name inspection for compatibility
// checks. The loaded GLB itself is not kept — only the set of node names the
// clip's tracks address; the drop path re-loads the GLB through the lane
// runtime as before.
private beginClipInspection(editor: Editor, mediaId: string): void {
this.activeClipNames = undefined;
this.compatByCharacter.clear();
let promise = this.clipNodeNamesCache.get(mediaId);
if (!promise) {
promise = editor.activeScene.loadRawGlb(mediaId).then((glb) => {
const clip = glb?.animations?.[0];
if (!clip) {
// Unreadable or animation-less: don't cache, so a transient load
// failure can retry on the next drag.
this.clipNodeNamesCache.delete(mediaId);
return null;
}
const names = new Set<string>();
for (const track of clip.tracks) {
const nodeName = track.name.split(".")[0];
if (nodeName) names.add(nodeName);
}
return [...names];
});
this.clipNodeNamesCache.set(mediaId, promise);
}
void promise.then((names) => {
// Ignore if this drag ended or another item's drag superseded it.
if (usePageSceneStore.getState().dragItem?.media_id !== mediaId) return;
this.activeClipNames = names;
this.compatByCharacter.clear();
});
}
// True when at least one of the clip's tracks resolves to a node under the
// character — the same direct-bind rule CharacterAnimationManager plays by.
private characterAcceptsClip(characterUuid: string): boolean {
const cached = this.compatByCharacter.get(characterUuid);
if (cached !== undefined) return cached;
const names = this.activeClipNames;
if (!names || names.length === 0) return false;
// Top-level lookup, matching every other target resolution in the
// pipeline (completeAnimationDrop, the raycast ascent, the engine's
// findObject): clip targets are always outliner-level roots. A deep
// traverse here could validate a nested object the drop path can't
// resolve — green badge, silently lost drop.
const root = this.editor?.activeScene.scene.children.find(
(child) => child.uuid === characterUuid,
);
const accepts =
!!root && names.some((name) => root.getObjectByName(name) !== undefined);
this.compatByCharacter.set(characterUuid, accepts);
return accepts;
}
}
const dragAndDrop = new DndAsset();
@@ -80,6 +80,15 @@ export interface OutlinerItem {
// True for the render-camera placeholder ("::CAM::") — the outliner shows a
// view-from-camera button for these rows.
isCamera?: boolean;
// True when the object contains a SkinnedMesh — the timeline gives it a
// clip row and animation drags can target it (bind check still applies).
hasSkeleton?: boolean;
// Display names of clips baked into the object's own GLB, in index order.
// Non-empty → the timeline clip row offers a baked-clip picker.
bakedClips?: string[];
// Whether the persistent skeleton overlay (outliner bone icon) is on for
// this object. Mirrors object.userData.skeletonVisible.
skeletonVisible?: boolean;
}
// The currently inspected object in the right-hand control panel.
@@ -98,6 +107,9 @@ export interface DragPosition {
currY: number;
}
// See PageSceneState.animationDropState.
export type AnimationDropState = "none" | "checking" | "ok" | "blocked";
export interface PrecisionSelectorCoords {
x: number;
y: number;
@@ -187,10 +199,22 @@ interface PageSceneState {
timelineTracks: TimelineTrack[];
timelineClipLanes: ClipLane[];
timelineSelectedKeyframeId: string | null;
// Selected clip strip (click-to-select). Drives the strip's × visibility
// and Del/Backspace removal — mirrors keyframe selection.
timelineSelectedClipLaneId: string | null;
// Left keyframe of the segment whose easing curve is being edited in the
// Motion popover (opened from the curve chip BETWEEN two keyframes).
// Distinct from timelineSelectedKeyframeId, which drives selection/delete.
timelineEasingKeyframeId: string | null;
// Object whose timeline row should be scrolled into view. Set (with
// timelineExpanded) after a clip is added; consumed and cleared by an
// effect in TimelineEditor. A store field because TimelineEditor is
// unmounted while collapsed, so expand + scroll can't happen in one tick.
timelineRevealObjectUuid: string | null;
// Timeline "focus" toggle: show only the selected object's track row.
// Follows the selection; with nothing selected, all rows show. Store-held
// (not TimelineEditor state) so it survives the collapse/expand remount.
timelineFocusSelected: boolean;
// record output
producedArtifact: ProducedArtifact | null;
@@ -198,6 +222,10 @@ interface PageSceneState {
// layout / panels
assetModalVisible: boolean;
// The standalone animations-only library modal ("Add Animation" button).
// Mutually exclusive with assetModalVisible (see openAnimationsModal /
// openAssetModal), so at most one library panel is ever open.
animationsModalVisible: boolean;
assetModalVisibleDuringDrag: boolean;
// True while an asset is being dragged out of the library modal. The modal
// stays open but goes pointer-transparent (and translucent when reopen is on)
@@ -220,6 +248,11 @@ interface PageSceneState {
canDrop: boolean;
dragItem: MediaItem | null;
dragPosition: DragPosition;
// Live verdict for the animation-clip drag under the cursor, shown as a
// badge on the drag ghost: "none" (not an animation drag), "checking"
// (clip still loading), "ok" (target binds the clip), "blocked"
// (no target, or a rig/bone-name mismatch).
animationDropState: AnimationDropState;
// object panel (right-hand inspector for selected object)
objectPanelShowing: boolean;
@@ -297,7 +330,10 @@ interface PageSceneState {
setTimelineTracks: (tracks: TimelineTrack[]) => void;
setTimelineClipLanes: (clipLanes: ClipLane[]) => void;
setTimelineSelectedKeyframe: (id: string | null) => void;
setTimelineSelectedClipLane: (id: string | null) => void;
setTimelineEasingKeyframe: (id: string | null) => void;
setTimelineRevealObjectUuid: (uuid: string | null) => void;
setTimelineFocusSelected: (focus: boolean) => void;
setProducedArtifact: (artifact: ProducedArtifact | null) => void;
clearProducedArtifact: () => void;
setRecordingProgress: (progress: RecordingProgress | null) => void;
@@ -309,6 +345,7 @@ interface PageSceneState {
// layout
setAssetModalVisible: (visible: boolean) => void;
setAnimationsModalVisible: (visible: boolean) => void;
setAssetModalVisibleDuringDrag: (visible: boolean) => void;
setAssetDraggingUnder: (dragging: boolean) => void;
setReopenAfterDrag: (reopen: boolean) => void;
@@ -324,6 +361,7 @@ interface PageSceneState {
setCanDrop: (canDrop: boolean) => void;
setDragItem: (item: MediaItem | null) => void;
setDragPosition: (pos: DragPosition) => void;
setAnimationDropState: (state: AnimationDropState) => void;
// object panel
showObjectPanel: (obj?: ObjectPanelObject) => void;
@@ -393,7 +431,10 @@ export const usePageSceneStore = create<PageSceneState>((set, get) => ({
timelineTracks: [],
timelineClipLanes: [],
timelineSelectedKeyframeId: null,
timelineSelectedClipLaneId: null,
timelineEasingKeyframeId: null,
timelineRevealObjectUuid: null,
timelineFocusSelected: false,
producedArtifact: null,
recordingProgress: null,
ignoreKeyDelete: false,
@@ -401,6 +442,7 @@ export const usePageSceneStore = create<PageSceneState>((set, get) => ({
isPromptBoxFocused: false,
assetModalVisible: false,
animationsModalVisible: false,
assetModalVisibleDuringDrag: true,
assetDraggingUnder: false,
reopenAfterDrag: false,
@@ -415,6 +457,7 @@ export const usePageSceneStore = create<PageSceneState>((set, get) => ({
canDrop: false,
dragItem: null,
dragPosition: { currX: 0, currY: 0 },
animationDropState: "none",
objectPanelShowing: false,
objectPanelCurrent: undefined,
@@ -500,7 +543,12 @@ export const usePageSceneStore = create<PageSceneState>((set, get) => ({
setTimelineTracks: (tracks) => set({ timelineTracks: tracks }),
setTimelineClipLanes: (clipLanes) => set({ timelineClipLanes: clipLanes }),
setTimelineSelectedKeyframe: (id) => set({ timelineSelectedKeyframeId: id }),
setTimelineSelectedClipLane: (id) =>
set({ timelineSelectedClipLaneId: id }),
setTimelineEasingKeyframe: (id) => set({ timelineEasingKeyframeId: id }),
setTimelineRevealObjectUuid: (uuid) =>
set({ timelineRevealObjectUuid: uuid }),
setTimelineFocusSelected: (focus) => set({ timelineFocusSelected: focus }),
setProducedArtifact: (artifact) => set({ producedArtifact: artifact }),
clearProducedArtifact: () =>
set((s) => {
@@ -530,6 +578,8 @@ export const usePageSceneStore = create<PageSceneState>((set, get) => ({
// layout actions
setAssetModalVisible: (visible) => set({ assetModalVisible: visible }),
setAnimationsModalVisible: (visible) =>
set({ animationsModalVisible: visible }),
setAssetModalVisibleDuringDrag: (visible) =>
set({ assetModalVisibleDuringDrag: visible }),
setAssetDraggingUnder: (dragging) => set({ assetDraggingUnder: dragging }),
@@ -558,6 +608,7 @@ export const usePageSceneStore = create<PageSceneState>((set, get) => ({
setCanDrop: (canDrop) => set({ canDrop }),
setDragItem: (item) => set({ dragItem: item }),
setDragPosition: (pos) => set({ dragPosition: pos }),
setAnimationDropState: (state) => set({ animationDropState: state }),
// object panel actions
showObjectPanel: (obj) =>
@@ -54,7 +54,6 @@ import { OnboardingHelper } from "./comps/OnboardingHelper";
import { PerfStatsOverlay } from "./comps/PerfStatsOverlay";
import { EntranceDebugPanel } from "./comps/EntranceDebugPanel";
import { Outliner } from "./comps/Outliner";
import { AnimationsDrawer } from "./comps/AnimationsDrawer";
import { PoseModeSelector } from "./comps/PoseModeSelector";
import { ExitCameraView } from "./comps/ExitCameraView";
import { RecordControls } from "./comps/RecordControls";
@@ -519,16 +518,6 @@ export const Stage3DBody = ({
<Outliner />
</div>
{/* Animations drawer: right-docked, only when a character is
selected. Gated to build mode (hide via CSS, never unmount). */}
<div
className={`absolute right-4 top-1/2 -translate-y-1/2${
isRecord ? " hidden" : ""
}`}
>
<AnimationsDrawer />
</div>
<div
className="absolute bottom-0 left-0 right-0"
onClick={handleOverlayClick}
@@ -8,6 +8,8 @@ export * from "./setCameraAspect";
export * from "./setObjectColor";
export * from "./toggleObjectLock";
export * from "./toggleObjectVisibility";
export * from "./toggleSkeletonHelper";
export * from "./openAnimationsModal";
export * from "./openAssetModal";
export * from "./transformObject";
export * from "./timeline";
@@ -0,0 +1,13 @@
import { usePageSceneStore } from "../PageSceneStore";
// Open the standalone animations-only library modal (the "Add Animation"
// button next to Enter Pose Mode). Closes the presets modal so only one
// library panel is ever open, and clears leftover drag-under state so the
// panel opens fully shown (a reopen-off drag leaves it faded-hidden until
// the next open).
export function openAnimationsModal(): void {
const store = usePageSceneStore.getState();
store.setAssetModalVisible(false);
store.setAnimationsModalVisible(true);
store.setAssetDraggingUnder(false);
}
@@ -7,6 +7,7 @@ import { usePageSceneStore } from "../PageSceneStore";
export function openAssetModal(): void {
const store = usePageSceneStore.getState();
store.setAssetModalVisibleDuringDrag(true);
store.setAnimationsModalVisible(false); // one library panel at a time
store.setAssetModalVisible(true);
store.setAssetDraggingUnder(false);
}
@@ -5,6 +5,8 @@
import type Editor from "../engine/editor";
import type { EasingSpec } from "../engine/timeline/types";
import { RemoveClipLaneAction } from "../engine/editor/actions/RemoveClipLaneAction";
import { usePageSceneStore } from "../PageSceneStore";
// The clip source only needs an id + label — both a full MediaItem (drawer
// click) and a drag payload (drop) satisfy this.
@@ -13,9 +15,11 @@ interface ClipSource {
name?: string;
}
// Placeholder strip length (seconds) until the clip GLB reports its real
// duration; playback uses the loaded clip's actual length regardless.
const DEFAULT_CLIP_DURATION = 2;
// Default on-timeline width (seconds) for a fresh strip. Deliberately compact:
// the strip is the play WINDOW, not the clip length — long clips get one
// second by default and the user trims the strip wider to reveal more. Strips
// only auto-shrink below this when the clip itself is shorter.
const DEFAULT_CLIP_DURATION = 1;
export function createTimeline(editor: Editor): void {
editor.timelineController.create();
@@ -65,11 +69,16 @@ export function setKeyframeEasing(
editor.timelineController.setEasing(keyframeId, easing);
}
// Place a skeletal-animation clip (a Mixamo demo item) as a new stacked lane
// under `characterUuid`. Drops at the current playhead by default, or at an
// explicit `atTime` when dropped onto a specific point in the timeline. The
// real clip length replaces DEFAULT_CLIP_DURATION once the GLB loads (the
// strip starts at the placeholder width, flagged autoDuration).
// Place a skeletal-animation clip on `characterUuid`'s single clip row.
// Defaults to the current playhead; pass an explicit `atTime` for a precise
// timeline drop, or 0 to take the earliest free slot. Either way the
// controller snaps to a non-overlapping position and returns null when the
// row has no free slot. Strips start at the compact DEFAULT_CLIP_DURATION
// width (the play window — trim wider to reveal more of the clip); once the
// GLB loads the strip shrinks if the clip is shorter (autoDuration).
// A successful add expands the timeline and scrolls its row list to the
// character (timelineRevealObjectUuid, consumed by TimelineEditor) so the
// new strip is immediately visible.
export function addClipToCharacter(
editor: Editor,
characterUuid: string,
@@ -78,7 +87,7 @@ export function addClipToCharacter(
): string | null {
if (!item.media_id) return null;
const startTime = atTime ?? editor.timelineController.getPlayhead();
return editor.timelineController.addClipLane(characterUuid, {
const laneId = editor.timelineController.addClipLane(characterUuid, {
sourceMediaId: item.media_id,
name: item.name ?? "Animation",
startTime,
@@ -86,6 +95,38 @@ export function addClipToCharacter(
loop: false,
autoDuration: true,
});
if (laneId) {
const store = usePageSceneStore.getState();
store.setTimelineRevealObjectUuid(characterUuid);
store.setTimelineExpanded(true);
}
return laneId;
}
// Place one of an object's own baked clips (object.animations[clipIndex]) on
// its clip row at the earliest free slot (or an explicit `atTime`). The clip's
// real length is known up front, so no autoDuration placeholder is needed.
// Returns null when the object/clip doesn't exist or the row has no free slot.
export function addBakedClipToObject(
editor: Editor,
objectUuid: string,
clipIndex: number,
atTime = 0,
): string | null {
const object = editor.activeScene.scene.getObjectByProperty(
"uuid",
objectUuid,
);
const clip = object?.animations?.[clipIndex];
if (!clip) return null;
return editor.timelineController.addClipLane(objectUuid, {
sourceMediaId: "",
bakedClipIndex: clipIndex,
name: clip.name || `Clip ${clipIndex + 1}`,
startTime: atTime,
duration: Math.min(clip.duration || DEFAULT_CLIP_DURATION, DEFAULT_CLIP_DURATION),
loop: false,
});
}
export function moveClipLane(
@@ -112,8 +153,14 @@ export function setClipLoop(
editor.timelineController.setClipLoop(laneId, loop);
}
// Individually undoable (RemoveClipLaneAction) — both the strip's × button
// and Del/Backspace route through here.
export function removeClipLane(editor: Editor, laneId: string): void {
editor.timelineController.removeClipLane(laneId);
const lane = editor.timelineController.getClipLane(laneId);
if (!lane) return;
const action = new RemoveClipLaneAction(editor.timelineController, lane);
action.apply();
editor.history.record(action);
}
export function saveTimeline(editor: Editor): void {
@@ -0,0 +1,15 @@
import type Editor from "../engine/editor";
// Toggle the persistent skeleton overlay for one object (the outliner's
// bone icon). Editor-only chrome — the helper lives on layer 1, so it never
// appears in renders/captures — but the flag persists in the scene JSON via
// userData.skeletonVisible. Deliberately not undoable (view state, not
// scene content), matching the FK-mode visualization it complements.
export function toggleSkeletonHelper(editor: Editor, uuid: string): void {
editor.skeletonHelpers.setVisible(
uuid,
!editor.skeletonHelpers.isVisible(uuid),
);
// Refresh so the outliner row's bone icon reflects the new state.
editor.selection.refreshOutliner();
}
@@ -200,15 +200,22 @@ export interface PageSceneAdapter {
}): ReactNode;
// "Upload your own model" host modal. Rendered from inside the lib's
// AssetModal — the lib owns the trigger button + modal control state,
// the host owns the actual upload UI (file picker, validation,
// progress, splat conversion, etc.).
// AssetModal / AnimationsModal — the lib owns the trigger button + modal
// control state, the host owns the actual upload UI (file picker,
// validation, progress, splat conversion, etc.).
renderAssetUploader(props: {
isOpen: boolean;
onClose: () => void;
onSuccess: (category: FilterEngineCategories) => void;
title: string;
titleIcon: IconDefinition;
// Preselect the uploader's "Upload as ..." category toggle when opened
// from a scoped library (the animations panel passes "animation"); a
// scoped open also keeps the host's My Library from popping over that
// panel on success. Literal strings rather than FilterEngineCategories:
// the upload-modal lib declares its own copy of that enum, and the two
// enum types aren't assignable across package boundaries.
initialCategory?: "animation" | "character";
}): ReactNode;
// Image upload modal — distinct from renderAssetUploader because
@@ -1,100 +0,0 @@
// Animations drawer: when a character is selected, lists the curated Mixamo
// 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";
import { usePageSceneStore } from "../../PageSceneStore";
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);
const characters = usePageSceneStore((s) => s.characters);
const character = characters.find(
(c) => c.kind === "character" && c.id === selectedObject?.id,
);
if (!character) return null;
return (
<div className="glass glass-no-hover pointer-events-auto flex w-52 flex-col gap-2 rounded-2xl p-3 shadow-xl">
<div className="px-1 text-xs font-medium text-base-fg/70">
Animations · {character.name}
</div>
<div className="grid max-h-[46vh] grid-cols-2 gap-2 overflow-y-auto pe-1">
{demoAnimationItems.map((item) => (
<button
key={item.media_id}
type="button"
draggable
title={`Add “${item.name}” to ${character.name} — click, or drag onto the timeline`}
className="group flex flex-col items-stretch gap-1 rounded-lg border border-white/10 bg-black/30 p-1 text-left transition-colors hover:border-white/40"
onDragStart={(e) => {
e.dataTransfer.setData(
ANIMATION_CLIP_MIME,
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)
}
>
<div className="aspect-square w-full overflow-hidden rounded-md bg-black/40">
{item.thumbnail && (
<img
src={item.thumbnail}
alt={item.name ?? "Animation"}
className="h-full w-full object-cover opacity-90 transition-opacity group-hover:opacity-100"
draggable={false}
/>
)}
</div>
<span className="truncate px-0.5 text-[11px] text-base-fg/90">
{item.name}
</span>
</button>
))}
</div>
</div>
);
};
export default AnimationsDrawer;
@@ -1 +0,0 @@
export { AnimationsDrawer } from "./AnimationsDrawer";
@@ -0,0 +1,331 @@
import { Checkbox } from "@storyteller/ui-checkbox";
import { Modal } from "@storyteller/ui-modal";
import {
faSearch,
faLayerGroup,
faPersonRunning,
faUpFromLine,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import { CloseButton } from "@storyteller/ui-close-button";
import { Input } from "@storyteller/ui-input";
import React, {
ChangeEvent,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useShallow } from "zustand/shallow";
import { twMerge } from "tailwind-merge";
import { EngineContext } from "../../contexts/EngineContext/EngineContext";
import { ItemElements } from "./shared/ItemElements";
import { useAnimationLibrary } from "./hooks";
import { isAnyStatusFetching } from "./utilities/misc";
import { FilterEngineCategories } from "../../enums";
import { usePageSceneStore } from "../../PageSceneStore";
import type { MediaItem } from "../../models/assets";
type AnimationsTabId = "all" | "presets" | "uploaded";
type AnimationsTab = {
id: AnimationsTabId;
label: string;
icon: typeof faLayerGroup;
items: MediaItem[];
};
// Standalone animations-only library, opened from the "Add Animation" button
// next to "Enter Pose Mode" (see PoseModeSelector). A slimmed-down sibling of
// AssetModal: same floating draggable panel, same drag-under behavior, same
// ItemElements grid (cards click-add to the selected character and
// pointer-drag onto characters/timeline rows via DndAsset), but only
// animations, split into All / Presets / Uploaded tabs.
export const AnimationsModal = () => {
const editor = useContext(EngineContext);
const {
animationsModalVisible,
assetDraggingUnder,
reopenAfterDrag,
currentUserToken,
setAnimationsModalVisible,
setReopenAfterDrag,
} = usePageSceneStore(
useShallow((s) => ({
animationsModalVisible: s.animationsModalVisible,
assetDraggingUnder: s.assetDraggingUnder,
reopenAfterDrag: s.reopenAfterDrag,
currentUserToken: s.currentUserToken,
setAnimationsModalVisible: s.setAnimationsModalVisible,
setReopenAfterDrag: s.setReopenAfterDrag,
})),
);
const [activeTab, setActiveTab] = useState<AnimationsTabId>("all");
const [searchTerm, setSearchTerm] = useState("");
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const {
userAnimations,
defaultAnimations,
allAnimations,
fetchUserAnimations,
loadMoreUserAnimations,
hasMoreUserAnimations,
fetchStatuses,
} = useAnimationLibrary();
const handleReopenChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.checked;
setTimeout(() => {
setReopenAfterDrag(newValue);
}, 0);
};
const handleClose = () => {
setAnimationsModalVisible(false);
};
const handleOpenUpload = () => {
// Same auth gate as the Controls3D upload entries — anonymous
// visitors get the signup CTA instead of the uploader.
if (!currentUserToken && editor?.adapter.promptSignup) {
editor.adapter.promptSignup("upload-3d");
return;
}
setIsUploadModalOpen(true);
};
const handleUploadSuccess = (category: FilterEngineCategories) => {
setIsUploadModalOpen(false);
fetchUserAnimations();
// Land the user on their new clip. The uploader is preselected to
// Animation here, but the category toggles stay user-editable — only
// jump to Uploaded when the upload actually was an animation.
if (category === FilterEngineCategories.ANIMATION) {
setActiveTab("uploaded");
setSearchTerm("");
}
};
useEffect(() => {
if (animationsModalVisible) {
// Clear leftover drag-under state on every open (same safety net as
// AssetModal: a reopen-off drag closes the panel while keeping
// assetDraggingUnder true so its fade-out doesn't flash).
usePageSceneStore.getState().setAssetDraggingUnder(false);
// Pick up animations the user uploaded since the last open (silent
// for anonymous users — see the hook's suppressErrorToast).
fetchUserAnimations();
const timer = setTimeout(() => {
searchInputRef.current?.focus();
}, 100);
return () => clearTimeout(timer);
}
return undefined;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [animationsModalVisible]);
const tabs = useMemo<AnimationsTab[]>(
() => [
{ id: "all", label: "All", icon: faLayerGroup, items: allAnimations },
{
id: "presets",
label: "Presets",
icon: faPersonRunning,
items: defaultAnimations,
},
{
id: "uploaded",
label: "Uploaded",
icon: faUpFromLine,
items: userAnimations,
},
],
[allAnimations, defaultAnimations, userAnimations],
);
const currentTab = tabs.find((tab) => tab.id === activeTab) ?? tabs[0];
const displayedItems = useMemo(() => {
if (!searchTerm) return currentTab.items;
const searchLower = searchTerm.toLowerCase();
return currentTab.items.filter(
(item) =>
item.name?.toLowerCase().includes(searchLower) ||
item.description?.toLowerCase().includes(searchLower),
);
}, [currentTab.items, searchTerm]);
useEffect(() => {
setSearchTerm("");
}, [activeTab]);
const isFetching = isAnyStatusFetching(fetchStatuses);
const clearSearch = () => setSearchTerm("");
const renderContent = () => {
if (
activeTab === "uploaded" &&
userAnimations.length === 0 &&
!isFetching
) {
return (
<div className="flex h-full flex-col items-center justify-center gap-1 text-center">
<p className="text-sm text-white/70">No uploaded animations yet.</p>
<p className="text-xs text-white/40">
Upload a Mixamo GLB or FBX to add your own clips.
</p>
<Button
variant="primary"
icon={faUpFromLine}
className="mt-3"
onClick={handleOpenUpload}
>
Upload animation
</Button>
</div>
);
}
return (
/* Uploaded tab pages through the user's library (the API caps a page
at 100); scroll near the bottom loads the next page. Presets stay a
single curated page. */
<ItemElements
items={displayedItems}
busy={isFetching}
debug={`animations-modal-${currentTab.id}`}
hasMore={currentTab.id === "uploaded" ? hasMoreUserAnimations : undefined}
onLoadMore={
currentTab.id === "uploaded" ? loadMoreUserAnimations : undefined
}
/>
);
};
return (
<>
<Modal
isOpen={animationsModalVisible}
onClose={handleClose}
className="relative h-[640px] max-w-4xl"
childPadding={false}
showClose={false}
backdropClassName="bg-transparent"
draggable={true}
closeOnOutsideClick={false}
allowBackgroundInteraction={true}
// Same drag-under behavior as AssetModal: pointer-transparent while an
// asset is dragged out, translucent with "Reopen after adding" on,
// eased out entirely with it off (closing after the drop).
contentInteractive={!assetDraggingUnder}
contentDimmed={assetDraggingUnder && reopenAfterDrag}
contentHidden={assetDraggingUnder && !reopenAfterDrag}
>
<Modal.DragHandle>
<div className="absolute left-0 top-0 z-[50] h-[46px] w-full cursor-move" />
</Modal.DragHandle>
<div className="grid h-full grid-cols-12 gap-3">
<div className="relative col-span-3 flex h-full flex-col p-3 pt-2 after:absolute after:right-0 after:top-0 after:h-full after:w-px after:bg-gray-200 after:bg-white/10">
<div className="flex items-center justify-between gap-2.5 py-0.5">
<h2 className="text-[18px] font-semibold opacity-80">
Animations
</h2>
</div>
<hr className="my-2 w-full border-white/10" />
<div className="flex h-full flex-col space-y-2">
{tabs.map((tab) => (
<Button
key={tab.id}
variant={activeTab === tab.id ? "primary" : "secondary"}
className={twMerge(
"w-full justify-start rounded-xl border border-white/[2%] bg-white/[4%] px-3.5 py-2.5 text-left hover:bg-white/15",
activeTab === tab.id &&
"border-primary bg-primary/10 hover:bg-primary/10",
)}
onClick={() => setActiveTab(tab.id)}
>
<FontAwesomeIcon
icon={tab.icon}
className="mr-2 opacity-70"
/>
{tab.label}
</Button>
))}
</div>
<div className="mt-auto flex flex-col gap-3 pt-3">
<Button
variant="secondary"
icon={faUpFromLine}
iconClassName="opacity-70"
className="w-full justify-center rounded-xl border border-white/10 bg-white/[6%] px-3.5 py-2.5 hover:bg-white/15"
onClick={handleOpenUpload}
>
Upload animation
</Button>
<Checkbox
id="animations-reopen-after-add"
checked={reopenAfterDrag}
onChange={handleReopenChange}
label="Reopen after adding"
/>
</div>
</div>
<div className="col-span-9 p-3 pb-0 ps-0 pt-2">
<div className="flex h-full flex-col">
<div className="h-full">
<div className="flex items-center gap-4">
<div className="relative grow">
<Input
ref={searchInputRef}
placeholder="Search animations"
className="relative z-[51] grow"
inputClassName="pr-2.5"
icon={faSearch}
value={searchTerm}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setSearchTerm(e.target.value)
}
iconClassName="text-white/60"
/>
{searchTerm && (
<CloseButton
onClick={clearSearch}
className="absolute right-2.5 top-1/2 z-[51] h-4 w-4 -translate-y-1/2 bg-white/10 text-[10px] hover:bg-white/20"
/>
)}
</div>
<CloseButton
onClick={handleClose}
className="relative z-[51]"
/>
</div>
<div className="overflow-auto-y mt-4 h-[574px]">
{renderContent()}
</div>
</div>
</div>
</div>
</div>
</Modal>
{/* Host-rendered upload modal — the same adapter slot AssetModal uses,
scoped to animations so "Upload as Animation" comes preselected and
My Library stays closed on success (this panel shows the result). */}
{editor &&
editor.adapter.renderAssetUploader({
isOpen: isUploadModalOpen,
onClose: () => setIsUploadModalOpen(false),
onSuccess: handleUploadSuccess,
title: "Upload Animation",
titleIcon: faUpFromLine,
initialCategory: "animation",
})}
</>
);
};
@@ -11,6 +11,7 @@ import {
faMountainCity,
faDog,
faFaceGrinStars,
faPersonRunning,
faUpFromLine,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -30,7 +31,11 @@ import { twMerge } from "tailwind-merge";
import { EngineContext } from "../../contexts/EngineContext/EngineContext";
import { ItemElements } from "./shared/ItemElements";
import { useUserObjects, useFeaturedObjects } from "./hooks";
import {
useAnimationLibrary,
useUserObjects,
useFeaturedObjects,
} from "./hooks";
import { isAnyStatusFetching } from "./utilities/misc";
import { FilterEngineCategories, FilterMediaType } from "../../enums";
import {
@@ -85,6 +90,7 @@ const AllTabSection = ({
// Mapping from upload category to AssetTab id, so the modal can
// auto-switch the user to the tab matching what they just uploaded.
const categoryToTabIdMap: Partial<Record<FilterEngineCategories, string>> = {
[FilterEngineCategories.ANIMATION]: "animations",
[FilterEngineCategories.CHARACTER]: "characters",
[FilterEngineCategories.CREATURE]: "creatures",
[FilterEngineCategories.IMAGE_PLANE]: "image-planes",
@@ -151,6 +157,10 @@ export const AssetModal = () => {
// doesn't change mid-drag, so this effect doesn't re-run then.
usePageSceneStore.getState().setAssetDraggingUnder(false);
// Pick up animations the user uploaded since the last open (silent
// for anonymous users — see the hook's suppressErrorToast).
fetchUserAnimations();
const lastUploadedTab = sessionStorage.getItem("lastUploadedTab");
if (lastUploadedTab) {
setActiveAssetTab(lastUploadedTab);
@@ -252,6 +262,15 @@ export const AssetModal = () => {
defaultErrorMessage: "Error fetching featured image planes",
});
// Animations data shared with the standalone AnimationsModal. Unlike the
// other user-asset hooks (dead "Mine" tab), fetchUserAnimations fires on
// every modal open — silently, since anonymous users legitimately 401.
const {
allAnimations,
fetchUserAnimations,
fetchStatuses: animationFetchStatuses,
} = useAnimationLibrary();
const isFetching = isAnyStatusFetching([
userCharactersFetchStatus,
userObjectsFetchStatus,
@@ -263,6 +282,7 @@ export const AssetModal = () => {
featuredSetsFetchStatus,
featuredCreaturesFetchStatus,
featuredImagePlanesFetchStatus,
...animationFetchStatuses,
]);
const assetTabs = useMemo<AssetTab[]>(() => {
@@ -345,6 +365,16 @@ export const AssetModal = () => {
? (featuredCreatures ?? [])
: (userCreatures ?? []),
},
{
id: "animations",
label: "Animations",
labelSingle: "Animation",
icon: faPersonRunning,
engineCategory: FilterEngineCategories.ANIMATION,
// Uploads-first ordering with the demo-clip fallback lives in
// useAnimationLibrary (shared with the standalone AnimationsModal).
items: activeLibraryTab === "library" ? allAnimations : [],
},
{
id: "skybox",
label: "Skybox",
@@ -357,6 +387,7 @@ export const AssetModal = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
[
activeLibraryTab,
allAnimations,
featuredCharacters,
featuredCreatures,
featuredObjects,
@@ -459,6 +490,7 @@ export const AssetModal = () => {
fetchUserSets();
fetchUserCreatures();
fetchUserImagePlanes();
fetchUserAnimations();
};
const clearSearch = () => setSearchTerm("");
@@ -1,2 +1,3 @@
export * from "./useAnimationLibrary";
export * from "./useUserObjects";
export * from "./useFeaturedObjects";
@@ -0,0 +1,189 @@
import { useCallback, useContext, useMemo } from "react";
import { create } from "zustand";
import { EngineContext } from "../../../contexts/EngineContext/EngineContext";
import type { PageSceneAdapter } from "../../../adapter";
import {
FetchStatus,
FilterEngineCategories,
FilterMediaType,
} from "../../../enums";
import {
fetchFeaturedMediaItems,
fetchUserMediaItems,
} from "../utilities/fetchMediaItems";
import { demoAnimationItems } from "../../../signals/demoAssets";
import type { MediaItem } from "../../../models/assets";
import type { Pagination } from "../../../models/pagination";
// Skeletal animation clips for the library UIs (the AssetModal Animations
// section and the standalone AnimationsModal). GLB/GLTF only — the timeline's
// clip loader (Scene.loadRawGlb) can't consume other formats (e.g. vmd).
//
// The state lives in ONE module-level store: both modals are mounted at the
// same time, and per-hook state meant two featured fetches on every pagescene
// load, two error toasts on a backend failure, and user-animation lists that
// could disagree between the modals. Fetching is also LAZY — nothing fires on
// mount; fetchUserAnimations (which both modals call on open and after an
// upload) refreshes the user list every time and triggers the featured fetch
// until it has succeeded once.
//
// The user's own uploads lead, then the server-curated set; the hardcoded
// demo clips are only the fallback while the API has no featured animations
// (deliberately replace, not merge). The user fetch is silent since anonymous
// users legitimately 401.
interface AnimationLibraryState {
featuredAnimations?: MediaItem[];
featuredFetchStatus: FetchStatus;
userAnimations?: MediaItem[];
userFetchStatus: FetchStatus;
// Page-based pagination of the LAST user fetch (0-based `current` of
// `total_page_count`) — drives load-more for libraries past page one.
userNextPage?: Pagination;
}
const useAnimationLibraryStore = create<AnimationLibraryState>(() => ({
featuredAnimations: undefined,
featuredFetchStatus: FetchStatus.READY,
userAnimations: undefined,
userFetchStatus: FetchStatus.READY,
userNextPage: undefined,
}));
const hasMorePages = (page: Pagination | undefined): boolean =>
!!page && page.current + 1 < page.total_page_count;
const dedupeById = (items: MediaItem[]): MediaItem[] => {
const seen = new Set<string>();
return items.filter((item) => {
if (seen.has(item.media_id)) return false;
seen.add(item.media_id);
return true;
});
};
const ANIMATION_FILTERS = {
filterEngineCategories: [FilterEngineCategories.ANIMATION],
filterMediaType: [FilterMediaType.GLB, FilterMediaType.GLTF],
};
// Featured animations rarely change: fetched once per session, retried on a
// later open only after a failure. One pipeline = one toast per attempt.
let featuredSucceeded = false;
const fetchFeaturedAnimations = async (
adapter: PageSceneAdapter,
): Promise<void> => {
const store = useAnimationLibraryStore;
if (featuredSucceeded) return;
if (store.getState().featuredFetchStatus === FetchStatus.IN_PROGRESS) return;
store.setState({ featuredFetchStatus: FetchStatus.IN_PROGRESS });
const result = await fetchFeaturedMediaItems(
{
...ANIMATION_FILTERS,
defaultErrorMessage: "Error fetching featured animations",
},
adapter,
);
if (result.status === FetchStatus.SUCCESS) featuredSucceeded = true;
store.setState({
featuredFetchStatus: result.status,
...(result.mediaItems ? { featuredAnimations: result.mediaItems } : {}),
});
};
const fetchUserAnimationsShared = async (
adapter: PageSceneAdapter,
pageIndex = 0,
): Promise<void> => {
const store = useAnimationLibraryStore;
if (store.getState().userFetchStatus === FetchStatus.IN_PROGRESS) return;
store.setState({ userFetchStatus: FetchStatus.IN_PROGRESS });
const result = await fetchUserMediaItems(
{
...ANIMATION_FILTERS,
defaultErrorMessage: "Error fetching your animations",
suppressErrorToast: true,
nextPageIndex: pageIndex,
},
adapter,
);
store.setState((prev) => ({
userFetchStatus: result.status,
...(result.mediaItems
? {
// Page 0 = refresh (replace); later pages append, deduped by
// media id in case a refresh raced a load-more.
userAnimations:
pageIndex > 0
? dedupeById([
...(prev.userAnimations ?? []),
...result.mediaItems,
])
: result.mediaItems,
userNextPage: result.nextPage,
}
: {}),
}));
};
const loadMoreUserAnimationsShared = (adapter: PageSceneAdapter): void => {
const { userFetchStatus, userNextPage } = useAnimationLibraryStore.getState();
if (userFetchStatus === FetchStatus.IN_PROGRESS) return;
if (!hasMorePages(userNextPage)) return;
void fetchUserAnimationsShared(adapter, userNextPage!.current + 1);
};
export const useAnimationLibrary = (): {
userAnimations: MediaItem[];
defaultAnimations: MediaItem[];
allAnimations: MediaItem[];
fetchUserAnimations: () => void;
// Scroll-driven pagination of the user's uploads (the featured list stays
// a single page; user libraries are the unbounded ones).
loadMoreUserAnimations: () => void;
hasMoreUserAnimations: boolean;
fetchStatuses: FetchStatus[];
} => {
const editor = useContext(EngineContext);
const {
featuredAnimations,
featuredFetchStatus,
userAnimations,
userFetchStatus,
userNextPage,
} = useAnimationLibraryStore();
const fetchUserAnimations = useCallback(() => {
const adapter = editor?.adapter;
if (!adapter) return;
void fetchFeaturedAnimations(adapter);
void fetchUserAnimationsShared(adapter);
}, [editor]);
const loadMoreUserAnimations = useCallback(() => {
const adapter = editor?.adapter;
if (!adapter) return;
loadMoreUserAnimationsShared(adapter);
}, [editor]);
const defaultAnimations = useMemo(
() =>
featuredAnimations?.length ? featuredAnimations : demoAnimationItems,
[featuredAnimations],
);
const allAnimations = useMemo(
() => [...(userAnimations ?? []), ...defaultAnimations],
[userAnimations, defaultAnimations],
);
return {
userAnimations: userAnimations ?? [],
defaultAnimations,
allAnimations,
fetchUserAnimations,
loadMoreUserAnimations,
hasMoreUserAnimations: hasMorePages(userNextPage),
fetchStatuses: [featuredFetchStatus, userFetchStatus],
};
};
@@ -14,6 +14,9 @@ interface useUserObjectsProps {
defaultErrorMessage: string;
filterEngineCategories: FilterEngineCategories[];
filterMediaTypes?: FilterMediaType[];
// Fail quietly (no toast) — for fetches expected to fail for anonymous
// users, e.g. the Animations tab's background "my animations" load.
suppressErrorToast?: boolean;
}
export const useUserObjects = (props: useUserObjectsProps) => {
@@ -51,14 +54,19 @@ export const useUserObjects = (props: useUserObjectsProps) => {
return;
}
const { filterEngineCategories, filterMediaTypes, defaultErrorMessage } =
props;
const {
filterEngineCategories,
filterMediaTypes,
defaultErrorMessage,
suppressErrorToast,
} = props;
const result = await fetchUserMediaItems(
{
filterEngineCategories,
filterMediaType: filterMediaTypes,
defaultErrorMessage,
nextPageIndex,
suppressErrorToast,
},
editor.adapter,
);
@@ -1,2 +1,3 @@
export { AnimationsModal } from "./AnimationsModal";
export { AssetMenu } from "./AssetMenu";
export { AssetModal } from "./AssetModal";
@@ -1,10 +1,16 @@
import { useContext } from "react";
import { useContext, useState } from "react";
import { Badge } from "@storyteller/ui-badge";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faUpDownLeftRight } from "@fortawesome/pro-solid-svg-icons";
import {
faCube,
faPersonRunning,
faUpDownLeftRight,
} from "@fortawesome/pro-solid-svg-icons";
import { EngineContext } from "../../../contexts/EngineContext/EngineContext";
import dragAndDrop from "../../../DragAndDrop/DndAsset";
import { AssetType } from "../../../enums";
import { addClipToCharacter } from "../../../actions";
import { AssetType, ToastTypes } from "../../../enums";
import { usePageSceneStore } from "../../../PageSceneStore";
import type { MediaItem } from "../../../models/assets";
interface Props {
@@ -37,13 +43,67 @@ const patchExpressionObjectType = (mediaType: string) => {
export const ItemElement = ({ item }: Props) => {
const editor = useContext(EngineContext);
// Broken thumbnail URL (404, CORS) → swap to the icon placeholder instead
// of the browser's broken-image glyph + alt text. Keyed by media id, NOT a
// boolean: the virtualized grid reuses component instances with positional
// cell keys, so a plain flag would stick to the CELL and blank out
// whichever item scrolls/filters into it next.
const [failedMediaId, setFailedMediaId] = useState<string | null>(null);
const thumbnailFailed = failedMediaId === item.media_id;
// Animation cards are click-to-add (clip onto the selected character's
// timeline row, next free slot via the controller's overlap guard) — parity
// with the retired AnimationsDrawer. Other asset types are drag-only; a
// click on them ends as a no-op in DndAsset.
const handleClick = () => {
if (item.type !== AssetType.ANIMATION || !editor) return;
// A very fast flick can release before React commits the modal's
// pointer-transparency, so the browser still synthesizes a click on the
// card — but that gesture was a DRAG (aborted or not), never a click.
if (dragAndDrop.wasDragGesture) return;
const store = usePageSceneStore.getState();
const selectedId = store.selectedObject?.id;
// Same eligibility as the drag path and the timeline rows: characters OR
// any skinned object (creatures, rigged uploads). Click-to-add rejecting
// what a drop accepts read as broken.
const target = selectedId
? (store.characters.find((c) => c.id === selectedId) ??
store.outlinerItems.find(
(o) => o.id === selectedId && o.hasSkeleton,
))
: undefined;
if (!target) {
editor.adapter.showToast(
ToastTypes.WARNING,
"Select a character or rigged object in the scene, then click an animation to add it.",
);
return;
}
// atTime 0 → resolveFreeStart snaps to the earliest free slot on the row.
const laneId = addClipToCharacter(editor, target.id, item, 0);
if (!laneId) {
editor.adapter.showToast(
ToastTypes.WARNING,
`No room left on ${target.name}'s timeline for "${item.name}".`,
);
return;
}
// Successful add: honor the same "Reopen after adding" preference drags
// use, so with reopen off the library closes and the expanded timeline
// (addClipToCharacter reveals it) is unobstructed.
if (!store.reopenAfterDrag) {
if (store.assetModalVisible) store.setAssetModalVisible(false);
if (store.animationsModalVisible) store.setAnimationsModalVisible(false);
}
};
return (
<div className="group relative w-full select-none overflow-hidden transition-all duration-200">
{item.media_type && (
<Badge
label={
item.type === AssetType.CHARACTER
item.type === AssetType.CHARACTER ||
item.type === AssetType.ANIMATION
? mapCharacterObjectType(item.media_type)
: item.type === AssetType.EXPRESSION
? patchExpressionObjectType(item.media_type)
@@ -56,19 +116,43 @@ export const ItemElement = ({ item }: Props) => {
<div
className="pointer-events-none relative aspect-[16/12] w-full select-none overflow-hidden rounded-xl border-[3px] border-white/5 bg-brand-secondary-600 object-cover object-center transition-all group-hover:border-brand-primary"
onPointerDown={(event) => dragAndDrop.onPointerDown(event, item, editor)}
onClick={handleClick}
style={{ cursor: "grab", pointerEvents: "auto" }}
>
<img
crossOrigin="anonymous"
referrerPolicy="no-referrer"
src={item.thumbnail}
alt={item.name}
className="h-full w-full object-cover object-center"
/>
{item.thumbnail && !thumbnailFailed ? (
<img
crossOrigin="anonymous"
referrerPolicy="no-referrer"
src={item.thumbnail}
alt={item.name}
className="h-full w-full object-cover object-center"
onError={() => setFailedMediaId(item.media_id)}
/>
) : (
/* Missing/broken thumbnail: type icon on a slightly lighter card
so it reads as a deliberate placeholder, not a load failure. */
<div className="flex h-full w-full items-center justify-center bg-brand-secondary-500/60">
<FontAwesomeIcon
icon={
item.type === AssetType.ANIMATION ? faPersonRunning : faCube
}
className="text-2xl text-white/30"
/>
</div>
)}
<div className="text-shadow-md absolute inset-0 flex items-center justify-center bg-brand-primary-950/50 text-[13px] font-medium text-white opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<FontAwesomeIcon icon={faUpDownLeftRight} className="mr-1.5" />
Drag to Scene
{item.type === AssetType.ANIMATION ? (
<>
<FontAwesomeIcon icon={faPersonRunning} className="mr-1.5" />
Add to Character
</>
) : (
<>
<FontAwesomeIcon icon={faUpDownLeftRight} className="mr-1.5" />
Drag to Scene
</>
)}
</div>
</div>
<div className="pointer-events-none w-full select-none truncate py-1.5 text-start text-[13px] text-white/80 transition-all duration-200">
@@ -25,6 +25,9 @@ interface fetchMediaItemsInterface {
defaultErrorMessage?: string;
nextPageCursor?: string; // for featured items' infinite pagination
nextPageIndex?: number; // for user item's normal pagination
// Fail quietly instead of toasting — for background fetches that are
// EXPECTED to fail sometimes (e.g. user-asset lists for anonymous users).
suppressErrorToast?: boolean;
}
export const fetchUserMediaItems = async (
@@ -36,6 +39,7 @@ export const fetchUserMediaItems = async (
filterMediaType,
defaultErrorMessage,
nextPageIndex,
suppressErrorToast,
} = args;
const response = await adapter.listUserMediaFiles({
@@ -54,14 +58,19 @@ export const fetchUserMediaItems = async (
return {
mediaItems: newSetObjects,
status: FetchStatus.SUCCESS,
// Page-based pagination (0-based `current` of `total_page_count`) —
// callers use it to drive load-more past the first page.
nextPage: response.pagination,
};
}
adapter.showToast(
ToastTypes.ERROR,
response.errorMessage ??
defaultErrorMessage ??
"Unknown Error in Fetching Media Items",
);
if (!suppressErrorToast) {
adapter.showToast(
ToastTypes.ERROR,
response.errorMessage ??
defaultErrorMessage ??
"Unknown Error in Fetching Media Items",
);
}
return { status: FetchStatus.ERROR };
};
@@ -4,6 +4,7 @@ import { useSignals } from "@preact/signals-react/runtime";
import {
faArrowsRotate,
faArrowsUpDownLeftRight,
faFilm,
faGlobe,
faMagicWandSparkles,
faPlus,
@@ -25,9 +26,13 @@ import {
import { twMerge } from "tailwind-merge";
import { EngineContext } from "../../contexts/EngineContext/EngineContext";
import { openAssetModal, setTransformMode } from "../../actions";
import {
openAnimationsModal,
openAssetModal,
setTransformMode,
} from "../../actions";
import { usePageSceneStore, type TransformMode } from "../../PageSceneStore";
import { AssetModal } from "../AssetMenu";
import { AnimationsModal, AssetModal } from "../AssetMenu";
export interface Controls3DProps {
/** Show the magic-wand "Create 3D model from image" shortcut next
@@ -120,6 +125,9 @@ export const Controls3D = ({
case "presets":
openAssetModal();
break;
case "animations":
openAnimationsModal();
break;
case "library":
handleOpenGalleryModal();
break;
@@ -196,6 +204,14 @@ export const Controls3D = ({
),
action: "presets",
},
{
label: "Animations",
selected: false,
icon: (
<FontAwesomeIcon icon={faFilm} className="h-4 w-4" />
),
action: "animations",
},
{
label: "My Library",
selected: false,
@@ -319,6 +335,7 @@ export const Controls3D = ({
</div>
<AssetModal />
<AnimationsModal />
{editor &&
editor.adapter.renderAssetUploader({
@@ -12,7 +12,7 @@ export const DragComponent = () => (
height={120}
crossOrigin="anonymous"
getState={() => {
const { dragItem, dragPosition, assetDraggingUnder } =
const { dragItem, dragPosition, assetDraggingUnder, animationDropState } =
usePageSceneStore.getState();
if (!dragItem || !assetDraggingUnder) return null;
const thumbnail =
@@ -23,6 +23,12 @@ export const DragComponent = () => (
y: dragPosition.currY,
thumbnail,
label: dragItem.name || dragItem.media_id,
// Animation drags validate their target live; "checking" renders no
// badge (verdict unknown while the clip is still loading).
indicator:
animationDropState === "ok" || animationDropState === "blocked"
? animationDropState
: undefined,
};
}}
/>
@@ -2,6 +2,7 @@ import { useContext } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowRightToBracket,
faBone,
faEye,
faEyeSlash,
faLock,
@@ -18,6 +19,7 @@ import { OutlinerItem, usePageSceneStore } from "../../PageSceneStore";
import { openAssetModal } from "../../actions/openAssetModal";
import { toggleObjectLock } from "../../actions/toggleObjectLock";
import { toggleObjectVisibility } from "../../actions/toggleObjectVisibility";
import { toggleSkeletonHelper } from "../../actions/toggleSkeletonHelper";
import { toggleCameraView } from "../../actions/cameraView";
const OutlinerRow = ({ item }: { item: OutlinerItem }) => {
@@ -55,6 +57,12 @@ const OutlinerRow = ({ item }: { item: OutlinerItem }) => {
toggleCameraView(editorEngine);
};
const handleToggleSkeleton = (e: React.MouseEvent) => {
e.stopPropagation();
if (!editorEngine) return;
toggleSkeletonHelper(editorEngine, item.id);
};
return (
<div
role="button"
@@ -92,6 +100,22 @@ const OutlinerRow = ({ item }: { item: OutlinerItem }) => {
</div>
</button>
)}
{item.hasSkeleton && (
/* Editor-only skeleton overlay (never part of renders); state
persists with the scene. */
<button
onClick={handleToggleSkeleton}
title={item.skeletonVisible ? "Hide skeleton" : "Show skeleton"}
className={twMerge(
"text-white/60 transition-colors duration-100 hover:text-white text-xs",
item.skeletonVisible && "text-white/90",
)}
>
<div className="w-3">
<FontAwesomeIcon icon={faBone} />
</div>
</button>
)}
<button
onClick={handleToggleLock}
title={item.locked ? "Unlock" : "Lock"}
@@ -2,9 +2,14 @@ import React from "react";
import { useShallow } from "zustand/shallow";
import { Tooltip } from "@storyteller/ui-tooltip";
import { Button } from "@storyteller/ui-button";
import { faCheck, faPersonRunning } from "@fortawesome/pro-solid-svg-icons";
import {
faCheck,
faFilm,
faPersonRunning,
} from "@fortawesome/pro-solid-svg-icons";
import { usePageSceneStore } from "../PageSceneStore";
import { getActiveEditor } from "../contexts/EngineContext/EngineContext";
import { openAnimationsModal } from "../actions";
interface PoseModeSelectorProps {}
@@ -58,6 +63,24 @@ export const PoseModeSelector: React.FC<PoseModeSelectorProps> = () => {
)}
</>
</Tooltip>
{/* Animations-only library for the selected character. Hidden while
posing so the row stays a single "Done" affordance. */}
{poseMode === "select" && (
<Tooltip
content={"Add an animation to this character"}
position={"bottom"}
delay={300}
closeOnClick={true}
>
<Button
icon={faFilm}
onClick={() => openAnimationsModal()}
className="rounded-xl shadow-xl outline-none focus-visible:outline-none"
>
Add Animation
</Button>
</Tooltip>
)}
</div>
);
};
@@ -1,6 +1,6 @@
import { useContext } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faPlay, faPause, faChevronDown } from "@fortawesome/pro-solid-svg-icons";
import { faPlay, faPause, faChevronUp } from "@fortawesome/pro-solid-svg-icons";
import { EngineContext } from "../../contexts/EngineContext/EngineContext";
import { pauseTimeline, playTimeline, seekTimeline } from "../../actions";
import { usePageSceneStore } from "../../PageSceneStore";
@@ -66,7 +66,7 @@ export const TimelineBar = ({ readOnly = false }: { readOnly?: boolean }) => {
onClick={() => setExpanded(true)}
className="flex h-7 w-7 items-center justify-center rounded-full text-base-fg/60 hover:bg-white/10"
>
<FontAwesomeIcon icon={faChevronDown} className="h-3 w-3" />
<FontAwesomeIcon icon={faChevronUp} className="h-3 w-3" />
</button>
)}
</div>
@@ -1,12 +1,15 @@
import { useContext, useRef } from "react";
import { useContext, useEffect, useRef, useState } from "react";
import ReactDOM from "react-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faFilm,
faPlus,
faRepeat,
faXmark,
} from "@fortawesome/pro-solid-svg-icons";
import { EngineContext } from "../../contexts/EngineContext/EngineContext";
import {
addBakedClipToObject,
moveClipLane,
removeClipLane,
resizeClipLane,
@@ -17,11 +20,19 @@ import {
type ClipLane,
} from "../../PageSceneStore";
import { DEFAULT_TIMELINE_FPS } from "../../engine/timeline/types";
import { ToastTypes } from "../../enums";
import { fractionToTime, quantizeToFrame, timeToFraction } from "./timelineUtils";
interface TimelineClipRowProps {
objectUuid: string;
lanes: ClipLane[];
duration: number;
// Display names of the clips baked into the object's own GLB; non-empty
// renders the "+" baked-clip picker in the label column.
bakedClips?: string[];
// Whether animation drags can land on this row (skinned objects). Rows for
// baked-only objects render without the drop hint.
droppable?: boolean;
}
// Drag intent for the current pointer gesture.
@@ -29,16 +40,61 @@ type Drag =
| { laneId: string; kind: "move"; grabOffset: number } // seconds pointer→start
| { laneId: string; kind: "resize" };
// A character's skeletal-animation clips on ONE shared row (single row per
// character; the overlap guard in TimelineController keeps them a
// non-overlapping sequence). Each strip drags to move, its right edge trims
// length, the loop chip toggles repeat, the × removes it. Dropping a clip from
// the Animations drawer is handled by the character block wrapper in
// TimelineEditor, so an empty row still accepts drops.
export const TimelineClipRow = ({ lanes, duration }: TimelineClipRowProps) => {
// An object's animation clips on ONE shared row (the overlap guard in
// TimelineController keeps them a non-overlapping sequence). Each strip drags
// to move, its right edge trims length, the loop chip toggles repeat, the ×
// removes it (for baked clips that only unschedules — the clip itself stays
// on the model and in the picker). Library-clip drops are handled by the row
// wrapper in TimelineEditor, so an empty row still accepts drops; baked clips
// are added through the "+" picker.
export const TimelineClipRow = ({
objectUuid,
lanes,
duration,
bakedClips,
droppable,
}: TimelineClipRowProps) => {
const editor = useContext(EngineContext);
const laneRef = useRef<HTMLDivElement>(null);
const drag = useRef<Drag | null>(null);
// Baked-clip picker anchor (button rect at open time; null = closed). The
// popup PORTALS to document.body: rendered in place it would be clipped by
// the row list's overflow-y-auto ancestor — the top row's upward popup was
// effectively invisible.
const [pickerAnchor, setPickerAnchor] = useState<DOMRect | null>(null);
const pickerMenuRef = useRef<HTMLDivElement>(null);
const selectedLaneId = usePageSceneStore(
(s) => s.timelineSelectedClipLaneId,
);
// A fixed-position popup can't follow its button, so any outside scroll
// or resize closes it (scrolling INSIDE the popup's own list is exempt).
useEffect(() => {
if (!pickerAnchor) return undefined;
const onScroll = (e: Event) => {
if (pickerMenuRef.current?.contains(e.target as Node)) return;
setPickerAnchor(null);
};
const onResize = () => setPickerAnchor(null);
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onResize);
};
}, [pickerAnchor]);
const addBaked = (clipIndex: number) => {
setPickerAnchor(null);
if (!editor) return;
const laneId = addBakedClipToObject(editor, objectUuid, clipIndex);
if (!laneId) {
editor.adapter.showToast(
ToastTypes.WARNING,
"No room left on this row for that clip.",
);
}
};
const fps =
editor?.timelineController.getTimeline()?.fps ?? DEFAULT_TIMELINE_FPS;
@@ -73,11 +129,68 @@ export const TimelineClipRow = ({ lanes, duration }: TimelineClipRowProps) => {
<div className="flex w-32 shrink-0 items-center gap-2 truncate ps-6 text-xs text-base-fg/50">
<FontAwesomeIcon icon={faFilm} className="h-3 w-3 opacity-60" />
<span className="truncate">Animation</span>
{bakedClips && bakedClips.length > 0 && (
<div className="shrink-0">
<button
type="button"
title="Add one of this object's baked animations"
className={`flex h-4 w-4 items-center justify-center rounded-[4px] transition-colors ${
pickerAnchor
? "bg-brand-primary text-white"
: "bg-white/10 text-base-fg/70 hover:bg-white/20 hover:text-base-fg"
}`}
onClick={(e) =>
setPickerAnchor((open) =>
open ? null : e.currentTarget.getBoundingClientRect(),
)
}
>
<FontAwesomeIcon icon={faPlus} className="h-2.5 w-2.5" />
</button>
{pickerAnchor &&
ReactDOM.createPortal(
<>
{/* click-away backdrop */}
<div
className="fixed inset-0"
style={{ zIndex: 9998 }}
onClick={() => setPickerAnchor(null)}
/>
{/* Opens upward from the button (the timeline docks to the
screen bottom, so up always has room). */}
<div
ref={pickerMenuRef}
className="fixed max-h-40 w-44 overflow-y-auto rounded-lg border border-white/10 bg-ui-controls py-1 shadow-xl"
style={{
zIndex: 9999,
left: pickerAnchor.left,
bottom: window.innerHeight - pickerAnchor.top + 4,
}}
>
{bakedClips.map((clipName, clipIndex) => (
<button
key={clipIndex}
type="button"
className="block w-full truncate px-2.5 py-1 text-left text-[11px] text-base-fg/90 hover:bg-white/10"
onClick={() => addBaked(clipIndex)}
>
{clipName}
</button>
))}
</div>
</>,
document.body,
)}
</div>
)}
</div>
<div
ref={laneRef}
className="relative h-6 flex-1 rounded-md bg-black/20"
/* overflow-hidden: strips are engine-clamped into the lane, but any
transient out-of-range placement must clip here rather than paint
over the add-button column. */
className="relative h-6 flex-1 overflow-hidden rounded-md bg-black/20"
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerLeave={endDrag}
@@ -85,23 +198,39 @@ export const TimelineClipRow = ({ lanes, duration }: TimelineClipRowProps) => {
>
{lanes.length === 0 && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-md border border-dashed border-white/15 text-[10px] text-base-fg/30">
Drag an animation here
{droppable
? "Drag an animation here"
: "Add a baked animation with +"}
</div>
)}
{lanes.map((lane) => {
const strip = lane.strip;
const isSelected = selectedLaneId === lane.id;
const leftPct = timeToFraction(strip.startTime, duration) * 100;
const widthPct = Math.max(
(strip.duration / Math.max(duration, 1e-6)) * 100,
1.5, // stay visible while a fresh clip's real length is loading
);
return (
/* data-clip-strip: exempt from the click-away deselect in
TimelineEditor (pointerdown selects; the same gesture must
not immediately deselect). */
<div
key={lane.id}
className="absolute inset-y-0 flex items-center gap-1 rounded-md border border-brand-primary/60 bg-brand-primary/25 pe-1 ps-1.5 text-[10px] text-white"
data-clip-strip
/* pe-2.5 keeps the × (and name) clear of the absolute w-1.5
trim handle on the right edge */
className={`absolute inset-y-0 flex items-center gap-1 rounded-md border pe-2.5 ps-1.5 text-[10px] text-white ${
isSelected
? "border-white/80 bg-brand-primary/45"
: "border-brand-primary/60 bg-brand-primary/25"
}`}
style={{ left: `${leftPct}%`, width: `${widthPct}%` }}
onPointerDown={(e) => {
usePageSceneStore
.getState()
.setTimelineSelectedClipLane(lane.id);
e.currentTarget.setPointerCapture(e.pointerId);
drag.current = {
laneId: lane.id,
@@ -132,15 +261,26 @@ export const TimelineClipRow = ({ lanes, duration }: TimelineClipRowProps) => {
<span className="min-w-0 flex-1 truncate">{strip.name}</span>
<button
type="button"
title="Remove animation"
className="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-[3px] text-white/70 hover:bg-white/20 hover:text-white"
onPointerDown={(e) => e.stopPropagation()}
onClick={() => editor && removeClipLane(editor, lane.id)}
>
<FontAwesomeIcon icon={faXmark} className="h-2.5 w-2.5" />
</button>
{/* × only on the selected strip — an always-visible remove was
far too easy to hit by accident. Del/Backspace also removes
the selected strip, and removal is undoable. */}
{isSelected && (
<button
type="button"
title="Remove animation (Del)"
className="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-[3px] text-white/70 hover:bg-white/20 hover:text-white"
onPointerDown={(e) => e.stopPropagation()}
onClick={() => {
if (!editor) return;
removeClipLane(editor, lane.id);
usePageSceneStore
.getState()
.setTimelineSelectedClipLane(null);
}}
>
<FontAwesomeIcon icon={faXmark} className="h-2.5 w-2.5" />
</button>
)}
{/* right-edge trim handle */}
<div
@@ -4,25 +4,26 @@ import {
faPlay,
faPause,
faBackwardStep,
faCrosshairs,
faForwardStep,
faChevronUp,
faChevronDown,
faTrash,
} from "@fortawesome/pro-solid-svg-icons";
import { Button } from "@storyteller/ui-button";
import { Tooltip } from "@storyteller/ui-tooltip";
import { EngineContext } from "../../contexts/EngineContext/EngineContext";
import {
addClipToCharacter,
cancelTimeline,
deleteKeyframe,
pauseTimeline,
playTimeline,
removeClipLane,
saveTimeline,
seekTimeline,
} from "../../actions";
import { usePageSceneStore } from "../../PageSceneStore";
import { DEFAULT_TIMELINE_FPS } from "../../engine/timeline/types";
import {
ANIMATION_CLIP_MIME,
formatTimecode,
formatTimecodeFrames,
fractionToTime,
@@ -54,17 +55,42 @@ export const TimelineEditor = () => {
const selectedKeyframeId = usePageSceneStore(
(s) => s.timelineSelectedKeyframeId,
);
const selectedClipLaneId = usePageSceneStore(
(s) => s.timelineSelectedClipLaneId,
);
const revealObjectUuid = usePageSceneStore(
(s) => s.timelineRevealObjectUuid,
);
const selectedObject = usePageSceneStore((s) => s.selectedObject);
const focusSelected = usePageSceneStore((s) => s.timelineFocusSelected);
const setFocusSelected = usePageSceneStore(
(s) => s.setTimelineFocusSelected,
);
const setExpanded = usePageSceneStore((s) => s.setTimelineExpanded);
// Every scene object gets a track row (empty lanes included), so the
// editor never looks blank while the scene has content — no selection
// required to see where keyframes can go.
// required to see where keyframes can go. The focus toggle narrows the
// list to the selected object's row; with nothing selected (or the
// selection not an outliner row) it falls back to showing everything
// rather than an empty list.
const trackByUuid = new Map(tracks.map((t) => [t.objectUuid, t]));
const rows = outlinerItems;
const focusedRows =
focusSelected && selectedObject
? outlinerItems.filter((item) => item.id === selectedObject.id)
: [];
const rows = focusedRows.length > 0 ? focusedRows : outlinerItems;
// Only characters get skeletal-animation clip lanes. Clip drags are accepted
// on a character's block; other rows ignore them.
// Clip-row eligibility: characters and any skinned object (creatures,
// rigged uploads) accept animation drags — the bind check still gates the
// drop — and any object with baked clips gets a row for its picker even
// without a skeleton (baked transform animations play through the mixer
// too).
const characterIds = new Set(characters.map((c) => c.id));
const acceptsClipDrops = (item: (typeof rows)[number]) =>
characterIds.has(item.id) || !!item.hasSkeleton;
const hasClipRow = (item: (typeof rows)[number]) =>
acceptsClipDrops(item) || (item.bakedClips?.length ?? 0) > 0;
const clipLanesByChar = new Map<string, typeof clipLanes>();
for (const lane of clipLanes) {
const list = clipLanesByChar.get(lane.objectUuid) ?? [];
@@ -115,9 +141,22 @@ export const TimelineEditor = () => {
) {
return;
}
// The selection can be stale (Cancel / undo of a Save replace the
// timeline wholesale). Never swallow the key for a target that no
// longer exists — clear the selection and let the engine's own
// Delete handler (selected scene object) see the event.
const keyframeExists = editor?.timelineController
.getTimeline()
?.tracks.some((t) =>
t.keyframes.some((k) => k.id === selectedKeyframeId),
);
if (!editor || !keyframeExists) {
usePageSceneStore.getState().setTimelineSelectedKeyframe(null);
return;
}
e.preventDefault();
e.stopPropagation();
if (editor) deleteKeyframe(editor, selectedKeyframeId);
deleteKeyframe(editor, selectedKeyframeId);
usePageSceneStore.getState().setTimelineSelectedKeyframe(null);
};
const onPointerDown = (e: PointerEvent) => {
@@ -138,6 +177,78 @@ export const TimelineEditor = () => {
};
}, [selectedKeyframeId, editor]);
// Selected clip strip: Del/Backspace removes it (undoable via
// RemoveClipLaneAction), and clicking anything that isn't a strip
// deselects. Mirrors the keyframe selection handlers above; both keydown
// listeners can coexist since strip-selection clears keyframe-selection
// (a strip isn't [data-keyframe]) and vice versa — the guard below is
// belt-and-braces for the impossible both-selected case.
useEffect(() => {
if (!selectedClipLaneId) return undefined;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Delete" && e.key !== "Backspace") return;
const target = e.target as HTMLElement | null;
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target?.isContentEditable
) {
return;
}
if (usePageSceneStore.getState().timelineSelectedKeyframeId) return;
// Same staleness rule as the keyframe handler above: a dead id must
// not eat the key meant for the engine's scene-object Delete.
if (
!editor ||
!editor.timelineController.getClipLane(selectedClipLaneId)
) {
usePageSceneStore.getState().setTimelineSelectedClipLane(null);
return;
}
e.preventDefault();
e.stopPropagation();
removeClipLane(editor, selectedClipLaneId);
usePageSceneStore.getState().setTimelineSelectedClipLane(null);
};
const onPointerDown = (e: PointerEvent) => {
const target = e.target as HTMLElement | null;
if (target?.closest("[data-clip-strip]")) return;
usePageSceneStore.getState().setTimelineSelectedClipLane(null);
};
document.addEventListener("keydown", onKeyDown, true);
document.addEventListener("pointerdown", onPointerDown, true);
return () => {
document.removeEventListener("keydown", onKeyDown, true);
document.removeEventListener("pointerdown", onPointerDown, true);
};
}, [selectedClipLaneId, editor]);
// Scroll the row list to the object whose clip was just added
// (addClipToCharacter sets the uuid alongside timelineExpanded). A store
// field rather than a call because the editor is unmounted while
// collapsed: expanding mounts it, this effect runs post-mount with the
// uuid already set, scrolls, then clears.
useEffect(() => {
if (!revealObjectUuid) return;
const store = usePageSceneStore.getState();
// Focus mode can hide the revealed row (clip dropped onto a character
// that isn't the selected object): fall back to all tracks so the new
// clip is visible. The effect re-runs off the focusSelected dep once
// the row exists, then scrolls.
if (
store.timelineFocusSelected &&
store.selectedObject?.id !== revealObjectUuid
) {
store.setTimelineFocusSelected(false);
return;
}
const row = document.querySelector(
`[data-timeline-row-uuid="${CSS.escape(revealObjectUuid)}"]`,
);
row?.scrollIntoView({ block: "nearest" });
store.setTimelineRevealObjectUuid(null);
}, [revealObjectUuid, focusSelected]);
// Clicking anywhere outside the popover (canvas included) dismisses it;
// the easing chips opt out so they can toggle it themselves.
useEffect(() => {
@@ -184,35 +295,6 @@ export const TimelineEditor = () => {
);
};
// Drop an animation clip (dragged from the Animations drawer) onto a
// character at the pointer's time. Lane geometry matches the ruler, so the
// ruler rect converts pointer-x → time exactly as scrubbing does.
const dropClipOnCharacter = (characterId: string, e: React.DragEvent) => {
const ruler = rulerRef.current;
if (!ruler || !editor) return;
const raw = e.dataTransfer.getData(ANIMATION_CLIP_MIME);
if (!raw) return;
e.preventDefault();
let payload: { media_id?: string; name?: string };
try {
payload = JSON.parse(raw);
} catch {
return;
}
if (!payload.media_id) return;
const rect = ruler.getBoundingClientRect();
const time = quantizeToFrame(
fractionToTime((e.clientX - rect.left) / rect.width, duration),
fps,
);
addClipToCharacter(
editor,
characterId,
{ media_id: payload.media_id, name: payload.name },
time,
);
};
return (
<div
id="timeline-editor"
@@ -251,13 +333,39 @@ export const TimelineEditor = () => {
<span className="ml-2 tabular-nums text-xs text-base-fg/60">
{formatTimecodeFrames(playhead, fps)} / {formatTimecode(duration)}
</span>
{/* ml-auto lives on a wrapper: Tooltip's className styles the popup
panel, and its trigger div is hard-coded to "relative". */}
<div className="ml-auto">
<Tooltip
content={
focusSelected
? "Show all tracks"
: "Show only the selected object's track"
}
position="top"
delay={300}
closeOnClick
>
<button
type="button"
onClick={() => setFocusSelected(!focusSelected)}
className={`flex h-7 w-7 items-center justify-center rounded-full transition-colors ${
focusSelected
? "bg-primary/20 text-white"
: "text-base-fg/60 hover:bg-white/10"
}`}
>
<FontAwesomeIcon icon={faCrosshairs} className="h-3.5 w-3.5" />
</button>
</Tooltip>
</div>
<button
type="button"
title="Collapse timeline"
onClick={() => setExpanded(false)}
className="ml-auto flex h-7 w-7 items-center justify-center rounded-full text-base-fg/60 hover:bg-white/10"
className="flex h-7 w-7 items-center justify-center rounded-full text-base-fg/60 hover:bg-white/10"
>
<FontAwesomeIcon icon={faChevronUp} className="h-3 w-3" />
<FontAwesomeIcon icon={faChevronDown} className="h-3 w-3" />
</button>
</div>
@@ -267,6 +375,10 @@ export const TimelineEditor = () => {
<div className="w-32 shrink-0" />
<div
ref={rulerRef}
// Tagged so DndAsset can convert an animation drop's pointer-x
// into a timeline time using this exact rect (lane geometry
// matches the ruler, same conversion scrubbing uses).
data-timeline-ruler=""
className="relative h-5 flex-1 cursor-pointer touch-none text-[10px] text-base-fg/50"
onPointerDown={(e) => {
isScrubbing.current = true;
@@ -283,11 +395,41 @@ export const TimelineEditor = () => {
isScrubbing.current = false;
}}
>
<span className="absolute left-0">{formatTimecode(0)}</span>
{/* labels are centered on their ticks (the edge ones spill a
little into the spacer columns, which is intentional) */}
<span className="absolute left-0 -translate-x-1/2">
{formatTimecode(0)}
</span>
<span className="absolute left-1/2 -translate-x-1/2">
{formatTimecode(duration / 2)}
</span>
<DurationLabel className="absolute right-0" />
<DurationLabel className="absolute right-0 translate-x-1/2" />
{/* tick marks in three tiers: major every 5 s, second ticks,
and faint sub-ticks between them. Sub-tick density adapts
to the timeline length so long timelines don't turn the
ruler into a solid smear. */}
{(() => {
const subsPerSecond = duration <= 15 ? 4 : duration <= 30 ? 2 : 1;
const count = Math.floor(duration * subsPerSecond) + 1;
return Array.from({ length: count }, (_, i) => {
const t = i / subsPerSecond;
const isSecond = i % subsPerSecond === 0;
const isMajor = isSecond && t % 5 === 0;
return (
<span
key={i}
className={`pointer-events-none absolute bottom-0 w-px ${
isMajor
? "h-2 bg-white/30"
: isSecond
? "h-1.5 bg-white/20"
: "h-1 bg-white/10"
}`}
style={{ left: `${timeToFraction(t, duration) * 100}%` }}
/>
);
});
})()}
</div>
<div className="w-7 shrink-0" />
</div>
@@ -304,34 +446,33 @@ export const TimelineEditor = () => {
alignment with the ruler-driven playhead. */
<div className="max-h-[154px] overflow-y-auto overscroll-contain [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
{rows.map((item) => {
const isCharacter = characterIds.has(item.id);
const lanes = isCharacter ? clipLanesByChar.get(item.id) : undefined;
const droppable = acceptsClipDrops(item);
const clipRow = hasClipRow(item);
const lanes = clipRow ? clipLanesByChar.get(item.id) : undefined;
return (
/* Clip-eligible rows advertise themselves as drop targets;
DndAsset hit-tests this attribute during animation drags
(pointer-based — no HTML5 DnD). Other rows stay untagged.
data-timeline-row-uuid tags EVERY row; it's the scroll
target for the post-add reveal effect above. */
<div
key={item.id}
onDragOver={
isCharacter
? (e) => {
// Allow the drop only when it carries an animation clip.
if (e.dataTransfer.types.includes(ANIMATION_CLIP_MIME)) {
e.preventDefault();
}
}
: undefined
}
onDrop={
isCharacter
? (e) => dropClipOnCharacter(item.id, e)
: undefined
}
data-timeline-row-uuid={item.id}
data-clip-drop-uuid={droppable ? item.id : undefined}
>
<TimelineTrackRow
item={item}
track={trackByUuid.get(item.id)}
duration={duration}
/>
{isCharacter && (
<TimelineClipRow lanes={lanes ?? []} duration={duration} />
{clipRow && (
<TimelineClipRow
objectUuid={item.id}
lanes={lanes ?? []}
duration={duration}
bakedClips={item.bakedClips}
droppable={droppable}
/>
)}
</div>
);
@@ -1,9 +1,5 @@
// Shared helpers for the timeline UI.
// DataTransfer MIME for dragging an animation clip from the Animations drawer
// onto a character's lane in the timeline. Payload is JSON: { media_id, name }.
export const ANIMATION_CLIP_MIME = "application/x-artcraft-animation-clip";
// Seconds → "m:ss" (e.g. 10 → "0:10", 65 → "1:05").
export const formatTimecode = (seconds: number): string => {
const clamped = Math.max(0, seconds);
@@ -146,6 +146,30 @@ export class EngineStoreBridge {
store.setTimelineDuration(e.duration);
store.setTimelineTracks(e.tracks);
store.setTimelineClipLanes(e.clipLanes);
// Prune selections whose target vanished — Cancel, undo of a Save
// and scene loads replace timeline content wholesale, and a stale
// id keeps TimelineEditor's capture-phase Del/Backspace handler
// alive, eating the key meant for the selected scene object.
const keyframeExists = (id: string) =>
e.tracks.some((t) => t.keyframes.some((k) => k.id === id));
if (
store.timelineSelectedKeyframeId &&
!keyframeExists(store.timelineSelectedKeyframeId)
) {
store.setTimelineSelectedKeyframe(null);
}
if (
store.timelineEasingKeyframeId &&
!keyframeExists(store.timelineEasingKeyframeId)
) {
store.setTimelineEasingKeyframe(null);
}
if (
store.timelineSelectedClipLaneId &&
!e.clipLanes.some((l) => l.id === store.timelineSelectedClipLaneId)
) {
store.setTimelineSelectedClipLane(null);
}
}),
bus.subscribe(TimelinePlayheadEvent, (e) => {
const store = usePageSceneStore.getState();
@@ -17,7 +17,8 @@ import * as THREE from "three";
import type Editor from "../editor";
import type { ClipLane } from "../timeline/types";
// Per-lane runtime state. `action` is null while the clip GLB is still loading.
// Per-lane runtime state. `action` is null while the clip GLB is still
// loading — or TERMINALLY, when the lane can't bind (see `boundRoot`).
interface LaneRuntime {
laneId: string;
characterUuid: string;
@@ -25,7 +26,26 @@ interface LaneRuntime {
startTime: number;
loop: boolean;
action: THREE.AnimationAction | null;
// The clip's natural length (drives loop modulo / end clamping).
clipDuration: number;
// The strip's on-timeline length — the authoritative play window. Strips
// default to a compact width, so this is usually shorter than the clip;
// a non-loop strip longer than the clip freezes on the last frame.
stripDuration: number;
// The scene root this runtime was assessed against, or null when the
// object didn't exist at add time. Every lane state is TERMINAL:
// revalidate() reassesses a lane only when the live root INSTANCE for its
// uuid differs from boundRoot (object appeared, vanished, or was replaced
// under the same uuid) — never on a timer or per-refresh retry loop.
boundRoot: THREE.Object3D | null;
}
// Captured local transform of one node, for rest-pose restoration.
interface RestTransform {
node: THREE.Object3D;
position: THREE.Vector3;
quaternion: THREE.Quaternion;
scale: THREE.Vector3;
}
export class CharacterAnimationManager {
@@ -35,8 +55,14 @@ export class CharacterAnimationManager {
private lanes = new Map<string, LaneRuntime>();
// laneId → monotonic token, so a superseded async load can't clobber state.
private loadTokens = new Map<string, number>();
// characterUuid → its skeleton(s), cached so bind-pose resets don't re-traverse.
private skeletons = new Map<string, THREE.Skeleton[]>();
// characterUuid → rest pose of every node BELOW the root (the root itself
// is excluded — its transform belongs to the user/gizmo/keyframes),
// captured when the mixer is first created. Restored in gaps instead of
// THREE.Skeleton.pose(): pose() rebuilds bone locals from inverse bind
// matrices, which mis-scales rigs whose armature carries a baked scale
// (mixamo cm-rigs) — that made characters visually vanish the moment a
// strip moved off the playhead.
private restPoses = new Map<string, RestTransform[]>();
constructor(private readonly editor: Editor) {}
@@ -52,9 +78,10 @@ export class CharacterAnimationManager {
if (!rt) {
void this.addLane(lane);
} else {
// Placement can change without a reload.
// Placement and trim can change without a reload.
rt.startTime = lane.strip.startTime;
rt.loop = lane.strip.loop;
rt.stripDuration = lane.strip.duration;
}
}
this.pruneMixers();
@@ -66,18 +93,43 @@ export class CharacterAnimationManager {
// Characters that have a clip covering this playhead. Everyone else is
// reset to their bind (T) pose below.
const posed = new Set<string>();
// Pick ONE winning lane per character first. Strip intervals are closed
// at both ends so seek-to-end still holds a final pose, but strips are
// deliberately snapped flush (resolveFreeStart), so at a shared boundary
// — A ends exactly where B starts, and scrubs/records land on exact
// frame times — both would qualify. Enabling both blends A's last frame
// with B's first at half weight each, a garbage pose the deterministic
// recorder would encode into video. The LATER strip (smaller local time)
// wins the boundary.
const winners = new Map<string, LaneRuntime>();
for (const rt of this.lanes.values()) {
const action = rt.action;
if (!action) continue;
let local = playhead - rt.startTime;
const dur = rt.clipDuration;
const active = local >= 0 && (rt.loop || local <= dur);
if (!active) {
const local = playhead - rt.startTime;
if (local < 0 || local > rt.stripDuration) {
action.enabled = false;
continue;
}
const incumbent = winners.get(rt.characterUuid);
if (!incumbent) {
winners.set(rt.characterUuid, rt);
continue;
}
if (local < playhead - incumbent.startTime) {
incumbent.action!.enabled = false;
winners.set(rt.characterUuid, rt);
} else {
action.enabled = false;
}
}
for (const rt of winners.values()) {
let local = playhead - rt.startTime;
// The strip's on-timeline width is the play window; the clip's natural
// length only drives the loop modulo / final-frame clamp inside it.
const dur = rt.clipDuration;
if (rt.loop && dur > 0) local = local % dur;
else local = Math.min(local, dur);
const action = rt.action!;
action.enabled = true;
action.paused = true; // we drive time ourselves; don't auto-advance
action.time = local;
@@ -86,19 +138,59 @@ export class CharacterAnimationManager {
}
// 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.
// frame, so explicitly restore the captured rest 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);
if (!posed.has(uuid)) this.resetToRestPose(uuid);
}
for (const mixer of this.mixers.values()) mixer.update(0);
}
// Reassess lanes/mixers whose scene object CHANGED — appeared (undo
// restoring a deleted character), vanished, or was replaced under the
// same uuid (delete→undo, in-session scene reloads); mixers and rest
// poses hold object-instance references, so a replaced root would
// otherwise keep the runtime animating the detached old object forever.
//
// Called on every outliner refresh (the funnel all object-lifecycle
// changes pass through) but deliberately LOOP-FREE: lane states are
// terminal, and "changed" is one map lookup per lane/mixer against the
// top-level roots — no traverses, no fetches, and sync() runs only when
// something was actually purged. An idle refresh costs O(children+lanes).
revalidate(): void {
const timeline = this.editor.timelineController.getTimeline();
if (!timeline) return;
const roots = new Map<string, THREE.Object3D>();
for (const child of this.editor.activeScene.scene.children) {
roots.set(child.uuid, child);
}
let changed = false;
for (const [uuid, mixer] of [...this.mixers]) {
if (roots.get(uuid) !== mixer.getRoot()) {
mixer.stopAllAction();
this.mixers.delete(uuid);
this.restPoses.delete(uuid);
changed = true;
}
}
for (const rt of [...this.lanes.values()]) {
if ((roots.get(rt.characterUuid) ?? null) !== rt.boundRoot) {
this.disposeLane(rt.laneId);
changed = true;
}
}
// sync() re-registers the purged lanes against the new reality; each
// lands in a fresh terminal state (bound / missing / unbindable).
// Re-posing happens in addLane's own tail evaluate.
if (changed) this.sync(timeline.clipLanes);
}
clear(): void {
for (const laneId of [...this.lanes.keys()]) this.disposeLane(laneId);
for (const uuid of this.mixers.keys()) this.resetToRestPose(uuid);
for (const mixer of this.mixers.values()) mixer.stopAllAction();
this.mixers.clear();
this.skeletons.clear();
this.restPoses.clear();
}
// ─── internals ────────────────────────────────────────────────────────
@@ -115,24 +207,78 @@ export class CharacterAnimationManager {
loop: lane.strip.loop,
action: null,
clipDuration: lane.strip.duration,
stripDuration: lane.strip.duration,
boundRoot: null,
};
this.lanes.set(lane.id, rt);
const character = this.findObject(lane.objectUuid);
if (!character) return;
const glb = await this.editor.activeScene.loadRawGlb(lane.strip.sourceMediaId);
// Bail if this lane was removed/replaced while the clip was loading.
if (this.loadTokens.get(lane.id) !== token || !this.lanes.has(lane.id)) {
if (!character) {
// Terminal "missing object" state (boundRoot stays null). The
// registration is KEPT so sync() never re-attempts it; revalidate()
// revives the lane exactly once, when a root for this uuid
// (re)appears — e.g. undo restoring a deleted character.
return;
}
const clip = glb?.animations?.[0];
if (!clip) {
console.warn(
"Animation clip has no animations track:",
rt.boundRoot = character;
let clip: THREE.AnimationClip | undefined;
if (lane.strip.bakedClipIndex !== undefined) {
// Baked clip: sourced from the object's own `animations[]`, which is
// never mutated (baked clips stay on the model and in the picker).
// CLONED per lane: mixer.clipAction caches one action per (clip, root),
// so handing two strips the same AnimationClip instance makes them
// fight over a single action — whichever lane evaluates last wins,
// freezing the other strip's window — and disposeLane's uncacheClip
// would kill the surviving strip's action along with the removed one.
// Clones share the underlying keyframe arrays, so this is cheap.
// Resolves synchronously, so no load-token dance is needed.
const source = character.animations?.[lane.strip.bakedClipIndex];
if (!source) {
console.warn(
`Baked clip index ${lane.strip.bakedClipIndex} not found on object ${lane.objectUuid} (object has ${character.animations?.length ?? 0} baked clips).`,
);
return;
}
clip = source.clone();
} else {
// Skeletal clips need a rig to bind to, and a uuid-resolvable root is
// always FULLY loaded (every load path stamps the target uuid only
// after its GLB arrives — verified across create, scene-load and
// undo-restore paths), so a rig-less subtree is a terminal verdict,
// not a loading state: the lane stays registered as unbindable — one
// warn, no fetch, no retry — until revalidate() sees the root
// instance change.
let hasRig = false;
character.traverse((node) => {
if (
(node as THREE.Bone).isBone ||
(node as THREE.SkinnedMesh).isSkinnedMesh
) {
hasRig = true;
}
});
if (!hasRig) {
console.warn(
`Animation clip lane targets object ${lane.objectUuid}, which has no rig (no bones/skinned mesh) — strip will not play.`,
);
return;
}
const glb = await this.editor.activeScene.loadRawGlb(
lane.strip.sourceMediaId,
);
return;
// Bail if this lane was removed/replaced while the clip was loading.
if (this.loadTokens.get(lane.id) !== token || !this.lanes.has(lane.id)) {
return;
}
clip = glb?.animations?.[0];
if (!clip) {
console.warn(
"Animation clip has no animations track:",
lane.strip.sourceMediaId,
);
return;
}
}
const mixer = this.getMixer(lane.objectUuid, character);
@@ -147,8 +293,12 @@ export class CharacterAnimationManager {
// Diagnostic for the direct-bind vs retarget question: if none of the
// clip's tracks resolve to a node on the character, the character won't
// move — that's the signal to wire SkeletonUtils.retargetClip.
if (!this.clipBindsToCharacter(clip, character)) {
// move — that's the signal to wire SkeletonUtils.retargetClip. (A baked
// clip trivially binds to its own model; the check is skipped.)
if (
lane.strip.bakedClipIndex === undefined &&
!this.clipBindsToCharacter(clip, character)
) {
console.warn(
`Animation clip "${clip.name}" bound 0 tracks on character ${lane.objectUuid} — likely a rig/bone-name mismatch (retarget needed).`,
);
@@ -164,6 +314,9 @@ export class CharacterAnimationManager {
this.evaluateAt(this.editor.timelineController.getPlayhead());
}
// NB: uncaching releases MIXER state only — for baked clips the underlying
// AnimationClip stays untouched on the object's `animations[]` (baked clips
// are never removed from the model, only unscheduled).
private disposeLane(laneId: string): void {
const rt = this.lanes.get(laneId);
if (!rt) return;
@@ -177,14 +330,17 @@ export class CharacterAnimationManager {
this.lanes.delete(laneId);
}
// Drop mixers whose character no longer has any lanes.
// Drop mixers whose character no longer has any lanes. The character is
// restored to its rest pose first so removing the last strip doesn't
// leave it frozen mid-animation.
private pruneMixers(): void {
const live = new Set([...this.lanes.values()].map((l) => l.characterUuid));
for (const uuid of [...this.mixers.keys()]) {
if (!live.has(uuid)) {
this.resetToRestPose(uuid);
this.mixers.get(uuid)?.stopAllAction();
this.mixers.delete(uuid);
this.skeletons.delete(uuid);
this.restPoses.delete(uuid);
}
}
}
@@ -194,27 +350,40 @@ export class CharacterAnimationManager {
if (!mixer) {
mixer = new THREE.AnimationMixer(root);
this.mixers.set(uuid, mixer);
this.skeletons.set(uuid, this.collectSkeletons(root));
this.restPoses.set(uuid, this.captureRestPose(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();
// Snapshot the local transforms of every node BELOW the root — the pose the
// model is in when its first clip arrives (normally the load/T pose). The
// root is excluded: its transform is owned by the gizmo/keyframe system and
// must never snap back on a gap.
private captureRestPose(root: THREE.Object3D): RestTransform[] {
const rest: RestTransform[] = [];
for (const child of root.children) {
child.traverse((node) => {
rest.push({
node,
position: node.position.clone(),
quaternion: node.quaternion.clone(),
scale: node.scale.clone(),
});
});
}
return rest;
}
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;
// Restore a character to the pose captured at mixer creation — the
// "default state" shown wherever no clip covers the playhead.
private resetToRestPose(characterUuid: string): void {
const rest = this.restPoses.get(characterUuid);
if (!rest) return;
for (const { node, position, quaternion, scale } of rest) {
node.position.copy(position);
node.quaternion.copy(quaternion);
node.scale.copy(scale);
}
}
// True if at least one of the clip's tracks names a node that exists under
@@ -231,7 +400,11 @@ export class CharacterAnimationManager {
});
}
// Lane targets are always top-level scene roots (drops, clicks and baked
// adds all attach to outliner-level objects) — no deep traverse needed.
private findObject(uuid: string): THREE.Object3D | undefined {
return this.editor.activeScene.scene.getObjectByProperty("uuid", uuid);
return this.editor.activeScene.scene.children.find(
(child) => child.uuid === uuid,
);
}
}
@@ -22,6 +22,7 @@ import { CameraController } from "./editor/CameraController";
import { HistoryManager } from "./editor/HistoryManager";
import { TimelineController } from "./editor/TimelineController";
import { CharacterAnimationManager } from "./animation/CharacterAnimationManager";
import { SkeletonHelperController } from "./editor/SkeletonHelperController";
import { DeleteAction } from "./editor/actions/DeleteAction";
import { TransformAction } from "./editor/actions/TransformAction";
@@ -38,6 +39,8 @@ import {
GridVisibleChangedEvent,
CameraViewToggleRequestedEvent,
InspectorPanelChangedEvent,
OutlinerRefreshedEvent,
PoseModeChangedEvent,
SceneLoadedEvent,
SceneResetEvent,
TransformSpaceChangedEvent,
@@ -110,6 +113,13 @@ class Editor {
// mesh in/out of the THREE.js scene). Cleared on unmountEngine.
private gridSubscription: () => void;
private cameraViewSubscription: () => void;
private skeletonSyncSubscription: () => void;
private poseModeSubscription: () => void;
// Persistent per-object skeleton overlays (outliner bone icon).
readonly skeletonHelpers = new SkeletonHelperController(
() => this.activeScene.scene,
);
// Holds the in-flight transform action between gizmo dragstart and
// dragend. Null whenever no drag is in progress.
@@ -196,6 +206,31 @@ class Editor {
() => this.cameraController.switchCameraView(),
);
// Skeleton overlays and animation runtimes reconcile on every outliner
// refresh — add/delete/load/new-scene all funnel through one, so no
// bespoke lifecycle hooks. The runtime revalidation matters for objects
// recreated under the same uuid (delete→undo, in-session scene reloads),
// whose mixers would otherwise stay bound to the detached old instance.
this.skeletonSyncSubscription = this.bus.subscribe(
OutlinerRefreshedEvent,
() => {
this.skeletonHelpers.sync();
this.characterAnimationManager.revalidate();
},
);
// FK/pose mode draws its own rig overlay for the posed character —
// suppress our persistent helper for it so bones aren't double-drawn,
// and restore when pose mode exits.
this.poseModeSubscription = this.bus.subscribe(PoseModeChangedEvent, (e) => {
if (e.mode === "pose") {
this.skeletonHelpers.suppress(
this.mouse_controls?.selected?.[0]?.uuid ?? null,
);
} else {
this.skeletonHelpers.suppress(null);
}
});
// PostProcessingPipeline must exist before Scene because Scene's
// load paths invoke updateSurfaceIdAttributeToMesh as a callback.
this.postProcessing = new PostProcessingPipeline();
@@ -261,6 +296,7 @@ class Editor {
getMediaUrlsByTokens: this.adapter.getMediaUrlsByTokens
? (tokens) => this.adapter.getMediaUrlsByTokens!(tokens)
: undefined,
onGlbLoaded: () => this.selection.refreshOutliner(),
},
);
this.activeScene.initialize();
@@ -311,6 +347,7 @@ class Editor {
getTimeline: () => this.timelineController.getTimeline(),
loadTimeline: (timeline) =>
this.timelineController.loadTimeline(timeline),
resetTimeline: () => this.timelineController.reset(),
getRenderCameraTransform: () => {
const cam = this.cameraController.cam_obj;
if (!cam) return null;
@@ -826,6 +863,9 @@ class Editor {
public async newScene(sceneTitleInput: string) {
this.activeScene.clear();
// A fresh scene starts with a fresh timeline — the previous scene's
// tracks and clip strips must not carry over.
this.timelineController.reset();
this.cameraController.cam_obj = this.activeScene.get_object_by_name(
this.cameraController.camera_name,
);
@@ -1014,6 +1054,16 @@ class Editor {
}
}
// Liveness for late async callbacks that captured this instance (e.g. a
// queued animation drop resolving after its clip fetch): false once
// unmountEngine ran — including the EngineProvider teardown/recreate
// footgun, where this instance is abandoned for a fresh Editor. A
// same-instance remount flips it back, and completing against a
// remounted editor is correct.
get isLive(): boolean {
return this.isMounted;
}
remountEngine() {
const store = usePageSceneStore.getState();
if (!store.is3DEditorInitialized) {
@@ -1054,6 +1104,9 @@ class Editor {
this.bus.emit(new EngineInitializedEvent(false));
this.gridSubscription();
this.cameraViewSubscription();
this.skeletonSyncSubscription();
this.poseModeSubscription();
this.skeletonHelpers.clear();
this.storeBridge.dispose();
console.log("3D Editor Engine unmounted");
}
@@ -0,0 +1,109 @@
import * as THREE from "three";
// Persistent per-object skeleton visualization, toggled from the outliner's
// bone icon. Independent of FK/pose mode's own transient SkeletonHelper
// (FKHelper): while FK mode targets an object, this controller's helper for
// that object is suppressed so the rig isn't double-drawn, and restored when
// FK mode exits — the user's toggle state is never lost.
//
// Helpers live on LAYER 1 (editor-only). The render camera renders layer 0
// exclusively, so skeletons never appear in captures/recordings.
//
// The on/off flag lives in object.userData.skeletonVisible, which rides the
// scene JSON's user_data serialization (with an explicit restore in the
// proxy load path), so it survives save/load. Reconciliation is driven off
// OutlinerRefreshedEvent — every add/delete/load/new-scene funnels through
// an outliner refresh, so sync() needs no bespoke lifecycle hooks.
export class SkeletonHelperController {
private helpers = new Map<string, THREE.SkeletonHelper>();
private suppressedUuid: string | null = null;
constructor(private readonly getScene: () => THREE.Scene) {}
isVisible(uuid: string): boolean {
return this.find(uuid)?.userData?.skeletonVisible === true;
}
setVisible(uuid: string, visible: boolean): void {
const obj = this.find(uuid);
if (!obj) return;
obj.userData.skeletonVisible = visible;
this.sync();
}
// Hide one object's helper while FK mode draws its own rig overlay for it
// (null = nothing suppressed). The userData flag is untouched, so the
// helper reappears when the suppression lifts.
suppress(uuid: string | null): void {
this.suppressedUuid = uuid;
this.applyVisibility();
}
// Reconcile helpers with the scene: create for flagged objects (once
// their bones exist), drop helpers whose object vanished or was
// unflagged. Idempotent and cheap.
//
// A helper is also considered dead when it is ORPHANED (in-session scene
// reloads — loadScene/loadCache/Reset — strip every scene child wholesale;
// the map survives, the helper doesn't) or bound to a REPLACED object
// instance (delete→undo recreates objects under their saved uuids). Both
// are purged and recreated fresh so the toggle keeps working across
// reloads instead of flipping visibility on a disposed detached helper.
sync(): void {
const scene = this.getScene();
const wanted = new Map<string, THREE.Object3D>();
for (const child of scene.children) {
if (child.userData?.skeletonVisible === true) {
wanted.set(child.uuid, child);
}
}
for (const [uuid, helper] of [...this.helpers]) {
const obj = wanted.get(uuid);
if (!obj || helper.parent !== scene || helper.root !== obj) {
// parent-based removal: after Scene.initialize() the helper may
// still hang off the PREVIOUS THREE.Scene instance.
helper.parent?.remove(helper);
helper.dispose();
this.helpers.delete(uuid);
}
}
for (const [uuid, obj] of wanted) {
if (this.helpers.has(uuid)) continue;
if (!this.hasBones(obj)) continue; // GLB still loading — next sync
const helper = new THREE.SkeletonHelper(obj);
helper.layers.set(1); // editor-only: excluded from the render camera
scene.add(helper);
this.helpers.set(uuid, helper);
}
this.applyVisibility();
}
clear(): void {
const scene = this.getScene();
for (const helper of this.helpers.values()) {
scene.remove(helper);
helper.dispose();
}
this.helpers.clear();
this.suppressedUuid = null;
}
private applyVisibility(): void {
for (const [uuid, helper] of this.helpers) {
helper.visible = uuid !== this.suppressedUuid;
}
}
private hasBones(obj: THREE.Object3D): boolean {
let found = false;
obj.traverse((child) => {
if ((child as THREE.Bone).isBone) found = true;
});
return found;
}
// Top-level scene objects only — helpers attach to outliner-level roots.
private find(uuid: string): THREE.Object3D | undefined {
return this.getScene().children.find((child) => child.uuid === uuid);
}
}
@@ -77,6 +77,26 @@ export class TimelineController {
this.emitPlayhead();
}
// Reset to a fresh empty timeline (scene switch / new scene). Unlike
// create() — a no-op when a timeline already exists — this REPLACES
// whatever is loaded, dropping tracks, strips and their runtimes, so a
// scene saved without a timeline (or a brand-new scene) never inherits
// the previous scene's animation data.
reset(): void {
this.timeline = {
duration: DEFAULT_TIMELINE_DURATION,
fps: DEFAULT_TIMELINE_FPS,
tracks: [],
clipLanes: [],
};
this.saved = cloneTimeline(this.timeline);
this.playhead = 0;
this.isPlaying = false;
this.syncClipLanes(); // disposes every lane runtime, prunes mixers
this.emitChanged();
this.emitPlayhead();
}
// Replace the live timeline wholesale (used by undo/redo of a save).
loadTimeline(data: TimelineData): void {
this.timeline = cloneTimeline(data);
@@ -120,13 +140,44 @@ export class TimelineController {
}
// Set the max timeline duration (seconds). Clamped to [1, 60] — covers the
// required 5–30s range with headroom. Keeps the playhead in range.
// required 5–30s range with headroom. Keeps the playhead in range, and
// re-fits clip strips into the new range: a shrink used to strand strips
// beyond the end — rendered outside the lane, unreachable by playback, and
// trimming one computed a NEGATIVE max duration (clamping it to a frame).
setDuration(seconds: number): void {
if (!this.timeline || !Number.isFinite(seconds)) return;
this.timeline.duration = Math.max(1, Math.min(60, seconds));
if (this.playhead > this.timeline.duration) {
this.playhead = this.timeline.duration;
const duration = Math.max(1, Math.min(60, seconds));
this.timeline.duration = duration;
if (this.playhead > duration) {
this.playhead = duration;
}
// Ascending order so earlier strips claim room first; a strip longer
// than the whole timeline shrinks to fit, and resolveFreeStart re-snaps
// anything now out of range into the nearest free slot.
const lanes = [...this.timeline.clipLanes].sort(
(a, b) => a.strip.startTime - b.strip.startTime,
);
let lanesChanged = false;
for (const lane of lanes) {
const strip = lane.strip;
if (strip.startTime + strip.duration <= duration) continue;
lanesChanged = true;
if (strip.duration > duration) {
strip.duration = duration;
strip.autoDuration = false;
}
const freeStart = this.resolveFreeStart(
lane.objectUuid,
lane.id,
strip.startTime,
strip.duration,
);
// No free slot in the shrunken timeline: park the strip flush with the
// end; overlaps (if any) resolve on the next add/move like restore's.
strip.startTime = freeStart ?? Math.max(0, duration - strip.duration);
}
if (lanesChanged) this.syncClipLanes();
this.evaluate();
this.emitChanged();
this.emitPlayhead();
}
@@ -238,7 +289,8 @@ export class TimelineController {
// Add a new stacked clip lane for `objectUuid` (a character). The clip is
// dropped at `startTime`, clamped so it starts within the timeline. Returns
// the new lane id, or null if there is no timeline.
// the new lane id, or null if there is no timeline or no free slot left on
// the character's row (callers surface that to the user).
addClipLane(objectUuid: string, strip: Omit<ClipStrip, "id">): string | null {
if (!this.timeline) return null;
// Characters hold clips on a single row: snap the drop to the nearest free
@@ -249,6 +301,7 @@ export class TimelineController {
strip.startTime,
strip.duration,
);
if (startTime === null) return null;
const lane: ClipLane = {
id: THREE.MathUtils.generateUUID(),
objectUuid,
@@ -267,12 +320,16 @@ export class TimelineController {
if (!this.timeline) return;
const lane = this.timeline.clipLanes.find((l) => l.id === laneId);
if (!lane) return;
lane.strip.startTime = this.resolveFreeStart(
const freeStart = this.resolveFreeStart(
lane.objectUuid,
laneId,
startTime,
lane.strip.duration,
);
// No free slot for the drag target — keep the strip where it was (its
// current position is always valid since it's excluded from the search).
if (freeStart === null) return;
lane.strip.startTime = freeStart;
this.syncClipLanes();
this.evaluate();
this.emitChanged();
@@ -311,30 +368,73 @@ export class TimelineController {
}
// Called by CharacterAnimationManager once a clip GLB has loaded and its
// real length is known. Adopts the natural length only for a fresh drop
// (autoDuration still set) — a user-trimmed strip keeps its hand-set length.
// real length is known. Fresh strips default to a compact width
// (DEFAULT_CLIP_DURATION in actions/timeline.ts); if the clip is actually
// SHORTER than that placeholder, shrink the strip so it never claims time
// the clip can't fill. Never grows — a long clip stays at the compact
// width until the user trims it out. User-trimmed strips are untouched.
resolveClipDuration(laneId: string, naturalDuration: number): void {
if (!this.timeline || !(naturalDuration > 0)) return;
const lane = this.timeline.clipLanes.find((l) => l.id === laneId);
if (!lane || lane.strip.autoDuration === false) return;
// Adopt the natural length but never let it overrun the next clip or the
// timeline end (single-row, non-overlapping).
const nextStart = this.nextStartAfter(
lane.objectUuid,
laneId,
lane.strip.startTime,
);
const maxDur = nextStart - lane.strip.startTime;
lane.strip.duration = Math.min(naturalDuration, maxDur);
lane.strip.duration = Math.min(lane.strip.duration, naturalDuration);
lane.strip.autoDuration = false;
// Keep the runtime's strip window in sync with the (possibly shrunk) width.
this.syncClipLanes();
this.emitChanged();
}
// Removal is SESSION-INDEPENDENT (it carries its own undo step via
// RemoveClipLaneAction), so it is mirrored into the `saved` snapshot too:
// otherwise Cancel would resurrect the deleted strip and the next Save
// would double-record the removal (its before-state still holding the
// strip costs the user a phantom undo step).
removeClipLane(laneId: string): void {
if (!this.timeline) return;
this.timeline.clipLanes = this.timeline.clipLanes.filter(
(l) => l.id !== laneId,
);
if (this.saved) {
this.saved.clipLanes = this.saved.clipLanes.filter(
(l) => l.id !== laneId,
);
}
this.syncClipLanes();
this.evaluate();
this.emitChanged();
}
getClipLane(laneId: string): ClipLane | null {
return this.timeline?.clipLanes.find((l) => l.id === laneId) ?? null;
}
// The lane's entry in the `saved` snapshot (which may hold an older
// placement than the live one, or none if the strip was added after the
// last save). RemoveClipLaneAction captures it so undo can restore BOTH
// worlds exactly.
getSavedClipLane(laneId: string): ClipLane | null {
const lane = this.saved?.clipLanes.find((l) => l.id === laneId);
return lane ? JSON.parse(JSON.stringify(lane)) : null;
}
// Re-insert a previously removed lane verbatim (undo of a removal). The
// strip returns to its exact prior slot; in the rare case another clip was
// moved onto that slot after the delete, the strips can momentarily
// overlap — the overlap guard re-applies on the next add/move.
// `savedLane` (when the strip existed in the last-saved snapshot) undoes
// the removal's `saved` mirroring at its saved-time placement, so a later
// Cancel restores exactly the pre-removal saved state.
restoreClipLane(lane: ClipLane, savedLane?: ClipLane): void {
if (!this.timeline) return;
if (this.timeline.clipLanes.some((l) => l.id === lane.id)) return;
this.timeline.clipLanes.push(JSON.parse(JSON.stringify(lane)));
if (
savedLane &&
this.saved &&
!this.saved.clipLanes.some((l) => l.id === savedLane.id)
) {
this.saved.clipLanes.push(JSON.parse(JSON.stringify(savedLane)));
}
this.syncClipLanes();
this.evaluate();
this.emitChanged();
@@ -405,12 +505,13 @@ export class TimelineController {
// Snap `desiredStart` to the nearest position where a `duration`-long strip
// fits without overlapping the character's other clips, within the timeline.
// Used by add + move so a character's clips stay a non-overlapping sequence.
// Returns null when the row has no free slot for `duration` at all.
private resolveFreeStart(
characterUuid: string,
exceptLaneId: string | null,
desiredStart: number,
duration: number,
): number {
): number | null {
if (!this.timeline) return desiredStart;
const maxStart = Math.max(0, this.timeline.duration - duration);
const clamp = (s: number) => Math.max(0, Math.min(s, maxStart));
@@ -426,7 +527,7 @@ export class TimelineController {
const valid = candidates
.map(clamp)
.filter((s) => !this.overlapsAny(s, duration, others));
if (valid.length === 0) return desired; // no room — accept the clamp
if (valid.length === 0) return null; // row is full — reject the drop
valid.sort((a, b) => Math.abs(a - desired) - Math.abs(b - desired));
return valid[0];
}
@@ -0,0 +1,35 @@
import type { UndoableAction } from "../HistoryManager";
import type { TimelineController } from "../TimelineController";
import type { ClipLane } from "../../timeline/types";
// Removing a clip strip (the strip's × or Del/Backspace) gets its own undo
// step — unlike other clip edits, which ride the timeline Save/Cancel
// session, a deletion is destructive enough to deserve immediate undo. To
// keep that session-independence honest, removeClipLane also strips the
// lane from the controller's `saved` snapshot (else Cancel would resurrect
// it and the next Save would double-record the removal); this action
// therefore captures BOTH the live lane and its saved-snapshot entry (which
// may differ in placement, or be absent for a never-saved strip) so undo
// restores both worlds exactly. Snapshots are deep-cloned at construction
// so later mutations can't corrupt them.
export class RemoveClipLaneAction implements UndoableAction {
readonly label = "Remove Animation Clip";
private readonly lane: ClipLane;
private readonly savedLane: ClipLane | null;
constructor(
private readonly controller: TimelineController,
lane: ClipLane,
) {
this.lane = JSON.parse(JSON.stringify(lane));
this.savedLane = controller.getSavedClipLane(lane.id);
}
apply(): void {
this.controller.removeClipLane(this.lane.id);
}
revert(): void {
this.controller.restoreClipLane(this.lane, this.savedLane ?? undefined);
}
}
@@ -6,7 +6,7 @@ import { StoryTellerProxyScene } from "../proxy/storyteller_proxy_scene";
import { CameraAspectRatio } from "../enums";
import type Scene from "./scene";
import type { EngineEventBus } from "./events/EngineEventBus";
import type { TimelineData } from "./timeline/types";
import { cloneTimeline, type TimelineData } from "./timeline/types";
import {
CameraAspectRatioChangedEvent,
CamerasReplacedEvent,
@@ -75,6 +75,9 @@ export type SaveManagerDeps = {
// Animation timeline persistence (in-memory controller ↔ scene JSON).
getTimeline: () => TimelineData | null;
loadTimeline: (timeline: TimelineData) => void;
// Fresh empty timeline — used when the loaded scene carries no usable
// timeline, so the previous scene's animation data is never inherited.
resetTimeline: () => void;
// Live render-camera (::CAM:: frustum) transform, read at save time so the
// persisted render-camera position reflects the actual scene object rather
@@ -167,11 +170,29 @@ export class SaveManager {
};
});
// The in-memory timeline retains tracks/clip lanes for deleted objects
// (so undoing the delete restores its strips), but those orphans must
// not outlive the session: filter anything whose object has no live
// scene root, on a CLONE — saves stay clean and reloads can't resurrect
// orphans, while the live timeline (and delete-undo) is untouched.
const liveTimeline = this.deps.getTimeline();
let timeline: TimelineData | null = null;
if (liveTimeline) {
const liveUuids = new Set(scene.scene.children.map((c) => c.uuid));
timeline = cloneTimeline(liveTimeline);
timeline.tracks = timeline.tracks.filter((t) =>
liveUuids.has(t.objectUuid),
);
timeline.clipLanes = timeline.clipLanes.filter((l) =>
liveUuids.has(l.objectUuid),
);
}
return {
version,
scene: scene_json,
...sceneGenerationMetadata,
timeline: this.deps.getTimeline() ?? null,
timeline,
skybox: scene.skybox,
camera_data: {
position: camera?.position,
@@ -319,7 +340,9 @@ export class SaveManager {
this.deps.refreshCamObj();
// Restore the animation timeline. Guard legacy scenes where `timeline`
// was a `""` stub (or absent/null).
// was a `""` stub (or absent/null) — those RESET rather than fall
// through, so the previous scene's tracks/strips are never inherited
// by a scene that saved none.
const timeline = scene_json["timeline"];
if (
timeline &&
@@ -327,6 +350,8 @@ export class SaveManager {
Array.isArray(timeline.tracks)
) {
this.deps.loadTimeline(timeline);
} else {
this.deps.resetTimeline();
}
return true;
@@ -36,6 +36,11 @@ export type SceneDeps = {
// Scene.warmMediaURLs falls back to Promise.all of the single-token
// method.
getMediaUrlsByTokens?: (tokens: string[]) => Promise<Record<string, string>>;
// Fired after a GLB finishes loading into the scene. Outliner metadata
// (hasSkeleton, bakedClips) is computed at refresh time, so the editor
// uses this to refresh rows that were rendered before the async load
// completed (e.g. saved-scene proxy loads).
onGlbLoaded?: () => void;
};
class Scene {
@@ -933,6 +938,7 @@ class Scene {
if (glb.animations.length > 0) {
child_result.animations = glb.animations;
}
this.deps.onGlbLoaded?.();
}
this.updateSurfaceIdAttributeToMesh(this.scene);
return child_result;
@@ -24,6 +24,15 @@ export type SceneObject = {
visible: boolean;
locked: boolean;
isCamera: boolean;
// True when the object contains a SkinnedMesh — it can accept skeletal
// animation clips (subject to the bone-name bind check).
hasSkeleton: boolean;
// Whether the persistent skeleton overlay is toggled on (userData flag).
skeletonVisible: boolean;
// Display names of the clips baked into the object's own GLB
// (object.animations), in index order. Empty for objects without baked
// animations.
bakedClips: string[];
};
export interface SceneManagerAPI {
@@ -223,6 +232,13 @@ export class SceneManager implements SceneManagerAPI {
if (locked == undefined) {
locked = false;
}
let hasSkeleton = false;
object.traverse((child) => {
if ((child as THREE.SkinnedMesh).isSkinnedMesh) hasSkeleton = true;
});
const bakedClips = (object.animations ?? []).map(
(clip, index) => clip.name || `Clip ${index + 1}`,
);
return {
id: object.uuid,
icon: faicon,
@@ -231,6 +247,9 @@ export class SceneManager implements SceneManagerAPI {
visible: object.visible,
locked: object.userData["locked"],
isCamera: object.name == "::CAM::",
hasSkeleton,
bakedClips,
skeletonVisible: object.userData["skeletonVisible"] === true,
};
}
@@ -29,29 +29,37 @@ export interface TimelineTrack {
keyframes: Keyframe[]; // kept sorted ascending by time
}
// A skeletal animation clip (Mixamo) placed on a character's lane. Only the
// reference + placement is serialized; the actual THREE.AnimationClip is
// resolved from `sourceMediaId` and retargeted onto the character at runtime.
// An animation clip placed on an object's clip row. Only the reference +
// placement is serialized; the actual THREE.AnimationClip is resolved at
// runtime — from the `sourceMediaId` GLB for library clips, or from the
// object's own baked `animations[]` when `bakedClipIndex` is set.
export interface ClipStrip {
id: string;
sourceMediaId: string; // media id of the animation GLB (reload + retarget)
sourceMediaId: string; // media id of the animation GLB ("" for baked clips)
// Set for a clip baked into the object's own GLB: index into the object's
// `animations[]` array. Baked clips are never removed from the THREE model —
// removing/disabling a baked strip only stops scheduling it; the clip stays
// on the object (and in the row's picker) for later reuse.
bakedClipIndex?: number;
name: string; // display label
startTime: number; // seconds from timeline start
duration: number; // seconds (clip length on the timeline)
loop: boolean;
// True until the clip GLB has loaded and its real length is known. While
// true, `duration` is only a placeholder width and gets replaced by the
// clip's natural length on load. A user trim clears this so the natural
// length never clobbers a hand-set duration on reload.
// true, `duration` is the compact default width; on load the strip only
// SHRINKS (when the clip is shorter than the default) — it never grows.
// A user trim clears this so a hand-set duration survives reloads. Baked
// strips know their length up front and never set this.
autoDuration?: boolean;
}
// One animation lane under a character. Multiple lanes (stacked) = multiple
// clips on the same character; each lane holds a single clip strip. Only
// character objects get clip lanes.
// One animation lane under an object; each lane holds a single clip strip.
// All of an object's strips render on ONE row and never overlap (see the
// overlap guard in TimelineController). Rows exist for characters and for
// any object with a skeleton or baked clips.
export interface ClipLane {
id: string;
objectUuid: string; // the character this lane animates
objectUuid: string; // the object this lane animates
strip: ClipStrip;
}
@@ -281,6 +281,11 @@ export class StoryTellerProxyScene {
if (json_object.user_data?.shapeType !== undefined) {
obj.userData["shapeType"] = json_object.user_data.shapeType;
}
// Persistent skeleton-overlay toggle (outliner bone icon). The engine
// recreates the helper from this flag on the post-load outliner sync.
if (json_object.user_data?.skeletonVisible !== undefined) {
obj.userData["skeletonVisible"] = json_object.user_data.skeletonVisible;
}
if (json_object.visible !== undefined) {
this.scene.setVisible(obj.uuid, json_object.visible);
}
@@ -45,8 +45,11 @@ export enum MediaFileAnimationType {
}
// Accepted file extensions (lowercase — the backend rejects uppercase).
// FBX is accepted at the picker but converted to GLB in the browser before
// preview/upload (convertFbxToGlb) — the rest of the pipeline is GLTF-only.
export enum OBJECT_FILE_TYPE {
GLB = "glb",
FBX = "fbx",
}
export enum IMAGEPLANE_FILE_TYPE {
@@ -65,5 +68,12 @@ export const getFileName = (file: File): string =>
export const getFileExtension = (file: File): string =>
file.name.substring(file.name.lastIndexOf("."));
// Per-file row status shown in the multi-file upload sidebar.
export type FileEntryStatus = "idle" | "uploading" | "success" | "error";
// Per-file row status shown in the multi-file upload sidebar. "converting"
// = an FBX being normalized to GLB client-side before it can be previewed
// or uploaded.
export type FileEntryStatus =
| "idle"
| "converting"
| "uploading"
| "success"
| "error";
@@ -1,10 +1,12 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { PanelGroup, Panel, PanelResizeHandle } from "react-resizable-panels";
import { ListDropdown } from "@storyteller/ui-list-dropdown";
import { Select } from "@storyteller/ui-select";
import { Button } from "@storyteller/ui-button";
import { FileUploader } from "@storyteller/ui-file-uploader";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faBone,
faCube,
faXmark,
faCheck,
@@ -15,7 +17,12 @@ import {
faSpinner,
} from "@fortawesome/pro-solid-svg-icons";
import * as THREE from "three";
import { loadPreviewOnCanvas, snapshotCanvasAsThumbnail } from "./utilities";
import {
convertFbxToGlb,
loadPreviewOnCanvas,
readGlbAnimationDurationMillis,
snapshotCanvasAsThumbnail,
} from "./utilities";
import { upload3DObjects } from "./utilities/upload3DObjects";
import { upload3DObjectsBatch } from "./utilities/upload3DObjectsBatch";
import {
@@ -44,6 +51,9 @@ interface Props {
};
onClose: () => void;
onUploadProgress: (newState: UploaderState) => void;
// Fired when a previewed model turns out to be a mesh-less skeleton
// (animation-only export) — the modal preselects "Upload as Animation".
onMeshlessDetected?: () => void;
}
export const UploadFiles3D = ({
@@ -53,6 +63,7 @@ export const UploadFiles3D = ({
options,
onClose,
onUploadProgress,
onMeshlessDetected,
}: Props) => {
const canvasRef = useRef<HTMLCanvasElement | undefined>(undefined);
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
@@ -91,9 +102,23 @@ export const UploadFiles3D = ({
const seedFiles = initialFiles ?? [];
// FBX files are accepted at the picker but normalized to GLB in the
// browser (convertFbxToGlb) before they can be previewed or uploaded —
// they enter as "converting" and swap to the converted GLB when done.
const isFbx = (file: File) => file.name.toLowerCase().endsWith(".fbx");
const [fileEntries, setFileEntries] = useState<FileEntry[]>(
seedFiles.map((f) => ({ file: f, status: "idle" })),
seedFiles.map((f) => ({
file: f,
status: isFbx(f) ? "converting" : "idle",
})),
);
// Fresh mirrors for async completions (conversion callbacks outlive
// renders): the live entry list, and the File the preview effect is
// currently showing (the converting ORIGINAL while an FBX converts).
const fileEntriesRef = useRef(fileEntries);
fileEntriesRef.current = fileEntries;
const currentPreviewFileRef = useRef<File | null>(null);
const [previewIndex, setPreviewIndex] = useState(0);
// Incremented on every handleFilesChange so useEffect re-runs even when count stays the same
const [filesVersion, setFilesVersion] = useState(0);
@@ -108,6 +133,43 @@ export const UploadFiles3D = ({
total: number;
} | null>(null);
const [selectionError, setSelectionError] = useState<string | undefined>();
// In-flight submission guard (see handleSubmit): the state drives the
// Upload button, the ref blocks same-tick re-entry before the disabled
// state has re-rendered.
const [isSubmitting, setIsSubmitting] = useState(false);
const isSubmittingRef = useRef(false);
// Baked-animation preview: clip names reported by the loader (empty for
// still models — the picker only renders when there are clips), the
// selected clip (-1 = none/T-pose; the first clip autoplays), and the
// loader's switch function for the current preview.
const [previewAnimations, setPreviewAnimations] = useState<string[]>([]);
const [previewClip, setPreviewClip] = useState(0);
const selectAnimationRef = useRef<((index: number) => void) | null>(null);
// Skeleton overlay: offered for any rigged model — real bones OR a
// mesh-less node hierarchy (converted animation exports lose bone-ness;
// see NodeHierarchyHelper) — on by default for mesh-less ones, where
// there'd otherwise be nothing to see.
const [previewHasRig, setPreviewHasRig] = useState(false);
const [previewSkeletonVisible, setPreviewSkeletonVisible] = useState(false);
const setSkeletonVisibleRef = useRef<((visible: boolean) => void) | null>(
null,
);
const handlePreviewClipChange = (value: string | number) => {
const index = Number(value);
setPreviewClip(index);
selectAnimationRef.current?.(index);
};
const handleSkeletonToggle = () => {
setPreviewSkeletonVisible((visible) => {
const next = !visible;
setSkeletonVisibleRef.current?.(next);
return next;
});
};
const disposeRenderer = () => {
if (rendererRef.current) {
@@ -117,25 +179,107 @@ export const UploadFiles3D = ({
}
};
// Normalize an FBX to GLB in the background. Completion swaps the entry's
// File by identity, so it's naturally a no-op if the user re-picked or
// removed the file mid-conversion.
const beginFbxConversion = (original: File) => {
convertFbxToGlb(original)
.then((converted) => {
// Discarded pick (re-picked/removed mid-conversion): nothing to
// swap, and no reason to reload anyone's preview.
if (!fileEntriesRef.current.some((e) => e.file === original)) return;
setFileEntries((prev) =>
prev.map((entry) =>
entry.file === original
? { file: converted, status: "idle" as FileEntryStatus }
: entry,
),
);
// Re-run the preview effect ONLY when the converted entry is the
// one being previewed — a blanket bump reloaded the current
// preview once per completed conversion in a multi-FBX pick.
if (currentPreviewFileRef.current === original) {
setFilesVersion((v) => v + 1);
}
})
.catch((error) => {
setFileEntries((prev) =>
prev.map((entry) =>
entry.file === original
? {
...entry,
status: "error" as FileEntryStatus,
errorMessage: `FBX conversion failed: ${String(error)}`,
}
: entry,
),
);
});
};
// Kick off conversions for any FBX files seeded via initialFiles.
useEffect(() => {
const currentFile = fileEntries[previewIndex]?.file;
seedFiles.filter(isFbx).forEach(beginFbxConversion);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const currentEntry = fileEntries[previewIndex];
const currentFile = currentEntry?.file;
currentPreviewFileRef.current = currentFile ?? null;
if (!canvasRef.current || !currentFile) return;
// Every preview transition starts from a clean slate — including the
// early return below: leaving the previous model's clip dropdown and
// bone toggle rendered over the "Converting FBX…" overlay had them
// operating on the disposed previous scene.
disposeRenderer();
setPreviewStatus({ type: "init" });
setPreviewAnimations([]);
setPreviewClip(0);
setPreviewHasRig(false);
setPreviewSkeletonVisible(false);
selectAnimationRef.current = null;
setSkeletonVisibleRef.current = null;
const { renderer, camera } = loadPreviewOnCanvas({
file: currentFile,
canvas: canvasRef.current,
statusCallback: setPreviewStatus,
});
// Nothing to preview until an FBX has been converted (the loader can't
// parse FBX; a failed conversion also stays un-previewed).
if (currentEntry.status === "converting" || isFbx(currentFile)) {
return;
}
const { renderer, camera, selectAnimation, setSkeletonVisible, cancel } =
loadPreviewOnCanvas({
file: currentFile,
canvas: canvasRef.current,
statusCallback: setPreviewStatus,
onAnimationsAvailable: setPreviewAnimations,
onModelInfo: (info) => {
if (!info.hasMesh) onMeshlessDetected?.();
setPreviewHasRig(info.hasBones || !info.hasMesh);
setPreviewSkeletonVisible(!info.hasMesh);
},
});
rendererRef.current = renderer;
cameraRef.current = camera;
selectAnimationRef.current = selectAnimation;
setSkeletonVisibleRef.current = setSkeletonVisible;
return disposeRenderer;
return () => {
// Cancel BEFORE disposing: an in-flight load's callbacks must not
// render on the disposed renderer or write into the next preview's
// status/animation/rig state (or its thumbnail).
cancel();
disposeRenderer();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [previewIndex, filesVersion]);
// Deliberately keyed on previewStatus ONLY: each load produces its own
// init→OK transition, so this fires exactly once per successful load. A
// previewIndex dep would also fire it on a bare index switch while the
// PREVIOUS load's "OK" lingers — snapshotting file A's frame under file
// B's key.
useEffect(() => {
if (previewStatus.type === "OK" && canvasRef.current) {
snapshotCanvasAsThumbnail({
@@ -148,7 +292,8 @@ export const UploadFiles3D = ({
},
});
}
}, [previewStatus, previewIndex]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [previewStatus]);
useEffect(() => {
if (!fileSubtypes || fileSubtypes.length === 0) {
@@ -160,14 +305,18 @@ export const UploadFiles3D = ({
);
}, [fileSubtypes]);
const updateFileStatus = (
index: number,
// Status updates are keyed by FILE IDENTITY, never by row index: async
// upload/parse callbacks outlive list mutations (removing a row, re-picks),
// and a positional index captured before an await would land the status on
// whatever row shifted into that slot.
const updateFileStatusByFile = (
file: File,
status: FileEntryStatus,
errorMessage?: string,
) => {
setFileEntries((prev) =>
prev.map((entry, i) =>
i === index ? { ...entry, status, errorMessage } : entry,
prev.map((entry) =>
entry.file === file ? { ...entry, status, errorMessage } : entry,
),
);
};
@@ -183,70 +332,201 @@ export const UploadFiles3D = ({
setFilesVersion((v) => v + 1);
};
// Resolve the backend-required clip durations for an animation upload.
// Files with no clips are marked as errors (the backend would reject them)
// and excluded from the returned map.
const resolveAnimationDurations = async (
files: File[],
): Promise<Map<File, number>> => {
const durations = new Map<File, number>();
await Promise.all(
files.map(async (file) => {
// A parse REJECTION (corrupt file) and a clip-less null are
// different problems — conflating them sent users hunting for
// missing clips in files that simply couldn't be read.
let millis: number | null;
let unreadable = false;
try {
millis = await readGlbAnimationDurationMillis(file);
} catch {
millis = null;
unreadable = true;
}
if (millis === null) {
updateFileStatusByFile(
file,
"error",
unreadable
? "Couldn't read this file — it may be corrupt."
: "No animation clips found in this file.",
);
} else {
durations.set(file, millis);
}
}),
);
return durations;
};
const isAnimationUpload =
engineCategory === FilterEngineCategories.ANIMATION;
const retrySingleFile = async (index: number) => {
const entry = fileEntries[index];
if (!entry || entry.status === "uploading") return;
updateFileStatus(index, "uploading");
if (!entry || entry.status === "uploading" || entry.status === "converting")
return;
// A failed FBX conversion retries the conversion, not an upload — the
// raw FBX must never reach the backend.
if (isFbx(entry.file)) {
updateFileStatusByFile(entry.file, "converting");
beginFbxConversion(entry.file);
return;
}
// Mark uploading BEFORE the duration parse: leaving the row on "error"
// through that await kept the retry button live, so rapid clicks ran
// duplicate uploads.
updateFileStatusByFile(entry.file, "uploading");
let durationMillis: number | undefined;
if (isAnimationUpload) {
let millis: number | null;
let unreadable = false;
try {
millis = await readGlbAnimationDurationMillis(entry.file);
} catch {
millis = null;
unreadable = true;
}
if (millis === null) {
updateFileStatusByFile(
entry.file,
"error",
unreadable
? "Couldn't read this file — it may be corrupt."
: "No animation clips found in this file.",
);
return;
}
durationMillis = millis;
}
await upload3DObjects({
title: entry.file.name.slice(0, entry.file.name.lastIndexOf(".")),
assetFile: entry.file,
engineCategory,
animationType: subtype,
durationMillis,
thumbnailSnapshot: thumbnails.get(entry.file),
progressCallback: (state) => {
if (state.status === UploaderStates.success) {
updateFileStatus(index, "success");
updateFileStatusByFile(entry.file, "success");
} else if (
state.status === UploaderStates.assetError ||
state.status === UploaderStates.coverCreateError ||
state.status === UploaderStates.coverSetError
) {
updateFileStatus(index, "error", state.errorMessage);
updateFileStatusByFile(entry.file, "error", state.errorMessage);
}
},
});
};
const handleFilesChange = (files: File[]) => {
setFileEntries(files.map((f) => ({ file: f, status: "idle" })));
setFileEntries(
files.map((f) => ({
file: f,
status: isFbx(f) ? "converting" : "idle",
})),
);
setPreviewIndex(0);
setFilesVersion((v) => v + 1);
setThumbnails(new Map());
setSelectionError(undefined);
setOverallProgress(null);
setIsUploading(false);
files.filter(isFbx).forEach(beginFbxConversion);
};
const handleSubmit = () => {
const handleSubmit = async () => {
// Animation submissions await GLB parsing for hundreds of ms before any
// state changes, so a second click in that window would run the whole
// submission twice (duplicate backend assets).
if (isSubmittingRef.current) return;
isSubmittingRef.current = true;
setIsSubmitting(true);
if (fileEntries.length === 0) {
setSelectionError("Please select a file to upload.");
releaseSubmitGuard();
return;
}
if (fileEntries.some((e) => e.status === "converting")) {
setSelectionError("Still converting FBX files — one moment.");
releaseSubmitGuard();
return;
}
const pendingEntries = fileEntries
.map((e, i) => ({ entry: e, originalIndex: i }))
.filter(
({ entry }) => entry.status !== "success" && entry.file !== undefined,
);
const files = pendingEntries.map(({ entry }) => entry.file!);
const originalIndices = pendingEntries.map(
({ originalIndex }) => originalIndex,
// Un-converted FBX entries (conversion failed) are excluded — the raw
// FBX must never be uploaded.
const pendingEntries = fileEntries.filter(
(entry) =>
entry.status !== "success" &&
entry.file !== undefined &&
!isFbx(entry.file),
);
// Entries dropped from the submission (failed FBX conversions here,
// clip-less animation files below) still count against the run's
// outcome — a run that silently skips files must not report success.
let excludedCount =
fileEntries.filter((e) => e.status !== "success").length -
pendingEntries.length;
let files = pendingEntries.map((entry) => entry.file!);
if (files.length === 0) {
setSelectionError(
excludedCount > 0
? `${excludedCount} file(s) could not be converted and cannot be uploaded.`
: "Please select a file to upload.",
);
releaseSubmitGuard();
return;
}
// Animation uploads must carry the clip duration; clip-less files are
// flagged and dropped from the submission.
let durations: Map<File, number> | undefined;
if (isAnimationUpload) {
durations = await resolveAnimationDurations(files);
const keep = files.map((file) => durations!.has(file));
excludedCount += keep.filter((kept) => !kept).length;
files = files.filter((_, i) => keep[i]);
if (files.length === 0) {
setSelectionError("No animation clips found in the selected file(s).");
releaseSubmitGuard();
return;
}
}
const pendingThumbnails = new Map<File, Blob>(
files
.map((file) => [file, thumbnails.get(file) ?? undefined])
.filter((pair): pair is [File, Blob] => pair[1] !== undefined),
);
if (files.length === 1 && fileEntries.length === 1) {
// Exclusions force fileEntries.length > files.length, so this fast path
// can only ever be a clean single-file submission; the excludedCount
// check makes that invariant explicit rather than incidental.
if (excludedCount === 0 && files.length === 1 && fileEntries.length === 1) {
upload3DObjects({
title: files[0].name.slice(0, files[0].name.lastIndexOf(".")),
assetFile: files[0],
engineCategory,
animationType: subtype,
durationMillis: durations?.get(files[0]),
thumbnailSnapshot: thumbnails.get(files[0]),
progressCallback: onUploadProgress,
});
// upload3DObjects reports uploadingAsset synchronously, so the modal
// has already switched off this view — safe to release the guard.
releaseSubmitGuard();
return;
}
@@ -258,13 +538,18 @@ export const UploadFiles3D = ({
thumbnails: pendingThumbnails,
engineCategory,
animationType: subtype,
durationsMillis: durations,
onFileStatusChange: (batchIndex, status, errorMessage) =>
updateFileStatus(originalIndices[batchIndex], status, errorMessage),
updateFileStatusByFile(files[batchIndex], status, errorMessage),
onOverallProgress: (completed, total) =>
setOverallProgress({ current: completed, total }),
onComplete: (allSucceeded, anySucceeded) => {
setIsUploading(false);
if (allSucceeded) {
releaseSubmitGuard();
// allSucceeded only covers the files actually submitted; excluded
// entries count as failures, so a partial run stays on the list
// view where per-row errors and the failure count tell the truth.
if (allSucceeded && excludedCount === 0) {
onUploadProgress({ status: UploaderStates.success });
} else if (!anySucceeded) {
onUploadProgress({
@@ -276,15 +561,26 @@ export const UploadFiles3D = ({
});
};
const retryAllFailed = () => {
const failedIndices = fileEntries
.map((e, i) => (e.status === "error" ? i : -1))
.filter((i) => i !== -1);
if (failedIndices.length === 0) return;
const releaseSubmitGuard = () => {
isSubmittingRef.current = false;
setIsSubmitting(false);
};
const retryAllFailed = async () => {
// FBX conversion failures are excluded (they retry per-row as a
// re-conversion); this batch path only re-uploads real GLBs.
let failedFiles = fileEntries
.filter((e) => e.status === "error" && !isFbx(e.file))
.map((e) => e.file);
if (failedFiles.length === 0) return;
let durations: Map<File, number> | undefined;
if (isAnimationUpload) {
durations = await resolveAnimationDurations(failedFiles);
failedFiles = failedFiles.filter((file) => durations!.has(file));
if (failedFiles.length === 0) return;
}
const failedFiles = failedIndices
.map((i) => fileEntries[i].file!)
.filter((file) => file !== undefined);
const failedThumbnails = new Map<File, Blob>(
failedFiles
.map((file): [File, Blob | undefined] => [
@@ -302,8 +598,9 @@ export const UploadFiles3D = ({
thumbnails: failedThumbnails,
engineCategory,
animationType: subtype,
durationsMillis: durations,
onFileStatusChange: (batchIndex, status, errorMessage) =>
updateFileStatus(failedIndices[batchIndex], status, errorMessage),
updateFileStatusByFile(failedFiles[batchIndex], status, errorMessage),
onOverallProgress: (completed, total) =>
setOverallProgress({ current: completed, total }),
onComplete: (allSucceeded, anySucceeded) => {
@@ -320,14 +617,75 @@ export const UploadFiles3D = ({
});
};
// Overlayed on the preview canvas: bone toggle for rigged models and the
// clip picker for models with baked animations; still models get no extra
// chrome. Shared by the single and multi layouts.
const animationPicker = (previewAnimations.length > 0 ||
previewHasRig) && (
<div className="pointer-events-auto absolute right-2 top-2 z-10 flex items-start gap-2">
{previewHasRig && (
<button
type="button"
title={
previewSkeletonVisible ? "Hide skeleton" : "Show skeleton"
}
onClick={handleSkeletonToggle}
className={`flex h-10 w-10 items-center justify-center rounded-md border transition-colors ${
previewSkeletonVisible
? "border-primary bg-primary/20 text-white"
: "border-ui-controls-border bg-ui-controls text-white/70 hover:text-white"
}`}
>
<FontAwesomeIcon icon={faBone} />
</button>
)}
{previewAnimations.length > 0 && (
<div className="w-44">
<Select
value={String(previewClip)}
onChange={handlePreviewClipChange}
options={[
...previewAnimations.map((name, index) => ({
label: name,
value: String(index),
})),
{ label: "T-pose (none)", value: "-1" },
]}
/>
</div>
)}
</div>
);
const isMulti = fileEntries.length > 1;
const anyFailed = fileEntries.some((e) => e.status === "error");
const anyUploading = fileEntries.some((e) => e.status === "uploading");
const hasUploadStarted = fileEntries.some((e) => e.status !== "idle");
const anyConverting = fileEntries.some((e) => e.status === "converting");
// "Started" means an actual upload — FBX conversion (and its failures)
// must not hide the Upload button.
const hasUploadStarted = fileEntries.some(
(e) => e.status === "uploading" || e.status === "success",
);
const allDone =
fileEntries.length > 0 &&
fileEntries.every((e) => e.status === "success" || e.status === "error");
const currentFile = fileEntries[previewIndex]?.file;
const isConvertingCurrent =
fileEntries[previewIndex]?.status === "converting";
// Overlayed on the preview canvas while the current FBX is normalizing,
// or when its conversion failed (the sidebar only exists in multi mode).
const currentEntry = fileEntries[previewIndex];
const convertingOverlay = isConvertingCurrent ? (
<h6 className="pointer-events-none absolute left-0 top-1/2 -mt-5 flex w-full items-center justify-center gap-2.5 text-center opacity-60">
<FontAwesomeIcon icon={faSpinner} className="animate-spin" />
Converting FBX to GLB...
</h6>
) : currentEntry?.status === "error" && isFbx(currentEntry.file) ? (
<h6 className="pointer-events-none absolute left-0 top-1/2 -mt-5 w-full px-4 text-center text-red-400">
{currentEntry.errorMessage ?? "FBX conversion failed."}
</h6>
) : null;
return (
<div className="flex flex-col gap-3">
@@ -376,7 +734,8 @@ export const UploadFiles3D = ({
<FontAwesomeIcon icon={faXmark} />
</button>
)}
{entry.status === "uploading" && (
{(entry.status === "uploading" ||
entry.status === "converting") && (
<FontAwesomeIcon
icon={faSpinner}
className="animate-spin opacity-60"
@@ -426,6 +785,8 @@ export const UploadFiles3D = ({
className="pointer-events-none h-full min-h-48 !w-full"
ref={canvasCallbackRef}
/>
{animationPicker}
{convertingOverlay}
{!currentFile && (
<h6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 flex w-full items-center justify-center gap-2.5 text-center opacity-50">
<FontAwesomeIcon icon={faCube} />
@@ -472,6 +833,8 @@ export const UploadFiles3D = ({
className="pointer-events-none h-full min-h-48 !w-full"
ref={canvasCallbackRef}
/>
{animationPicker}
{convertingOverlay}
{!currentFile && (
<h6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 flex w-full items-center justify-center gap-2.5 text-center opacity-50">
<FontAwesomeIcon icon={faCube} />
@@ -514,9 +877,13 @@ export const UploadFiles3D = ({
<Button
variant="primary"
onClick={handleSubmit}
disabled={fileEntries.length === 0}
disabled={fileEntries.length === 0 || anyConverting || isSubmitting}
>
Upload
{anyConverting
? "Converting..."
: isSubmitting
? "Uploading..."
: "Upload"}
</Button>
)}
</div>
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Checkbox } from "@storyteller/ui-checkbox";
import { LoadingDots } from "@storyteller/ui-loading";
import { Modal } from "@storyteller/ui-modal";
@@ -26,6 +26,12 @@ interface Props {
title: string;
titleIcon: IconDefinition;
initialFiles?: File[];
// Preselects the matching "Upload as ..." toggle on open (the user can
// still change it) and, being a scoped open, skips auto-opening the
// gallery on success — the scoped caller shows the result itself.
// Literal strings so callers aren't coupled to this lib's
// FilterEngineCategories declaration.
initialCategory?: "animation" | "character";
options?: {
fileSubtypes?: { [key: string]: string }[];
hasLength?: boolean;
@@ -36,29 +42,46 @@ interface Props {
const objectFileTypes = Object.values(OBJECT_FILE_TYPE);
export function UploadModal3D(props: Props) {
const { isOpen, onClose, onSuccess, title, titleIcon, initialFiles, options } = props;
const {
isOpen,
onClose,
onSuccess,
title,
titleIcon,
initialFiles,
initialCategory,
options,
} = props;
const [uploaderState, setUploaderState] =
useState<UploaderState>(initialUploaderState);
const [isCharacter, setIsCharacter] = useState(false);
const [isAnimation, setIsAnimation] = useState(false);
// Once the user has touched either toggle, mesh-less auto-detection stops
// overriding their choice.
const userTouchedCategory = useRef(false);
const selectedCategory = isCharacter
? FilterEngineCategories.CHARACTER
: FilterEngineCategories.OBJECT;
const selectedCategory = isAnimation
? FilterEngineCategories.ANIMATION
: isCharacter
? FilterEngineCategories.CHARACTER
: FilterEngineCategories.OBJECT;
const characterAnimationOptions = useMemo(() => {
if (!isCharacter) return undefined;
// Rig-type options for character AND animation uploads; the default
// (first entry) differs — characters lead with Mixamo ArKit, plain
// animation clips with Mixamo.
const rigTypeOptions = useMemo(() => {
if (!isCharacter && !isAnimation) return undefined;
const preferred = isAnimation
? MediaFileAnimationType.Mixamo
: MediaFileAnimationType.MixamoArKit;
const values = Object.values(MediaFileAnimationType);
const sorted = values.sort((a, b) =>
a === MediaFileAnimationType.MixamoArKit
? -1
: b === MediaFileAnimationType.MixamoArKit
? 1
: a.localeCompare(b),
a === preferred ? -1 : b === preferred ? 1 : a.localeCompare(b),
);
const toLabel = (v: string) =>
v.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
return sorted.map((v) => ({ [toLabel(v)]: v }));
}, [isCharacter]);
}, [isCharacter, isAnimation]);
const updateUploaderState = (newState: UploaderState) => {
setUploaderState(newState);
@@ -71,9 +94,11 @@ export function UploadModal3D(props: Props) {
useEffect(() => {
if (isOpen) {
resetModalState();
setIsCharacter(false);
setIsCharacter(initialCategory === "character");
setIsAnimation(initialCategory === "animation");
userTouchedCategory.current = false;
}
}, [isOpen]);
}, [isOpen, initialCategory]);
const UploaderModalContent = () => {
@@ -81,12 +106,31 @@ export function UploadModal3D(props: Props) {
case UploaderStates.ready:
return (
<div className="space-y-4">
<Checkbox
id="upload-as-character"
checked={isCharacter}
onChange={(e) => setIsCharacter(e.target.checked)}
label="Upload as Character"
/>
{/* Mutually exclusive category toggles. Mesh-less skeleton files
preselect Animation (onMeshlessDetected below), but the user
stays in control once they've clicked either. */}
<div className="flex items-center gap-6">
<Checkbox
id="upload-as-character"
checked={isCharacter}
onChange={(e) => {
userTouchedCategory.current = true;
setIsCharacter(e.target.checked);
if (e.target.checked) setIsAnimation(false);
}}
label="Upload as Character"
/>
<Checkbox
id="upload-as-animation"
checked={isAnimation}
onChange={(e) => {
userTouchedCategory.current = true;
setIsAnimation(e.target.checked);
if (e.target.checked) setIsCharacter(false);
}}
label="Upload as Animation"
/>
</div>
<UploadFiles3D
title={title}
engineCategory={selectedCategory}
@@ -94,10 +138,18 @@ export function UploadModal3D(props: Props) {
initialFiles={initialFiles}
options={{
...(options ?? {}),
fileSubtypes: characterAnimationOptions,
fileSubtypes: rigTypeOptions,
}}
onClose={onClose}
onUploadProgress={updateUploaderState}
onMeshlessDetected={() => {
if (userTouchedCategory.current) return;
// A scoped open's explicit category is the CALLER's intent —
// auto-detect only fills in when nobody chose anything.
if (initialCategory) return;
setIsAnimation(true);
setIsCharacter(false);
}}
/>
</div>
);
@@ -119,10 +171,14 @@ export function UploadModal3D(props: Props) {
case UploaderStates.success: {
return (
<UploadSuccess
title="3D model"
title={isAnimation ? "Animation" : "3D model"}
onOk={() => {
galleryModalVisibleViewMode.value = true;
galleryModalVisibleDuringDrag.value = true;
// Category-scoped opens come from a library panel that shows
// the upload itself — don't pop My Library over it.
if (initialCategory == null) {
galleryModalVisibleViewMode.value = true;
galleryModalVisibleDuringDrag.value = true;
}
onSuccess(selectedCategory);
onClose();
}}
@@ -0,0 +1,136 @@
import { FBXLoader } from "three/addons/loaders/FBXLoader.js";
import { GLTFExporter } from "three/addons/exporters/GLTFExporter.js";
// Convert an FBX file (e.g. a Mixamo download) to a binary GLB, preserving
// skeletal animation clips. Everything downstream of the picker — the
// preview canvas, the backend asset, the scene loader, the timeline clip
// loader, Viewer3D — is GLTF-only, so FBX is normalized here once instead
// of teaching an FBX loader to every consumer.
//
// The parse + export are synchronous CPU work, so they run in a Web Worker
// (convertFbxToGlb.worker.ts) to keep the UI responsive; the worker handles
// jobs one at a time, which also stops a multi-file pick from
// oversubscribing cores. If the worker can't start (bundler/environment),
// conversion falls back to the main thread — same result, just jankier.
//
// Caveats (fine for the Mixamo use case, revisit if needed):
// - Embedded/relative-path textures may not survive; geometry, skeleton and
// clips do.
// - Units are exported as parsed (Mixamo rigs are cm-scaled); no rescaling
// is applied here.
export async function convertFbxToGlb(file: File): Promise<File> {
const glb = await convert(file);
const stem = file.name.slice(0, file.name.lastIndexOf("."));
return new File([glb], `${stem}.glb`, { type: "model/gltf-binary" });
}
// ─── worker plumbing ──────────────────────────────────────────────────────
// Signals "the worker itself is broken" (script failed to load/crash), as
// opposed to a legitimate conversion failure of one file — only the former
// falls back to the main thread.
class WorkerUnavailableError extends Error {}
interface PendingJob {
resolve: (glb: ArrayBuffer) => void;
reject: (error: Error) => void;
}
// undefined = not attempted yet; null = unavailable (fall back permanently).
let worker: Worker | null | undefined;
let nextJobId = 0;
const pendingJobs = new Map<number, PendingJob>();
async function convert(file: File): Promise<ArrayBuffer> {
const workerInstance = getWorker();
if (workerInstance) {
try {
// The buffer is transferred (detached) to the worker, so the fallback
// below re-reads it from the File.
return await convertInWorker(workerInstance, await file.arrayBuffer());
} catch (error) {
if (!(error instanceof WorkerUnavailableError)) throw error;
}
}
return convertOnMainThread(await file.arrayBuffer());
}
function getWorker(): Worker | null {
if (worker !== undefined) return worker;
if (typeof Worker === "undefined") {
worker = null;
return worker;
}
try {
worker = new Worker(
new URL("./convertFbxToGlb.worker.ts", import.meta.url),
{ type: "module" },
);
worker.onmessage = (event) => {
const data = event.data as
| { id: number; ok: true; glb: ArrayBuffer }
| { id: number; ok: false; error: string };
const job = pendingJobs.get(data.id);
if (!job) return;
pendingJobs.delete(data.id);
if (data.ok) job.resolve(data.glb);
else job.reject(new Error(data.error));
};
worker.onerror = () => {
// The worker script itself failed — retire it and push in-flight jobs
// onto the main-thread fallback.
const jobs = [...pendingJobs.values()];
pendingJobs.clear();
worker?.terminate();
worker = null;
for (const job of jobs) job.reject(new WorkerUnavailableError());
};
worker.onmessageerror = () => {
// A message failed structured-clone deserialization. Without this the
// affected job would hang forever — entry stuck on "converting",
// Upload stuck at "Converting...". We can't tell WHICH message died,
// so fail every pending job into a retryable error (a plain Error,
// not WorkerUnavailableError: the worker itself still works, so no
// main-thread fallback).
const jobs = [...pendingJobs.values()];
pendingJobs.clear();
for (const job of jobs) {
job.reject(
new Error("FBX conversion result failed to decode — please retry."),
);
}
};
} catch {
worker = null;
}
return worker;
}
function convertInWorker(
workerInstance: Worker,
buffer: ArrayBuffer,
): Promise<ArrayBuffer> {
return new Promise<ArrayBuffer>((resolve, reject) => {
const id = nextJobId++;
pendingJobs.set(id, { resolve, reject });
workerInstance.postMessage({ id, buffer }, [buffer]);
});
}
async function convertOnMainThread(buffer: ArrayBuffer): Promise<ArrayBuffer> {
const group = new FBXLoader().parse(buffer, "");
return new Promise<ArrayBuffer>((resolve, reject) => {
new GLTFExporter().parse(
group,
(result) => {
if (result instanceof ArrayBuffer) {
resolve(result);
} else {
reject(new Error("GLTF export did not produce binary output."));
}
},
(error) => reject(error),
{ binary: true, animations: group.animations ?? [] },
);
});
}
@@ -0,0 +1,48 @@
import { FBXLoader } from "three/addons/loaders/FBXLoader.js";
import { GLTFExporter } from "three/addons/exporters/GLTFExporter.js";
// Runs the CPU-heavy FBX parse + GLTF export off the main thread so big
// files can't freeze the UI. One message in ({ id, buffer }) → one message
// out ({ id, ok, glb } or { id, ok: false, error }); the GLB buffer is
// transferred, not copied. Texture processing (rare for Mixamo files) uses
// OffscreenCanvas inside GLTFExporter, which our Chromium targets support.
interface ConvertRequest {
id: number;
buffer: ArrayBuffer;
}
type ConvertResponse =
| { id: number; ok: true; glb: ArrayBuffer }
| { id: number; ok: false; error: string };
// Typed shim over the worker global — avoids needing the "webworker" TS lib
// in a config that otherwise targets the DOM.
const scope = self as unknown as {
onmessage: ((event: MessageEvent<ConvertRequest>) => void) | null;
postMessage: (message: ConvertResponse, transfer?: Transferable[]) => void;
};
scope.onmessage = async (event) => {
const { id, buffer } = event.data;
try {
const group = new FBXLoader().parse(buffer, "");
const glb = await new Promise<ArrayBuffer>((resolve, reject) => {
new GLTFExporter().parse(
group,
(result) => {
if (result instanceof ArrayBuffer) {
resolve(result);
} else {
reject(new Error("GLTF export did not produce binary output."));
}
},
(error) => reject(error),
{ binary: true, animations: group.animations ?? [] },
);
});
scope.postMessage({ id, ok: true, glb }, [glb]);
} catch (error) {
scope.postMessage({ id, ok: false, error: String(error) });
}
};
@@ -1,4 +1,6 @@
export * from "./convertFbxToGlb";
export * from "./loadPreviewOnCanvas";
export * from "./readGlbAnimationDuration";
export * from "./thumbnailHelpers";
export * from "./uploadAsset";
export * from "./upload3DObjects";
@@ -3,6 +3,7 @@ import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { FontLoader } from "three/addons/loaders/FontLoader.js";
import { TextGeometry } from "three/addons/geometries/TextGeometry.js";
import { SplatFileType, SplatMesh } from "@sparkjsdev/spark";
import { NodeHierarchyHelper } from "@storyteller/ui-viewer-3d";
interface LoaderInterface {
file: File;
@@ -10,21 +11,47 @@ interface LoaderInterface {
scene: THREE.Scene;
renderer: THREE.WebGLRenderer;
statusCallback: (statusObject: { type: string; message?: string }) => void;
// Receives the model's baked AnimationClips (possibly empty) once loaded.
onAnimations?: (clips: THREE.AnimationClip[]) => void;
// Reports what the loaded model contains — hasMesh:false means a
// skeleton/animation-only file (e.g. a Mixamo "without skin" export).
onModelInfo?: (info: { hasMesh: boolean; hasBones: boolean }) => void;
// True once this preview has been superseded — async loader callbacks
// must become no-ops (no renders on a disposed renderer, no status/UI
// writes into the NEXT preview's state, no poisoned thumbnails).
isCancelled?: () => boolean;
}
interface PreviewReturn {
renderer: THREE.WebGLRenderer;
camera: THREE.PerspectiveCamera;
// Switch the previewed animation: a clip index, or -1 for none (skinned
// models rest in their bind/T-pose). No-op for animation-less models.
selectAnimation: (index: number) => void;
// Show/hide the skeleton overlay (rigged GLBs only; no-op otherwise).
// Defaults visible for mesh-less models so there's something to see.
setSkeletonVisible: (visible: boolean) => void;
// Call when a newer preview supersedes this one (effect cleanup): any
// still-in-flight load's callbacks become no-ops.
cancel: () => void;
}
export const loadPreviewOnCanvas = ({
file,
canvas,
statusCallback,
onAnimationsAvailable,
onModelInfo,
}: {
file: File;
canvas: HTMLCanvasElement;
statusCallback: (error: { type: string; message?: string }) => void;
// Called with the clips' display names when the loaded model has baked
// animations (never called with an empty list). The first clip autoplays.
onAnimationsAvailable?: (names: string[]) => void;
// Reports model contents (GLB only) — used to preselect "Upload as
// Animation" for mesh-less skeleton files and to offer the bone toggle.
onModelInfo?: (info: { hasMesh: boolean; hasBones: boolean }) => void;
}): PreviewReturn => {
const scene = new THREE.Scene();
@@ -57,26 +84,128 @@ export const loadPreviewOnCanvas = ({
let splatMesh: SplatMesh | null = null;
// Supersession flag: each preview invocation owns one. The effect that
// created this preview flips it in its cleanup, and every async loader
// callback checks it — a stale load must not render on the disposed
// renderer, write status/animation/rig state into the NEXT preview, or
// trigger a thumbnail snapshot of the wrong model.
let cancelled = false;
const cancel = () => {
cancelled = true;
};
const isCancelled = () => cancelled;
// Animation playback state for GLBs with baked clips. The mixer is rooted
// at the preview scene (the GLB's children are re-parented into it) — clip
// tracks resolve their nodes by name, so binding still works.
const clock = new THREE.Clock();
let mixer: THREE.AnimationMixer | null = null;
let clips: THREE.AnimationClip[] = [];
// Rest pose for "T-pose (none)": local transforms snapshotted before the
// first action plays. THREE.Skeleton.pose() is deliberately NOT used — it
// rebuilds bone locals from inverse bind matrices and mis-scales rigs
// whose armature bakes a scale (mixamo cm-rigs), and converted plain-node
// rigs have no Skeleton to pose at all.
let restPose: Array<{
node: THREE.Object3D;
position: THREE.Vector3;
quaternion: THREE.Quaternion;
scale: THREE.Vector3;
}> | null = null;
const selectAnimation = (index: number) => {
if (!mixer) return;
mixer.stopAllAction();
if (index >= 0) {
const clip = clips[index];
if (clip) mixer.clipAction(clip).reset().play();
} else {
// "None": a stopped action would freeze the model on its last
// evaluated frame — put every node back to its load pose instead.
for (const { node, position, quaternion, scale } of restPose ?? []) {
node.position.copy(position);
node.quaternion.copy(quaternion);
node.scale.copy(scale);
}
}
};
// Skeleton overlay for rigged GLBs, shown by default when the model has
// no mesh (skeleton/animation-only exports would otherwise be invisible).
// Real bones get THREE.SkeletonHelper; mesh-less models whose joints
// re-imported as plain nodes (GLTF only round-trips bone-ness through a
// skin, and mesh-less exports have none) get the generic hierarchy lines.
let skeletonHelper: THREE.SkeletonHelper | NodeHierarchyHelper | null = null;
const setSkeletonVisible = (visible: boolean) => {
if (skeletonHelper) skeletonHelper.visible = visible;
};
if (file.name.endsWith(".glb")) {
glbLoader({ file, scene, camera, renderer, statusCallback });
glbLoader({
file,
scene,
camera,
renderer,
statusCallback,
isCancelled,
onModelInfo: (info) => {
if (info.hasBones || !info.hasMesh) {
skeletonHelper = info.hasBones
? new THREE.SkeletonHelper(scene)
: new NodeHierarchyHelper(scene);
skeletonHelper.visible = !info.hasMesh;
scene.add(skeletonHelper);
}
onModelInfo?.(info);
},
onAnimations: (loadedClips) => {
clips = loadedClips;
if (clips.length === 0) return;
// Snapshot the load pose before any action can evaluate (the first
// mixer.update runs on the next animation-loop frame). Lights get
// captured too — nothing animates them, so restoring is a no-op.
restPose = [];
scene.traverse((node) => {
restPose!.push({
node,
position: node.position.clone(),
quaternion: node.quaternion.clone(),
scale: node.scale.clone(),
});
});
mixer = new THREE.AnimationMixer(scene);
mixer.clipAction(clips[0]).play(); // autoplay the first clip
onAnimationsAvailable?.(
clips.map((clip, index) => clip.name || `Clip ${index + 1}`),
);
},
});
} else if (file.name.endsWith(".pmd")) {
pmdLoader({ file, scene, camera, renderer, statusCallback });
pmdLoader({ file, scene, camera, renderer, statusCallback, isCancelled });
} else if (
file.name.endsWith(".png") ||
file.name.endsWith(".jpg") ||
file.name.endsWith(".jpeg") ||
file.name.endsWith(".gif")
) {
imagePlaneLoader({ file, scene, camera, renderer, statusCallback });
imagePlaneLoader({
file,
scene,
camera,
renderer,
statusCallback,
isCancelled,
});
} else if (file.name.endsWith(".spz")) {
file
.arrayBuffer()
.then((arrayBuffer) => {
if (cancelled) return;
splatMesh = new SplatMesh({
fileBytes: arrayBuffer,
fileType: SplatFileType.SPZ,
onLoad: () => {
scene.add(splatMesh!);
if (!cancelled) scene.add(splatMesh!);
},
});
@@ -104,12 +233,16 @@ export const loadPreviewOnCanvas = ({
}
const animate = function () {
// Tick the clock every frame (not just when a mixer exists) so the first
// mixer update doesn't get a giant delta.
const delta = clock.getDelta();
mixer?.update(delta);
renderer.render(scene, camera);
splatMesh?.rotateY(0.01);
};
renderer.setAnimationLoop(animate);
return { renderer, camera };
return { renderer, camera, selectAnimation, setSkeletonVisible, cancel };
};
const glbLoader = ({
@@ -118,30 +251,67 @@ const glbLoader = ({
scene,
renderer,
statusCallback,
onAnimations,
onModelInfo,
isCancelled,
}: LoaderInterface) => {
const loader = new GLTFLoader();
loader.load(
URL.createObjectURL(file),
(data) => {
data.scene.children.forEach((child) => {
if (isCancelled?.()) return;
// Inspect BEFORE re-parenting (the loop below moves children out of
// data.scene).
let hasMesh = false;
let hasBones = false;
data.scene.traverse((node) => {
if ((node as THREE.Mesh).isMesh) hasMesh = true;
if ((node as THREE.Bone).isBone) hasBones = true;
});
// Iterate a COPY: scene.add() re-parents the child, which mutates
// data.scene.children mid-loop and would skip every other child.
const addedRoots: THREE.Object3D[] = [];
[...data.scene.children].forEach((child) => {
child.userData["color"] = "#FFFFFF";
scene.add(child);
addedRoots.push(child);
});
const box = new THREE.Box3();
scene.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.geometry.computeBoundingBox();
box.expandByObject(object);
}
});
const center = new THREE.Vector3();
const size = new THREE.Vector3();
box.getCenter(center);
box.getSize(size);
const radius = Math.max(size.x, size.y, size.z) * 0.5;
// Fit the camera once, after ALL children are in. Mesh geometry
// drives the framing; mesh-less skeleton exports fall back to node
// world positions so an empty box can never NaN the camera math —
// ALL nodes, not just Bones: converted mesh-less rigs re-import as
// plain Object3Ds (GLTF only round-trips bone-ness through a skin),
// so a bones-only fallback found nothing for exactly the models that
// need it. Only the re-parented GLB roots are sampled; the preview
// lights would wildly inflate the box.
const box = new THREE.Box3();
scene.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.geometry.computeBoundingBox();
box.expandByObject(object);
}
});
if (box.isEmpty()) {
scene.updateMatrixWorld(true);
const point = new THREE.Vector3();
for (const root of addedRoots) {
root.traverse((node) => {
box.expandByPoint(node.getWorldPosition(point));
});
}
}
const center = new THREE.Vector3();
const size = new THREE.Vector3();
box.getCenter(center);
box.getSize(size);
const radius = Math.max(size.x, size.y, size.z) * 0.5;
// Skip the fit for empty or point-degenerate boxes (a lone-node
// fallback collapses to a point; distance 0 would park the camera
// inside the model with a zero near plane) — the default camera
// still shows something.
if (!box.isEmpty() && radius > 1e-4) {
const fov = camera.fov * (Math.PI / 180);
const distance = (radius * 1.2) / Math.tan(fov * 0.5);
@@ -155,16 +325,27 @@ const glbLoader = ({
camera.near = distance * 0.01;
camera.far = distance * 100;
camera.updateProjectionMatrix();
}
renderer.render(scene, camera);
statusCallback({
type: "OK",
message: "Preview should be available",
});
// AFTER the model is in the preview scene: the mixer's property
// bindings resolve nodes under `scene`, so wiring animations any
// earlier binds against an empty graph and silently plays nothing.
// And BEFORE the render + OK: onModelInfo is where the skeleton
// helper gets created — emitting OK first let the thumbnail snapshot
// race the next animation frame and capture a mesh-less model as a
// blank render.
onAnimations?.(data.animations ?? []);
onModelInfo?.({ hasMesh, hasBones });
renderer.render(scene, camera);
statusCallback({
type: "OK",
message: "Preview should be available",
});
},
undefined,
(loaderError) => {
if (isCancelled?.()) return;
statusCallback({
type: "GLB Loader Error",
message: String(loaderError),
@@ -178,12 +359,14 @@ const pmdLoader = ({
scene,
renderer,
statusCallback,
isCancelled,
}: LoaderInterface) => {
camera.position.z = 30;
const loader = new FontLoader();
loader.load(
"https://threejs.org/examples/fonts/helvetiker_regular.typeface.json",
(font) => {
if (isCancelled?.()) return;
const textGeometry = new TextGeometry("MMD", {
font: font,
size: 100,
@@ -219,7 +402,12 @@ const pmdLoader = ({
);
};
const imagePlaneLoader = ({ file, scene, statusCallback }: LoaderInterface) => {
const imagePlaneLoader = ({
file,
scene,
statusCallback,
isCancelled,
}: LoaderInterface) => {
const geometry = new THREE.PlaneGeometry(1, 1);
const loader = new THREE.TextureLoader();
const texture = loader.load(
@@ -227,6 +415,7 @@ const imagePlaneLoader = ({ file, scene, statusCallback }: LoaderInterface) => {
undefined,
undefined,
(loaderError) => {
if (isCancelled?.()) return;
statusCallback({
type: "Image Plane Loader Error",
message: String(loaderError),
@@ -0,0 +1,22 @@
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
// Longest animation clip in a GLB file, in whole milliseconds — the value the
// backend requires (maybe_duration_millis) for engine_category=animation
// uploads. Returns null when the file has no clips (an animation upload of
// such a file should be rejected client-side rather than 400 on the server).
// Parses headlessly; nothing is added to any scene.
export async function readGlbAnimationDurationMillis(
file: File,
): Promise<number | null> {
const buffer = await file.arrayBuffer();
const gltf = await new Promise<{ animations?: { duration: number }[] }>(
(resolve, reject) => {
new GLTFLoader().parse(buffer, "", resolve, reject);
},
);
const durations = (gltf.animations ?? []).map((clip) => clip.duration);
if (durations.length === 0) return null;
const maxSeconds = Math.max(...durations);
if (!(maxSeconds > 0)) return null;
return Math.round(maxSeconds * 1000);
}
@@ -13,6 +13,7 @@ export const upload3DObjects = async ({
engineCategory,
thumbnailSnapshot,
animationType,
durationMillis,
progressCallback,
}: {
title: string;
@@ -20,6 +21,8 @@ export const upload3DObjects = async ({
engineCategory: FilterEngineCategories;
thumbnailSnapshot: Blob | undefined;
animationType?: MediaFileAnimationType;
// Required by the backend when engineCategory is "animation".
durationMillis?: number;
progressCallback: (newState: UploaderState) => void;
}) => {
progressCallback({ status: UploaderStates.uploadingAsset });
@@ -29,6 +32,7 @@ export const upload3DObjects = async ({
title,
engineCategory,
animationType,
durationMillis,
});
if (!assetResponse.success || !assetResponse.data) {
@@ -11,6 +11,7 @@ export const upload3DObjectsBatch = async ({
thumbnails,
engineCategory,
animationType,
durationsMillis,
onFileStatusChange,
onOverallProgress,
onComplete,
@@ -19,6 +20,8 @@ export const upload3DObjectsBatch = async ({
thumbnails: Map<File, Blob>;
engineCategory: FilterEngineCategories;
animationType?: MediaFileAnimationType;
// Per-file clip durations; required by the backend for animation uploads.
durationsMillis?: Map<File, number>;
onFileStatusChange: (
index: number,
status: FileEntryStatus,
@@ -43,6 +46,7 @@ export const upload3DObjectsBatch = async ({
assetFile: file,
engineCategory,
animationType,
durationMillis: durationsMillis?.get(file),
thumbnailSnapshot: thumbnails.get(file),
progressCallback: (state) => {
if (state.status === UploaderStates.success) {
@@ -11,11 +11,14 @@ export const uploadAsset = async ({
title,
engineCategory,
animationType,
durationMillis,
}: {
file: File;
title: string;
engineCategory: FilterEngineCategories;
animationType?: MediaFileAnimationType;
// Required by the backend when engineCategory is "animation".
durationMillis?: number;
}) => {
const mediaUploadApi = new MediaUploadApi();
const fileExtension = getFileExtension(file);
@@ -44,6 +47,7 @@ export const uploadAsset = async ({
engine_category: engineCategory,
maybe_title: title,
maybe_animation_type: animationType,
maybe_duration_millis: durationMillis,
uuid: uuidv4(),
});
}
@@ -17,6 +17,12 @@
{
"path": "../file-uploader"
},
{
"path": "../select"
},
{
"path": "../viewer-3d"
},
{
"path": "../list-dropdown"
},
@@ -58,6 +58,12 @@
{
"path": "../file-uploader/tsconfig.lib.json"
},
{
"path": "../select/tsconfig.lib.json"
},
{
"path": "../viewer-3d/tsconfig.lib.json"
},
{
"path": "../list-dropdown/tsconfig.lib.json"
},
@@ -1,2 +1,3 @@
export * from './lib/viewer-3d';
export * from './lib/NodeHierarchyHelper';
@@ -0,0 +1,82 @@
import * as THREE from "three";
// SkeletonHelper equivalent for rigs whose joints are NOT THREE.Bone
// instances. GLTF only round-trips bone-ness through a skin, and exporters
// only write a skin for a SkinnedMesh — so a mesh-less animation export
// (e.g. a Mixamo "without skin" FBX converted to GLB) re-imports as a plain
// Object3D hierarchy, for which THREE.SkeletonHelper draws nothing. This
// helper draws parent→child segments for every descendant pair instead,
// refreshing from world matrices on each render exactly like SkeletonHelper.
export class NodeHierarchyHelper extends THREE.LineSegments {
private readonly pairs: Array<[THREE.Object3D, THREE.Object3D]>;
constructor(root: THREE.Object3D) {
const pairs: Array<[THREE.Object3D, THREE.Object3D]> = [];
root.traverse((node) => {
// Skip segments hanging off the container root itself — those draw
// spokes from the model origin; only intra-hierarchy links inform.
if (node !== root && node.parent && node.parent !== root) {
pairs.push([node.parent, node]);
}
});
const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
"position",
new THREE.BufferAttribute(new Float32Array(pairs.length * 2 * 3), 3),
);
const material = new THREE.LineBasicMaterial({
color: 0x00ff88,
depthTest: false,
depthWrite: false,
transparent: true,
toneMapped: false,
});
super(geometry, material);
this.pairs = pairs;
// Positions are written in WORLD space each update; the helper's own
// transform stays identity (mirrors THREE.SkeletonHelper).
this.matrixAutoUpdate = false;
this.frustumCulled = false;
this.renderOrder = 999;
}
override updateMatrixWorld(force?: boolean): void {
const position = this.geometry.getAttribute(
"position",
) as THREE.BufferAttribute;
const vector = new THREE.Vector3();
let index = 0;
for (const [parent, child] of this.pairs) {
vector.setFromMatrixPosition(parent.matrixWorld);
position.setXYZ(index++, vector.x, vector.y, vector.z);
vector.setFromMatrixPosition(child.matrixWorld);
position.setXYZ(index++, vector.x, vector.y, vector.z);
}
position.needsUpdate = true;
super.updateMatrixWorld(force);
}
dispose(): void {
this.geometry.dispose();
(this.material as THREE.Material).dispose();
}
}
// True when the subtree contains real THREE.Bone joints — the signal to
// prefer THREE.SkeletonHelper over the generic hierarchy overlay.
export const objectHasBones = (root: THREE.Object3D): boolean => {
let found = false;
root.traverse((node) => {
if ((node as THREE.Bone).isBone) found = true;
});
return found;
};
// Pick the right rig overlay for a model: real bones → SkeletonHelper,
// otherwise the generic node-hierarchy lines.
export const createRigHelper = (
root: THREE.Object3D,
): THREE.SkeletonHelper | NodeHierarchyHelper =>
objectHasBones(root) ? new THREE.SkeletonHelper(root) : new NodeHierarchyHelper(root);
@@ -3,6 +3,41 @@ import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { SplatMesh } from "@sparkjsdev/spark";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBone } from "@fortawesome/pro-solid-svg-icons";
import { Select } from "@storyteller/ui-select";
import {
NodeHierarchyHelper,
createRigHelper,
} from "./NodeHierarchyHelper";
// Dropdown value for the "no animation" choice (models rest in their bind /
// T-pose). Clip values are their stringified index.
const NO_ANIMATION_VALUE = "-1";
// Bounding box for camera framing. Geometry-less models (skeleton/animation-
// only exports) produce an EMPTY Box3 from setFromObject — which would NaN
// the camera math — so fall back to sampling EVERY node's world position:
// the joints of converted mesh-less exports are plain Object3Ds, not Bones
// (GLTF only round-trips bone-ness through a skin), so a bones-only fallback
// found nothing for exactly the models that need it. A final humanoid-ish
// default covers truly empty/degenerate models (a lone node collapses to a
// point, which would blow up the 2/maxDim scale math).
const computeModelBox = (target: THREE.Object3D): THREE.Box3 => {
const box = new THREE.Box3().setFromObject(target);
if (box.isEmpty()) {
target.updateMatrixWorld(true);
const point = new THREE.Vector3();
target.traverse((node) => {
box.expandByPoint(node.getWorldPosition(point));
});
}
const size = box.getSize(new THREE.Vector3());
if (Math.max(size.x, size.y, size.z) < 1e-4) {
box.set(new THREE.Vector3(-1, 0, -1), new THREE.Vector3(1, 2, 1));
}
return box;
};
export interface Viewer3DProps {
modelUrl?: string;
@@ -33,6 +68,75 @@ export function Viewer3D({
const [isModelLoaded, setIsModelLoaded] = useState(false);
const thumbnailCapturedRef = useRef(false);
// Animation playback for models that ship clips. The dropdown only renders
// when the loaded model actually has animations; the first clip autoplays.
const mixerRef = useRef<THREE.AnimationMixer | null>(null);
const clipsRef = useRef<THREE.AnimationClip[]>([]);
const clockRef = useRef(new THREE.Clock());
const [animationNames, setAnimationNames] = useState<string[]>([]);
const [selectedClip, setSelectedClip] = useState(-1);
// Skeleton overlay for rigged models. Defaults ON for mesh-less models
// (skeleton/animation-only exports would otherwise render nothing). Real
// bones get THREE.SkeletonHelper; converted mesh-less exports (whose
// joints re-import as plain nodes — GLTF only round-trips bone-ness via a
// skin) get the generic NodeHierarchyHelper.
const skeletonHelperRef = useRef<
THREE.SkeletonHelper | NodeHierarchyHelper | null
>(null);
const [hasRig, setHasRig] = useState(false);
const [skeletonVisible, setSkeletonVisible] = useState(false);
const stopAnimations = () => {
mixerRef.current?.stopAllAction();
mixerRef.current = null;
clipsRef.current = [];
};
const removeSkeletonHelper = () => {
const helper = skeletonHelperRef.current;
if (helper) {
sceneRef.current?.remove(helper);
helper.dispose();
skeletonHelperRef.current = null;
}
};
// Rest pose for "T-pose (none)": local transforms of every model node,
// captured at load before any clip plays. THREE.Skeleton.pose() is
// deliberately NOT used — it rebuilds bone locals from inverse bind
// matrices and mis-scales rigs whose armature bakes a scale (mixamo
// cm-rigs), the same mechanism as the timeline's "vanishing character"
// bug — and plain-node converted rigs have no Skeleton to pose at all.
const restPoseRef = useRef<Array<{
node: THREE.Object3D;
position: THREE.Vector3;
quaternion: THREE.Quaternion;
scale: THREE.Vector3;
}> | null>(null);
const captureRestPose = (model: THREE.Object3D) => {
const rest: NonNullable<typeof restPoseRef.current> = [];
model.traverse((node) => {
rest.push({
node,
position: node.position.clone(),
quaternion: node.quaternion.clone(),
scale: node.scale.clone(),
});
});
restPoseRef.current = rest;
};
const restoreRestPose = () => {
for (const { node, position, quaternion, scale } of restPoseRef.current ??
[]) {
node.position.copy(position);
node.quaternion.copy(quaternion);
node.scale.copy(scale);
}
};
useEffect(() => {
if (!containerRef.current) return;
@@ -118,6 +222,11 @@ export function Viewer3D({
cubeRef.current.rotation.y += 0.01;
}
// Tick the clock every frame (not just when a mixer exists) so the
// first mixer update after a model loads doesn't get a giant delta.
const delta = clockRef.current.getDelta();
mixerRef.current?.update(delta);
controls.update();
renderer.render(scene, camera);
};
@@ -200,20 +309,27 @@ export function Viewer3D({
scene.remove(loadedModelRef.current);
loadedModelRef.current = null;
}
stopAnimations();
removeSkeletonHelper();
restPoseRef.current = null;
setAnimationNames([]);
setSelectedClip(-1);
setHasRig(false);
setSkeletonVisible(false);
if (cubeRef.current) {
cubeRef.current.visible = false;
}
const onModelLoaded = (model: THREE.Object3D) => {
const box = new THREE.Box3().setFromObject(model);
const box = computeModelBox(model);
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 2 / maxDim;
model.scale.multiplyScalar(scale);
const scaledBox = new THREE.Box3().setFromObject(model);
const scaledBox = computeModelBox(model);
const scaledCenter = scaledBox.getCenter(new THREE.Vector3());
const scaledSize = scaledBox.getSize(new THREE.Vector3());
@@ -338,6 +454,39 @@ export function Viewer3D({
console.log("[Viewer3D] Model loaded successfully");
const model = gltf.scene;
onModelLoaded(model);
// Snapshot the load pose before any action can evaluate (the
// first mixer.update runs on the next animation frame).
captureRestPose(model);
// Wire up any clips the model ships with; autoplay the first one.
const clips = gltf.animations ?? [];
if (clips.length > 0) {
const mixer = new THREE.AnimationMixer(model);
mixerRef.current = mixer;
clipsRef.current = clips;
mixer.clipAction(clips[0]).play();
setAnimationNames(
clips.map((clip, index) => clip.name || `Clip ${index + 1}`),
);
setSelectedClip(0);
}
// Skeleton overlay: offered for any model with real bones, and for
// any MESH-LESS model regardless (its joints may have re-imported
// as plain nodes — without the overlay there is nothing to see).
// Shown by default only in the mesh-less case.
let modelHasBones = false;
let modelHasMesh = false;
model.traverse((node) => {
if ((node as THREE.Bone).isBone) modelHasBones = true;
if ((node as THREE.Mesh).isMesh) modelHasMesh = true;
});
if (modelHasBones || !modelHasMesh) {
const helper = createRigHelper(model);
helper.visible = !modelHasMesh;
scene.add(helper);
skeletonHelperRef.current = helper;
setHasRig(true);
setSkeletonVisible(!modelHasMesh);
}
},
(progress) => {
console.log(
@@ -355,6 +504,8 @@ export function Viewer3D({
}
return () => {
stopAnimations();
removeSkeletonHelper();
if (loadedModelRef.current && sceneRef.current) {
sceneRef.current.remove(loadedModelRef.current);
loadedModelRef.current = null;
@@ -362,6 +513,27 @@ export function Viewer3D({
};
}, [modelUrl, onThumbnailCapture, showGrid]);
// Reflect the toggle onto the helper.
useEffect(() => {
if (skeletonHelperRef.current) {
skeletonHelperRef.current.visible = skeletonVisible;
}
}, [skeletonVisible]);
// Switch the playing clip when the dropdown changes. The load path starts
// clip 0 directly, so this only needs to handle user switches.
useEffect(() => {
const mixer = mixerRef.current;
if (!mixer) return;
mixer.stopAllAction();
if (selectedClip >= 0) {
const clip = clipsRef.current[selectedClip];
if (clip) mixer.clipAction(clip).reset().play();
} else {
restoreRestPose();
}
}, [selectedClip]);
// Update grid visibility when prop changes
useEffect(() => {
if (gridRef.current) {
@@ -396,6 +568,43 @@ export function Viewer3D({
</div>
)}
{/* Top-right controls: skeleton overlay toggle (rigged models) and
the animation picker (models that ship clips). Models with
neither get no extra chrome. */}
{showViewer && (hasRig || animationNames.length > 0) && (
<div className="absolute right-2.5 top-2.5 z-20 flex items-start gap-2">
{hasRig && (
<button
type="button"
title={skeletonVisible ? "Hide skeleton" : "Show skeleton"}
onClick={() => setSkeletonVisible((visible) => !visible)}
className={`flex h-10 w-10 items-center justify-center rounded-md border transition-colors ${
skeletonVisible
? "border-primary bg-primary/20 text-white"
: "border-ui-controls-border bg-ui-controls text-white/70 hover:text-white"
}`}
>
<FontAwesomeIcon icon={faBone} />
</button>
)}
{animationNames.length > 0 && (
<div className="w-44">
<Select
value={String(selectedClip)}
onChange={(value) => setSelectedClip(Number(value))}
options={[
...animationNames.map((name, index) => ({
label: name,
value: String(index),
})),
{ label: "T-pose (none)", value: NO_ANIMATION_VALUE },
]}
/>
</div>
)}
</div>
)}
<div
ref={containerRef}
className="h-full w-full"
@@ -2,6 +2,9 @@
"files": [],
"include": [],
"references": [
{
"path": "../select"
},
{
"path": "./tsconfig.lib.json"
},
@@ -41,5 +41,10 @@
"src/**/*.jsx",
"src/**/*.ts",
"src/**/*.tsx"
],
"references": [
{
"path": "../select/tsconfig.lib.json"
}
]
}
+168 -88
View File
File diff suppressed because it is too large Load Diff