@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,144 @@
|
|
|
1
|
+
import type { Command } from "./Command";
|
|
2
|
+
import { CommandHistory } from "./CommandHistory";
|
|
3
|
+
import type { EditorProjectAssetDocument } from "./ProjectSerializer";
|
|
4
|
+
|
|
5
|
+
export type CartoonAssetCategory = "character" | "set" | "prop" | "camera" | "light" | "audio" | "material" | "unknown";
|
|
6
|
+
|
|
7
|
+
export interface CartoonEditorAssetReference extends EditorProjectAssetDocument {
|
|
8
|
+
readonly kind?: "aura-asset-ref" | "editor-asset-ref";
|
|
9
|
+
readonly category?: CartoonAssetCategory;
|
|
10
|
+
readonly profile?: "cartoon-character" | "cartoon-set" | "cartoon-prop" | "cartoon-audio" | string;
|
|
11
|
+
readonly style?: string;
|
|
12
|
+
readonly rigType?: "humanoid" | "creature" | "prop" | "none";
|
|
13
|
+
readonly lipSyncReady?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface AssetDropPlacement {
|
|
17
|
+
readonly targetId?: string | number;
|
|
18
|
+
readonly position?: { readonly x: number; readonly y: number; readonly z: number };
|
|
19
|
+
readonly rotation?: { readonly x: number; readonly y: number; readonly z: number; readonly w?: number };
|
|
20
|
+
readonly scale?: { readonly x: number; readonly y: number; readonly z: number };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AssetDropResult<TNode> {
|
|
24
|
+
readonly asset: CartoonEditorAssetReference;
|
|
25
|
+
readonly node: TNode;
|
|
26
|
+
readonly placement: AssetDropPlacement;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface AssetDropZoneOptions<TNode> {
|
|
30
|
+
readonly history?: CommandHistory;
|
|
31
|
+
readonly createNode: (asset: CartoonEditorAssetReference, placement: AssetDropPlacement) => TNode;
|
|
32
|
+
readonly addNode: (node: TNode, placement: AssetDropPlacement) => void;
|
|
33
|
+
readonly removeNode: (node: TNode) => void;
|
|
34
|
+
readonly onDrop?: (result: AssetDropResult<TNode>) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class AssetDropZone<TNode = unknown> {
|
|
38
|
+
readonly history: CommandHistory;
|
|
39
|
+
|
|
40
|
+
constructor(private readonly options: AssetDropZoneOptions<TNode>) {
|
|
41
|
+
this.history = options.history ?? new CommandHistory();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async dropAsset(asset: CartoonEditorAssetReference, placement: AssetDropPlacement = {}): Promise<AssetDropResult<TNode>> {
|
|
45
|
+
validateAssetReference(asset);
|
|
46
|
+
validatePlacement(placement);
|
|
47
|
+
const node = this.options.createNode(asset, placement);
|
|
48
|
+
await this.history.execute(new DropAssetCommand(this.options, asset, node, placement));
|
|
49
|
+
const result = { asset, node, placement };
|
|
50
|
+
this.options.onDrop?.(result);
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
bindElement(element: HTMLElement): () => void {
|
|
55
|
+
const dragover = (event: DragEvent): void => {
|
|
56
|
+
event.preventDefault();
|
|
57
|
+
if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
|
|
58
|
+
};
|
|
59
|
+
const drop = (event: DragEvent): void => {
|
|
60
|
+
event.preventDefault();
|
|
61
|
+
const asset = readAssetFromDataTransfer(event.dataTransfer);
|
|
62
|
+
if (!asset) return;
|
|
63
|
+
void this.dropAsset(asset, {
|
|
64
|
+
position: {
|
|
65
|
+
x: Number(element.dataset.dropX ?? 0),
|
|
66
|
+
y: Number(element.dataset.dropY ?? 0),
|
|
67
|
+
z: Number(element.dataset.dropZ ?? 0)
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
element.addEventListener("dragover", dragover);
|
|
72
|
+
element.addEventListener("drop", drop);
|
|
73
|
+
return () => {
|
|
74
|
+
element.removeEventListener("dragover", dragover);
|
|
75
|
+
element.removeEventListener("drop", drop);
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
class DropAssetCommand<TNode> implements Command {
|
|
81
|
+
readonly name: string;
|
|
82
|
+
|
|
83
|
+
constructor(
|
|
84
|
+
private readonly options: Pick<AssetDropZoneOptions<TNode>, "addNode" | "removeNode">,
|
|
85
|
+
private readonly asset: CartoonEditorAssetReference,
|
|
86
|
+
private readonly node: TNode,
|
|
87
|
+
private readonly placement: AssetDropPlacement
|
|
88
|
+
) {
|
|
89
|
+
this.name = `Drop asset ${asset.id}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
execute(): void {
|
|
93
|
+
this.options.addNode(this.node, this.placement);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
undo(): void {
|
|
97
|
+
this.options.removeNode(this.node);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function serializeAssetForDrag(asset: CartoonEditorAssetReference): string {
|
|
102
|
+
validateAssetReference(asset);
|
|
103
|
+
return JSON.stringify(asset);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function readAssetFromDataTransfer(dataTransfer: DataTransfer | null): CartoonEditorAssetReference | undefined {
|
|
107
|
+
if (!dataTransfer) return undefined;
|
|
108
|
+
const encoded = dataTransfer.getData("application/x-aura3d-asset") || dataTransfer.getData("text/plain");
|
|
109
|
+
if (!encoded.trim()) return undefined;
|
|
110
|
+
const parsed = JSON.parse(encoded) as CartoonEditorAssetReference;
|
|
111
|
+
validateAssetReference(parsed);
|
|
112
|
+
return parsed;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function validateAssetReference(asset: CartoonEditorAssetReference): void {
|
|
116
|
+
if (!asset.id?.trim()) throw new Error("Dropped Aura3D asset id is required.");
|
|
117
|
+
if (!asset.name?.trim()) throw new Error(`Dropped Aura3D asset name is required: ${asset.id}`);
|
|
118
|
+
if ((asset.uri && /^https?:\/\//i.test(asset.uri) || asset.source && /^https?:\/\//i.test(asset.source)) && asset.kind !== "aura-asset-ref") {
|
|
119
|
+
throw new Error(`Dropped asset "${asset.id}" must be a typed Aura3D asset reference, not a raw URL.`);
|
|
120
|
+
}
|
|
121
|
+
if (asset.source !== undefined && !asset.source.trim()) throw new Error(`Dropped Aura3D asset source cannot be empty: ${asset.id}`);
|
|
122
|
+
if (asset.license !== undefined && !asset.license.trim()) throw new Error(`Dropped Aura3D asset license cannot be empty: ${asset.id}`);
|
|
123
|
+
if (asset.category === "character") {
|
|
124
|
+
if (asset.rigType === "none") throw new Error(`Cartoon character asset "${asset.id}" must be rigged.`);
|
|
125
|
+
if ((asset.clips?.length ?? 0) === 0) throw new Error(`Cartoon character asset "${asset.id}" requires animation clip metadata.`);
|
|
126
|
+
if (!asset.lipSyncReady) throw new Error(`Cartoon character asset "${asset.id}" requires lip-sync readiness metadata.`);
|
|
127
|
+
}
|
|
128
|
+
if ((asset.category === "set" || asset.category === "audio") && !asset.license) {
|
|
129
|
+
throw new Error(`Cartoon ${asset.category} asset "${asset.id}" requires license metadata.`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function validatePlacement(placement: AssetDropPlacement): void {
|
|
134
|
+
validateVector(placement.position, "position");
|
|
135
|
+
validateVector(placement.rotation, "rotation");
|
|
136
|
+
validateVector(placement.scale, "scale");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function validateVector(vector: { readonly x: number; readonly y: number; readonly z: number } | undefined, label: string): void {
|
|
140
|
+
if (!vector) return;
|
|
141
|
+
if (!Number.isFinite(vector.x) || !Number.isFinite(vector.y) || !Number.isFinite(vector.z)) {
|
|
142
|
+
throw new Error(`Asset drop ${label} must contain finite x/y/z values.`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { CommandHistory } from "./CommandHistory";
|
|
2
|
+
import { CurveEditor } from "./CurveEditor";
|
|
3
|
+
import { KeyframeEditor, readTimelineKeyframes, type TimelineKeyframe } from "./KeyframeEditor";
|
|
4
|
+
import type { TimelineClip } from "./TimelineModel";
|
|
5
|
+
|
|
6
|
+
export type EditorCameraVector3 = readonly [number, number, number];
|
|
7
|
+
|
|
8
|
+
export interface EditorCameraPathKeyframe {
|
|
9
|
+
readonly id: string;
|
|
10
|
+
readonly time: number;
|
|
11
|
+
readonly position: EditorCameraVector3;
|
|
12
|
+
readonly target: EditorCameraVector3;
|
|
13
|
+
readonly fov: number;
|
|
14
|
+
readonly focusDistance?: number;
|
|
15
|
+
readonly shake?: number;
|
|
16
|
+
readonly interpolation?: TimelineKeyframe["interpolation"];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface EditorCameraPathSample {
|
|
20
|
+
readonly time: number;
|
|
21
|
+
readonly position: EditorCameraVector3;
|
|
22
|
+
readonly target: EditorCameraVector3;
|
|
23
|
+
readonly fov: number;
|
|
24
|
+
readonly focusDistance?: number;
|
|
25
|
+
readonly shake: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface EditorCameraPathEvidence {
|
|
29
|
+
readonly clipId: string;
|
|
30
|
+
readonly cameraKeyframeCount: number;
|
|
31
|
+
readonly editablePropertyCount: number;
|
|
32
|
+
readonly sampledPointCount: number;
|
|
33
|
+
readonly evidence: {
|
|
34
|
+
readonly shotCameraMoveEditing: boolean;
|
|
35
|
+
readonly cameraPositionCurves: boolean;
|
|
36
|
+
readonly cameraTargetCurves: boolean;
|
|
37
|
+
readonly fovCurve: boolean;
|
|
38
|
+
readonly deterministicSampling: true;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const CAMERA_PROPERTY_PATHS = [
|
|
43
|
+
"camera.position.x",
|
|
44
|
+
"camera.position.y",
|
|
45
|
+
"camera.position.z",
|
|
46
|
+
"camera.target.x",
|
|
47
|
+
"camera.target.y",
|
|
48
|
+
"camera.target.z",
|
|
49
|
+
"camera.fov",
|
|
50
|
+
"camera.focusDistance",
|
|
51
|
+
"camera.shake"
|
|
52
|
+
] as const;
|
|
53
|
+
|
|
54
|
+
type CameraPropertyPath = typeof CAMERA_PROPERTY_PATHS[number];
|
|
55
|
+
|
|
56
|
+
export class CameraPathEditor {
|
|
57
|
+
private readonly keyframes: KeyframeEditor;
|
|
58
|
+
private readonly curves: CurveEditor;
|
|
59
|
+
|
|
60
|
+
constructor(history = new CommandHistory()) {
|
|
61
|
+
this.keyframes = new KeyframeEditor(history);
|
|
62
|
+
this.curves = new CurveEditor(history);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
get commandHistory(): CommandHistory {
|
|
66
|
+
return this.keyframes.commandHistory;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async setCameraKeyframe(clip: TimelineClip, keyframe: EditorCameraPathKeyframe): Promise<EditorCameraPathKeyframe> {
|
|
70
|
+
const normalized = normalizeCameraKeyframe(keyframe);
|
|
71
|
+
const interpolation = normalized.interpolation ?? "linear";
|
|
72
|
+
for (const [propertyPath, value] of cameraKeyframeEntries(normalized)) {
|
|
73
|
+
await this.keyframes.addKeyframe(clip, {
|
|
74
|
+
id: cameraKeyframePropertyId(normalized.id, propertyPath),
|
|
75
|
+
propertyPath,
|
|
76
|
+
time: normalized.time,
|
|
77
|
+
value,
|
|
78
|
+
interpolation
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
clip.properties.cameraPathEdited = "true";
|
|
82
|
+
clip.properties.cameraPathKeyframeIds = JSON.stringify(readCameraPathKeyframes(clip).map((frame) => frame.id));
|
|
83
|
+
return normalized;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
readCameraPathKeyframes(clip: TimelineClip): readonly EditorCameraPathKeyframe[] {
|
|
87
|
+
return readCameraPathKeyframes(clip);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
sample(clip: TimelineClip, time: number): EditorCameraPathSample {
|
|
91
|
+
assertFinite(time, "Camera path sample time");
|
|
92
|
+
const fallback = this.readCameraPathKeyframes(clip)[0];
|
|
93
|
+
if (!fallback) throw new Error(`Camera path clip "${clip.id}" has no editable camera keyframes.`);
|
|
94
|
+
return {
|
|
95
|
+
time,
|
|
96
|
+
position: [
|
|
97
|
+
numericCurveValue(this.curves.sample(clip, "camera.position.x", time).value, fallback.position[0]),
|
|
98
|
+
numericCurveValue(this.curves.sample(clip, "camera.position.y", time).value, fallback.position[1]),
|
|
99
|
+
numericCurveValue(this.curves.sample(clip, "camera.position.z", time).value, fallback.position[2])
|
|
100
|
+
],
|
|
101
|
+
target: [
|
|
102
|
+
numericCurveValue(this.curves.sample(clip, "camera.target.x", time).value, fallback.target[0]),
|
|
103
|
+
numericCurveValue(this.curves.sample(clip, "camera.target.y", time).value, fallback.target[1]),
|
|
104
|
+
numericCurveValue(this.curves.sample(clip, "camera.target.z", time).value, fallback.target[2])
|
|
105
|
+
],
|
|
106
|
+
fov: numericCurveValue(this.curves.sample(clip, "camera.fov", time).value, fallback.fov),
|
|
107
|
+
focusDistance: optionalNumericCurveValue(this.curves.sample(clip, "camera.focusDistance", time).value, fallback.focusDistance),
|
|
108
|
+
shake: numericCurveValue(this.curves.sample(clip, "camera.shake", time).value, fallback.shake ?? 0)
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
evidence(clip: TimelineClip): EditorCameraPathEvidence {
|
|
113
|
+
const keyframes = this.readCameraPathKeyframes(clip);
|
|
114
|
+
const encodedKeyframes = readTimelineKeyframes(clip);
|
|
115
|
+
const fovEvidence = this.curves.evidence(clip, "camera.fov");
|
|
116
|
+
return {
|
|
117
|
+
clipId: clip.id,
|
|
118
|
+
cameraKeyframeCount: keyframes.length,
|
|
119
|
+
editablePropertyCount: CAMERA_PROPERTY_PATHS.filter((path) => encodedKeyframes.some((keyframe) => keyframe.propertyPath === path)).length,
|
|
120
|
+
sampledPointCount: fovEvidence.sampledPointCount,
|
|
121
|
+
evidence: {
|
|
122
|
+
shotCameraMoveEditing: clip.properties.cameraPathEdited === "true" && keyframes.length >= 2,
|
|
123
|
+
cameraPositionCurves: ["camera.position.x", "camera.position.y", "camera.position.z"].every((path) => encodedKeyframes.some((keyframe) => keyframe.propertyPath === path)),
|
|
124
|
+
cameraTargetCurves: ["camera.target.x", "camera.target.y", "camera.target.z"].every((path) => encodedKeyframes.some((keyframe) => keyframe.propertyPath === path)),
|
|
125
|
+
fovCurve: fovEvidence.keyframeCount >= 2,
|
|
126
|
+
deterministicSampling: true
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function createCameraPathEditor(history?: CommandHistory): CameraPathEditor {
|
|
133
|
+
return new CameraPathEditor(history);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function readCameraPathKeyframes(clip: TimelineClip): readonly EditorCameraPathKeyframe[] {
|
|
137
|
+
const groups = new Map<string, Partial<Record<CameraPropertyPath, number>> & { time?: number }>();
|
|
138
|
+
for (const keyframe of readTimelineKeyframes(clip)) {
|
|
139
|
+
const parsed = parseCameraKeyframePropertyId(keyframe.id);
|
|
140
|
+
if (!parsed) continue;
|
|
141
|
+
if (typeof keyframe.value !== "number") continue;
|
|
142
|
+
const group = groups.get(parsed.frameId) ?? {};
|
|
143
|
+
group[parsed.propertyPath] = keyframe.value;
|
|
144
|
+
group.time = keyframe.time;
|
|
145
|
+
groups.set(parsed.frameId, group);
|
|
146
|
+
}
|
|
147
|
+
return [...groups.entries()].flatMap(([id, values]) => {
|
|
148
|
+
const position = tupleFrom(values["camera.position.x"], values["camera.position.y"], values["camera.position.z"]);
|
|
149
|
+
const target = tupleFrom(values["camera.target.x"], values["camera.target.y"], values["camera.target.z"]);
|
|
150
|
+
if (!position || !target || values["camera.fov"] === undefined || values.time === undefined) return [];
|
|
151
|
+
return [{
|
|
152
|
+
id,
|
|
153
|
+
time: values.time,
|
|
154
|
+
position,
|
|
155
|
+
target,
|
|
156
|
+
fov: values["camera.fov"],
|
|
157
|
+
focusDistance: values["camera.focusDistance"],
|
|
158
|
+
shake: values["camera.shake"]
|
|
159
|
+
}];
|
|
160
|
+
}).sort((a, b) => a.time - b.time || a.id.localeCompare(b.id));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function cameraKeyframeEntries(keyframe: EditorCameraPathKeyframe): readonly (readonly [CameraPropertyPath, number])[] {
|
|
164
|
+
return [
|
|
165
|
+
["camera.position.x", keyframe.position[0]],
|
|
166
|
+
["camera.position.y", keyframe.position[1]],
|
|
167
|
+
["camera.position.z", keyframe.position[2]],
|
|
168
|
+
["camera.target.x", keyframe.target[0]],
|
|
169
|
+
["camera.target.y", keyframe.target[1]],
|
|
170
|
+
["camera.target.z", keyframe.target[2]],
|
|
171
|
+
["camera.fov", keyframe.fov],
|
|
172
|
+
["camera.focusDistance", keyframe.focusDistance ?? 0],
|
|
173
|
+
["camera.shake", keyframe.shake ?? 0]
|
|
174
|
+
];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function normalizeCameraKeyframe(keyframe: EditorCameraPathKeyframe): EditorCameraPathKeyframe {
|
|
178
|
+
const id = keyframe.id.trim();
|
|
179
|
+
if (!id) throw new Error("Camera path keyframe id is required.");
|
|
180
|
+
assertFinite(keyframe.time, "Camera path keyframe time");
|
|
181
|
+
if (keyframe.time < 0) throw new RangeError("Camera path keyframe time must be non-negative.");
|
|
182
|
+
assertVector3(keyframe.position, "Camera path keyframe position");
|
|
183
|
+
assertVector3(keyframe.target, "Camera path keyframe target");
|
|
184
|
+
assertFinite(keyframe.fov, "Camera path keyframe fov");
|
|
185
|
+
if (keyframe.fov <= 0) throw new RangeError("Camera path keyframe fov must be positive.");
|
|
186
|
+
if (keyframe.focusDistance !== undefined) assertFinite(keyframe.focusDistance, "Camera path keyframe focusDistance");
|
|
187
|
+
if (keyframe.shake !== undefined) assertFinite(keyframe.shake, "Camera path keyframe shake");
|
|
188
|
+
return { ...keyframe, id };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function cameraKeyframePropertyId(frameId: string, propertyPath: string): string {
|
|
192
|
+
return `${frameId}#${propertyPath}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function parseCameraKeyframePropertyId(id: string): { readonly frameId: string; readonly propertyPath: CameraPropertyPath } | undefined {
|
|
196
|
+
const separator = id.lastIndexOf("#");
|
|
197
|
+
if (separator <= 0) return undefined;
|
|
198
|
+
const propertyPath = id.slice(separator + 1);
|
|
199
|
+
if (!CAMERA_PROPERTY_PATHS.includes(propertyPath as CameraPropertyPath)) return undefined;
|
|
200
|
+
return { frameId: id.slice(0, separator), propertyPath: propertyPath as CameraPropertyPath };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function tupleFrom(x: number | undefined, y: number | undefined, z: number | undefined): EditorCameraVector3 | undefined {
|
|
204
|
+
return x === undefined || y === undefined || z === undefined ? undefined : [x, y, z];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function numericCurveValue(value: unknown, fallback: number): number {
|
|
208
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function optionalNumericCurveValue(value: unknown, fallback: number | undefined): number | undefined {
|
|
212
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
213
|
+
return fallback;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function assertVector3(value: EditorCameraVector3, label: string): void {
|
|
217
|
+
if (value.length !== 3) throw new Error(`${label} must contain three numeric components.`);
|
|
218
|
+
for (const component of value) assertFinite(component, label);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function assertFinite(value: number, label: string): void {
|
|
222
|
+
if (!Number.isFinite(value)) throw new RangeError(`${label} must be finite.`);
|
|
223
|
+
}
|