mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-31 07:27:51 +00:00
3D: Animation clip blending (#1861)
This commit is contained in:
1 parent
d598896e40
commit
a048458956
10 files changed
+441
-69
No files matched your search
@@ -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();
|
||||
|
||||
+92
-42
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user