3D: Animation clip blending (#1861)

This commit is contained in:
Tanish Manku authored and GitHub committed 2026-08-07 18:48:54 -07:00
1 parent d598896e40
commit a048458956
10 files changed
+441 -69

No files matched your search

+26 -3
View File
@@ -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
@@ -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<PageSceneState>((set, get) => ({
timelineSelectedKeyframeId: null,
timelineSelectedClipLaneId: null,
timelineEasingKeyframeId: null,
timelineEasingClipLaneId: null,
timelineRevealObjectUuid: null,
timelineFocusSelected: false,
producedArtifact: null,
@@ -546,6 +551,7 @@ export const usePageSceneStore = create<PageSceneState>((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 }),
@@ -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 {
@@ -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<SVGSVGElement>(null);
const [dragging, setDragging] = useState<null | "p1" | "p2">(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()}
>
<div className="mb-2 text-xs font-semibold text-base-fg/80">Motion</div>
<div className="mb-2 text-xs font-semibold text-base-fg/80">{title}</div>
<div className="mb-3 grid grid-cols-4 gap-1">
{PRESETS.map((p) => {
const preset = EASING_PRESETS[p.name];
@@ -182,6 +186,7 @@ export const MotionPopover = ({ keyframe, leftPercent }: MotionPopoverProps) =>
}}
/>
</svg>
{footer}
</div>
);
};
@@ -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 = ({
</div>
);
})}
{/* 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 (
<button
key={`transition-${lane.id}`}
type="button"
data-transition-chip
title={
active
? "Edit transition into the next clip"
: "Blend into the next clip"
}
className={`absolute top-1/2 z-10 flex h-4 w-4 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border transition-colors ${
active
? "border-brand-primary bg-brand-primary text-white"
: "border-dashed border-white/30 bg-black/30 text-white/40 hover:border-white/60 hover:text-white/80"
}`}
style={{ left: `${midPct}%` }}
onPointerDown={(e) => e.stopPropagation()}
onClick={() => toggleTransition(lane)}
>
<FontAwesomeIcon icon={faBezierCurve} className="h-2 w-2" />
</button>
);
})}
</div>
{/* spacer to match the keyframe row's add-button column (keeps lane
@@ -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 = () => {
</div>
{/* 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 ? (
<div
className="pointer-events-none absolute inset-y-0"
style={{ left: LANE_LEFT, right: LANE_RIGHT }}
>
<MotionPopover
keyframe={easingAnchor.keyframe}
easing={easingAnchor.keyframe.easing}
onChange={(next) =>
editor &&
setKeyframeEasing(editor, easingAnchor.keyframe.id, next)
}
leftPercent={easingAnchor.leftPercent}
/>
</div>
)}
) : clipTransitionAnchor ? (
<div
className="pointer-events-none absolute inset-y-0"
style={{ left: LANE_LEFT, right: LANE_RIGHT }}
>
<MotionPopover
title="Transition"
easing={clipTransitionAnchor.easing}
onChange={(next) =>
editor &&
setClipTransitionEasing(
editor,
clipTransitionAnchor.laneId,
next,
)
}
leftPercent={clipTransitionAnchor.leftPercent}
footer={
<button
type="button"
className="mt-2 w-full rounded-md border border-ui-controls-border/60 px-2 py-1.5 text-[11px] text-base-fg/80 transition-colors hover:bg-ui-controls/40"
onClick={() => {
if (editor) {
setClipTransitionEasing(
editor,
clipTransitionAnchor.laneId,
null,
);
}
usePageSceneStore.getState().setTimelineEasingClipLane(null);
}}
>
Remove transition
</button>
}
/>
</div>
) : null}
</div>
{/* footer */}
@@ -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();
@@ -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<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>();
// 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<string, LaneRuntime[]>();
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);
@@ -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;
@@ -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<EasingPresetName, EasingSpec> = {
};
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;