mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-11 20:32:12 +00:00
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:
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";
|
||||
|
||||
Reference in new issue
Block a user