diff --git a/frontend/libs/components/pagescene/src/lib/PageSceneStore.ts b/frontend/libs/components/pagescene/src/lib/PageSceneStore.ts index b42e4dece2..f2dfa23373 100644 --- a/frontend/libs/components/pagescene/src/lib/PageSceneStore.ts +++ b/frontend/libs/components/pagescene/src/lib/PageSceneStore.ts @@ -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((set, get) => ({ timelineIsPlaying: false, timelineDuration: 10, timelineTracks: [], + timelineClipLanes: [], timelineSelectedKeyframeId: null, timelineEasingKeyframeId: null, producedArtifact: null, @@ -493,6 +498,7 @@ export const usePageSceneStore = create((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 }), diff --git a/frontend/libs/components/pagescene/src/lib/engine/EngineStoreBridge.ts b/frontend/libs/components/pagescene/src/lib/engine/EngineStoreBridge.ts index b4eef16d9c..5296146d37 100644 --- a/frontend/libs/components/pagescene/src/lib/engine/EngineStoreBridge.ts +++ b/frontend/libs/components/pagescene/src/lib/engine/EngineStoreBridge.ts @@ -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(); 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 a9cdebf857..89163e7dd2 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,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): 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 : [], ), ); } diff --git a/frontend/libs/components/pagescene/src/lib/engine/events/EngineEvent.ts b/frontend/libs/components/pagescene/src/lib/engine/events/EngineEvent.ts index 306434d4f8..cd8d052547 100644 --- a/frontend/libs/components/pagescene/src/lib/engine/events/EngineEvent.ts +++ b/frontend/libs/components/pagescene/src/lib/engine/events/EngineEvent.ts @@ -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(); } 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 0af31d8e0c..5dc8f05c59 100644 --- a/frontend/libs/components/pagescene/src/lib/engine/timeline/types.ts +++ b/frontend/libs/components/pagescene/src/lib/engine/timeline/types.ts @@ -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";