diff --git a/frontend/libs/components/pagescene/CLAUDE.md b/frontend/libs/components/pagescene/CLAUDE.md index 5805099be1..9b3e6dcea9 100644 --- a/frontend/libs/components/pagescene/CLAUDE.md +++ b/frontend/libs/components/pagescene/CLAUDE.md @@ -306,9 +306,32 @@ then one row per character is the right model. `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. + modulo / final-frame clamp inside it, so trimming a strip shorter genuinely cuts playback. + Strips default to **loop: true** — dragging a strip wider than the clip keeps the motion + cycling (a walk keeps walking, per tester expectation/old-studio behavior); the loop chip opts + a strip into play-once/hold-last-frame instead. `resolveClipDuration` only SHRINKS an + `autoDuration` strip when the clip is shorter than the default. +- **Gap transitions (opt-in blending)**: `ClipStrip.transitionEasing` on the LEADING strip + (presence = enabled; mirrors keyframes storing easing "into the next") cross-fades the strip's + exit pose into the next strip's entry pose across the gap between them. `evaluateAt` enables + BOTH actions pinned at their exit/entry frames with weights `(1−w, w)` from + `cubicBezierYForX` — weights sum to 1 so the mixer computes a proper lerp; this is the one + deliberate exception to the one-winner rule, and it's continuous at both gap edges (w=0 at the + leading strip's end, w=1 at the next strip's start). Gaps without a transition keep the rest + pose; leading/trailing row gaps never blend. UI: a `data-transition-chip` button at EVERY + consecutive-strip boundary (ghost = off, solid = on) — auto-placement packs strips flush, so + enabling on a flush/too-tight boundary first calls `ensureTransitionGap` + (`DEFAULT_TRANSITION_GAP` = 0.3s: shift the next strip right, bounded by its follower/timeline + end, then trim the leading strip if shifting isn't enough; a too-packed row rejects with a + toast and mutates nothing). Click enables with `DEFAULT_EASING` and opens the generalized + `MotionPopover` (`easing`/`onChange`/`footer` props — keyframes use the same component) with a + "Remove transition" footer; popover selection rides `timelineEasingClipLaneId` — bridge-pruned + both when the lane vanishes AND when it loses its transition with the id intact (Cancel / + undo-of-Save revert the easing wholesale; an open popover would otherwise re-create it on the + next curve drag) — and the keyframe popover takes render priority so the two never coexist. + Edits ride the Save/Cancel session; serialization needs no special casing (`transitionEasing` + rides `TimelineData` through every wholesale-clone path: scene JSON save/load, Save/Cancel + snapshots, SaveTimelineAction, RemoveClipLaneAction restore). - **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 diff --git a/frontend/libs/components/pagescene/src/lib/PageSceneStore.ts b/frontend/libs/components/pagescene/src/lib/PageSceneStore.ts index 937c3a95e3..89284aaf0d 100644 --- a/frontend/libs/components/pagescene/src/lib/PageSceneStore.ts +++ b/frontend/libs/components/pagescene/src/lib/PageSceneStore.ts @@ -206,6 +206,9 @@ interface PageSceneState { // Motion popover (opened from the curve chip BETWEEN two keyframes). // Distinct from timelineSelectedKeyframeId, which drives selection/delete. timelineEasingKeyframeId: string | null; + // Leading clip lane whose gap-transition curve is being edited in the + // Motion popover (opened from the chip in the gap between two strips). + timelineEasingClipLaneId: 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 @@ -332,6 +335,7 @@ interface PageSceneState { setTimelineSelectedKeyframe: (id: string | null) => void; setTimelineSelectedClipLane: (id: string | null) => void; setTimelineEasingKeyframe: (id: string | null) => void; + setTimelineEasingClipLane: (id: string | null) => void; setTimelineRevealObjectUuid: (uuid: string | null) => void; setTimelineFocusSelected: (focus: boolean) => void; setProducedArtifact: (artifact: ProducedArtifact | null) => void; @@ -433,6 +437,7 @@ export const usePageSceneStore = create((set, get) => ({ timelineSelectedKeyframeId: null, timelineSelectedClipLaneId: null, timelineEasingKeyframeId: null, + timelineEasingClipLaneId: null, timelineRevealObjectUuid: null, timelineFocusSelected: false, producedArtifact: null, @@ -546,6 +551,7 @@ export const usePageSceneStore = create((set, get) => ({ setTimelineSelectedClipLane: (id) => set({ timelineSelectedClipLaneId: id }), setTimelineEasingKeyframe: (id) => set({ timelineEasingKeyframeId: id }), + setTimelineEasingClipLane: (id) => set({ timelineEasingClipLaneId: id }), setTimelineRevealObjectUuid: (uuid) => set({ timelineRevealObjectUuid: uuid }), setTimelineFocusSelected: (focus) => set({ timelineFocusSelected: focus }), diff --git a/frontend/libs/components/pagescene/src/lib/actions/timeline.ts b/frontend/libs/components/pagescene/src/lib/actions/timeline.ts index 2bbc9fa1dc..e0f7351eda 100644 --- a/frontend/libs/components/pagescene/src/lib/actions/timeline.ts +++ b/frontend/libs/components/pagescene/src/lib/actions/timeline.ts @@ -76,6 +76,10 @@ export function setKeyframeEasing( // 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). +// Strips default to LOOPING: dragging a strip wider than the clip's natural +// length keeps the motion cycling (a walk keeps walking), matching the old +// studio's behavior — the strip's loop chip opts into play-once/hold-last- +// frame instead. // 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. @@ -92,7 +96,7 @@ export function addClipToCharacter( name: item.name ?? "Animation", startTime, duration: DEFAULT_CLIP_DURATION, - loop: false, + loop: true, autoDuration: true, }); if (laneId) { @@ -125,7 +129,8 @@ export function addBakedClipToObject( name: clip.name || `Clip ${clipIndex + 1}`, startTime: atTime, duration: Math.min(clip.duration || DEFAULT_CLIP_DURATION, DEFAULT_CLIP_DURATION), - loop: false, + // Loop by default, same as library strips (see addClipToCharacter). + loop: true, }); } @@ -153,6 +158,26 @@ export function setClipLoop( editor.timelineController.setClipLoop(laneId, loop); } +// Set (or clear, with null) the opt-in pose transition from `laneId`'s strip +// into the next strip on its row. +export function setClipTransitionEasing( + editor: Editor, + laneId: string, + easing: EasingSpec | null, +): void { + editor.timelineController.setClipTransitionEasing(laneId, easing); +} + +// Open a gap after `laneId`'s strip for a transition to play in, when its +// boundary with the next strip is flush/too tight. Returns false when the +// row is too packed to make room (callers toast). +export function ensureClipTransitionGap( + editor: Editor, + laneId: string, +): boolean { + return editor.timelineController.ensureTransitionGap(laneId); +} + // Individually undoable (RemoveClipLaneAction) — both the strip's × button // and Del/Backspace route through here. export function removeClipLane(editor: Editor, laneId: string): void { diff --git a/frontend/libs/components/pagescene/src/lib/comps/Timeline/MotionPopover.tsx b/frontend/libs/components/pagescene/src/lib/comps/Timeline/MotionPopover.tsx index 4d4e879258..ea58587ddb 100644 --- a/frontend/libs/components/pagescene/src/lib/comps/Timeline/MotionPopover.tsx +++ b/frontend/libs/components/pagescene/src/lib/comps/Timeline/MotionPopover.tsx @@ -1,16 +1,15 @@ -import { useContext, useRef, useState } from "react"; -import { EngineContext } from "../../contexts/EngineContext/EngineContext"; -import { setKeyframeEasing } from "../../actions"; +import { ReactNode, useRef, useState } from "react"; import { EASING_PRESETS, type EasingPresetName, type EasingSpec, - type Keyframe, } from "../../engine/timeline/types"; -// The Motion popover edits a keyframe's easing curve (the interpolation -// INTO the next keyframe). Presets across the top, an editable cubic-bezier -// curve below with two draggable handles and a dotted linear reference. +// The Motion popover edits a cubic-bezier easing curve. Presets across the +// top, an editable curve below with two draggable handles and a dotted +// linear reference. Presentational: callers own the wiring — keyframe +// easing (TimelineEditor) and clip gap-transitions (TimelineEditor via the +// gap chips in TimelineClipRow) both reuse it through `easing`/`onChange`. const BOX = 150; // svg drawing box (px), domain [0,1]×[0,1] const PAD = 14; // padding so handles/labels aren't clipped @@ -28,21 +27,26 @@ const PRESETS: { name: EasingPresetName; label: string }[] = [ ]; interface MotionPopoverProps { - keyframe: Keyframe; + easing: EasingSpec; + onChange: (next: EasingSpec) => void; // Horizontal anchor as a percentage across the editor (playhead/keyframe pos). leftPercent: number; + title?: string; + // Optional extra row under the curve (e.g. "Remove transition"). + footer?: ReactNode; } -export const MotionPopover = ({ keyframe, leftPercent }: MotionPopoverProps) => { - const editor = useContext(EngineContext); +export const MotionPopover = ({ + easing, + onChange, + leftPercent, + title = "Motion", + footer, +}: MotionPopoverProps) => { const svgRef = useRef(null); const [dragging, setDragging] = useState(null); - const easing = keyframe.easing; - - const commit = (next: EasingSpec) => { - if (editor) setKeyframeEasing(editor, keyframe.id, next); - }; + const commit = onChange; const pointerToNorm = (e: React.PointerEvent) => { const svg = svgRef.current; @@ -76,7 +80,7 @@ export const MotionPopover = ({ keyframe, leftPercent }: MotionPopoverProps) => onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} > -
Motion
+
{title}
{PRESETS.map((p) => { const preset = EASING_PRESETS[p.name]; @@ -182,6 +186,7 @@ export const MotionPopover = ({ keyframe, leftPercent }: MotionPopoverProps) => }} /> + {footer}
); }; diff --git a/frontend/libs/components/pagescene/src/lib/comps/Timeline/TimelineClipRow.tsx b/frontend/libs/components/pagescene/src/lib/comps/Timeline/TimelineClipRow.tsx index fbb7f7efd3..1b2aa11481 100644 --- a/frontend/libs/components/pagescene/src/lib/comps/Timeline/TimelineClipRow.tsx +++ b/frontend/libs/components/pagescene/src/lib/comps/Timeline/TimelineClipRow.tsx @@ -2,6 +2,7 @@ import { useContext, useEffect, useRef, useState } from "react"; import ReactDOM from "react-dom"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { + faBezierCurve, faFilm, faPlus, faRepeat, @@ -10,16 +11,21 @@ import { import { EngineContext } from "../../contexts/EngineContext/EngineContext"; import { addBakedClipToObject, + ensureClipTransitionGap, moveClipLane, removeClipLane, resizeClipLane, setClipLoop, + setClipTransitionEasing, } from "../../actions"; import { usePageSceneStore, type ClipLane, } from "../../PageSceneStore"; -import { DEFAULT_TIMELINE_FPS } from "../../engine/timeline/types"; +import { + DEFAULT_EASING, + DEFAULT_TIMELINE_FPS, +} from "../../engine/timeline/types"; import { ToastTypes } from "../../enums"; import { fractionToTime, quantizeToFrame, timeToFraction } from "./timelineUtils"; @@ -84,6 +90,34 @@ export const TimelineClipRow = ({ }; }, [pickerAnchor]); + // Opt-in gap transition: first click on a boundary chip enables the blend + // (default curve) and opens the Motion popover; on an active chip it + // toggles the popover. Flush/too-tight boundaries (the default — auto- + // placement packs strips back-to-back) first get a small gap opened for + // the blend to play in; a row too packed to make room rejects with a + // toast. Keyframe-easing selection is cleared so the two popovers never + // contend. + const toggleTransition = (lane: ClipLane) => { + if (!editor) return; + const store = usePageSceneStore.getState(); + if (store.timelineEasingClipLaneId === lane.id) { + store.setTimelineEasingClipLane(null); + return; + } + if (!lane.strip.transitionEasing) { + if (!ensureClipTransitionGap(editor, lane.id)) { + editor.adapter.showToast( + ToastTypes.WARNING, + "No room on this row to open a gap for the transition.", + ); + return; + } + setClipTransitionEasing(editor, lane.id, DEFAULT_EASING); + } + store.setTimelineEasingKeyframe(null); + store.setTimelineEasingClipLane(lane.id); + }; + const addBaked = (clipIndex: number) => { setPickerAnchor(null); if (!editor) return; @@ -295,6 +329,47 @@ export const TimelineClipRow = ({ ); })} + + {/* Opt-in gap transitions: a chip at every consecutive-strip + boundary enables/edits the pose blend across the gap + (transitionEasing on the LEADING strip). Auto-placement packs + strips flush, so the chip renders even at a zero gap — enabling + there first opens a small gap (toggleTransition) for the blend + to play in. data-transition-chip exempts it from the popover's + click-away. */} + {[...lanes] + .sort((a, b) => a.strip.startTime - b.strip.startTime) + .map((lane, i, sorted) => { + const next = sorted[i + 1]; + if (!next) return null; + const gapStart = lane.strip.startTime + lane.strip.duration; + const gapEnd = next.strip.startTime; + const active = !!lane.strip.transitionEasing; + const midPct = + timeToFraction((gapStart + gapEnd) / 2, duration) * 100; + return ( + + ); + })} {/* spacer to match the keyframe row's add-button column (keeps lane diff --git a/frontend/libs/components/pagescene/src/lib/comps/Timeline/TimelineEditor.tsx b/frontend/libs/components/pagescene/src/lib/comps/Timeline/TimelineEditor.tsx index b6382ba74e..ee99a73e37 100644 --- a/frontend/libs/components/pagescene/src/lib/comps/Timeline/TimelineEditor.tsx +++ b/frontend/libs/components/pagescene/src/lib/comps/Timeline/TimelineEditor.tsx @@ -20,9 +20,14 @@ import { removeClipLane, saveTimeline, seekTimeline, + setClipTransitionEasing, + setKeyframeEasing, } from "../../actions"; import { usePageSceneStore } from "../../PageSceneStore"; -import { DEFAULT_TIMELINE_FPS } from "../../engine/timeline/types"; +import { + DEFAULT_EASING, + DEFAULT_TIMELINE_FPS, +} from "../../engine/timeline/types"; import { formatTimecode, formatTimecodeFrames, @@ -125,6 +130,34 @@ export const TimelineEditor = () => { } } + // Clip gap-transition popover: anchored at the midpoint of the gap between + // the leading strip (which stores transitionEasing) and the next strip on + // its row. Same lane-area coordinate system as the keyframe popover. + const easingClipLaneId = usePageSceneStore((s) => s.timelineEasingClipLaneId); + const clipTransitionAnchor = (() => { + if (!easingClipLaneId) return null; + const lead = clipLanes.find((l) => l.id === easingClipLaneId); + if (!lead) return null; + const leadEnd = lead.strip.startTime + lead.strip.duration; + // Epsilon matches TimelineController.ensureTransitionGap: a strip + // trimmed flush can put leadEnd a float hair past the neighbour's start. + const next = clipLanes + .filter( + (l) => + l.objectUuid === lead.objectUuid && + l.id !== lead.id && + l.strip.startTime >= leadEnd - 1e-6, + ) + .sort((a, b) => a.strip.startTime - b.strip.startTime)[0]; + if (!next) return null; + return { + laneId: lead.id, + easing: lead.strip.transitionEasing ?? DEFAULT_EASING, + leftPercent: + timeToFraction((leadEnd + next.strip.startTime) / 2, duration) * 100, + }; + })(); + // Selected keyframe: Delete/Backspace removes it (capture phase, so the // engine's document-level Delete — which deletes the selected scene // object — never sees the key), and clicking anywhere that isn't a @@ -268,6 +301,25 @@ export const TimelineEditor = () => { document.removeEventListener("pointerdown", onPointerDown, true); }, [easingKeyframeId]); + // Same dismissal for the clip gap-transition popover (its chips carry + // data-transition-chip). + useEffect(() => { + if (!easingClipLaneId) return undefined; + const onPointerDown = (e: PointerEvent) => { + const target = e.target as HTMLElement | null; + if ( + target?.closest("#motion-popover") || + target?.closest("[data-transition-chip]") + ) { + return; + } + usePageSceneStore.getState().setTimelineEasingClipLane(null); + }; + document.addEventListener("pointerdown", onPointerDown, true); + return () => + document.removeEventListener("pointerdown", onPointerDown, true); + }, [easingClipLaneId]); + const playheadFraction = timeToFraction(playhead, duration); const togglePlay = () => { @@ -496,18 +548,61 @@ export const TimelineEditor = () => { {/* Anchor the popover in the lane area (same geometry as the - playhead overlay) so its left % lines up with the easing chip. */} - {easingAnchor && ( + playhead overlay) so its left % lines up with the easing chip. + Keyframe easing takes priority over a clip transition so the two + popovers (which share #motion-popover) never render together. */} + {easingAnchor ? (
+ editor && + setKeyframeEasing(editor, easingAnchor.keyframe.id, next) + } leftPercent={easingAnchor.leftPercent} />
- )} + ) : clipTransitionAnchor ? ( +
+ + editor && + setClipTransitionEasing( + editor, + clipTransitionAnchor.laneId, + next, + ) + } + leftPercent={clipTransitionAnchor.leftPercent} + footer={ + + } + /> +
+ ) : null} {/* footer */} diff --git a/frontend/libs/components/pagescene/src/lib/engine/EngineStoreBridge.ts b/frontend/libs/components/pagescene/src/lib/engine/EngineStoreBridge.ts index d3919b7095..4b1c02d602 100644 --- a/frontend/libs/components/pagescene/src/lib/engine/EngineStoreBridge.ts +++ b/frontend/libs/components/pagescene/src/lib/engine/EngineStoreBridge.ts @@ -170,6 +170,21 @@ export class EngineStoreBridge { ) { store.setTimelineSelectedClipLane(null); } + // The transition popover must close not only when its lane vanishes + // but also when the lane LOSES its transition while surviving — + // Cancel and undo-of-a-Save revert transitionEasing wholesale with + // lane ids intact, and a popover left open would silently re-create + // the reverted transition on the next curve drag. (The enable flow + // is safe: toggleTransition sets the easing BEFORE selecting the + // lane, so this prune never races it.) + if (store.timelineEasingClipLaneId) { + const easingLane = e.clipLanes.find( + (l) => l.id === store.timelineEasingClipLaneId, + ); + if (!easingLane || !easingLane.strip.transitionEasing) { + store.setTimelineEasingClipLane(null); + } + } }), bus.subscribe(TimelinePlayheadEvent, (e) => { const store = usePageSceneStore.getState(); diff --git a/frontend/libs/components/pagescene/src/lib/engine/animation/CharacterAnimationManager.ts b/frontend/libs/components/pagescene/src/lib/engine/animation/CharacterAnimationManager.ts index 7c0fb1beb9..e94f4d05a9 100644 --- a/frontend/libs/components/pagescene/src/lib/engine/animation/CharacterAnimationManager.ts +++ b/frontend/libs/components/pagescene/src/lib/engine/animation/CharacterAnimationManager.ts @@ -15,7 +15,8 @@ import * as THREE from "three"; import type Editor from "../editor"; -import type { ClipLane } from "../timeline/types"; +import type { ClipLane, EasingSpec } from "../timeline/types"; +import { cubicBezierYForX } from "../timeline/interpolation"; // Per-lane runtime state. `action` is null while the clip GLB is still // loading — or TERMINALLY, when the lane can't bind (see `boundRoot`). @@ -38,6 +39,8 @@ interface LaneRuntime { // 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; + // Opt-in transition curve into the NEXT strip on the row (see ClipStrip). + transitionEasing?: EasingSpec; } // Captured local transform of one node, for rest-pose restoration. @@ -78,10 +81,11 @@ export class CharacterAnimationManager { if (!rt) { void this.addLane(lane); } else { - // Placement and trim can change without a reload. + // Placement, trim and transition can change without a reload. rt.startTime = lane.strip.startTime; rt.loop = lane.strip.loop; rt.stripDuration = lane.strip.duration; + rt.transitionEasing = lane.strip.transitionEasing; } } this.pruneMixers(); @@ -93,48 +97,93 @@ 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(); - // 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(); + // Process per character. Inside a strip, exactly ONE lane wins: strip + // intervals are closed at both ends (seek-to-end holds a final pose) and + // strips snap flush, so at a shared boundary the LATER strip (smaller + // local time) wins — enabling both would blend A's last frame with B's + // first at half weight each, a garbage pose the deterministic recorder + // would encode into video. In a GAP, the OPT-IN transition on the + // leading strip (transitionEasing) cross-fades its exit pose into the + // next strip's entry pose — the one deliberate two-action case; weights + // sum to 1, so the mixer computes a proper lerp. + const byCharacter = new Map(); for (const rt of this.lanes.values()) { - const action = rt.action; - if (!action) continue; - 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; - } + if (!rt.action) continue; + const list = byCharacter.get(rt.characterUuid); + if (list) list.push(rt); + else byCharacter.set(rt.characterUuid, [rt]); } - 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; - action.setEffectiveWeight(1); - posed.add(rt.characterUuid); + for (const [uuid, lanes] of byCharacter) { + lanes.sort((a, b) => a.startTime - b.startTime); + + let winner: LaneRuntime | null = null; + for (const rt of lanes) { + const local = playhead - rt.startTime; + if (local < 0 || local > rt.stripDuration) continue; + if (!winner || local < playhead - winner.startTime) winner = rt; + } + + // Gap: find the bracketing strips; blend only when the leading one + // opted in and the gap has real width. + let blendPrev: LaneRuntime | null = null; + let blendNext: LaneRuntime | null = null; + let blendWeight = 0; + if (!winner) { + let prev: LaneRuntime | null = null; + let next: LaneRuntime | null = null; + for (const rt of lanes) { + if (rt.startTime + rt.stripDuration <= playhead) prev = rt; + if (next === null && rt.startTime >= playhead) next = rt; + } + if (prev?.transitionEasing && next) { + const prevEnd = prev.startTime + prev.stripDuration; + const span = next.startTime - prevEnd; + if (span > 1e-6) { + const progress = (playhead - prevEnd) / span; + blendWeight = Math.max( + 0, + Math.min(1, cubicBezierYForX(prev.transitionEasing, progress)), + ); + blendPrev = prev; + blendNext = next; + } + } + } + + for (const rt of lanes) { + const action = rt.action!; + if (rt === winner) { + 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. + const dur = rt.clipDuration; + if (rt.loop && dur > 0) local = local % dur; + else local = Math.min(local, dur); + action.enabled = true; + action.paused = true; // we drive time ourselves; don't auto-advance + action.time = local; + action.setEffectiveWeight(1); + posed.add(uuid); + } else if (rt === blendPrev || rt === blendNext) { + // Pinned poses: the leading strip holds its EXIT frame (loop-wrap + // included, matching what its own end displays), the next strip + // its ENTRY frame. + const dur = rt.clipDuration; + const exit = + rt.loop && dur > 0 + ? rt.stripDuration % dur + : Math.min(rt.stripDuration, dur); + action.enabled = true; + action.paused = true; + action.time = rt === blendPrev ? exit : 0; + action.setEffectiveWeight( + rt === blendPrev ? 1 - blendWeight : blendWeight, + ); + posed.add(uuid); + } else { + action.enabled = false; + } + } } // 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 @@ -209,6 +258,7 @@ export class CharacterAnimationManager { clipDuration: lane.strip.duration, stripDuration: lane.strip.duration, boundRoot: null, + transitionEasing: lane.strip.transitionEasing, }; this.lanes.set(lane.id, rt); diff --git a/frontend/libs/components/pagescene/src/lib/engine/editor/TimelineController.ts b/frontend/libs/components/pagescene/src/lib/engine/editor/TimelineController.ts index 4d89be0259..50da707aa2 100644 --- a/frontend/libs/components/pagescene/src/lib/engine/editor/TimelineController.ts +++ b/frontend/libs/components/pagescene/src/lib/engine/editor/TimelineController.ts @@ -16,6 +16,7 @@ import { DEFAULT_EASING, DEFAULT_TIMELINE_DURATION, DEFAULT_TIMELINE_FPS, + DEFAULT_TRANSITION_GAP, type ClipLane, type ClipStrip, type EasingSpec, @@ -356,6 +357,73 @@ export class TimelineController { this.emitChanged(); } + // Set (or clear, with null) the opt-in pose transition from `laneId`'s + // strip into the NEXT strip on its row. Rides the Save/Cancel session + // like every other clip edit. + setClipTransitionEasing(laneId: string, easing: EasingSpec | null): void { + if (!this.timeline) return; + const lane = this.timeline.clipLanes.find((l) => l.id === laneId); + if (!lane) return; + if (easing) lane.strip.transitionEasing = { ...easing }; + else delete lane.strip.transitionEasing; + this.syncClipLanes(); + this.evaluate(); + this.emitChanged(); + } + + // Guarantee at least `gap` seconds of daylight between `laneId`'s strip and + // the NEXT strip on its row, so an enabled transition has room to play. + // Auto-placement packs strips flush (resolveFreeStart), so this is what + // makes the boundary chip usable without hand-dragging a gap open first. + // Makes room by shifting the next strip right (bounded by the strip after + // it / the timeline end), then by trimming the leading strip when shifting + // alone isn't enough. Returns false — mutating nothing — when there is no + // next strip or the row is too packed to make room (callers surface that). + // Rides the Save/Cancel session like every other clip edit. + ensureTransitionGap(laneId: string, gap = DEFAULT_TRANSITION_GAP): boolean { + if (!this.timeline) return false; + const lead = this.timeline.clipLanes.find((l) => l.id === laneId); + if (!lead) return false; + const leadEnd = lead.strip.startTime + lead.strip.duration; + // Epsilon: a strip trimmed flush against its neighbour can put leadEnd a + // float-rounding hair PAST the neighbour's start; `>= leadEnd` alone + // would then miss it and misreport "no next strip". + const next = this.timeline.clipLanes + .filter( + (l) => + l.objectUuid === lead.objectUuid && + l.id !== lead.id && + l.strip.startTime >= leadEnd - 1e-6, + ) + .sort((a, b) => a.strip.startTime - b.strip.startTime)[0]; + if (!next) return false; + const needed = gap - (next.strip.startTime - leadEnd); + if (needed <= 0) return true; + const followBound = this.nextStartAfter( + lead.objectUuid, + next.id, + next.strip.startTime, + ); + const shiftAvail = Math.max( + 0, + followBound - (next.strip.startTime + next.strip.duration), + ); + const minDur = 1 / (this.timeline.fps || DEFAULT_TIMELINE_FPS); + const trimAvail = Math.max(0, lead.strip.duration - minDur); + if (shiftAvail + trimAvail < needed) return false; + const shift = Math.min(needed, shiftAvail); + next.strip.startTime += shift; + const trim = needed - shift; + if (trim > 0) { + lead.strip.duration -= trim; + lead.strip.autoDuration = false; + } + this.syncClipLanes(); + this.evaluate(); + this.emitChanged(); + return true; + } + // Toggle whether a clip loops for the remainder of its strip. setClipLoop(laneId: string, loop: boolean): void { if (!this.timeline) return; diff --git a/frontend/libs/components/pagescene/src/lib/engine/timeline/types.ts b/frontend/libs/components/pagescene/src/lib/engine/timeline/types.ts index 33aa091f54..17b8177e30 100644 --- a/frontend/libs/components/pagescene/src/lib/engine/timeline/types.ts +++ b/frontend/libs/components/pagescene/src/lib/engine/timeline/types.ts @@ -51,6 +51,13 @@ export interface ClipStrip { // 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; + // OPT-IN transition into the FOLLOWING strip on the same row: when set, + // the gap between this strip's end and the next strip's start blends this + // strip's exit pose into the next strip's entry pose along this curve + // (same cubic-bezier semantics as keyframe easing). Absent = the gap + // shows the rest pose, as always. Lives on the LEADING strip, mirroring + // how keyframes store the easing "into the next". + transitionEasing?: EasingSpec; } // One animation lane under an object; each lane holds a single clip strip. @@ -80,6 +87,9 @@ export const EASING_PRESETS: Record = { }; export const DEFAULT_EASING: EasingSpec = EASING_PRESETS.easeInOut; +// Gap (seconds) auto-opened between two flush strips when the user enables a +// transition at their boundary — the blend needs room on the timeline to play. +export const DEFAULT_TRANSITION_GAP = 0.3; export const DEFAULT_TIMELINE_DURATION = 10; // seconds export const DEFAULT_TIMELINE_FPS = 30;