@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,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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
export type EpisodeReviewStatus = "draft" | "needs-review" | "approved" | "rejected";
|
|
2
|
+
|
|
3
|
+
export interface EpisodeReviewRejectedFrame {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly time: number;
|
|
6
|
+
readonly reason: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface EpisodeReviewNote {
|
|
10
|
+
readonly id: string;
|
|
11
|
+
readonly author: string;
|
|
12
|
+
readonly text: string;
|
|
13
|
+
readonly time?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface EpisodeReviewWaveformLane {
|
|
17
|
+
readonly id: string;
|
|
18
|
+
readonly label: string;
|
|
19
|
+
readonly startTime: number;
|
|
20
|
+
readonly duration: number;
|
|
21
|
+
readonly peakCount: number;
|
|
22
|
+
readonly pathPointCount: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface EpisodeReviewManualVisemeEdit {
|
|
26
|
+
readonly id: string;
|
|
27
|
+
readonly characterId: string;
|
|
28
|
+
readonly visemeId: string;
|
|
29
|
+
readonly startTime: number;
|
|
30
|
+
readonly endTime: number;
|
|
31
|
+
readonly reason?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface EpisodeReviewPanelState {
|
|
35
|
+
readonly packageId: string;
|
|
36
|
+
readonly packageHash?: string;
|
|
37
|
+
readonly status: EpisodeReviewStatus;
|
|
38
|
+
readonly reviewer?: string;
|
|
39
|
+
readonly approvedAt?: string;
|
|
40
|
+
readonly notes: readonly EpisodeReviewNote[];
|
|
41
|
+
readonly rejectedFrames: readonly EpisodeReviewRejectedFrame[];
|
|
42
|
+
readonly waveformLanes?: readonly EpisodeReviewWaveformLane[];
|
|
43
|
+
readonly manualVisemeEdits?: readonly EpisodeReviewManualVisemeEdit[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface EpisodeReviewPanelSnapshot extends EpisodeReviewPanelState {
|
|
47
|
+
readonly kind: "episode-review-panel";
|
|
48
|
+
readonly approvalRecorded: boolean;
|
|
49
|
+
readonly rejectedFrameCount: number;
|
|
50
|
+
readonly noteCount: number;
|
|
51
|
+
readonly waveformLaneCount: number;
|
|
52
|
+
readonly manualVisemeEditCount: number;
|
|
53
|
+
readonly reviewUiEvidence: {
|
|
54
|
+
readonly waveformReview: boolean;
|
|
55
|
+
readonly manualVisemeEdits: boolean;
|
|
56
|
+
readonly visualFrameReview: boolean;
|
|
57
|
+
readonly reviewerNotes: boolean;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class EpisodeReviewPanel {
|
|
62
|
+
private state: EpisodeReviewPanelState;
|
|
63
|
+
|
|
64
|
+
constructor(state: EpisodeReviewPanelState) {
|
|
65
|
+
this.state = sanitizeReviewState(state);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
addNote(note: EpisodeReviewNote): EpisodeReviewPanelSnapshot {
|
|
69
|
+
this.state = sanitizeReviewState({
|
|
70
|
+
...this.state,
|
|
71
|
+
notes: [...this.state.notes, sanitizeReviewNote(note)]
|
|
72
|
+
});
|
|
73
|
+
return this.snapshot();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
rejectFrame(frame: EpisodeReviewRejectedFrame): EpisodeReviewPanelSnapshot {
|
|
77
|
+
this.state = sanitizeReviewState({
|
|
78
|
+
...this.state,
|
|
79
|
+
status: "rejected",
|
|
80
|
+
rejectedFrames: [...this.state.rejectedFrames, sanitizeRejectedFrame(frame)]
|
|
81
|
+
});
|
|
82
|
+
return this.snapshot();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
setWaveformLanes(waveformLanes: readonly EpisodeReviewWaveformLane[]): EpisodeReviewPanelSnapshot {
|
|
86
|
+
this.state = sanitizeReviewState({
|
|
87
|
+
...this.state,
|
|
88
|
+
waveformLanes: waveformLanes.map(sanitizeWaveformLane)
|
|
89
|
+
});
|
|
90
|
+
return this.snapshot();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
applyManualVisemeEdit(edit: EpisodeReviewManualVisemeEdit): EpisodeReviewPanelSnapshot {
|
|
94
|
+
const sanitized = sanitizeManualVisemeEdit(edit);
|
|
95
|
+
this.state = sanitizeReviewState({
|
|
96
|
+
...this.state,
|
|
97
|
+
manualVisemeEdits: [
|
|
98
|
+
...((this.state.manualVisemeEdits ?? []).filter((candidate) => candidate.id !== sanitized.id)),
|
|
99
|
+
sanitized
|
|
100
|
+
]
|
|
101
|
+
});
|
|
102
|
+
return this.snapshot();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
approve(reviewer: string, approvedAt = new Date(0).toISOString()): EpisodeReviewPanelSnapshot {
|
|
106
|
+
const name = nonEmpty(reviewer, "Episode review reviewer");
|
|
107
|
+
if (this.state.rejectedFrames.length > 0) {
|
|
108
|
+
throw new Error("Episode review cannot be approved while rejected frames are present.");
|
|
109
|
+
}
|
|
110
|
+
this.state = sanitizeReviewState({
|
|
111
|
+
...this.state,
|
|
112
|
+
status: "approved",
|
|
113
|
+
reviewer: name,
|
|
114
|
+
approvedAt
|
|
115
|
+
});
|
|
116
|
+
return this.snapshot();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
markNeedsReview(): EpisodeReviewPanelSnapshot {
|
|
120
|
+
this.state = sanitizeReviewState({
|
|
121
|
+
...this.state,
|
|
122
|
+
status: "needs-review",
|
|
123
|
+
reviewer: undefined,
|
|
124
|
+
approvedAt: undefined
|
|
125
|
+
});
|
|
126
|
+
return this.snapshot();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
serialize(): EpisodeReviewPanelState {
|
|
130
|
+
return sanitizeReviewState(this.state);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
snapshot(): EpisodeReviewPanelSnapshot {
|
|
134
|
+
const waveformLanes = this.state.waveformLanes ?? [];
|
|
135
|
+
const manualVisemeEdits = this.state.manualVisemeEdits ?? [];
|
|
136
|
+
return {
|
|
137
|
+
kind: "episode-review-panel",
|
|
138
|
+
...this.serialize(),
|
|
139
|
+
approvalRecorded: this.state.status === "approved" && Boolean(this.state.reviewer),
|
|
140
|
+
rejectedFrameCount: this.state.rejectedFrames.length,
|
|
141
|
+
noteCount: this.state.notes.length,
|
|
142
|
+
waveformLaneCount: waveformLanes.length,
|
|
143
|
+
manualVisemeEditCount: manualVisemeEdits.length,
|
|
144
|
+
reviewUiEvidence: {
|
|
145
|
+
waveformReview: waveformLanes.length > 0,
|
|
146
|
+
manualVisemeEdits: manualVisemeEdits.length > 0,
|
|
147
|
+
visualFrameReview: this.state.rejectedFrames.length > 0,
|
|
148
|
+
reviewerNotes: this.state.notes.length > 0
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function createEpisodeReviewPanel(state: EpisodeReviewPanelState): EpisodeReviewPanel {
|
|
155
|
+
return new EpisodeReviewPanel(state);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function sanitizeReviewState(state: EpisodeReviewPanelState): EpisodeReviewPanelState {
|
|
159
|
+
return {
|
|
160
|
+
packageId: nonEmpty(state.packageId, "Episode review packageId"),
|
|
161
|
+
packageHash: state.packageHash,
|
|
162
|
+
status: state.status,
|
|
163
|
+
reviewer: state.reviewer,
|
|
164
|
+
approvedAt: state.approvedAt,
|
|
165
|
+
notes: state.notes.map(sanitizeReviewNote),
|
|
166
|
+
rejectedFrames: state.rejectedFrames.map(sanitizeRejectedFrame),
|
|
167
|
+
waveformLanes: (state.waveformLanes ?? []).map(sanitizeWaveformLane),
|
|
168
|
+
manualVisemeEdits: (state.manualVisemeEdits ?? []).map(sanitizeManualVisemeEdit)
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function sanitizeReviewNote(note: EpisodeReviewNote): EpisodeReviewNote {
|
|
173
|
+
return {
|
|
174
|
+
id: nonEmpty(note.id, "Episode review note id"),
|
|
175
|
+
author: nonEmpty(note.author, "Episode review note author"),
|
|
176
|
+
text: nonEmpty(note.text, "Episode review note text"),
|
|
177
|
+
time: note.time
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function sanitizeRejectedFrame(frame: EpisodeReviewRejectedFrame): EpisodeReviewRejectedFrame {
|
|
182
|
+
if (!Number.isFinite(frame.time) || frame.time < 0) {
|
|
183
|
+
throw new Error("Episode review rejected frame time must be a non-negative finite number.");
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
id: nonEmpty(frame.id, "Episode review rejected frame id"),
|
|
187
|
+
time: frame.time,
|
|
188
|
+
reason: nonEmpty(frame.reason, "Episode review rejected frame reason")
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function sanitizeWaveformLane(lane: EpisodeReviewWaveformLane): EpisodeReviewWaveformLane {
|
|
193
|
+
if (!Number.isFinite(lane.startTime) || lane.startTime < 0) {
|
|
194
|
+
throw new Error("Episode review waveform lane startTime must be a non-negative finite number.");
|
|
195
|
+
}
|
|
196
|
+
if (!Number.isFinite(lane.duration) || lane.duration < 0) {
|
|
197
|
+
throw new Error("Episode review waveform lane duration must be a non-negative finite number.");
|
|
198
|
+
}
|
|
199
|
+
if (!Number.isInteger(lane.peakCount) || lane.peakCount < 0) {
|
|
200
|
+
throw new Error("Episode review waveform lane peakCount must be a non-negative integer.");
|
|
201
|
+
}
|
|
202
|
+
if (!Number.isInteger(lane.pathPointCount) || lane.pathPointCount < 0) {
|
|
203
|
+
throw new Error("Episode review waveform lane pathPointCount must be a non-negative integer.");
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
id: nonEmpty(lane.id, "Episode review waveform lane id"),
|
|
207
|
+
label: nonEmpty(lane.label, "Episode review waveform lane label"),
|
|
208
|
+
startTime: lane.startTime,
|
|
209
|
+
duration: lane.duration,
|
|
210
|
+
peakCount: lane.peakCount,
|
|
211
|
+
pathPointCount: lane.pathPointCount
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function sanitizeManualVisemeEdit(edit: EpisodeReviewManualVisemeEdit): EpisodeReviewManualVisemeEdit {
|
|
216
|
+
if (!Number.isFinite(edit.startTime) || edit.startTime < 0) {
|
|
217
|
+
throw new Error("Episode review manual viseme startTime must be a non-negative finite number.");
|
|
218
|
+
}
|
|
219
|
+
if (!Number.isFinite(edit.endTime) || edit.endTime <= edit.startTime) {
|
|
220
|
+
throw new Error("Episode review manual viseme endTime must be greater than startTime.");
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
id: nonEmpty(edit.id, "Episode review manual viseme id"),
|
|
224
|
+
characterId: nonEmpty(edit.characterId, "Episode review manual viseme characterId"),
|
|
225
|
+
visemeId: nonEmpty(edit.visemeId, "Episode review manual viseme visemeId"),
|
|
226
|
+
startTime: edit.startTime,
|
|
227
|
+
endTime: edit.endTime,
|
|
228
|
+
reason: edit.reason
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function nonEmpty(value: string, label: string): string {
|
|
233
|
+
const trimmed = value.trim();
|
|
234
|
+
if (!trimmed) throw new Error(`${label} must be a non-empty string.`);
|
|
235
|
+
return trimmed;
|
|
236
|
+
}
|