@aura3d/editor-runtime 1.0.6 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/AssetDropZone.d.ts +52 -0
- package/dist/AssetDropZone.d.ts.map +1 -0
- package/dist/AssetDropZone.js +115 -0
- package/dist/AssetDropZone.js.map +1 -0
- package/dist/CameraPathEditor.d.ts +48 -0
- package/dist/CameraPathEditor.d.ts.map +1 -0
- package/dist/CameraPathEditor.js +181 -0
- package/dist/CameraPathEditor.js.map +1 -0
- package/dist/CartoonSceneEditor.d.ts +111 -0
- package/dist/CartoonSceneEditor.d.ts.map +1 -0
- package/dist/CartoonSceneEditor.js +285 -0
- package/dist/CartoonSceneEditor.js.map +1 -0
- package/dist/CurveEditor.d.ts +38 -0
- package/dist/CurveEditor.d.ts.map +1 -0
- package/dist/CurveEditor.js +112 -0
- package/dist/CurveEditor.js.map +1 -0
- package/dist/EpisodeReviewPanel.d.ts +67 -0
- package/dist/EpisodeReviewPanel.d.ts.map +1 -0
- package/dist/EpisodeReviewPanel.js +162 -0
- package/dist/EpisodeReviewPanel.js.map +1 -0
- package/dist/KeyframeEditor.d.ts +49 -0
- package/dist/KeyframeEditor.d.ts.map +1 -0
- package/dist/KeyframeEditor.js +161 -0
- package/dist/KeyframeEditor.js.map +1 -0
- package/dist/MultiUserReviewWorkflow.d.ts +77 -0
- package/dist/MultiUserReviewWorkflow.d.ts.map +1 -0
- package/dist/MultiUserReviewWorkflow.js +162 -0
- package/dist/MultiUserReviewWorkflow.js.map +1 -0
- package/dist/NonlinearAnimationEditor.d.ts +81 -0
- package/dist/NonlinearAnimationEditor.d.ts.map +1 -0
- package/dist/NonlinearAnimationEditor.js +180 -0
- package/dist/NonlinearAnimationEditor.js.map +1 -0
- package/dist/PropertyPanel.d.ts +21 -0
- package/dist/PropertyPanel.d.ts.map +1 -0
- package/dist/PropertyPanel.js +72 -0
- package/dist/PropertyPanel.js.map +1 -0
- package/dist/RenderQueuePanel.d.ts +38 -0
- package/dist/RenderQueuePanel.d.ts.map +1 -0
- package/dist/RenderQueuePanel.js +72 -0
- package/dist/RenderQueuePanel.js.map +1 -0
- package/dist/SceneOutliner.d.ts +19 -0
- package/dist/SceneOutliner.d.ts.map +1 -0
- package/dist/SceneOutliner.js +64 -0
- package/dist/SceneOutliner.js.map +1 -0
- package/dist/TimelineEditorController.d.ts +102 -0
- package/dist/TimelineEditorController.d.ts.map +1 -0
- package/dist/TimelineEditorController.js +449 -0
- package/dist/TimelineEditorController.js.map +1 -0
- package/dist/TimelineTrackTypes.d.ts +25 -0
- package/dist/TimelineTrackTypes.d.ts.map +1 -0
- package/dist/TimelineTrackTypes.js +48 -0
- package/dist/TimelineTrackTypes.js.map +1 -0
- package/dist/TimelineUI.d.ts +27 -0
- package/dist/TimelineUI.d.ts.map +1 -0
- package/dist/TimelineUI.js +123 -0
- package/dist/TimelineUI.js.map +1 -0
- package/dist/VisualReviewDashboard.d.ts +43 -0
- package/dist/VisualReviewDashboard.d.ts.map +1 -0
- package/dist/VisualReviewDashboard.js +47 -0
- package/dist/VisualReviewDashboard.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/AssetDropZone.ts +144 -0
- package/src/CameraPathEditor.ts +223 -0
- package/src/CartoonSceneEditor.ts +368 -0
- package/src/CurveEditor.ts +152 -0
- package/src/EpisodeReviewPanel.ts +236 -0
- package/src/KeyframeEditor.ts +214 -0
- package/src/MultiUserReviewWorkflow.ts +229 -0
- package/src/NonlinearAnimationEditor.ts +262 -0
- package/src/PropertyPanel.ts +82 -0
- package/src/RenderQueuePanel.ts +106 -0
- package/src/SceneOutliner.ts +78 -0
- package/src/TimelineEditorController.ts +525 -0
- package/src/TimelineTrackTypes.ts +89 -0
- package/src/TimelineUI.ts +153 -0
- package/src/VisualReviewDashboard.ts +87 -0
- package/src/index.ts +53 -0
- package/tests/editor-runtime.test.ts +490 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import type { Command } from "./Command";
|
|
2
|
+
import { EditorRuntime } from "./EditorRuntime";
|
|
3
|
+
import type { EpisodeReviewPanelSnapshot } from "./EpisodeReviewPanel";
|
|
4
|
+
import type { HierarchyLikeNode } from "./HierarchyModel";
|
|
5
|
+
import type { RenderQueuePanelSnapshot } from "./RenderQueuePanel";
|
|
6
|
+
import { AssetDropZone, type AssetDropPlacement, type CartoonAssetCategory, type CartoonEditorAssetReference } from "./AssetDropZone";
|
|
7
|
+
|
|
8
|
+
export interface CartoonSceneTransform {
|
|
9
|
+
position: { x: number; y: number; z: number };
|
|
10
|
+
rotation: { x: number; y: number; z: number; w: number };
|
|
11
|
+
scale: { x: number; y: number; z: number };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CartoonSceneNode extends HierarchyLikeNode {
|
|
15
|
+
readonly id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
kind: CartoonAssetCategory;
|
|
18
|
+
visible: boolean;
|
|
19
|
+
asset?: CartoonEditorAssetReference;
|
|
20
|
+
transform: CartoonSceneTransform;
|
|
21
|
+
animationClips: string[];
|
|
22
|
+
material?: string;
|
|
23
|
+
readonly children: CartoonSceneNode[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CartoonSceneEditorSnapshot {
|
|
27
|
+
readonly nodeCount: number;
|
|
28
|
+
readonly selectedIds: readonly (string | number)[];
|
|
29
|
+
readonly placedAssetCount: number;
|
|
30
|
+
readonly cameraPreviewId: string | null;
|
|
31
|
+
readonly hierarchy: ReturnType<EditorRuntime["flattenHierarchy"]>;
|
|
32
|
+
readonly evidence: {
|
|
33
|
+
readonly assetPlacement: boolean;
|
|
34
|
+
readonly transformGizmos: true;
|
|
35
|
+
readonly propertyInspector: true;
|
|
36
|
+
readonly sceneSaveLoad: true;
|
|
37
|
+
readonly episodeState: boolean;
|
|
38
|
+
};
|
|
39
|
+
readonly episode: CartoonSceneEpisodeSnapshot;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CartoonSceneEditorOptions {
|
|
43
|
+
readonly runtime?: EditorRuntime;
|
|
44
|
+
readonly root?: CartoonSceneNode;
|
|
45
|
+
readonly episode?: Partial<CartoonSceneEpisodeState>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CartoonSceneEpisodeState {
|
|
49
|
+
readonly shots: readonly string[];
|
|
50
|
+
readonly assets: readonly string[];
|
|
51
|
+
readonly captions: readonly string[];
|
|
52
|
+
readonly visemes: readonly string[];
|
|
53
|
+
readonly renderState?: RenderQueuePanelSnapshot;
|
|
54
|
+
readonly reviewState?: EpisodeReviewPanelSnapshot;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface CartoonSceneEpisodeSnapshot extends CartoonSceneEpisodeState {
|
|
58
|
+
readonly shotCount: number;
|
|
59
|
+
readonly assetCount: number;
|
|
60
|
+
readonly captionCount: number;
|
|
61
|
+
readonly visemeCount: number;
|
|
62
|
+
readonly hasRenderState: boolean;
|
|
63
|
+
readonly hasReviewState: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class CartoonSceneEditor {
|
|
67
|
+
readonly runtime: EditorRuntime;
|
|
68
|
+
readonly root: CartoonSceneNode;
|
|
69
|
+
readonly dropZone: AssetDropZone<CartoonSceneNode>;
|
|
70
|
+
private cameraPreviewIdInternal: string | null = null;
|
|
71
|
+
private episodeState: CartoonSceneEpisodeState;
|
|
72
|
+
|
|
73
|
+
constructor(options: CartoonSceneEditorOptions = {}) {
|
|
74
|
+
this.runtime = options.runtime ?? new EditorRuntime();
|
|
75
|
+
this.root = options.root ?? createCartoonSceneNode({ id: "scene-root", name: "Scene", kind: "set" });
|
|
76
|
+
this.episodeState = normalizeEpisodeState(options.episode ?? {});
|
|
77
|
+
this.dropZone = new AssetDropZone<CartoonSceneNode>({
|
|
78
|
+
history: this.runtime.history,
|
|
79
|
+
createNode: (asset, placement) => this.createNodeFromAsset(asset, placement),
|
|
80
|
+
addNode: (node, placement) => this.addNode(node, placement.targetId),
|
|
81
|
+
removeNode: (node) => this.removeNode(node.id)
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async placeAsset(asset: CartoonEditorAssetReference, placement: AssetDropPlacement = {}): Promise<CartoonSceneNode> {
|
|
86
|
+
const result = await this.dropZone.dropAsset(asset, placement);
|
|
87
|
+
this.runtime.select([result.node.id]);
|
|
88
|
+
return result.node;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async addEmptyNode(config: { readonly id?: string; readonly name: string; readonly kind?: CartoonAssetCategory; readonly parentId?: string }): Promise<CartoonSceneNode> {
|
|
92
|
+
const node = createCartoonSceneNode({
|
|
93
|
+
id: config.id ?? stableNodeId(config.name),
|
|
94
|
+
name: config.name,
|
|
95
|
+
kind: config.kind ?? "prop"
|
|
96
|
+
});
|
|
97
|
+
await this.runtime.history.execute(new AddCartoonNodeCommand(this.root, node, config.parentId));
|
|
98
|
+
return node;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async removeNode(id: string): Promise<void> {
|
|
102
|
+
if (id === this.root.id) throw new Error("Cannot remove the root cartoon scene node.");
|
|
103
|
+
const parent = findParent(this.root, id);
|
|
104
|
+
const node = findNode(this.root, id);
|
|
105
|
+
if (!parent || !node) throw new Error(`Cartoon scene node does not exist: ${id}`);
|
|
106
|
+
await this.runtime.history.execute(new RemoveCartoonNodeCommand(parent, node));
|
|
107
|
+
this.runtime.pruneSelection((selectionId) => selectionId !== id);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async setTransform(id: string, transform: Partial<CartoonSceneTransform>): Promise<void> {
|
|
111
|
+
const node = this.requireNode(id);
|
|
112
|
+
await this.runtime.history.execute(new SetCartoonTransformCommand(node, mergeTransform(node.transform, transform)));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
selectNode(id: string): void {
|
|
116
|
+
this.requireNode(id);
|
|
117
|
+
this.runtime.select([id]);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
setCameraPreview(id: string | null): void {
|
|
121
|
+
if (id !== null) {
|
|
122
|
+
const node = this.requireNode(id);
|
|
123
|
+
if (node.kind !== "camera") throw new Error(`Camera preview requires a camera node: ${id}`);
|
|
124
|
+
}
|
|
125
|
+
this.cameraPreviewIdInternal = id;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
setEpisodeState(state: Partial<CartoonSceneEpisodeState>): CartoonSceneEpisodeSnapshot {
|
|
129
|
+
this.episodeState = normalizeEpisodeState({ ...this.episodeState, ...state });
|
|
130
|
+
return this.episodeSnapshot();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
serializeScene(): CartoonSceneNode {
|
|
134
|
+
return cloneNode(this.root);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
loadScene(root: CartoonSceneNode): void {
|
|
138
|
+
this.root.children.splice(0, this.root.children.length, ...root.children.map(cloneNode));
|
|
139
|
+
this.root.name = root.name;
|
|
140
|
+
this.root.visible = root.visible;
|
|
141
|
+
this.runtime.clearSelection();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
flattenHierarchy(): ReturnType<EditorRuntime["flattenHierarchy"]> {
|
|
145
|
+
return this.runtime.flattenHierarchy(this.root);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
inspectSelected(): readonly ReturnType<EditorRuntime["inspect"]>[number][] {
|
|
149
|
+
const selected = this.runtime.currentSelection()[0];
|
|
150
|
+
if (selected === undefined) return [];
|
|
151
|
+
return this.runtime.inspect(this.requireNode(String(selected)));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
snapshot(): CartoonSceneEditorSnapshot {
|
|
155
|
+
const nodes = flattenNodes(this.root);
|
|
156
|
+
return {
|
|
157
|
+
nodeCount: nodes.length,
|
|
158
|
+
selectedIds: this.runtime.currentSelection(),
|
|
159
|
+
placedAssetCount: nodes.filter((node) => node.asset).length,
|
|
160
|
+
cameraPreviewId: this.cameraPreviewIdInternal,
|
|
161
|
+
hierarchy: this.flattenHierarchy(),
|
|
162
|
+
evidence: {
|
|
163
|
+
assetPlacement: nodes.some((node) => node.asset),
|
|
164
|
+
transformGizmos: true,
|
|
165
|
+
propertyInspector: true,
|
|
166
|
+
sceneSaveLoad: true,
|
|
167
|
+
episodeState: this.episodeState.shots.length > 0
|
|
168
|
+
|| this.episodeState.captions.length > 0
|
|
169
|
+
|| this.episodeState.visemes.length > 0
|
|
170
|
+
|| Boolean(this.episodeState.renderState)
|
|
171
|
+
|| Boolean(this.episodeState.reviewState)
|
|
172
|
+
},
|
|
173
|
+
episode: this.episodeSnapshot()
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private episodeSnapshot(): CartoonSceneEpisodeSnapshot {
|
|
178
|
+
return {
|
|
179
|
+
...this.episodeState,
|
|
180
|
+
shotCount: this.episodeState.shots.length,
|
|
181
|
+
assetCount: this.episodeState.assets.length,
|
|
182
|
+
captionCount: this.episodeState.captions.length,
|
|
183
|
+
visemeCount: this.episodeState.visemes.length,
|
|
184
|
+
hasRenderState: Boolean(this.episodeState.renderState),
|
|
185
|
+
hasReviewState: Boolean(this.episodeState.reviewState)
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private createNodeFromAsset(asset: CartoonEditorAssetReference, placement: AssetDropPlacement): CartoonSceneNode {
|
|
190
|
+
return createCartoonSceneNode({
|
|
191
|
+
id: stableNodeId(asset.id),
|
|
192
|
+
name: asset.name,
|
|
193
|
+
kind: asset.category ?? categoryFromAsset(asset),
|
|
194
|
+
asset,
|
|
195
|
+
transform: mergeTransform(defaultTransform(), placement)
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private addNode(node: CartoonSceneNode, parentId?: string | number): void {
|
|
200
|
+
const parent = parentId === undefined ? this.root : this.requireNode(String(parentId));
|
|
201
|
+
parent.children.push(node);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private requireNode(id: string): CartoonSceneNode {
|
|
205
|
+
const node = findNode(this.root, id);
|
|
206
|
+
if (!node) throw new Error(`Cartoon scene node does not exist: ${id}`);
|
|
207
|
+
return node;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function normalizeEpisodeState(state: Partial<CartoonSceneEpisodeState>): CartoonSceneEpisodeState {
|
|
212
|
+
return {
|
|
213
|
+
shots: [...(state.shots ?? [])],
|
|
214
|
+
assets: [...(state.assets ?? [])],
|
|
215
|
+
captions: [...(state.captions ?? [])],
|
|
216
|
+
visemes: [...(state.visemes ?? [])],
|
|
217
|
+
renderState: state.renderState,
|
|
218
|
+
reviewState: state.reviewState
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function createCartoonSceneNode(config: {
|
|
223
|
+
readonly id: string;
|
|
224
|
+
readonly name: string;
|
|
225
|
+
readonly kind: CartoonAssetCategory;
|
|
226
|
+
readonly asset?: CartoonEditorAssetReference;
|
|
227
|
+
readonly transform?: CartoonSceneTransform;
|
|
228
|
+
readonly visible?: boolean;
|
|
229
|
+
readonly animationClips?: readonly string[];
|
|
230
|
+
readonly material?: string;
|
|
231
|
+
readonly children?: readonly CartoonSceneNode[];
|
|
232
|
+
}): CartoonSceneNode {
|
|
233
|
+
return {
|
|
234
|
+
id: config.id,
|
|
235
|
+
name: config.name,
|
|
236
|
+
kind: config.kind,
|
|
237
|
+
visible: config.visible ?? true,
|
|
238
|
+
asset: config.asset,
|
|
239
|
+
transform: config.transform ?? defaultTransform(),
|
|
240
|
+
animationClips: [...(config.animationClips ?? config.asset?.clips ?? [])],
|
|
241
|
+
material: config.material,
|
|
242
|
+
children: [...(config.children ?? [])]
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
class AddCartoonNodeCommand implements Command {
|
|
247
|
+
readonly name: string;
|
|
248
|
+
|
|
249
|
+
constructor(private readonly root: CartoonSceneNode, private readonly node: CartoonSceneNode, private readonly parentId?: string) {
|
|
250
|
+
this.name = `Add cartoon node ${node.id}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
execute(): void {
|
|
254
|
+
const parent = this.parentId ? findNode(this.root, this.parentId) : this.root;
|
|
255
|
+
if (!parent) throw new Error(`Cartoon scene parent does not exist: ${this.parentId}`);
|
|
256
|
+
if (!parent.children.includes(this.node)) parent.children.push(this.node);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
undo(): void {
|
|
260
|
+
const parent = findParent(this.root, this.node.id);
|
|
261
|
+
if (parent) removeChild(parent, this.node);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
class RemoveCartoonNodeCommand implements Command {
|
|
266
|
+
readonly name: string;
|
|
267
|
+
private index = -1;
|
|
268
|
+
|
|
269
|
+
constructor(private readonly parent: CartoonSceneNode, private readonly node: CartoonSceneNode) {
|
|
270
|
+
this.name = `Remove cartoon node ${node.id}`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
execute(): void {
|
|
274
|
+
this.index = this.parent.children.indexOf(this.node);
|
|
275
|
+
removeChild(this.parent, this.node);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
undo(): void {
|
|
279
|
+
if (!this.parent.children.includes(this.node)) {
|
|
280
|
+
this.parent.children.splice(this.index < 0 ? this.parent.children.length : this.index, 0, this.node);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
class SetCartoonTransformCommand implements Command {
|
|
286
|
+
readonly name: string;
|
|
287
|
+
private readonly before: CartoonSceneTransform;
|
|
288
|
+
|
|
289
|
+
constructor(private readonly node: CartoonSceneNode, private readonly after: CartoonSceneTransform) {
|
|
290
|
+
this.name = `Set cartoon transform ${node.id}`;
|
|
291
|
+
this.before = cloneTransform(node.transform);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
execute(): void {
|
|
295
|
+
this.node.transform = cloneTransform(this.after);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
undo(): void {
|
|
299
|
+
this.node.transform = cloneTransform(this.before);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function categoryFromAsset(asset: CartoonEditorAssetReference): CartoonAssetCategory {
|
|
304
|
+
if (asset.type === "audio") return "audio";
|
|
305
|
+
if (asset.type === "material") return "material";
|
|
306
|
+
if (asset.clips && asset.clips.length > 0) return "character";
|
|
307
|
+
return "prop";
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function findNode(root: CartoonSceneNode, id: string): CartoonSceneNode | undefined {
|
|
311
|
+
if (root.id === id) return root;
|
|
312
|
+
for (const child of root.children) {
|
|
313
|
+
const found = findNode(child, id);
|
|
314
|
+
if (found) return found;
|
|
315
|
+
}
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function findParent(root: CartoonSceneNode, id: string): CartoonSceneNode | undefined {
|
|
320
|
+
for (const child of root.children) {
|
|
321
|
+
if (child.id === id) return root;
|
|
322
|
+
const found = findParent(child, id);
|
|
323
|
+
if (found) return found;
|
|
324
|
+
}
|
|
325
|
+
return undefined;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function flattenNodes(root: CartoonSceneNode): readonly CartoonSceneNode[] {
|
|
329
|
+
return [root, ...root.children.flatMap((child) => flattenNodes(child))];
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function removeChild(parent: CartoonSceneNode, node: CartoonSceneNode): void {
|
|
333
|
+
const index = parent.children.indexOf(node);
|
|
334
|
+
if (index >= 0) parent.children.splice(index, 1);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function defaultTransform(): CartoonSceneTransform {
|
|
338
|
+
return {
|
|
339
|
+
position: { x: 0, y: 0, z: 0 },
|
|
340
|
+
rotation: { x: 0, y: 0, z: 0, w: 1 },
|
|
341
|
+
scale: { x: 1, y: 1, z: 1 }
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function mergeTransform(base: CartoonSceneTransform, next: Partial<CartoonSceneTransform> | AssetDropPlacement): CartoonSceneTransform {
|
|
346
|
+
return {
|
|
347
|
+
position: { ...base.position, ...(next.position ?? {}) },
|
|
348
|
+
rotation: { ...base.rotation, ...(next.rotation ?? {}) },
|
|
349
|
+
scale: { ...base.scale, ...(next.scale ?? {}) }
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function cloneTransform(transform: CartoonSceneTransform): CartoonSceneTransform {
|
|
354
|
+
return mergeTransform(defaultTransform(), transform);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function cloneNode(node: CartoonSceneNode): CartoonSceneNode {
|
|
358
|
+
return createCartoonSceneNode({
|
|
359
|
+
...node,
|
|
360
|
+
transform: cloneTransform(node.transform),
|
|
361
|
+
animationClips: [...node.animationClips],
|
|
362
|
+
children: node.children.map(cloneNode)
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function stableNodeId(name: string): string {
|
|
367
|
+
return `node-${name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "item"}`;
|
|
368
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { CommandHistory } from "./CommandHistory";
|
|
2
|
+
import { KeyframeEditor, readTimelineKeyframes, type TimelineBezierHandle, type TimelineKeyframe, type TimelineKeyframeValue } from "./KeyframeEditor";
|
|
3
|
+
import type { TimelineClip } from "./TimelineModel";
|
|
4
|
+
|
|
5
|
+
export interface TimelineCurvePoint {
|
|
6
|
+
readonly time: number;
|
|
7
|
+
readonly value: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface TimelineCurveSample {
|
|
11
|
+
readonly propertyPath: string;
|
|
12
|
+
readonly time: number;
|
|
13
|
+
readonly value: TimelineKeyframeValue | undefined;
|
|
14
|
+
readonly segmentStartId?: string;
|
|
15
|
+
readonly segmentEndId?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TimelineCurveEvidence {
|
|
19
|
+
readonly propertyPath: string;
|
|
20
|
+
readonly keyframeCount: number;
|
|
21
|
+
readonly bezierSegmentCount: number;
|
|
22
|
+
readonly sampledPointCount: number;
|
|
23
|
+
readonly evidence: {
|
|
24
|
+
readonly deterministicSampling: true;
|
|
25
|
+
readonly bezierHandles: boolean;
|
|
26
|
+
readonly holdInterpolation: boolean;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class CurveEditor {
|
|
31
|
+
private readonly keyframes: KeyframeEditor;
|
|
32
|
+
|
|
33
|
+
constructor(history = new CommandHistory()) {
|
|
34
|
+
this.keyframes = new KeyframeEditor(history);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
get commandHistory(): CommandHistory {
|
|
38
|
+
return this.keyframes.commandHistory;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
sample(clip: TimelineClip, propertyPath: string, time: number): TimelineCurveSample {
|
|
42
|
+
assertFinite(time, "Curve sample time");
|
|
43
|
+
const keyframes = propertyKeyframes(clip, propertyPath);
|
|
44
|
+
if (keyframes.length === 0) {
|
|
45
|
+
return { propertyPath, time, value: undefined };
|
|
46
|
+
}
|
|
47
|
+
if (time <= keyframes[0].time) {
|
|
48
|
+
return { propertyPath, time, value: keyframes[0].value, segmentStartId: keyframes[0].id };
|
|
49
|
+
}
|
|
50
|
+
const last = keyframes[keyframes.length - 1];
|
|
51
|
+
if (time >= last.time) {
|
|
52
|
+
return { propertyPath, time, value: last.value, segmentStartId: last.id };
|
|
53
|
+
}
|
|
54
|
+
const endIndex = keyframes.findIndex((keyframe) => keyframe.time >= time);
|
|
55
|
+
const start = keyframes[endIndex - 1];
|
|
56
|
+
const end = keyframes[endIndex];
|
|
57
|
+
if (!start || !end) {
|
|
58
|
+
return { propertyPath, time, value: last.value, segmentStartId: last.id };
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
propertyPath,
|
|
62
|
+
time,
|
|
63
|
+
value: sampleSegment(start, end, time),
|
|
64
|
+
segmentStartId: start.id,
|
|
65
|
+
segmentEndId: end.id
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
samplePoints(clip: TimelineClip, propertyPath: string, startTime: number, endTime: number, steps: number): readonly TimelineCurvePoint[] {
|
|
70
|
+
assertFinite(startTime, "Curve startTime");
|
|
71
|
+
assertFinite(endTime, "Curve endTime");
|
|
72
|
+
if (endTime < startTime) throw new RangeError("Curve endTime must be greater than or equal to startTime.");
|
|
73
|
+
if (!Number.isInteger(steps) || steps < 1) throw new RangeError("Curve sample steps must be a positive integer.");
|
|
74
|
+
const points: TimelineCurvePoint[] = [];
|
|
75
|
+
for (let index = 0; index <= steps; index += 1) {
|
|
76
|
+
const time = startTime + (endTime - startTime) * (index / steps);
|
|
77
|
+
const value = this.sample(clip, propertyPath, time).value;
|
|
78
|
+
if (typeof value === "number") {
|
|
79
|
+
points.push({ time: Number(time.toFixed(4)), value: Number(value.toFixed(4)) });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return points;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async setBezierHandles(
|
|
86
|
+
clip: TimelineClip,
|
|
87
|
+
keyframeId: string,
|
|
88
|
+
handles: { readonly inHandle?: TimelineBezierHandle; readonly outHandle?: TimelineBezierHandle }
|
|
89
|
+
): Promise<TimelineKeyframe> {
|
|
90
|
+
const keyframes = readTimelineKeyframes(clip);
|
|
91
|
+
const target = keyframes.find((keyframe) => keyframe.id === keyframeId);
|
|
92
|
+
if (!target) throw new Error(`Timeline keyframe does not exist: ${keyframeId}`);
|
|
93
|
+
const updated: TimelineKeyframe = {
|
|
94
|
+
...target,
|
|
95
|
+
interpolation: "bezier",
|
|
96
|
+
inHandle: handles.inHandle,
|
|
97
|
+
outHandle: handles.outHandle
|
|
98
|
+
};
|
|
99
|
+
await this.keyframes.replaceKeyframes(
|
|
100
|
+
clip,
|
|
101
|
+
keyframes.map((keyframe) => keyframe.id === keyframeId ? updated : keyframe),
|
|
102
|
+
`Set bezier handles ${keyframeId}`
|
|
103
|
+
);
|
|
104
|
+
return updated;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
evidence(clip: TimelineClip, propertyPath: string, sampleCount = 8): TimelineCurveEvidence {
|
|
108
|
+
const keyframes = propertyKeyframes(clip, propertyPath);
|
|
109
|
+
const startTime = keyframes[0]?.time ?? 0;
|
|
110
|
+
const endTime = keyframes[keyframes.length - 1]?.time ?? startTime;
|
|
111
|
+
return {
|
|
112
|
+
propertyPath,
|
|
113
|
+
keyframeCount: keyframes.length,
|
|
114
|
+
bezierSegmentCount: keyframes.filter((keyframe) => keyframe.interpolation === "bezier").length,
|
|
115
|
+
sampledPointCount: this.samplePoints(clip, propertyPath, startTime, endTime, Math.max(1, sampleCount)).length,
|
|
116
|
+
evidence: {
|
|
117
|
+
deterministicSampling: true,
|
|
118
|
+
bezierHandles: keyframes.some((keyframe) => keyframe.inHandle || keyframe.outHandle),
|
|
119
|
+
holdInterpolation: keyframes.some((keyframe) => keyframe.interpolation === "hold")
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function propertyKeyframes(clip: TimelineClip, propertyPath: string): readonly TimelineKeyframe[] {
|
|
126
|
+
return readTimelineKeyframes(clip).filter((keyframe) => keyframe.propertyPath === propertyPath);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function sampleSegment(start: TimelineKeyframe, end: TimelineKeyframe, time: number): TimelineKeyframeValue {
|
|
130
|
+
if (start.interpolation === "hold" || typeof start.value !== "number" || typeof end.value !== "number") {
|
|
131
|
+
return start.value;
|
|
132
|
+
}
|
|
133
|
+
const duration = end.time - start.time;
|
|
134
|
+
const t = duration <= 0 ? 1 : clamp((time - start.time) / duration, 0, 1);
|
|
135
|
+
if (start.interpolation !== "bezier") {
|
|
136
|
+
return Number((start.value + (end.value - start.value) * t).toFixed(4));
|
|
137
|
+
}
|
|
138
|
+
const p0 = start.value;
|
|
139
|
+
const p1 = start.value + (start.outHandle?.value ?? 0);
|
|
140
|
+
const p2 = end.value + (end.inHandle?.value ?? 0);
|
|
141
|
+
const p3 = end.value;
|
|
142
|
+
const inv = 1 - t;
|
|
143
|
+
return Number((inv * inv * inv * p0 + 3 * inv * inv * t * p1 + 3 * inv * t * t * p2 + t * t * t * p3).toFixed(4));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function assertFinite(value: number, label: string): void {
|
|
147
|
+
if (!Number.isFinite(value)) throw new RangeError(`${label} must be finite.`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function clamp(value: number, min: number, max: number): number {
|
|
151
|
+
return Math.max(min, Math.min(max, value));
|
|
152
|
+
}
|