- Gaps show the default pose: a disabled three.js action leaves the skeleton
frozen on its last frame, so evaluateAt now resets any character with no clip
under the playhead to its bind (T) pose via Skeleton.pose() (skeletons cached
per character). Fixes blank space after a clip holding the clip's last pose.
- Animation drags now show the shared DragGhost tilt card with the clip
thumbnail: the drawer's native-DnD handlers drive dragItem/dragPosition/
assetDraggingUnder and suppress the browser's default drag image, matching
object/character pickup motion.
The DEV block set the API host to window.location.origin (so /v1 calls hit the
Vite proxy → api.storyteller.ai) and then immediately called setDevelopment(),
which overrode it to http://localhost:12345 — a local backend that isn't
running. Every media-token resolution failed, so getMediaUrlByToken returned ''
and GLB fetches fell back to localhost, serving nothing. The inline comment
already described this line as disabled; it just wasn't commented out. Now it
is, so local dev resolves media through the proxy as intended.
Collapse the per-character clips onto a single timeline row (playback is
sequential — one clip at a time), replacing stacked lanes. Stacking only earned
its keep for additive layering, which is out of scope.
- TimelineController: add/move route through resolveFreeStart (snap to nearest
non-overlapping slot); resizeClipLane and resolveClipDuration clamp against
nextStartAfter so a strip never overruns the following clip. A character's
strips stay a non-overlapping sequence, so evaluateAt has at most one active
clip at any playhead.
- TimelineClipRow: renders all of a character's strips on one shared row
(shown even when empty, as a drop hint); per-strip move/trim/loop and an
inline x to remove.
- TimelineEditor: one TimelineClipRow per character instead of one per lane.
Stacked clip-strip rendering on the timeline with move/trim/loop/delete, drag-
and-drop from the Animations drawer onto a character's lane, and backfill of the
clip's real length into the strip once its GLB loads.
- TimelineClipRow: one stacked strip per clip lane under its character's
keyframe row; body drags to move, right edge trims length, loop chip toggles
repeat, trash removes. Clip edits ride the timeline Save/Cancel session (no
per-op undo, matching keyframes).
- TimelineEditor: each character row is a drop target for clip drags; drop time
is read from the ruler rect. Non-character rows ignore clip drags.
- AnimationsDrawer clips are now draggable (ANIMATION_CLIP_MIME) as well as
click-to-add.
- ClipStrip.autoDuration: a fresh drop seeds a placeholder width, then
CharacterAnimationManager.resolveClipDuration adopts the loaded clip's true
length (re-clamping start); a user trim clears the flag so reload never
clobbers a hand-set duration.
- clipBindsToCharacter warns when a clip resolves 0 tracks on the character —
the diagnostic for whether retargeting is needed.
Deterministic, playhead-driven skeletal-animation playback for character clip
lanes, plus a minimal click-to-add drawer so retargeting/binding can be verified
in the viewport before the full drag-drop / stacked-strip UI is built.
- CharacterAnimationManager: one AnimationMixer per character; each clip lane is
an AnimationAction posed at an absolute time from the playhead + mixer.update(0)
(scrub / play / record all pose identically). syncClipLanes() reconciles the
runtime on every lane change; a load token guards superseded async clip loads.
- Scene.loadRawGlb(media_id): raw GLTF (scene + animations) with no scene-add /
placeholder — sources clips for binding. Clips bind by node name (shared
mixamorig:* naming); SkeletonUtils.retargetClip is the documented fallback.
- TimelineController: getPlayhead(); evaluate() poses characters; sync on
add/move/remove clip lane + load/cancel/clear.
- actions: addClipToCharacter / removeClipLane.
- comps/AnimationsDrawer: right-docked, gated on a selected character; click a
curated clip to drop it as a lane at the playhead.
Compiles clean (tsc exit 0); playback is NOT yet runtime-verified.
Foundation for placing Mixamo skeletal-animation clips on the timeline as
stacked lanes under character objects, alongside the existing keyframe tracks.
- ClipStrip / ClipLane types; TimelineData gains a `clipLanes` array (only the
clip reference + placement is serialized — the AnimationClip is resolved and
retargeted from sourceMediaId at runtime).
- TimelineController: addClipLane / moveClipLane / removeClipLane / clipLanesFor,
create()/loadTimeline() init + backfill clipLanes, emitChanged carries them.
- TimelineChangedEvent → store mirror (timelineClipLanes) via EngineStoreBridge.
- Persistence rides the existing timeline save/load (getTimeline/loadTimeline).
- Record mode is now fully immutable: gate MouseControls mouse-look + focus()
on the viewport lock (new MouseControlsDeps.isViewportLocked → cameraController
.locked), matching the existing FreeCam/tick gates.
- Default main (viewport) camera pulled back + elevated to a Blender-style 3/4
view so a blank scene shows the full render-camera frustum.
- Auto-keyframe: mutating an already-keyframed object (gizmo drag-end or panel
commit) replaces/creates a keyframe at the playhead via
TimelineController.autoKeyIfTracked.
- loadTimeline backfills fps/duration/tracks defensively (legacy save safety).
Camera framing
- Replace the full-canvas Letterbox with CameraFrame: an inset, aspect-correct
frame (dimmed surround, rule-of-thirds, focal label) shown only in camera
view and record mode — the free viewport no longer shows mattes
- The frame shows the EXACT render framing: CameraController.setCameraFrameRect
offsets the viewport projection via camera.setViewOffset; snapshots and the
timeline recorder clear/reapply the offset so captures stay full-bleed
- recordTimeline now encodes at the render camera's resolution/aspect with the
grid hidden (was grabbing the raw viewport canvas)
- Frame avoids the bottom UI; expanded-timeline height is measured live
Aspect ratio
- Default is now 16:9 via DEFAULT_CAMERA_ASPECT_RATIO (store, controller,
scene reset, newScene); removed the model cold-sync that forced Square
- New top-right aspect popover (AspectRatioMenu rewritten on PopoverMenu with
proportional AspectRatioIcon, now exported from ui-promptbox)
Timeline
- Expanded editor shows every scene object as a track row (no selection
needed), capped at ~3.5 rows then scrollable (scrollbar hidden so lanes
stay width-aligned with the ruler)
- Restyled per design: dark lanes, keyframe diamonds + connecting line,
playhead diamond handle, select-none everywhere
- Scrubbing: drag the ruler to scrub smoothly, following the pointer 1:1;
times quantize to the timeline's frame grid (30fps) — transport readout
shows frame timecode (m:ss:ff); keyframe drags quantize the same way
- Fixed playhead/keyframe misalignment (scrollbar shrank lanes vs ruler;
label indent must be padding, not margin, to keep the w-32 column)
- Delete/Backspace removes the selected keyframe (capture-phase, so the
engine's delete-scene-object keybind doesn't fire); clicking away
deselects it
- Motion (easing) popover opens from the curve chip BETWEEN keyframes (new
timelineEasingKeyframeId store field), centered above the chip in lane
coordinates, backdrop-blurred, dismissed on outside click; selecting a
keyframe no longer opens it
- Camera-track playback/scrubbing is now visible in camera view: the
timeline mirrors the animated ::CAM:: pose onto the viewport camera
Record mode
- Viewport camera fully locked: mouse-look blocked via isMovable(), right-drag
pan and wheel zoom gated in useFreeCam (build mode unaffected)
Chrome
- Outliner restyled as the "Scene" panel (+ Add via shared openAssetModal,
two-line rows with friendly kind labels, blue selected card), open by
default, vertically centered, fixed 45vh height independent of aspect ratio
- Build/Record pill fully rounded (new TabSelector listClassName prop)
- CameraStatusPill moved to the top-right camera cluster, height-matched
with the aspect popover
- ExitCameraView is a regular labeled button docked right of the timeline bar
- Remove SceneBuilderPromptBox (MCP not ready). The animation timeline is the
sole build-mode bottom UI: collapsed bar by default, expanded editor via
chevron; an empty timeline is auto-created on init; auto-expands on load when
the scene has keyframes or an imported THREE animation.
- Editable max timeline duration: TimelineController.setDuration (clamp 1-60s,
covers 5-30) + click-to-edit DurationLabel in the bar/ruler.
- Capture/Record output → review modal (CompletionModal) that appears FIRST
with a local preview (offline-safe), then uploads (images auto, videos manual)
with status; on success opens the app Lightbox on the token (all destinations
for the kind), on failure shows Retry.
- Adapter: add openMediaLightbox(token, kind); keep uploadMedia; remove
openImageInEditor/openVideoInEditor (Lightbox owns destination routing). Host
renders <Lightbox> and resolves cdnUrl via MediaFilesApi.
Compile-verified (tsc -b clean for pagescene + changed webapp files).
Adds the Scene Builder feature set to the pagescene 3D editor (+ the shared
ui-promptbox). All UI-level; compile-verified only (tsc -b), not yet runtime
tested — see frontend/libs/components/pagescene/CLAUDE.md for the full spec and
per-slice "unverified" list.
- Build/Record mode pill (SceneModePill, TabSelector) + store `sceneMode`.
- Build sub-modes: Manual (current) vs Prompted. Prompted prompt bar shows a
textbox + add-animation-timeline + prompt history + Update (Update is a stub
pending the MCP scene-descriptor backend). Toggle via ButtonIconSelect.
- Animation timeline (in-memory): TimelineController + keyframe model +
cubic-bezier interpolation ticked in the render loop; collapsed bar +
expanded multi-track editor + Motion easing popover (draggable bezier);
Save is an undoable action, Cancel reverts.
- Camera split (single camera): render camera is a selectable wireframe-frustum
placeholder ("::CAM::") in the scene; look-through camera view via
double-click / outliner view-from-camera icon; ExitCameraView button.
- Record mode: read-only playback through the locked render camera; build
chrome hidden; Capture (still) works, Record (video) is a stub.
### New shared lib: `@storyteller/omni-gen`
- The `omni-gen-hooks.ts` file was copy-pasted between `artcraft-webapp`
and
`artcraft-website` and had drifted (website copy was missing outage
toasts,
carried a stale hardcoded display-name map, and had weaker icon
resolution).
- Extracted the newer (webapp) version into a new lib
`frontend/libs/omni-gen`
(generated via `nx g @nx/react:library`): singleton session cache,
`useOmniGenImageModels()` / `useOmniGenVideoModels()`, outage message
constants.
- App-local toasts are injected via `setOmniGenErrorNotifier()`, called
once in
each app's `main.tsx`, since the hooks are singleton-cache based and
each app
has its own toast component.
### Consolidated model metadata into `@storyteller/model-list`
- `getModelDescription()` / `getModelInfo()` (tagline + info-blurb
fallbacks)
moved to `ModelDescriptions.ts`; API values still always win.
- `getModelDisplayName()` gained an optional `fullName` param (API name
wins);
the app-local wrapper is gone.
- Both apps' local `getModelCreatorIconPath()` replaced with the
existing,
stronger `getCreatorIconPathForModelId()` (checks `ALL_MODELS_LIST`
first,
then prefix match). The loader's duplicate `CREATOR_BY_PREFIX` map now
reuses
the same shared `MODEL_ID_PREFIX_CREATORS` table.
### Dead code removed
- `modelReconciliation.ts`: deprecated `reconcile*()` helpers (never
called) and
the empty, retired `BACKEND_TO_TAURI_*` alias maps.
`buildModelsFromListing()`
dropped its alias-map indirection accordingly (backend ids == tauriIds
since
the 2026-07 id migration). `modelCreatorFromBackend()` moved inline into
the
loader, its only consumer.
- Six unused static `*_PAGE_MODEL_LIST` constants in
`model-selectors-for-pages.tsx`
(superseded by the `use*PageModelList` hooks in #1710). The two
`IMAGE_TO_3D_*`
constants stay: 3D models are not served by the omni backend.
- Untracked the accidentally committed
`apps/artcraft/tsconfig.tsbuildinfo` and
gitignored `*.tsbuildinfo`.
### Website-visible improvements (from adopting the newer hooks)
- Model outage toasts now appear on the website (previously
webapp-only).
- Current display names from the canonical map (previously-unmapped ids
like
`gpt_image_2` and Midjourney models rendered as raw ids).
- Model taglines/descriptions now render in the website model picker.
- Midjourney and SwitchX creator icons now resolve.
Add a footer to the fullscreen prompt modal (desktop lib + webapp) so
focus mode is a complete editing surface, not just the textarea:
- Model selector, always-on reference-image row, and a Done button
(closes the modal) instead of Generate.
- Video: also surfaces the input-mode (keyframe/reference) picker and
the @Characters button (seedance_2p0). Shared image-row / character /
input-mode elements are reused inline and in the modal (DRY).
- Webapp: model selector + leftToolbar + image row in the footer.
Layout: editor area is flex-1/min-h-0/overflow-hidden with the image row
and footer shrink-0, so the editor takes only the leftover space and
scrolls internally — resizing the window shorter no longer overflows.
MentionTextarea gains a `style` prop so the modal can cap resize/height.
### Fullscreen focus mode (desktop + webapp)
- Added a top-right expand icon on every prompt editor that opens a
"Prompt" focus-mode overlay (blurred backdrop, `max-w-4xl` centered
card) for more prompting space.
- Same editor state — overlay binds to the same value + onChange, so
text/@-mentions persist. Auto-focuses with the caret at the end on open.
- Editor fills full height; @-mention dropdown no longer clipped; closes
on Esc / backdrop click.
- Reused `@storyteller/ui-modal`; shared `PromptFullscreenModal` +
button + `useFullscreenPrompt` hook across all 5 desktop promptboxes,
mirrored in the webapp's own PromptBox.
### 3D editor
- Model selector now renders inside the promptbox (was standalone on the
canvas).
- Pose button hides on deselect; pressing "Done" keeps it (flips back to
"Enter Pose Mode") while the character is still selected.
- Drag and drop into folders fix within gallery modal - doesn't close
immediately unless on 3D editor page.
### Tooltips & expand
- Fixed top-left menu tooltips getting stuck open after a tab change.
- Reworked image promptbox auto-grow/expand into a shared
`useAutoGrowEditorHeight` hook (no per-render style writes) with a
smooth height transition.
Reveal/RevealGroup called `motion(as)` inside the render body. Unlike
the cached `motion.div` accessor, calling `motion()` as a function
returns a brand-new component type on every invocation. So each
re-render produced a different element type at that position, React
unmounted and remounted the whole subtree, and any focused <input>
inside (the login/signup/forgot-password forms) lost focus on every
keystroke — you could only type one character at a time.
Cache one motion component per element type via a module-level map so
the component reference stays stable across renders. React now
reconciles in place and focus is preserved.
Verified with a Playwright A/B test on the login form: before the fix
typing "test@example.com" left only "t" with focus on <body> (1 blur
mid-type); after the fix the full value is retained, focus stays on the
input, zero blurs.
handleUseSelected only searched groupedItems (root library), so picks
made
while browsing inside a folder — which live in folderGroupedItems —
resolved
to nothing and an empty array was passed to onUseSelected. Search both
sources, deduping by id, so folder selections attach correctly.
Co-authored-by: Claude <noreply@anthropic.com>
Motion foundation + expressive entrance/interaction polish for the
webapp, following a "balanced" direction (restrained chrome, expressive
canvas).
Foundation
- Motion tokens: --ease-*/--dur-* CSS vars (styles.css) mirrored as JS
constants/presets in lib/motion.ts; prefers-reduced-motion respected.
- Shared Reveal/RevealGroup primitive (components/motion) — fade-up +
stagger.
Chrome & navigation
- Global route fade (components/motion/page-transition) keyed by
section.
- Sidebar active-indicator slide via framer layoutId + icon hover.
Pages
- Home: hero/eyebrow reveal, staggered launcher cards, spring lift/press
hover, double-bezel cards, nested arrow chip.
- Auth: AuthHeader staggers in (login+signup); form blocks cascade
after.
- Pricing: wire the orphaned data-reveal hero through the real Reveal.
- Library: sliding active indicator on tab/filter switchers (layoutId),
shimmer-sweep loading skeleton.
Pagescene entrance animations (user-drop only)
- EntranceAnimator: meshes get a transform-based pop/rise/fall drop with
a rubbery squash overshoot; splats get a per-gaussian radial settle
(Spark dyno) with center-out fade + height drop. Dev-only speed slider.
Gallery + asset drag-under UX
- On drag-start the gallery/asset modal becomes pointer-transparent so
the drag passes under onto the canvas; steps back to translucent (reopen
on) or eases fully out (reopen off), all interpolated. Shared DragGhost
(lift/tilt/follow) feeds both DnD systems. Media fades to placeholders
so thumbnails don't block the canvas. Asset modal always reopens fully
visible.
PageDraw: entrance fade on add + workspace vignette.
Dev-only (local testing): drop setDevelopment() API host pin; add /__cdn
proxy + maybeProxyCdnUrl so CDN splat/GLB assets dodge CORS.
The netlify_build.sh referenced by the Netlify build command was never
committed, so the build failed with "No such file or directory".
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>