mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
3D: Animations support + UI (#1856)
Co-authored-by: BFlat <bombayapirat@gmail.com>
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
-100
@@ -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";
|
||||
|
||||
+189
@@ -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],
|
||||
};
|
||||
};
|
||||
+10
-2
@@ -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">
|
||||
|
||||
+15
-6
@@ -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();
|
||||
|
||||
+215
-42
@@ -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];
|
||||
}
|
||||
|
||||
+35
@@ -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();
|
||||
}}
|
||||
|
||||
+136
@@ -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 ?? [] },
|
||||
);
|
||||
});
|
||||
}
|
||||
+48
@@ -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";
|
||||
|
||||
+216
-27
@@ -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),
|
||||
|
||||
+22
@@ -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);
|
||||
}
|
||||
+4
@@ -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) {
|
||||
|
||||
+4
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Generated
+168
-88
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user