Scene Builder: animation clip-lane data model (Mixamo drawer, phase A)

Foundation for placing Mixamo skeletal-animation clips on the timeline as
stacked lanes under character objects, alongside the existing keyframe tracks.

- ClipStrip / ClipLane types; TimelineData gains a `clipLanes` array (only the
  clip reference + placement is serialized — the AnimationClip is resolved and
  retargeted from sourceMediaId at runtime).
- TimelineController: addClipLane / moveClipLane / removeClipLane / clipLanesFor,
  create()/loadTimeline() init + backfill clipLanes, emitChanged carries them.
- TimelineChangedEvent → store mirror (timelineClipLanes) via EngineStoreBridge.
- Persistence rides the existing timeline save/load (getTimeline/loadTimeline).
This commit is contained in:
Tanish Manku committed 2026-07-07 15:28:50 -07:00
1 parent 11c7aa979d
commit 5c2fbbc9f3
5 files changed
+87 -3

No files matched your search

@@ -18,8 +18,10 @@ export type {
Keyframe,
TimelineTrack,
TimelineData,
ClipLane,
ClipStrip,
} from "./engine/timeline/types";
import type { TimelineTrack } from "./engine/timeline/types";
import type { TimelineTrack, ClipLane } from "./engine/timeline/types";
// A still (Capture) or video (Record) produced by Record mode, cached
// locally (object URL) before any upload. Powers the completion modal's
@@ -183,6 +185,7 @@ interface PageSceneState {
timelineIsPlaying: boolean;
timelineDuration: number;
timelineTracks: TimelineTrack[];
timelineClipLanes: ClipLane[];
timelineSelectedKeyframeId: string | null;
// Left keyframe of the segment whose easing curve is being edited in the
// Motion popover (opened from the curve chip BETWEEN two keyframes).
@@ -292,6 +295,7 @@ interface PageSceneState {
setTimelineIsPlaying: (playing: boolean) => void;
setTimelineDuration: (duration: number) => void;
setTimelineTracks: (tracks: TimelineTrack[]) => void;
setTimelineClipLanes: (clipLanes: ClipLane[]) => void;
setTimelineSelectedKeyframe: (id: string | null) => void;
setTimelineEasingKeyframe: (id: string | null) => void;
setProducedArtifact: (artifact: ProducedArtifact | null) => void;
@@ -387,6 +391,7 @@ export const usePageSceneStore = create<PageSceneState>((set, get) => ({
timelineIsPlaying: false,
timelineDuration: 10,
timelineTracks: [],
timelineClipLanes: [],
timelineSelectedKeyframeId: null,
timelineEasingKeyframeId: null,
producedArtifact: null,
@@ -493,6 +498,7 @@ export const usePageSceneStore = create<PageSceneState>((set, get) => ({
setTimelineIsPlaying: (playing) => set({ timelineIsPlaying: playing }),
setTimelineDuration: (duration) => set({ timelineDuration: duration }),
setTimelineTracks: (tracks) => set({ timelineTracks: tracks }),
setTimelineClipLanes: (clipLanes) => set({ timelineClipLanes: clipLanes }),
setTimelineSelectedKeyframe: (id) => set({ timelineSelectedKeyframeId: id }),
setTimelineEasingKeyframe: (id) => set({ timelineEasingKeyframeId: id }),
setProducedArtifact: (artifact) => set({ producedArtifact: artifact }),
@@ -145,6 +145,7 @@ export class EngineStoreBridge {
store.setTimelineExists(e.exists);
store.setTimelineDuration(e.duration);
store.setTimelineTracks(e.tracks);
store.setTimelineClipLanes(e.clipLanes);
}),
bus.subscribe(TimelinePlayheadEvent, (e) => {
const store = usePageSceneStore.getState();
@@ -16,6 +16,8 @@ import {
DEFAULT_EASING,
DEFAULT_TIMELINE_DURATION,
DEFAULT_TIMELINE_FPS,
type ClipLane,
type ClipStrip,
type EasingSpec,
type Keyframe,
type TimelineData,
@@ -52,6 +54,7 @@ export class TimelineController {
duration: DEFAULT_TIMELINE_DURATION,
fps: DEFAULT_TIMELINE_FPS,
tracks: [],
clipLanes: [],
};
this.saved = cloneTimeline(this.timeline);
this.playhead = 0;
@@ -81,6 +84,7 @@ export class TimelineController {
this.timeline.duration = DEFAULT_TIMELINE_DURATION;
}
if (!Array.isArray(this.timeline.tracks)) this.timeline.tracks = [];
if (!Array.isArray(this.timeline.clipLanes)) this.timeline.clipLanes = [];
this.saved = cloneTimeline(this.timeline);
this.playhead = Math.min(this.playhead, this.timeline.duration);
this.evaluate();
@@ -224,6 +228,55 @@ export class TimelineController {
this.emitChanged();
}
// ─── clip lanes (skeletal animation) ─────────────────────────────────────
// Add a new stacked clip lane for `objectUuid` (a character). The clip is
// dropped at `startTime`, clamped so it starts within the timeline. Returns
// the new lane id, or null if there is no timeline.
addClipLane(objectUuid: string, strip: Omit<ClipStrip, "id">): string | null {
if (!this.timeline) return null;
const startTime = Math.max(
0,
Math.min(strip.startTime, this.timeline.duration),
);
const lane: ClipLane = {
id: THREE.MathUtils.generateUUID(),
objectUuid,
strip: { ...strip, id: THREE.MathUtils.generateUUID(), startTime },
};
this.timeline.clipLanes.push(lane);
this.evaluate();
this.emitChanged();
return lane.id;
}
// Move a clip strip along its lane. Clamped so the strip stays within the
// timeline (its start never pushes the clip's end past `duration`).
moveClipLane(laneId: string, startTime: number): void {
if (!this.timeline) return;
const lane = this.timeline.clipLanes.find((l) => l.id === laneId);
if (!lane) return;
const maxStart = Math.max(0, this.timeline.duration - lane.strip.duration);
lane.strip.startTime = Math.max(0, Math.min(startTime, maxStart));
this.evaluate();
this.emitChanged();
}
removeClipLane(laneId: string): void {
if (!this.timeline) return;
this.timeline.clipLanes = this.timeline.clipLanes.filter(
(l) => l.id !== laneId,
);
this.evaluate();
this.emitChanged();
}
// All clip lanes for one character (stacked lanes, drop order).
clipLanesFor(objectUuid: string): ClipLane[] {
if (!this.timeline) return [];
return this.timeline.clipLanes.filter((l) => l.objectUuid === objectUuid);
}
// ─── save / cancel ──────────────────────────────────────────────────────
save(): void {
@@ -285,6 +338,7 @@ export class TimelineController {
this.timeline !== null,
this.timeline?.duration ?? DEFAULT_TIMELINE_DURATION,
this.timeline ? cloneTimeline(this.timeline).tracks : [],
this.timeline ? cloneTimeline(this.timeline).clipLanes : [],
),
);
}
@@ -21,7 +21,7 @@ import type {
TransformSpace,
} from "../../PageSceneStore";
import type { Camera } from "@storyteller/common";
import type { TimelineTrack } from "../timeline/types";
import type { ClipLane, TimelineTrack } from "../timeline/types";
export abstract class EngineEvent {
readonly timestamp: number = performance.now();
@@ -195,6 +195,7 @@ export class TimelineChangedEvent extends EngineEvent {
readonly exists: boolean,
readonly duration: number,
readonly tracks: TimelineTrack[],
readonly clipLanes: ClipLane[],
) {
super();
}
@@ -29,10 +29,32 @@ export interface TimelineTrack {
keyframes: Keyframe[]; // kept sorted ascending by time
}
// A skeletal animation clip (Mixamo) placed on a character's lane. Only the
// reference + placement is serialized; the actual THREE.AnimationClip is
// resolved from `sourceMediaId` and retargeted onto the character at runtime.
export interface ClipStrip {
id: string;
sourceMediaId: string; // media id of the animation GLB (reload + retarget)
name: string; // display label
startTime: number; // seconds from timeline start
duration: number; // seconds (clip length; trimmable later)
loop: boolean;
}
// One animation lane under a character. Multiple lanes (stacked) = multiple
// clips on the same character; each lane holds a single clip strip. Only
// character objects get clip lanes.
export interface ClipLane {
id: string;
objectUuid: string; // the character this lane animates
strip: ClipStrip;
}
export interface TimelineData {
duration: number; // seconds
fps: number;
tracks: TimelineTrack[];
tracks: TimelineTrack[]; // per-object transform keyframes
clipLanes: ClipLane[]; // per-character skeletal animation clips
}
export type EasingPresetName = "linear" | "easeIn" | "easeOut" | "easeInOut";