@aura3d/editor-runtime 1.0.6 → 1.3.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/AnimationSceneEditor.d.ts +111 -0
- package/dist/AnimationSceneEditor.d.ts.map +1 -0
- package/dist/AnimationSceneEditor.js +285 -0
- package/dist/AnimationSceneEditor.js.map +1 -0
- 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/EventTrackEditor.d.ts +54 -0
- package/dist/EventTrackEditor.d.ts.map +1 -0
- package/dist/EventTrackEditor.js +124 -0
- package/dist/EventTrackEditor.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 +32 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -1
- package/package.json +5 -4
- package/src/AnimationSceneEditor.ts +368 -0
- package/src/AssetDropZone.ts +144 -0
- package/src/CameraPathEditor.ts +223 -0
- package/src/CurveEditor.ts +152 -0
- package/src/EpisodeReviewPanel.ts +236 -0
- package/src/EventTrackEditor.ts +169 -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 +55 -0
- package/tests/editor-runtime.test.ts +490 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { TimelineEditorController, type TimelineEditorSnapshot } from "./TimelineEditorController";
|
|
2
|
+
import { TimelineModel, type TimelineClipConfig, type TimelineModelConfig, type TimelineTrackConfig } from "./TimelineModel";
|
|
3
|
+
import { createTimelineTrackConfig, type TimelineEditorTrackKind } from "./TimelineTrackTypes";
|
|
4
|
+
|
|
5
|
+
export type NonlinearBinAssetKind = "animation" | "audio" | "camera" | "shot" | "sequence" | "caption" | "viseme";
|
|
6
|
+
|
|
7
|
+
export interface NonlinearBinAsset {
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly name: string;
|
|
10
|
+
readonly kind: NonlinearBinAssetKind;
|
|
11
|
+
readonly assetId?: string | undefined;
|
|
12
|
+
readonly clipName?: string | undefined;
|
|
13
|
+
readonly duration?: number | undefined;
|
|
14
|
+
readonly metadata?: Readonly<Record<string, string | number | boolean>> | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface NonlinearSequenceConfig {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
readonly name: string;
|
|
20
|
+
readonly timeline?: TimelineModelConfig | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface NonlinearSequenceSnapshot {
|
|
24
|
+
readonly id: string;
|
|
25
|
+
readonly name: string;
|
|
26
|
+
readonly trackCount: number;
|
|
27
|
+
readonly clipCount: number;
|
|
28
|
+
readonly duration: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface NonlinearAnimationEditorSnapshot {
|
|
32
|
+
readonly kind: "nonlinear-animation-editor";
|
|
33
|
+
readonly activeSequenceId: string;
|
|
34
|
+
readonly sequenceCount: number;
|
|
35
|
+
readonly binAssetCount: number;
|
|
36
|
+
readonly nestedSequenceClipCount: number;
|
|
37
|
+
readonly totalTrackCount: number;
|
|
38
|
+
readonly totalClipCount: number;
|
|
39
|
+
readonly sequences: readonly NonlinearSequenceSnapshot[];
|
|
40
|
+
readonly activeTimeline: TimelineEditorSnapshot;
|
|
41
|
+
readonly evidence: {
|
|
42
|
+
readonly nonlinearSequences: boolean;
|
|
43
|
+
readonly trimSplitMoveDuplicate: boolean;
|
|
44
|
+
readonly nestedSequences: boolean;
|
|
45
|
+
readonly multiTrackTimeline: boolean;
|
|
46
|
+
readonly serialization: true;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface NonlinearAnimationEditorState {
|
|
51
|
+
readonly activeSequenceId: string;
|
|
52
|
+
readonly binAssets: readonly NonlinearBinAsset[];
|
|
53
|
+
readonly sequences: readonly NonlinearSequenceConfig[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface NonlinearSequenceRuntime {
|
|
57
|
+
readonly id: string;
|
|
58
|
+
readonly name: string;
|
|
59
|
+
readonly controller: TimelineEditorController;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class NonlinearAnimationEditor {
|
|
63
|
+
private readonly binAssets = new Map<string, NonlinearBinAsset>();
|
|
64
|
+
private readonly sequences = new Map<string, NonlinearSequenceRuntime>();
|
|
65
|
+
private activeSequenceId: string;
|
|
66
|
+
|
|
67
|
+
constructor(state: NonlinearAnimationEditorState) {
|
|
68
|
+
if (state.sequences.length === 0) throw new Error("Nonlinear animation editor requires at least one sequence.");
|
|
69
|
+
for (const asset of state.binAssets) this.addBinAsset(asset);
|
|
70
|
+
for (const sequence of state.sequences) this.createSequence(sequence);
|
|
71
|
+
this.activeSequenceId = nonEmpty(state.activeSequenceId, "Active sequence id");
|
|
72
|
+
this.requireSequence(this.activeSequenceId);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
addBinAsset(asset: NonlinearBinAsset): NonlinearBinAsset {
|
|
76
|
+
const clean = sanitizeBinAsset(asset);
|
|
77
|
+
this.binAssets.set(clean.id, clean);
|
|
78
|
+
return clean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
createSequence(config: NonlinearSequenceConfig): NonlinearSequenceSnapshot {
|
|
82
|
+
const id = nonEmpty(config.id, "Sequence id");
|
|
83
|
+
const timeline = new TimelineModel({
|
|
84
|
+
id,
|
|
85
|
+
name: config.name,
|
|
86
|
+
duration: 10,
|
|
87
|
+
frameRate: 24,
|
|
88
|
+
loopMode: "none",
|
|
89
|
+
...config.timeline
|
|
90
|
+
});
|
|
91
|
+
this.sequences.set(id, {
|
|
92
|
+
id,
|
|
93
|
+
name: nonEmpty(config.name, "Sequence name"),
|
|
94
|
+
controller: new TimelineEditorController({ timeline })
|
|
95
|
+
});
|
|
96
|
+
return this.sequenceSnapshot(this.requireSequence(id));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
selectSequence(sequenceId: string): NonlinearAnimationEditorSnapshot {
|
|
100
|
+
this.activeSequenceId = this.requireSequence(sequenceId).id;
|
|
101
|
+
return this.snapshot();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async addTrack(kind: TimelineEditorTrackKind, name?: string, options: Omit<TimelineTrackConfig, "name" | "type"> = {}): Promise<string> {
|
|
105
|
+
const track = await this.activeSequence().controller.addTrack(kind, name, options);
|
|
106
|
+
return track.id;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async insertAssetClip(trackId: string, assetId: string, options: Omit<TimelineClipConfig, "name" | "startTime" | "duration" | "assetId" | "clipName"> & {
|
|
110
|
+
readonly id?: string | undefined;
|
|
111
|
+
readonly name?: string | undefined;
|
|
112
|
+
readonly startTime: number;
|
|
113
|
+
readonly duration?: number | undefined;
|
|
114
|
+
}): Promise<string> {
|
|
115
|
+
const asset = this.requireBinAsset(assetId);
|
|
116
|
+
const clip = await this.activeSequence().controller.addClip(trackId, {
|
|
117
|
+
...options,
|
|
118
|
+
id: options.id ?? `${asset.id}-${Math.round(options.startTime * 1000)}`,
|
|
119
|
+
name: options.name ?? asset.name,
|
|
120
|
+
startTime: options.startTime,
|
|
121
|
+
duration: options.duration ?? asset.duration ?? 1,
|
|
122
|
+
assetId: asset.assetId ?? asset.id,
|
|
123
|
+
clipName: asset.clipName ?? asset.name,
|
|
124
|
+
properties: {
|
|
125
|
+
...(options.properties ?? {}),
|
|
126
|
+
binAssetId: asset.id,
|
|
127
|
+
binAssetKind: asset.kind
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
return clip.id;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async insertNestedSequence(trackId: string, sequenceId: string, options: Omit<TimelineClipConfig, "name" | "startTime" | "duration"> & {
|
|
134
|
+
readonly id?: string | undefined;
|
|
135
|
+
readonly startTime: number;
|
|
136
|
+
readonly duration?: number | undefined;
|
|
137
|
+
}): Promise<string> {
|
|
138
|
+
const nested = this.requireSequence(sequenceId);
|
|
139
|
+
const clip = await this.activeSequence().controller.addClip(trackId, {
|
|
140
|
+
...options,
|
|
141
|
+
id: options.id ?? `${sequenceId}-nested-${Math.round(options.startTime * 1000)}`,
|
|
142
|
+
name: nested.name,
|
|
143
|
+
startTime: options.startTime,
|
|
144
|
+
duration: options.duration ?? nested.controller.timeline.duration,
|
|
145
|
+
properties: {
|
|
146
|
+
...(options.properties ?? {}),
|
|
147
|
+
nestedSequenceId: nested.id
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
return clip.id;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async moveClip(clipId: string, startTime: number): Promise<void> {
|
|
154
|
+
await this.activeSequence().controller.moveClip(clipId, startTime);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async trimClip(clipId: string, duration: number, startTime?: number): Promise<void> {
|
|
158
|
+
await this.activeSequence().controller.resizeClip(clipId, duration, startTime);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async splitClip(clipId: string, splitTime: number): Promise<readonly string[]> {
|
|
162
|
+
const clips = await this.activeSequence().controller.splitClip(clipId, splitTime);
|
|
163
|
+
return clips.map((clip) => clip.id);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async duplicateClip(clipId: string, timeOffset?: number): Promise<string> {
|
|
167
|
+
const clip = await this.activeSequence().controller.duplicateClip(clipId, timeOffset);
|
|
168
|
+
return clip.id;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
serialize(): NonlinearAnimationEditorState {
|
|
172
|
+
return {
|
|
173
|
+
activeSequenceId: this.activeSequenceId,
|
|
174
|
+
binAssets: [...this.binAssets.values()],
|
|
175
|
+
sequences: [...this.sequences.values()].map((sequence) => ({
|
|
176
|
+
id: sequence.id,
|
|
177
|
+
name: sequence.name,
|
|
178
|
+
timeline: sequence.controller.serializeTimeline()
|
|
179
|
+
}))
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
snapshot(): NonlinearAnimationEditorSnapshot {
|
|
184
|
+
const sequences = [...this.sequences.values()];
|
|
185
|
+
const active = this.activeSequence();
|
|
186
|
+
const totalTrackCount = sequences.reduce((count, sequence) => count + sequence.controller.timeline.tracks.length, 0);
|
|
187
|
+
const totalClipCount = sequences.reduce((count, sequence) => count + sequence.controller.timeline.tracks.reduce((clipCount, track) => clipCount + track.clips.length, 0), 0);
|
|
188
|
+
const nestedSequenceClipCount = sequences.reduce((count, sequence) =>
|
|
189
|
+
count + sequence.controller.timeline.tracks.reduce((trackCount, track) =>
|
|
190
|
+
trackCount + track.clips.filter((clip) => typeof clip.properties.nestedSequenceId === "string").length, 0), 0);
|
|
191
|
+
return {
|
|
192
|
+
kind: "nonlinear-animation-editor",
|
|
193
|
+
activeSequenceId: active.id,
|
|
194
|
+
sequenceCount: sequences.length,
|
|
195
|
+
binAssetCount: this.binAssets.size,
|
|
196
|
+
nestedSequenceClipCount,
|
|
197
|
+
totalTrackCount,
|
|
198
|
+
totalClipCount,
|
|
199
|
+
sequences: sequences.map((sequence) => this.sequenceSnapshot(sequence)),
|
|
200
|
+
activeTimeline: active.controller.snapshot(),
|
|
201
|
+
evidence: {
|
|
202
|
+
nonlinearSequences: sequences.length > 1,
|
|
203
|
+
trimSplitMoveDuplicate: active.controller.history.canUndo || active.controller.history.canRedo,
|
|
204
|
+
nestedSequences: nestedSequenceClipCount > 0,
|
|
205
|
+
multiTrackTimeline: totalTrackCount > 1,
|
|
206
|
+
serialization: true
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private activeSequence(): NonlinearSequenceRuntime {
|
|
212
|
+
return this.requireSequence(this.activeSequenceId);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private requireSequence(id: string): NonlinearSequenceRuntime {
|
|
216
|
+
const sequence = this.sequences.get(id);
|
|
217
|
+
if (!sequence) throw new Error(`Unknown nonlinear sequence: ${id}`);
|
|
218
|
+
return sequence;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private requireBinAsset(id: string): NonlinearBinAsset {
|
|
222
|
+
const asset = this.binAssets.get(id);
|
|
223
|
+
if (!asset) throw new Error(`Unknown nonlinear bin asset: ${id}`);
|
|
224
|
+
return asset;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private sequenceSnapshot(sequence: NonlinearSequenceRuntime): NonlinearSequenceSnapshot {
|
|
228
|
+
const timeline = sequence.controller.timeline;
|
|
229
|
+
return {
|
|
230
|
+
id: sequence.id,
|
|
231
|
+
name: sequence.name,
|
|
232
|
+
trackCount: timeline.tracks.length,
|
|
233
|
+
clipCount: timeline.tracks.reduce((count, track) => count + track.clips.length, 0),
|
|
234
|
+
duration: timeline.duration
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function createNonlinearAnimationEditor(state: NonlinearAnimationEditorState): NonlinearAnimationEditor {
|
|
240
|
+
return new NonlinearAnimationEditor(state);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function sanitizeBinAsset(asset: NonlinearBinAsset): NonlinearBinAsset {
|
|
244
|
+
if (asset.duration !== undefined && (!Number.isFinite(asset.duration) || asset.duration <= 0)) {
|
|
245
|
+
throw new Error(`Nonlinear bin asset "${asset.id}" duration must be positive when provided.`);
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
id: nonEmpty(asset.id, "Bin asset id"),
|
|
249
|
+
name: nonEmpty(asset.name, "Bin asset name"),
|
|
250
|
+
kind: asset.kind,
|
|
251
|
+
assetId: asset.assetId,
|
|
252
|
+
clipName: asset.clipName,
|
|
253
|
+
duration: asset.duration,
|
|
254
|
+
metadata: asset.metadata
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function nonEmpty(value: string, label: string): string {
|
|
259
|
+
const trimmed = value.trim();
|
|
260
|
+
if (!trimmed) throw new Error(`${label} must be a non-empty string.`);
|
|
261
|
+
return trimmed;
|
|
262
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { CommandHistory } from "./CommandHistory";
|
|
2
|
+
import { InspectorModel, type InspectorEditableValue, type InspectorProperty } from "./InspectorModel";
|
|
3
|
+
|
|
4
|
+
export interface PropertyPanelField extends InspectorProperty {
|
|
5
|
+
readonly label: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface PropertyPanelOptions {
|
|
9
|
+
readonly history?: CommandHistory;
|
|
10
|
+
readonly inspector?: InspectorModel;
|
|
11
|
+
readonly onEdit?: (path: readonly string[], value: InspectorEditableValue) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class PropertyPanel {
|
|
15
|
+
readonly history: CommandHistory;
|
|
16
|
+
readonly inspector: InspectorModel;
|
|
17
|
+
private disposers: (() => void)[] = [];
|
|
18
|
+
|
|
19
|
+
constructor(options: PropertyPanelOptions = {}) {
|
|
20
|
+
this.history = options.history ?? new CommandHistory();
|
|
21
|
+
this.inspector = options.inspector ?? new InspectorModel();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe(target: object): readonly PropertyPanelField[] {
|
|
25
|
+
return this.inspector.describe(target).map((property) => ({
|
|
26
|
+
...property,
|
|
27
|
+
label: property.path.join(".")
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async edit(target: object, path: readonly string[], value: InspectorEditableValue): Promise<void> {
|
|
32
|
+
await this.history.execute(this.inspector.createSetPropertyCommand(target, path, value));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
render(target: object | undefined, container: HTMLElement, options: Pick<PropertyPanelOptions, "onEdit"> = {}): readonly PropertyPanelField[] {
|
|
36
|
+
this.dispose();
|
|
37
|
+
container.replaceChildren();
|
|
38
|
+
if (!target) return [];
|
|
39
|
+
const fields = this.describe(target);
|
|
40
|
+
const documentRef = container.ownerDocument;
|
|
41
|
+
for (const field of fields) {
|
|
42
|
+
const row = documentRef.createElement("label");
|
|
43
|
+
row.className = "aura-property-panel__row";
|
|
44
|
+
const name = documentRef.createElement("span");
|
|
45
|
+
name.className = "aura-property-panel__label";
|
|
46
|
+
name.textContent = field.label;
|
|
47
|
+
row.append(name);
|
|
48
|
+
if (field.editable) {
|
|
49
|
+
const input = documentRef.createElement("input");
|
|
50
|
+
input.className = "aura-property-panel__input";
|
|
51
|
+
input.name = field.label;
|
|
52
|
+
input.value = String(field.value);
|
|
53
|
+
input.type = field.type === "number" ? "number" : field.type === "boolean" ? "checkbox" : "text";
|
|
54
|
+
if (field.type === "boolean") input.checked = Boolean(field.value);
|
|
55
|
+
const change = (): void => {
|
|
56
|
+
const value = parseInput(input, field.type);
|
|
57
|
+
void this.edit(target, field.path, value).then(() => options.onEdit?.(field.path, value));
|
|
58
|
+
};
|
|
59
|
+
input.addEventListener("change", change);
|
|
60
|
+
this.disposers.push(() => input.removeEventListener("change", change));
|
|
61
|
+
row.append(input);
|
|
62
|
+
} else {
|
|
63
|
+
const value = documentRef.createElement("span");
|
|
64
|
+
value.className = "aura-property-panel__value";
|
|
65
|
+
value.textContent = field.type;
|
|
66
|
+
row.append(value);
|
|
67
|
+
}
|
|
68
|
+
container.append(row);
|
|
69
|
+
}
|
|
70
|
+
return fields;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
dispose(): void {
|
|
74
|
+
for (const dispose of this.disposers.splice(0)) dispose();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function parseInput(input: HTMLInputElement, type: InspectorProperty["type"]): InspectorEditableValue {
|
|
79
|
+
if (type === "number") return Number(input.value);
|
|
80
|
+
if (type === "boolean") return input.checked;
|
|
81
|
+
return input.value;
|
|
82
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export type RenderQueuePanelItemStatus = "queued" | "running" | "done" | "failed";
|
|
2
|
+
|
|
3
|
+
export interface RenderQueuePanelItem {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly label: string;
|
|
6
|
+
readonly status: RenderQueuePanelItemStatus;
|
|
7
|
+
readonly progress: number;
|
|
8
|
+
readonly currentFrame?: number;
|
|
9
|
+
readonly totalFrames?: number;
|
|
10
|
+
readonly outputPath?: string;
|
|
11
|
+
readonly error?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface RenderQueuePanelSnapshot {
|
|
15
|
+
readonly kind: "render-queue-panel";
|
|
16
|
+
readonly itemCount: number;
|
|
17
|
+
readonly queuedCount: number;
|
|
18
|
+
readonly runningCount: number;
|
|
19
|
+
readonly doneCount: number;
|
|
20
|
+
readonly failedCount: number;
|
|
21
|
+
readonly progress: number;
|
|
22
|
+
readonly items: readonly RenderQueuePanelItem[];
|
|
23
|
+
readonly outputPaths: readonly string[];
|
|
24
|
+
readonly errors: readonly string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class RenderQueuePanel {
|
|
28
|
+
private readonly items = new Map<string, RenderQueuePanelItem>();
|
|
29
|
+
|
|
30
|
+
constructor(items: readonly RenderQueuePanelItem[] = []) {
|
|
31
|
+
for (const item of items) this.upsert(item);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
upsert(item: RenderQueuePanelItem): RenderQueuePanelSnapshot {
|
|
35
|
+
this.items.set(item.id, sanitizeRenderQueueItem(item));
|
|
36
|
+
return this.snapshot();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
updateProgress(id: string, progress: number, options: {
|
|
40
|
+
readonly currentFrame?: number;
|
|
41
|
+
readonly totalFrames?: number;
|
|
42
|
+
readonly status?: RenderQueuePanelItemStatus;
|
|
43
|
+
readonly outputPath?: string;
|
|
44
|
+
readonly error?: string;
|
|
45
|
+
} = {}): RenderQueuePanelSnapshot {
|
|
46
|
+
const existing = this.items.get(id);
|
|
47
|
+
if (!existing) throw new Error(`Render queue item does not exist: ${id}`);
|
|
48
|
+
return this.upsert({
|
|
49
|
+
...existing,
|
|
50
|
+
progress,
|
|
51
|
+
status: options.status ?? existing.status,
|
|
52
|
+
currentFrame: options.currentFrame ?? existing.currentFrame,
|
|
53
|
+
totalFrames: options.totalFrames ?? existing.totalFrames,
|
|
54
|
+
outputPath: options.outputPath ?? existing.outputPath,
|
|
55
|
+
error: options.error ?? existing.error
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
snapshot(): RenderQueuePanelSnapshot {
|
|
60
|
+
const items = [...this.items.values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
61
|
+
const sum = items.reduce((total, item) => total + item.progress, 0);
|
|
62
|
+
return {
|
|
63
|
+
kind: "render-queue-panel",
|
|
64
|
+
itemCount: items.length,
|
|
65
|
+
queuedCount: items.filter((item) => item.status === "queued").length,
|
|
66
|
+
runningCount: items.filter((item) => item.status === "running").length,
|
|
67
|
+
doneCount: items.filter((item) => item.status === "done").length,
|
|
68
|
+
failedCount: items.filter((item) => item.status === "failed").length,
|
|
69
|
+
progress: items.length === 0 ? 0 : sum / items.length,
|
|
70
|
+
items,
|
|
71
|
+
outputPaths: items.flatMap((item) => item.outputPath ? [item.outputPath] : []),
|
|
72
|
+
errors: items.flatMap((item) => item.error ? [item.error] : [])
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function createRenderQueuePanel(items: readonly RenderQueuePanelItem[] = []): RenderQueuePanel {
|
|
78
|
+
return new RenderQueuePanel(items);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sanitizeRenderQueueItem(item: RenderQueuePanelItem): RenderQueuePanelItem {
|
|
82
|
+
const progress = Number.isFinite(item.progress) ? Math.min(1, Math.max(0, item.progress)) : Number.NaN;
|
|
83
|
+
if (!Number.isFinite(progress)) throw new Error("Render queue item progress must be finite.");
|
|
84
|
+
if (item.currentFrame !== undefined && (!Number.isFinite(item.currentFrame) || item.currentFrame < 0)) {
|
|
85
|
+
throw new Error("Render queue currentFrame must be a non-negative finite number.");
|
|
86
|
+
}
|
|
87
|
+
if (item.totalFrames !== undefined && (!Number.isFinite(item.totalFrames) || item.totalFrames <= 0)) {
|
|
88
|
+
throw new Error("Render queue totalFrames must be a positive finite number.");
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
id: nonEmpty(item.id, "Render queue item id"),
|
|
92
|
+
label: nonEmpty(item.label, "Render queue item label"),
|
|
93
|
+
status: item.status,
|
|
94
|
+
progress,
|
|
95
|
+
currentFrame: item.currentFrame,
|
|
96
|
+
totalFrames: item.totalFrames,
|
|
97
|
+
outputPath: item.outputPath,
|
|
98
|
+
error: item.error
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function nonEmpty(value: string, label: string): string {
|
|
103
|
+
const trimmed = value.trim();
|
|
104
|
+
if (!trimmed) throw new Error(`${label} must be a non-empty string.`);
|
|
105
|
+
return trimmed;
|
|
106
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { HierarchyModel, type HierarchyNodeDescriptor } from "./HierarchyModel";
|
|
2
|
+
import type { SelectionId } from "./Selection";
|
|
3
|
+
import type { AnimationSceneNode } from "./AnimationSceneEditor";
|
|
4
|
+
|
|
5
|
+
export interface SceneOutlinerItem extends HierarchyNodeDescriptor {
|
|
6
|
+
readonly icon: string;
|
|
7
|
+
readonly kind: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SceneOutlinerOptions {
|
|
11
|
+
readonly selectedIds?: ReadonlySet<SelectionId>;
|
|
12
|
+
readonly onSelect?: (id: SelectionId) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class SceneOutliner {
|
|
16
|
+
private readonly hierarchy = new HierarchyModel();
|
|
17
|
+
private disposers: (() => void)[] = [];
|
|
18
|
+
|
|
19
|
+
describe(root: AnimationSceneNode, selectedIds: ReadonlySet<SelectionId> = new Set()): readonly SceneOutlinerItem[] {
|
|
20
|
+
return this.hierarchy.flatten(root, selectedIds).map((item) => {
|
|
21
|
+
const node = findNode(root, String(item.id));
|
|
22
|
+
const kind = node?.kind ?? "unknown";
|
|
23
|
+
return {
|
|
24
|
+
...item,
|
|
25
|
+
kind,
|
|
26
|
+
icon: iconForKind(kind)
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
render(root: AnimationSceneNode, container: HTMLElement, options: SceneOutlinerOptions = {}): readonly SceneOutlinerItem[] {
|
|
32
|
+
this.dispose();
|
|
33
|
+
const items = this.describe(root, options.selectedIds);
|
|
34
|
+
container.replaceChildren();
|
|
35
|
+
const documentRef = container.ownerDocument;
|
|
36
|
+
for (const item of items) {
|
|
37
|
+
const row = documentRef.createElement("button");
|
|
38
|
+
row.type = "button";
|
|
39
|
+
row.className = "aura-scene-outliner__item";
|
|
40
|
+
row.dataset.nodeId = String(item.id);
|
|
41
|
+
row.dataset.kind = item.kind;
|
|
42
|
+
row.dataset.selected = String(item.selected);
|
|
43
|
+
row.style.paddingLeft = `${item.depth * 14}px`;
|
|
44
|
+
row.textContent = `${item.icon} ${item.name}`;
|
|
45
|
+
const click = (): void => options.onSelect?.(item.id);
|
|
46
|
+
row.addEventListener("click", click);
|
|
47
|
+
this.disposers.push(() => row.removeEventListener("click", click));
|
|
48
|
+
container.append(row);
|
|
49
|
+
}
|
|
50
|
+
return items;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
dispose(): void {
|
|
54
|
+
for (const dispose of this.disposers.splice(0)) dispose();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function iconForKind(kind: string): string {
|
|
59
|
+
switch (kind) {
|
|
60
|
+
case "character": return "[CHAR]";
|
|
61
|
+
case "set": return "[SET]";
|
|
62
|
+
case "prop": return "[PROP]";
|
|
63
|
+
case "camera": return "[CAM]";
|
|
64
|
+
case "light": return "[LIGHT]";
|
|
65
|
+
case "audio": return "[AUD]";
|
|
66
|
+
case "material": return "[MAT]";
|
|
67
|
+
default: return "[NODE]";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function findNode(root: AnimationSceneNode, id: string): AnimationSceneNode | undefined {
|
|
72
|
+
if (root.id === id) return root;
|
|
73
|
+
for (const child of root.children) {
|
|
74
|
+
const found = findNode(child, id);
|
|
75
|
+
if (found) return found;
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|