@aelionsdk/sdk 0.1.0-beta.1
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/LICENSE +21 -0
- package/README.md +63 -0
- package/dist/audio-controller.d.ts +25 -0
- package/dist/audio-controller.d.ts.map +1 -0
- package/dist/audio-controller.js +235 -0
- package/dist/audio-mastering.d.ts +27 -0
- package/dist/audio-mastering.d.ts.map +1 -0
- package/dist/audio-mastering.js +160 -0
- package/dist/composition.d.ts +75 -0
- package/dist/composition.d.ts.map +1 -0
- package/dist/composition.js +146 -0
- package/dist/default-schemas.d.ts +7 -0
- package/dist/default-schemas.d.ts.map +1 -0
- package/dist/default-schemas.js +18 -0
- package/dist/export-job.d.ts +22 -0
- package/dist/export-job.d.ts.map +1 -0
- package/dist/export-job.js +126 -0
- package/dist/extension-host.d.ts +90 -0
- package/dist/extension-host.d.ts.map +1 -0
- package/dist/extension-host.js +367 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/media-provider.d.ts +49 -0
- package/dist/media-provider.d.ts.map +1 -0
- package/dist/media-provider.js +388 -0
- package/dist/migration-materials.d.ts +10 -0
- package/dist/migration-materials.d.ts.map +1 -0
- package/dist/migration-materials.js +148 -0
- package/dist/migration.d.ts +109 -0
- package/dist/migration.d.ts.map +1 -0
- package/dist/migration.js +1232 -0
- package/dist/persistence.d.ts +73 -0
- package/dist/persistence.d.ts.map +1 -0
- package/dist/persistence.js +245 -0
- package/dist/player.d.ts +22 -0
- package/dist/player.d.ts.map +1 -0
- package/dist/player.js +522 -0
- package/dist/preview-controller.d.ts +62 -0
- package/dist/preview-controller.d.ts.map +1 -0
- package/dist/preview-controller.js +377 -0
- package/dist/preview-quality.d.ts +7 -0
- package/dist/preview-quality.d.ts.map +1 -0
- package/dist/preview-quality.js +8 -0
- package/dist/production-media-provider.d.ts +112 -0
- package/dist/production-media-provider.d.ts.map +1 -0
- package/dist/production-media-provider.js +749 -0
- package/dist/project-builder.d.ts +249 -0
- package/dist/project-builder.d.ts.map +1 -0
- package/dist/project-builder.js +953 -0
- package/dist/runtime-material-registry.d.ts +10 -0
- package/dist/runtime-material-registry.d.ts.map +1 -0
- package/dist/runtime-material-registry.js +23 -0
- package/dist/session.d.ts +29 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +1114 -0
- package/dist/types.d.ts +392 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +60 -0
package/dist/session.js
ADDED
|
@@ -0,0 +1,1114 @@
|
|
|
1
|
+
import { renderIrAudio } from '@aelionsdk/audio';
|
|
2
|
+
import { probeCapabilities } from '@aelionsdk/capability';
|
|
3
|
+
import { AelionError } from '@aelionsdk/core';
|
|
4
|
+
import { createRemoteExportContentId, exportFrozenRenderIrAv1Mp4, exportFrozenRenderIrHevcMp4, exportFrozenRenderIrMp4, exportFrozenRenderIrWebM, exportGif, exportStillImage, exportWav, preflightProfileExport, preflightWebMExport, runRemoteExport, selectExportProfile, } from '@aelionsdk/export';
|
|
5
|
+
import { canonicalStringify, ProjectValidator, } from '@aelionsdk/project-schema';
|
|
6
|
+
import { evaluateAnimatedValue, evaluateVisualState, IncrementalRenderCompiler, } from '@aelionsdk/render-ir';
|
|
7
|
+
import { RenderIrFrameRenderer, } from '@aelionsdk/renderer-worker';
|
|
8
|
+
import { EditingCommands, TransactionEngine, TransactionHistory, } from '@aelionsdk/transaction';
|
|
9
|
+
import { SessionAudioController, projectAudioMastering } from './audio-controller.js';
|
|
10
|
+
import { createMasteredAudioRenderer } from './audio-mastering.js';
|
|
11
|
+
import { AelionPlayer } from './player.js';
|
|
12
|
+
import { normalizePreviewQuality } from './preview-quality.js';
|
|
13
|
+
import { defaultSchemas } from './default-schemas.js';
|
|
14
|
+
import { ExportJob } from './export-job.js';
|
|
15
|
+
function unloaded() {
|
|
16
|
+
return new Error('Load an Aelion Project before using this session');
|
|
17
|
+
}
|
|
18
|
+
function channelCountForLayout(layout) {
|
|
19
|
+
if (layout === 'mono')
|
|
20
|
+
return 1;
|
|
21
|
+
if (layout === 'stereo')
|
|
22
|
+
return 2;
|
|
23
|
+
if (layout === '5.1')
|
|
24
|
+
return 6;
|
|
25
|
+
throw new RangeError(`Unsupported channel layout ${layout}`);
|
|
26
|
+
}
|
|
27
|
+
function objectValue(value) {
|
|
28
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
29
|
+
? value
|
|
30
|
+
: {};
|
|
31
|
+
}
|
|
32
|
+
function finiteValue(value, fallback) {
|
|
33
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
34
|
+
}
|
|
35
|
+
function directOpaqueVisualSource(ir, timeUs) {
|
|
36
|
+
const state = evaluateVisualState(ir, timeUs);
|
|
37
|
+
const active = state.clips[0];
|
|
38
|
+
if (state.transition !== undefined ||
|
|
39
|
+
state.clips.length !== 1 ||
|
|
40
|
+
active?.clip.kind !== 'visual-clip' ||
|
|
41
|
+
active.sourceTimeUs === null ||
|
|
42
|
+
active.materials.length !== 0) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
const clip = active.clip;
|
|
46
|
+
const visual = clip.visual;
|
|
47
|
+
if (visual.fit !== 'fill' ||
|
|
48
|
+
visual.blendMode !== 'normal' ||
|
|
49
|
+
visual.mask !== undefined ||
|
|
50
|
+
clip.materialInstanceIds.length !== 0) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
const evaluate = (value) => evaluateAnimatedValue(value, timeUs, clip.range.startUs);
|
|
54
|
+
const transform = visual.transform;
|
|
55
|
+
const position = objectValue(evaluate(transform.positionPx));
|
|
56
|
+
const anchor = objectValue(evaluate(transform.anchor));
|
|
57
|
+
const scale = objectValue(evaluate(transform.scale));
|
|
58
|
+
const skew = objectValue(evaluate(transform.skewDeg));
|
|
59
|
+
const crop = objectValue(evaluate(visual.crop));
|
|
60
|
+
if (finiteValue(position.x, ir.width / 2) !== ir.width / 2 ||
|
|
61
|
+
finiteValue(position.y, ir.height / 2) !== ir.height / 2 ||
|
|
62
|
+
finiteValue(anchor.x, 0.5) !== 0.5 ||
|
|
63
|
+
finiteValue(anchor.y, 0.5) !== 0.5 ||
|
|
64
|
+
finiteValue(scale.x, 1) !== 1 ||
|
|
65
|
+
finiteValue(scale.y, 1) !== 1 ||
|
|
66
|
+
finiteValue(skew.x, 0) !== 0 ||
|
|
67
|
+
finiteValue(skew.y, 0) !== 0 ||
|
|
68
|
+
finiteValue(evaluate(transform.rotationDeg), 0) !== 0 ||
|
|
69
|
+
finiteValue(evaluate(visual.opacity), 1) !== 1 ||
|
|
70
|
+
finiteValue(crop.left, 0) !== 0 ||
|
|
71
|
+
finiteValue(crop.top, 0) !== 0 ||
|
|
72
|
+
finiteValue(crop.right, 0) !== 0 ||
|
|
73
|
+
finiteValue(crop.bottom, 0) !== 0) {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
assetId: clip.source.assetId,
|
|
78
|
+
streamIndex: clip.source.streamIndex,
|
|
79
|
+
sourceTimeUs: active.sourceTimeUs,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function frameHasAlpha(frame) {
|
|
83
|
+
return frame.format === 'I420A' || frame.format === 'RGBA' || frame.format === 'BGRA';
|
|
84
|
+
}
|
|
85
|
+
const DEFAULT_MAX_DIAGNOSTICS = 256;
|
|
86
|
+
function diagnosticHistoryLimit(value) {
|
|
87
|
+
const limit = value ?? DEFAULT_MAX_DIAGNOSTICS;
|
|
88
|
+
if (!Number.isSafeInteger(limit) || limit <= 0) {
|
|
89
|
+
throw new RangeError('maxDiagnostics must be a positive safe integer');
|
|
90
|
+
}
|
|
91
|
+
return limit;
|
|
92
|
+
}
|
|
93
|
+
export class AelionSession {
|
|
94
|
+
#options;
|
|
95
|
+
#validator;
|
|
96
|
+
#compiler = new IncrementalRenderCompiler();
|
|
97
|
+
#renderer;
|
|
98
|
+
#lastDisposedRenderer;
|
|
99
|
+
#listeners = new Set();
|
|
100
|
+
#state = 'empty';
|
|
101
|
+
#capability;
|
|
102
|
+
#diagnostics = [];
|
|
103
|
+
#maxDiagnostics;
|
|
104
|
+
#droppedDiagnostics = 0;
|
|
105
|
+
#engine;
|
|
106
|
+
#history;
|
|
107
|
+
#commands;
|
|
108
|
+
#unsubscribeHistory;
|
|
109
|
+
#ir;
|
|
110
|
+
#compileStats;
|
|
111
|
+
#sequenceId;
|
|
112
|
+
#previewRequestedFrames = 0;
|
|
113
|
+
#previewRenderedFrames = 0;
|
|
114
|
+
#previewFailedFrames = 0;
|
|
115
|
+
#lastPreviewBackend;
|
|
116
|
+
#lastPreviewWidth;
|
|
117
|
+
#lastPreviewHeight;
|
|
118
|
+
#lastPreviewRenderScale;
|
|
119
|
+
#exportJobsStarted = 0;
|
|
120
|
+
#exportJobsCompleted = 0;
|
|
121
|
+
#exportJobsFailed = 0;
|
|
122
|
+
#exportJobsCancelled = 0;
|
|
123
|
+
#activeExportJob;
|
|
124
|
+
#nextExportJobId = 1;
|
|
125
|
+
#loadGeneration = 0;
|
|
126
|
+
#loadInProgress = 0;
|
|
127
|
+
#loadTail = Promise.resolve();
|
|
128
|
+
#disposeTask;
|
|
129
|
+
#activeInteractiveEdit;
|
|
130
|
+
#nextInteractiveEditId = 1;
|
|
131
|
+
player;
|
|
132
|
+
audio;
|
|
133
|
+
transaction;
|
|
134
|
+
preview;
|
|
135
|
+
export;
|
|
136
|
+
constructor(options = {}) {
|
|
137
|
+
this.#options = options;
|
|
138
|
+
this.#maxDiagnostics = diagnosticHistoryLimit(options.maxDiagnostics);
|
|
139
|
+
if (options.maxPendingFrames !== undefined &&
|
|
140
|
+
(!Number.isSafeInteger(options.maxPendingFrames) || options.maxPendingFrames <= 0)) {
|
|
141
|
+
throw new RangeError('maxPendingFrames must be a positive safe integer');
|
|
142
|
+
}
|
|
143
|
+
this.#validator = new ProjectValidator({
|
|
144
|
+
projectSchema: (options.schemas ?? defaultSchemas).project,
|
|
145
|
+
materialInstanceSchema: (options.schemas ?? defaultSchemas).materialInstance,
|
|
146
|
+
});
|
|
147
|
+
this.player = new AelionPlayer(this, (error) => this.#acceptPlayerError(error), options.runtimeAssets);
|
|
148
|
+
this.audio = new SessionAudioController({
|
|
149
|
+
ir: () => this.requireIr(),
|
|
150
|
+
project: () => {
|
|
151
|
+
const value = this.#engine?.getSnapshot();
|
|
152
|
+
if (value === undefined)
|
|
153
|
+
throw unloaded();
|
|
154
|
+
return value;
|
|
155
|
+
},
|
|
156
|
+
media: () => this.requireMedia(),
|
|
157
|
+
revision: () => {
|
|
158
|
+
const value = this.revision;
|
|
159
|
+
if (value === null)
|
|
160
|
+
throw unloaded();
|
|
161
|
+
return value;
|
|
162
|
+
},
|
|
163
|
+
edit: (label, callback) => this.#edit(callback, { label }),
|
|
164
|
+
});
|
|
165
|
+
this.preview = {
|
|
166
|
+
renderFrame: options => this.#renderPreviewFrame(options),
|
|
167
|
+
};
|
|
168
|
+
const commands = () => this.#requireCommands();
|
|
169
|
+
const canUndo = () => this.#history?.state.canUndo ?? false;
|
|
170
|
+
const canRedo = () => this.#history?.state.canRedo ?? false;
|
|
171
|
+
this.transaction = {
|
|
172
|
+
edit: (callback, editOptions = {}) => this.#edit(callback, editOptions),
|
|
173
|
+
beginInteractive: editOptions => this.#beginInteractiveEdit(editOptions),
|
|
174
|
+
undo: () => this.#undoChange(),
|
|
175
|
+
redo: () => this.#redoChange(),
|
|
176
|
+
get commands() {
|
|
177
|
+
return commands();
|
|
178
|
+
},
|
|
179
|
+
get canUndo() {
|
|
180
|
+
return canUndo();
|
|
181
|
+
},
|
|
182
|
+
get canRedo() {
|
|
183
|
+
return canRedo();
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
const activeExportJob = () => this.#activeExportJob ?? null;
|
|
187
|
+
this.export = {
|
|
188
|
+
preflight: options => this.#preflight(options),
|
|
189
|
+
preflightProfile: options => this.#preflightProfile(options),
|
|
190
|
+
negotiate: options => this.#negotiateExport(options),
|
|
191
|
+
start: options => this.#startExport(options),
|
|
192
|
+
startProfile: options => this.#startProfileExport(options),
|
|
193
|
+
startRemote: options => this.#startRemoteExport(options),
|
|
194
|
+
cancel: reason => this.#cancelExport(reason),
|
|
195
|
+
get activeJob() {
|
|
196
|
+
return activeExportJob();
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
get state() {
|
|
201
|
+
return this.#state;
|
|
202
|
+
}
|
|
203
|
+
get revision() {
|
|
204
|
+
return this.#engine?.revision ?? null;
|
|
205
|
+
}
|
|
206
|
+
async loadProject(value) {
|
|
207
|
+
this.#assertActive();
|
|
208
|
+
const validation = this.#validator.validate(value);
|
|
209
|
+
if (!validation.ok) {
|
|
210
|
+
for (const diagnostic of validation.diagnostics)
|
|
211
|
+
this.#recordDiagnostic(diagnostic);
|
|
212
|
+
throw new AelionError(validation.diagnostics);
|
|
213
|
+
}
|
|
214
|
+
const generation = this.#loadGeneration + 1;
|
|
215
|
+
this.#loadGeneration = generation;
|
|
216
|
+
this.#loadInProgress += 1;
|
|
217
|
+
const task = this.#loadTail.then(() => this.#installProject(validation.value.project, generation));
|
|
218
|
+
// Serialize reset/install work so a superseded load cannot finish resetting
|
|
219
|
+
// Player resources after a newer Project has already become visible. Keep the
|
|
220
|
+
// tail fulfilled so one failed/superseded load cannot block the next request.
|
|
221
|
+
this.#loadTail = task.then(() => undefined, () => undefined);
|
|
222
|
+
return task.finally(() => {
|
|
223
|
+
this.#loadInProgress -= 1;
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
async #installProject(project, generation) {
|
|
227
|
+
this.#assertLoadCurrent(generation);
|
|
228
|
+
this.#invalidateInteractiveEdit();
|
|
229
|
+
await this.player.reset();
|
|
230
|
+
this.#assertLoadCurrent(generation);
|
|
231
|
+
const engineRef = {};
|
|
232
|
+
const engine = new TransactionEngine(project, candidate => {
|
|
233
|
+
const result = this.#validator.validate(candidate);
|
|
234
|
+
return { ok: result.ok, diagnostics: result.diagnostics };
|
|
235
|
+
}, {
|
|
236
|
+
prepareCommit: commit => {
|
|
237
|
+
const current = engineRef.current;
|
|
238
|
+
if (current === undefined)
|
|
239
|
+
throw new Error('Transaction engine is not installed');
|
|
240
|
+
return this.#prepareCommit(current, commit);
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
engineRef.current = engine;
|
|
244
|
+
const history = new TransactionHistory(engine);
|
|
245
|
+
const commands = new EditingCommands({
|
|
246
|
+
get revision() {
|
|
247
|
+
return history.revision;
|
|
248
|
+
},
|
|
249
|
+
getSnapshot: () => history.getSnapshot(),
|
|
250
|
+
subscribe: listener => history.subscribe(listener),
|
|
251
|
+
edit: (editOptions, callback) => {
|
|
252
|
+
this.#assertTransactionAvailable();
|
|
253
|
+
if (this.#history !== history)
|
|
254
|
+
throw new Error('Editing command belongs to a stale Project');
|
|
255
|
+
if (this.#activeInteractiveEdit?.active === true) {
|
|
256
|
+
throw new Error('Finish or cancel the active interactive edit before starting another edit');
|
|
257
|
+
}
|
|
258
|
+
return history.edit(editOptions, callback);
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
const sequenceId = this.#options.sequenceId ?? project.settings.defaultSequenceId;
|
|
262
|
+
const compiler = new IncrementalRenderCompiler();
|
|
263
|
+
const compilation = compiler.compile(engine.getSnapshot(), sequenceId, engine.revision, {
|
|
264
|
+
resolveMaterialProgram: (definition, parameters) => this.#options.materials?.resolveProgram(definition, parameters),
|
|
265
|
+
});
|
|
266
|
+
// A synchronous host Material resolver may re-enter the Session and dispose
|
|
267
|
+
// it (or request a newer load) while compilation is running.
|
|
268
|
+
this.#assertLoadCurrent(generation);
|
|
269
|
+
const unsubscribeHistory = history.subscribe(commit => this.#publishCommit(engine, commit));
|
|
270
|
+
this.#unsubscribeHistory?.();
|
|
271
|
+
this.#engine = engine;
|
|
272
|
+
this.#unsubscribeHistory = unsubscribeHistory;
|
|
273
|
+
this.#history = history;
|
|
274
|
+
this.#commands = commands;
|
|
275
|
+
this.#sequenceId = sequenceId;
|
|
276
|
+
this.#compiler = compiler;
|
|
277
|
+
this.#compileStats = compilation.stats;
|
|
278
|
+
this.#ir = compilation.ir;
|
|
279
|
+
this.#setState('ready');
|
|
280
|
+
// `state-changed` listeners are user callbacks and may synchronously dispose
|
|
281
|
+
// the Session or request another Project. Roll back this just-installed
|
|
282
|
+
// candidate before surfacing the stale load so no engine survives disposal.
|
|
283
|
+
try {
|
|
284
|
+
this.#assertLoadCurrent(generation);
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
unsubscribeHistory();
|
|
288
|
+
if (this.#engine === engine) {
|
|
289
|
+
this.#engine = undefined;
|
|
290
|
+
this.#unsubscribeHistory = undefined;
|
|
291
|
+
this.#history = undefined;
|
|
292
|
+
this.#commands = undefined;
|
|
293
|
+
this.#sequenceId = undefined;
|
|
294
|
+
this.#ir = undefined;
|
|
295
|
+
this.#compileStats = undefined;
|
|
296
|
+
}
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
299
|
+
this.#emit({
|
|
300
|
+
type: 'project-loaded',
|
|
301
|
+
projectId: project.projectId,
|
|
302
|
+
revision: engine.revision,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
async renderFrame(options) {
|
|
306
|
+
return this.#renderPreviewFrame(options);
|
|
307
|
+
}
|
|
308
|
+
async #renderPreviewFrame(options) {
|
|
309
|
+
this.#previewRequestedFrames += 1;
|
|
310
|
+
this.#emitStats();
|
|
311
|
+
try {
|
|
312
|
+
const ir = this.requireIr();
|
|
313
|
+
const media = this.#options.media;
|
|
314
|
+
if (media === undefined)
|
|
315
|
+
throw new Error('AelionSession requires a media provider to render');
|
|
316
|
+
const previewQuality = normalizePreviewQuality(options);
|
|
317
|
+
const result = await this.#requireRenderer().render({
|
|
318
|
+
ir,
|
|
319
|
+
timeUs: options.timeUs,
|
|
320
|
+
source: media,
|
|
321
|
+
mode: 'preview',
|
|
322
|
+
preferredBackend: this.#options.preferredBackend ?? 'auto',
|
|
323
|
+
allowFallback: this.#options.allowBackendFallback ?? true,
|
|
324
|
+
renderScale: previewQuality.renderScale,
|
|
325
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
326
|
+
});
|
|
327
|
+
this.#previewRenderedFrames += 1;
|
|
328
|
+
this.#lastPreviewBackend = result.backend;
|
|
329
|
+
this.#lastPreviewWidth = result.width;
|
|
330
|
+
this.#lastPreviewHeight = result.height;
|
|
331
|
+
this.#lastPreviewRenderScale = result.renderScale;
|
|
332
|
+
this.#emitStats();
|
|
333
|
+
return result;
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
this.#previewFailedFrames += 1;
|
|
337
|
+
this.#recordErrorDiagnostics(error);
|
|
338
|
+
this.#emitStats();
|
|
339
|
+
throw error;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
async probeCapabilities(signal) {
|
|
343
|
+
this.#assertActive();
|
|
344
|
+
const capability = await probeCapabilities(signal === undefined ? {} : { signal });
|
|
345
|
+
this.#capability = capability;
|
|
346
|
+
for (const diagnostic of capability.diagnostics)
|
|
347
|
+
this.#recordDiagnostic(diagnostic);
|
|
348
|
+
this.#emit({ type: 'capability-changed', capability });
|
|
349
|
+
return capability;
|
|
350
|
+
}
|
|
351
|
+
getSnapshot() {
|
|
352
|
+
return Object.freeze({
|
|
353
|
+
state: this.#state,
|
|
354
|
+
revision: this.revision,
|
|
355
|
+
project: this.#engine?.getSnapshot() ?? null,
|
|
356
|
+
renderIr: this.#ir ?? null,
|
|
357
|
+
capability: this.#capability ?? null,
|
|
358
|
+
diagnostics: this.getDiagnostics(),
|
|
359
|
+
stats: this.getStats(),
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
getCapabilitySnapshot() {
|
|
363
|
+
return this.#capability ?? null;
|
|
364
|
+
}
|
|
365
|
+
getDiagnostics() {
|
|
366
|
+
return Object.freeze([...this.#diagnostics]);
|
|
367
|
+
}
|
|
368
|
+
getStats() {
|
|
369
|
+
const active = this.#activeExportJob?.getSnapshot();
|
|
370
|
+
const renderer = this.#renderer?.snapshot();
|
|
371
|
+
return Object.freeze({
|
|
372
|
+
schemaVersion: '1.0.0',
|
|
373
|
+
revision: this.revision,
|
|
374
|
+
diagnostics: Object.freeze({
|
|
375
|
+
retained: this.#diagnostics.length,
|
|
376
|
+
dropped: this.#droppedDiagnostics,
|
|
377
|
+
limit: this.#maxDiagnostics,
|
|
378
|
+
}),
|
|
379
|
+
compile: this.#compileStats ?? null,
|
|
380
|
+
preview: Object.freeze({
|
|
381
|
+
requestedFrames: this.#previewRequestedFrames,
|
|
382
|
+
renderedFrames: this.#previewRenderedFrames,
|
|
383
|
+
failedFrames: this.#previewFailedFrames,
|
|
384
|
+
lastBackend: this.#lastPreviewBackend ?? null,
|
|
385
|
+
lastWidth: this.#lastPreviewWidth ?? null,
|
|
386
|
+
lastHeight: this.#lastPreviewHeight ?? null,
|
|
387
|
+
lastRenderScale: this.#lastPreviewRenderScale ?? null,
|
|
388
|
+
pendingFrames: renderer?.pendingFrames ?? 0,
|
|
389
|
+
maxPendingFrames: renderer?.maxPendingFrames ?? this.#options.maxPendingFrames ?? 2,
|
|
390
|
+
rendererPresent: renderer !== undefined,
|
|
391
|
+
rendererDisposed: renderer?.disposed ?? true,
|
|
392
|
+
workerPendingRequests: renderer?.worker.pendingRequests ?? 0,
|
|
393
|
+
workerActiveRequests: renderer?.worker.activeRequests ?? 0,
|
|
394
|
+
workerCancelledRequests: renderer?.worker.cancelledRequests ?? 0,
|
|
395
|
+
lastDisposedRenderer: this.#lastDisposedRenderer === undefined
|
|
396
|
+
? null
|
|
397
|
+
: Object.freeze({
|
|
398
|
+
disposed: this.#lastDisposedRenderer.disposed,
|
|
399
|
+
pendingFrames: this.#lastDisposedRenderer.pendingFrames,
|
|
400
|
+
workerDisposed: this.#lastDisposedRenderer.worker.disposed,
|
|
401
|
+
workerPendingRequests: this.#lastDisposedRenderer.worker.pendingRequests,
|
|
402
|
+
workerActiveRequests: this.#lastDisposedRenderer.worker.activeRequests,
|
|
403
|
+
workerCancelledRequests: this.#lastDisposedRenderer.worker.cancelledRequests,
|
|
404
|
+
}),
|
|
405
|
+
}),
|
|
406
|
+
player: this.player.getStats(),
|
|
407
|
+
export: Object.freeze({
|
|
408
|
+
jobsStarted: this.#exportJobsStarted,
|
|
409
|
+
jobsCompleted: this.#exportJobsCompleted,
|
|
410
|
+
jobsFailed: this.#exportJobsFailed,
|
|
411
|
+
jobsCancelled: this.#exportJobsCancelled,
|
|
412
|
+
activeJobId: active?.id ?? null,
|
|
413
|
+
progress: active?.progress ?? 0,
|
|
414
|
+
}),
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
subscribe(typeOrListener, typedListener) {
|
|
418
|
+
this.#assertActive();
|
|
419
|
+
const listener = typeof typeOrListener === 'function'
|
|
420
|
+
? typeOrListener
|
|
421
|
+
: event => {
|
|
422
|
+
if (event.type === typeOrListener)
|
|
423
|
+
typedListener?.(event);
|
|
424
|
+
};
|
|
425
|
+
this.#listeners.add(listener);
|
|
426
|
+
return () => this.#listeners.delete(listener);
|
|
427
|
+
}
|
|
428
|
+
dispose() {
|
|
429
|
+
const existing = this.#disposeTask;
|
|
430
|
+
if (existing !== undefined)
|
|
431
|
+
return existing;
|
|
432
|
+
let resolveTask;
|
|
433
|
+
let rejectTask;
|
|
434
|
+
const task = new Promise((resolve, reject) => {
|
|
435
|
+
resolveTask = resolve;
|
|
436
|
+
rejectTask = reject;
|
|
437
|
+
});
|
|
438
|
+
// Publish the task before cleanup emits `state-changed`; a listener may call
|
|
439
|
+
// dispose() re-entrantly and must receive this exact Promise.
|
|
440
|
+
this.#disposeTask = task;
|
|
441
|
+
void this.#dispose().then(resolveTask, rejectTask);
|
|
442
|
+
return task;
|
|
443
|
+
}
|
|
444
|
+
async #dispose() {
|
|
445
|
+
this.#loadGeneration += 1;
|
|
446
|
+
this.#invalidateInteractiveEdit();
|
|
447
|
+
const drainLoads = this.#loadTail.then(() => undefined, () => undefined);
|
|
448
|
+
const renderer = this.#renderer;
|
|
449
|
+
const cancelExport = this.#cancelExport(new DOMException('AelionSession disposed', 'AbortError'));
|
|
450
|
+
this.#setState('disposed');
|
|
451
|
+
this.#listeners.clear();
|
|
452
|
+
this.#unsubscribeHistory?.();
|
|
453
|
+
this.#unsubscribeHistory = undefined;
|
|
454
|
+
this.#engine = undefined;
|
|
455
|
+
this.#history = undefined;
|
|
456
|
+
this.#commands = undefined;
|
|
457
|
+
this.#ir = undefined;
|
|
458
|
+
this.#compileStats = undefined;
|
|
459
|
+
this.#sequenceId = undefined;
|
|
460
|
+
this.#compiler.clear();
|
|
461
|
+
const results = await Promise.allSettled([
|
|
462
|
+
cancelExport,
|
|
463
|
+
this.player.dispose(),
|
|
464
|
+
drainLoads,
|
|
465
|
+
...(renderer === undefined ? [] : [renderer.dispose()]),
|
|
466
|
+
]);
|
|
467
|
+
if (renderer !== undefined)
|
|
468
|
+
this.#lastDisposedRenderer = renderer.snapshot();
|
|
469
|
+
this.#renderer = undefined;
|
|
470
|
+
const errors = [];
|
|
471
|
+
for (const result of results) {
|
|
472
|
+
if (result.status === 'rejected')
|
|
473
|
+
errors.push(result.reason);
|
|
474
|
+
}
|
|
475
|
+
if (errors.length > 0) {
|
|
476
|
+
throw new AggregateError(errors, 'One or more AelionSession resources failed to dispose');
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
requireIr() {
|
|
480
|
+
this.#assertActive();
|
|
481
|
+
if (this.#ir === undefined)
|
|
482
|
+
throw unloaded();
|
|
483
|
+
return this.#ir;
|
|
484
|
+
}
|
|
485
|
+
requireMedia() {
|
|
486
|
+
const media = this.#options.media;
|
|
487
|
+
if (media === undefined)
|
|
488
|
+
throw new Error('AelionSession requires a media provider');
|
|
489
|
+
return media;
|
|
490
|
+
}
|
|
491
|
+
notifyStatsChanged() {
|
|
492
|
+
if (this.#state !== 'disposed')
|
|
493
|
+
this.#emitStats();
|
|
494
|
+
}
|
|
495
|
+
#edit(callback, options) {
|
|
496
|
+
this.#assertTransactionAvailable();
|
|
497
|
+
if (this.#activeInteractiveEdit?.active === true) {
|
|
498
|
+
throw new Error('Finish or cancel the active interactive edit before starting another edit');
|
|
499
|
+
}
|
|
500
|
+
const history = this.#history;
|
|
501
|
+
if (history === undefined)
|
|
502
|
+
throw unloaded();
|
|
503
|
+
return history.edit({
|
|
504
|
+
...(options.label === undefined ? {} : { label: options.label }),
|
|
505
|
+
...(options.baseRevision === undefined ? {} : { baseRevision: options.baseRevision }),
|
|
506
|
+
}, callback);
|
|
507
|
+
}
|
|
508
|
+
#beginInteractiveEdit(options = {}) {
|
|
509
|
+
this.#assertTransactionAvailable();
|
|
510
|
+
if (this.#activeInteractiveEdit?.active === true) {
|
|
511
|
+
throw new Error('An interactive edit is already active');
|
|
512
|
+
}
|
|
513
|
+
const state = {
|
|
514
|
+
id: `interactive_${this.#nextInteractiveEditId.toString()}`,
|
|
515
|
+
...(options.label === undefined ? {} : { label: options.label }),
|
|
516
|
+
...(options.baseRevision === undefined ? {} : { baseRevision: options.baseRevision }),
|
|
517
|
+
active: true,
|
|
518
|
+
updateCount: 0,
|
|
519
|
+
};
|
|
520
|
+
this.#nextInteractiveEditId += 1;
|
|
521
|
+
this.#activeInteractiveEdit = state;
|
|
522
|
+
const isActive = () => state.active && this.#activeInteractiveEdit === state;
|
|
523
|
+
return Object.freeze({
|
|
524
|
+
get active() {
|
|
525
|
+
return isActive();
|
|
526
|
+
},
|
|
527
|
+
get updateCount() {
|
|
528
|
+
return state.updateCount;
|
|
529
|
+
},
|
|
530
|
+
update: (callback) => this.#updateInteractiveEdit(state, callback),
|
|
531
|
+
commit: () => this.#finishInteractiveEdit(state),
|
|
532
|
+
cancel: () => this.#cancelInteractiveEdit(state),
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
#updateInteractiveEdit(state, callback) {
|
|
536
|
+
this.#assertInteractiveEditActive(state);
|
|
537
|
+
const history = this.#history;
|
|
538
|
+
if (history === undefined)
|
|
539
|
+
throw unloaded();
|
|
540
|
+
const commit = history.edit({
|
|
541
|
+
...(state.label === undefined ? {} : { label: state.label }),
|
|
542
|
+
...(state.updateCount === 0 && state.baseRevision !== undefined
|
|
543
|
+
? { baseRevision: state.baseRevision }
|
|
544
|
+
: {}),
|
|
545
|
+
historyGroup: state.id,
|
|
546
|
+
}, callback);
|
|
547
|
+
state.updateCount += 1;
|
|
548
|
+
return commit;
|
|
549
|
+
}
|
|
550
|
+
#finishInteractiveEdit(state) {
|
|
551
|
+
this.#assertInteractiveEditActive(state);
|
|
552
|
+
this.#history?.finishGroup(state.id);
|
|
553
|
+
state.active = false;
|
|
554
|
+
this.#activeInteractiveEdit = undefined;
|
|
555
|
+
}
|
|
556
|
+
#cancelInteractiveEdit(state) {
|
|
557
|
+
this.#assertInteractiveEditActive(state);
|
|
558
|
+
const history = this.#history;
|
|
559
|
+
if (history === undefined)
|
|
560
|
+
throw unloaded();
|
|
561
|
+
const commit = state.updateCount === 0 ? null : history.cancelGroup(state.id);
|
|
562
|
+
state.active = false;
|
|
563
|
+
this.#activeInteractiveEdit = undefined;
|
|
564
|
+
return commit;
|
|
565
|
+
}
|
|
566
|
+
#assertInteractiveEditActive(state) {
|
|
567
|
+
this.#assertTransactionAvailable();
|
|
568
|
+
if (!state.active || this.#activeInteractiveEdit !== state) {
|
|
569
|
+
throw new Error('Interactive edit is no longer active');
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
#invalidateInteractiveEdit() {
|
|
573
|
+
if (this.#activeInteractiveEdit !== undefined)
|
|
574
|
+
this.#activeInteractiveEdit.active = false;
|
|
575
|
+
this.#activeInteractiveEdit = undefined;
|
|
576
|
+
}
|
|
577
|
+
#undoChange() {
|
|
578
|
+
this.#assertTransactionAvailable();
|
|
579
|
+
if (this.#activeInteractiveEdit?.active === true) {
|
|
580
|
+
throw new Error('Finish or cancel the active interactive edit before undo');
|
|
581
|
+
}
|
|
582
|
+
const history = this.#history;
|
|
583
|
+
if (history === undefined)
|
|
584
|
+
throw unloaded();
|
|
585
|
+
return history.undo();
|
|
586
|
+
}
|
|
587
|
+
#redoChange() {
|
|
588
|
+
this.#assertTransactionAvailable();
|
|
589
|
+
if (this.#activeInteractiveEdit?.active === true) {
|
|
590
|
+
throw new Error('Finish or cancel the active interactive edit before redo');
|
|
591
|
+
}
|
|
592
|
+
const history = this.#history;
|
|
593
|
+
if (history === undefined)
|
|
594
|
+
throw unloaded();
|
|
595
|
+
return history.redo();
|
|
596
|
+
}
|
|
597
|
+
#prepareCommit(engine, commit) {
|
|
598
|
+
this.#assertPreparedCommitCurrent(engine, commit.changeSet.baseRevision);
|
|
599
|
+
const sequenceId = this.#sequenceId;
|
|
600
|
+
if (sequenceId === undefined)
|
|
601
|
+
throw unloaded();
|
|
602
|
+
const baseCompiler = this.#compiler;
|
|
603
|
+
const compiler = baseCompiler.fork();
|
|
604
|
+
const compilation = compiler.compile(commit.snapshot, sequenceId, commit.revision, {
|
|
605
|
+
affectedEntityIds: commit.changeSet.affectedEntityIds,
|
|
606
|
+
affectedRanges: commit.changeSet.affectedRanges,
|
|
607
|
+
resolveMaterialProgram: (definition, parameters) => this.#options.materials?.resolveProgram(definition, parameters),
|
|
608
|
+
});
|
|
609
|
+
this.#assertPreparedCommitCurrent(engine, commit.changeSet.baseRevision);
|
|
610
|
+
let published = false;
|
|
611
|
+
return {
|
|
612
|
+
publish: () => {
|
|
613
|
+
if (published)
|
|
614
|
+
throw new Error('Prepared Render IR commit was already published');
|
|
615
|
+
this.#assertPreparedCommitCurrent(engine, commit.changeSet.baseRevision);
|
|
616
|
+
if (this.#compiler !== baseCompiler) {
|
|
617
|
+
throw new Error('Prepared Render IR compiler baseline is stale');
|
|
618
|
+
}
|
|
619
|
+
published = true;
|
|
620
|
+
this.#compiler = compiler;
|
|
621
|
+
this.#compileStats = compilation.stats;
|
|
622
|
+
this.#ir = compilation.ir;
|
|
623
|
+
},
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
#publishCommit(engine, commit) {
|
|
627
|
+
if (!this.#isCurrentEngine(engine, commit.revision))
|
|
628
|
+
return;
|
|
629
|
+
this.#emit({ type: 'project-changed', commit });
|
|
630
|
+
if (!this.#isCurrentEngine(engine, commit.revision))
|
|
631
|
+
return;
|
|
632
|
+
this.player.invalidate(commit.changeSet);
|
|
633
|
+
}
|
|
634
|
+
#assertTransactionAvailable() {
|
|
635
|
+
this.#assertActive();
|
|
636
|
+
if (this.#loadInProgress > 0) {
|
|
637
|
+
throw new Error('AelionSession Project transactions are unavailable while a load is pending');
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
#assertPreparedCommitCurrent(engine, baseRevision) {
|
|
641
|
+
this.#assertActive();
|
|
642
|
+
if (this.#engine !== engine || engine.revision !== baseRevision || this.#loadInProgress > 0) {
|
|
643
|
+
throw new DOMException('AelionSession transaction was superseded', 'AbortError');
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
#isCurrentEngine(engine, revision) {
|
|
647
|
+
return (this.#state !== 'disposed' &&
|
|
648
|
+
this.#engine === engine &&
|
|
649
|
+
this.#loadInProgress === 0 &&
|
|
650
|
+
engine.revision === revision &&
|
|
651
|
+
this.#ir?.revision === revision);
|
|
652
|
+
}
|
|
653
|
+
#frozenExportOptions(options, signal = options.signal, onProgress = options.onProgress, masteredRenderAudio, frozen) {
|
|
654
|
+
const ir = frozen?.ir ?? this.requireIr();
|
|
655
|
+
const media = frozen?.media ?? this.requireMedia();
|
|
656
|
+
return {
|
|
657
|
+
ir,
|
|
658
|
+
projectRevision: ir.revision,
|
|
659
|
+
videoBitrate: options.videoBitrate ?? 8_000_000,
|
|
660
|
+
audioBitrate: options.audioBitrate ?? 192_000,
|
|
661
|
+
...(this.#options.runtimeAssets?.exportWorker === undefined
|
|
662
|
+
? {}
|
|
663
|
+
: { workerUrl: this.#options.runtimeAssets.exportWorker }),
|
|
664
|
+
...(options.execution === undefined ? {} : { execution: options.execution }),
|
|
665
|
+
sink: options.sink,
|
|
666
|
+
renderFrame: request => this.#renderExportFrame(ir, media, request, signal),
|
|
667
|
+
renderAudio: masteredRenderAudio ??
|
|
668
|
+
(request => renderIrAudio({
|
|
669
|
+
ir,
|
|
670
|
+
startFrame: request.startFrame,
|
|
671
|
+
frameCount: request.frameCount,
|
|
672
|
+
channelCount: request.channelCount,
|
|
673
|
+
source: media,
|
|
674
|
+
...(signal === undefined ? {} : { signal }),
|
|
675
|
+
})),
|
|
676
|
+
...(signal === undefined ? {} : { signal }),
|
|
677
|
+
...(options.cleanupSink === undefined ? {} : { cleanupSink: options.cleanupSink }),
|
|
678
|
+
...(onProgress === undefined ? {} : { onProgress }),
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
async #preflight(options) {
|
|
682
|
+
const report = await preflightWebMExport(this.#frozenExportOptions(options));
|
|
683
|
+
for (const diagnostic of report.issues)
|
|
684
|
+
this.#recordDiagnostic(diagnostic);
|
|
685
|
+
return report;
|
|
686
|
+
}
|
|
687
|
+
async #createExportAudioRenderer(ir, source, processing, signal) {
|
|
688
|
+
return createMasteredAudioRenderer({
|
|
689
|
+
ir,
|
|
690
|
+
source,
|
|
691
|
+
...(processing === undefined ? {} : { processing }),
|
|
692
|
+
signal,
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
async #renderExportFrame(ir, media, request, signal) {
|
|
696
|
+
const direct = directOpaqueVisualSource(ir, request.timestampUs);
|
|
697
|
+
if (direct !== undefined) {
|
|
698
|
+
const source = await media.frameAt(direct.assetId, direct.streamIndex, direct.sourceTimeUs, signal, { purpose: 'export', maxDimension: Math.max(ir.width, ir.height) });
|
|
699
|
+
try {
|
|
700
|
+
if (!frameHasAlpha(source)) {
|
|
701
|
+
return new VideoFrame(source, {
|
|
702
|
+
timestamp: request.timestampUs,
|
|
703
|
+
duration: request.durationUs,
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
finally {
|
|
708
|
+
source.close();
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
const rendered = await this.#requireRenderer().render({
|
|
712
|
+
ir,
|
|
713
|
+
timeUs: request.timestampUs,
|
|
714
|
+
source: media,
|
|
715
|
+
mode: 'export',
|
|
716
|
+
preferredBackend: this.#options.preferredBackend ?? 'auto',
|
|
717
|
+
allowFallback: this.#options.allowBackendFallback ?? true,
|
|
718
|
+
...(signal === undefined ? {} : { signal }),
|
|
719
|
+
});
|
|
720
|
+
try {
|
|
721
|
+
return new VideoFrame(rendered.bitmap, {
|
|
722
|
+
timestamp: request.timestampUs,
|
|
723
|
+
duration: request.durationUs,
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
finally {
|
|
727
|
+
rendered.bitmap.close();
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
async #preflightProfile(options) {
|
|
731
|
+
const ir = this.requireIr();
|
|
732
|
+
const report = await preflightProfileExport({
|
|
733
|
+
ir,
|
|
734
|
+
projectRevision: ir.revision,
|
|
735
|
+
profile: options.profile,
|
|
736
|
+
sink: options.sink,
|
|
737
|
+
...('videoBitrate' in options ? { videoBitrate: options.videoBitrate } : {}),
|
|
738
|
+
...('audioBitrate' in options ? { audioBitrate: options.audioBitrate } : {}),
|
|
739
|
+
});
|
|
740
|
+
for (const diagnostic of report.issues)
|
|
741
|
+
this.#recordDiagnostic(diagnostic);
|
|
742
|
+
return report;
|
|
743
|
+
}
|
|
744
|
+
async #negotiateExport(options) {
|
|
745
|
+
const ir = this.requireIr();
|
|
746
|
+
return selectExportProfile({
|
|
747
|
+
...options,
|
|
748
|
+
width: ir.width,
|
|
749
|
+
height: ir.height,
|
|
750
|
+
framerate: ir.frameRate.numerator / ir.frameRate.denominator,
|
|
751
|
+
sampleRate: ir.sampleRate,
|
|
752
|
+
numberOfChannels: channelCountForLayout(ir.channelLayout),
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
#startExport(options) {
|
|
756
|
+
this.#assertActive();
|
|
757
|
+
if (this.#activeExportJob?.state === 'running') {
|
|
758
|
+
const diagnostics = [
|
|
759
|
+
{
|
|
760
|
+
code: 'EXPORT_JOB_ACTIVE',
|
|
761
|
+
severity: 'error',
|
|
762
|
+
message: 'AelionSession supports one active export; cancel it before starting another',
|
|
763
|
+
recoverable: true,
|
|
764
|
+
},
|
|
765
|
+
];
|
|
766
|
+
for (const diagnostic of diagnostics)
|
|
767
|
+
this.#recordDiagnostic(diagnostic);
|
|
768
|
+
throw new AelionError(diagnostics);
|
|
769
|
+
}
|
|
770
|
+
const ir = this.requireIr();
|
|
771
|
+
const media = this.requireMedia();
|
|
772
|
+
const project = this.#engine?.getSnapshot();
|
|
773
|
+
if (project === undefined)
|
|
774
|
+
throw unloaded();
|
|
775
|
+
const configuredProcessing = options.audioProcessing ?? projectAudioMastering(project, ir.sequenceId);
|
|
776
|
+
const processing = configuredProcessing === undefined ? undefined : structuredClone(configuredProcessing);
|
|
777
|
+
const id = `export-${this.#nextExportJobId.toString()}`;
|
|
778
|
+
this.#nextExportJobId += 1;
|
|
779
|
+
this.#exportJobsStarted += 1;
|
|
780
|
+
const job = new ExportJob({
|
|
781
|
+
id,
|
|
782
|
+
...(options.signal === undefined ? {} : { externalSignal: options.signal }),
|
|
783
|
+
run: async (signal, updateProgress) => {
|
|
784
|
+
try {
|
|
785
|
+
const mastering = await this.#createExportAudioRenderer(ir, media, processing, signal);
|
|
786
|
+
return await exportFrozenRenderIrWebM(this.#frozenExportOptions(options, signal, progress => {
|
|
787
|
+
updateProgress(progress);
|
|
788
|
+
options.onProgress?.(progress);
|
|
789
|
+
}, mastering.render, { ir, media }));
|
|
790
|
+
}
|
|
791
|
+
catch (error) {
|
|
792
|
+
// Publish structured export diagnostics before the await-compatible
|
|
793
|
+
// job rejects so callers observe one deterministic Session state.
|
|
794
|
+
this.#recordErrorDiagnostics(error);
|
|
795
|
+
throw error;
|
|
796
|
+
}
|
|
797
|
+
},
|
|
798
|
+
onSnapshot: snapshot => this.#acceptExportSnapshot(snapshot),
|
|
799
|
+
onSettled: settled => {
|
|
800
|
+
if (this.#activeExportJob === settled)
|
|
801
|
+
this.#activeExportJob = undefined;
|
|
802
|
+
this.#emitStats();
|
|
803
|
+
},
|
|
804
|
+
});
|
|
805
|
+
this.#activeExportJob = job;
|
|
806
|
+
this.#emitStats();
|
|
807
|
+
return job;
|
|
808
|
+
}
|
|
809
|
+
#startProfileExport(options) {
|
|
810
|
+
this.#assertActive();
|
|
811
|
+
if (this.#activeExportJob?.state === 'running') {
|
|
812
|
+
const diagnostic = {
|
|
813
|
+
code: 'EXPORT_JOB_ACTIVE',
|
|
814
|
+
severity: 'error',
|
|
815
|
+
message: 'AelionSession supports one active export; cancel it before starting another',
|
|
816
|
+
recoverable: true,
|
|
817
|
+
};
|
|
818
|
+
this.#recordDiagnostic(diagnostic);
|
|
819
|
+
throw new AelionError([diagnostic]);
|
|
820
|
+
}
|
|
821
|
+
const ir = this.requireIr();
|
|
822
|
+
const media = this.requireMedia();
|
|
823
|
+
const project = this.#engine?.getSnapshot();
|
|
824
|
+
if (project === undefined)
|
|
825
|
+
throw unloaded();
|
|
826
|
+
const configuredProcessing = options.audioProcessing ?? projectAudioMastering(project, ir.sequenceId);
|
|
827
|
+
const processing = configuredProcessing === undefined ? undefined : structuredClone(configuredProcessing);
|
|
828
|
+
const id = `export-${this.#nextExportJobId.toString()}`;
|
|
829
|
+
this.#nextExportJobId += 1;
|
|
830
|
+
this.#exportJobsStarted += 1;
|
|
831
|
+
const job = new ExportJob({
|
|
832
|
+
id,
|
|
833
|
+
...(options.signal === undefined ? {} : { externalSignal: options.signal }),
|
|
834
|
+
run: async (signal, updateProgress) => {
|
|
835
|
+
const onProgress = (progress) => {
|
|
836
|
+
updateProgress(progress);
|
|
837
|
+
options.onProgress?.(progress);
|
|
838
|
+
};
|
|
839
|
+
const cleanup = options.cleanupSink;
|
|
840
|
+
const renderFrame = (request) => this.#renderExportFrame(ir, media, request, signal);
|
|
841
|
+
let mastering;
|
|
842
|
+
const requireMastering = async () => {
|
|
843
|
+
mastering ??= await this.#createExportAudioRenderer(ir, media, processing, signal);
|
|
844
|
+
return mastering;
|
|
845
|
+
};
|
|
846
|
+
const renderAudio = async (request) => (await requireMastering()).render(request, signal);
|
|
847
|
+
try {
|
|
848
|
+
if (options.profile === 'webm-vp9-opus' ||
|
|
849
|
+
options.profile === 'mp4-h264-aac' ||
|
|
850
|
+
options.profile === 'mp4-av1-aac' ||
|
|
851
|
+
options.profile === 'mp4-hevc-aac') {
|
|
852
|
+
const frozen = this.#frozenExportOptions({
|
|
853
|
+
sink: options.sink,
|
|
854
|
+
...(options.videoBitrate === undefined
|
|
855
|
+
? {}
|
|
856
|
+
: { videoBitrate: options.videoBitrate }),
|
|
857
|
+
...(options.audioBitrate === undefined
|
|
858
|
+
? {}
|
|
859
|
+
: { audioBitrate: options.audioBitrate }),
|
|
860
|
+
...(cleanup === undefined ? {} : { cleanupSink: cleanup }),
|
|
861
|
+
...(options.audioProcessing === undefined
|
|
862
|
+
? {}
|
|
863
|
+
: { audioProcessing: options.audioProcessing }),
|
|
864
|
+
...(options.execution === undefined ? {} : { execution: options.execution }),
|
|
865
|
+
}, signal, onProgress, (await requireMastering()).render, { ir, media });
|
|
866
|
+
if (options.profile === 'mp4-h264-aac') {
|
|
867
|
+
return await exportFrozenRenderIrMp4(frozen);
|
|
868
|
+
}
|
|
869
|
+
if (options.profile === 'mp4-av1-aac') {
|
|
870
|
+
return await exportFrozenRenderIrAv1Mp4(frozen);
|
|
871
|
+
}
|
|
872
|
+
if (options.profile === 'mp4-hevc-aac') {
|
|
873
|
+
return await exportFrozenRenderIrHevcMp4(frozen);
|
|
874
|
+
}
|
|
875
|
+
return await exportFrozenRenderIrWebM(frozen);
|
|
876
|
+
}
|
|
877
|
+
if (options.profile === 'audio-wav') {
|
|
878
|
+
return await exportWav({
|
|
879
|
+
durationUs: ir.durationUs,
|
|
880
|
+
sampleRate: ir.sampleRate,
|
|
881
|
+
channelCount: channelCountForLayout(ir.channelLayout),
|
|
882
|
+
sink: options.sink,
|
|
883
|
+
renderAudio,
|
|
884
|
+
signal,
|
|
885
|
+
onProgress,
|
|
886
|
+
...(options.sampleFormat === undefined ? {} : { sampleFormat: options.sampleFormat }),
|
|
887
|
+
...(cleanup === undefined ? {} : { cleanupSink: cleanup }),
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
if (options.profile === 'animated-gif') {
|
|
891
|
+
return await exportGif({
|
|
892
|
+
durationUs: ir.durationUs,
|
|
893
|
+
width: ir.width,
|
|
894
|
+
height: ir.height,
|
|
895
|
+
frameRate: ir.frameRate,
|
|
896
|
+
sink: options.sink,
|
|
897
|
+
renderFrame,
|
|
898
|
+
signal,
|
|
899
|
+
onProgress,
|
|
900
|
+
...(options.loopCount === undefined ? {} : { loopCount: options.loopCount }),
|
|
901
|
+
...(cleanup === undefined ? {} : { cleanupSink: cleanup }),
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
const format = options.profile === 'still-png'
|
|
905
|
+
? 'png'
|
|
906
|
+
: options.profile === 'still-jpeg'
|
|
907
|
+
? 'jpeg'
|
|
908
|
+
: 'webp';
|
|
909
|
+
if (!('timeUs' in options))
|
|
910
|
+
throw new TypeError('Unsupported export profile');
|
|
911
|
+
return await exportStillImage({
|
|
912
|
+
timeUs: options.timeUs,
|
|
913
|
+
width: ir.width,
|
|
914
|
+
height: ir.height,
|
|
915
|
+
format,
|
|
916
|
+
sink: options.sink,
|
|
917
|
+
renderFrame,
|
|
918
|
+
signal,
|
|
919
|
+
...(options.quality === undefined ? {} : { quality: options.quality }),
|
|
920
|
+
...(cleanup === undefined ? {} : { cleanupSink: cleanup }),
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
catch (error) {
|
|
924
|
+
this.#recordErrorDiagnostics(error);
|
|
925
|
+
throw error;
|
|
926
|
+
}
|
|
927
|
+
},
|
|
928
|
+
onSnapshot: snapshot => this.#acceptExportSnapshot(snapshot),
|
|
929
|
+
onSettled: settled => {
|
|
930
|
+
if (this.#activeExportJob === settled)
|
|
931
|
+
this.#activeExportJob = undefined;
|
|
932
|
+
this.#emitStats();
|
|
933
|
+
},
|
|
934
|
+
});
|
|
935
|
+
this.#activeExportJob = job;
|
|
936
|
+
this.#emitStats();
|
|
937
|
+
return job;
|
|
938
|
+
}
|
|
939
|
+
#startRemoteExport(options) {
|
|
940
|
+
this.#assertActive();
|
|
941
|
+
if (this.#activeExportJob?.state === 'running') {
|
|
942
|
+
const diagnostic = {
|
|
943
|
+
code: 'EXPORT_JOB_ACTIVE',
|
|
944
|
+
severity: 'error',
|
|
945
|
+
message: 'AelionSession supports one active export; cancel it before starting another',
|
|
946
|
+
recoverable: true,
|
|
947
|
+
};
|
|
948
|
+
this.#recordDiagnostic(diagnostic);
|
|
949
|
+
throw new AelionError([diagnostic]);
|
|
950
|
+
}
|
|
951
|
+
const engine = this.#engine;
|
|
952
|
+
const ir = this.requireIr();
|
|
953
|
+
const sequenceId = this.#sequenceId;
|
|
954
|
+
if (engine === undefined || sequenceId === undefined)
|
|
955
|
+
throw unloaded();
|
|
956
|
+
const project = engine.getSnapshot();
|
|
957
|
+
const revision = ir.revision.toString();
|
|
958
|
+
const manifest = options.manifest ??
|
|
959
|
+
{
|
|
960
|
+
protocol: 'aelion.remote-export/1',
|
|
961
|
+
profileId: options.profile,
|
|
962
|
+
sequenceId,
|
|
963
|
+
revision,
|
|
964
|
+
project,
|
|
965
|
+
};
|
|
966
|
+
const canonicalManifestBytes = new TextEncoder().encode(canonicalStringify(manifest));
|
|
967
|
+
const id = `export-${this.#nextExportJobId.toString()}`;
|
|
968
|
+
this.#nextExportJobId += 1;
|
|
969
|
+
this.#exportJobsStarted += 1;
|
|
970
|
+
const job = new ExportJob({
|
|
971
|
+
id,
|
|
972
|
+
...(options.signal === undefined ? {} : { externalSignal: options.signal }),
|
|
973
|
+
run: async (signal, updateProgress) => {
|
|
974
|
+
try {
|
|
975
|
+
const contentId = await createRemoteExportContentId(canonicalManifestBytes, options.profile, revision);
|
|
976
|
+
return await runRemoteExport({
|
|
977
|
+
provider: options.provider,
|
|
978
|
+
authorizer: options.authorizer,
|
|
979
|
+
request: {
|
|
980
|
+
contentId,
|
|
981
|
+
idempotencyKey: options.idempotencyKey ?? contentId,
|
|
982
|
+
profileId: options.profile,
|
|
983
|
+
projectId: project.projectId,
|
|
984
|
+
sequenceId,
|
|
985
|
+
revision,
|
|
986
|
+
manifest,
|
|
987
|
+
},
|
|
988
|
+
signal,
|
|
989
|
+
onProgress: (progress, stage) => {
|
|
990
|
+
updateProgress(progress);
|
|
991
|
+
options.onProgress?.(progress, stage);
|
|
992
|
+
},
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
catch (error) {
|
|
996
|
+
this.#recordErrorDiagnostics(error);
|
|
997
|
+
throw error;
|
|
998
|
+
}
|
|
999
|
+
},
|
|
1000
|
+
onSnapshot: snapshot => this.#acceptExportSnapshot(snapshot),
|
|
1001
|
+
onSettled: settled => {
|
|
1002
|
+
if (this.#activeExportJob === settled)
|
|
1003
|
+
this.#activeExportJob = undefined;
|
|
1004
|
+
this.#emitStats();
|
|
1005
|
+
},
|
|
1006
|
+
});
|
|
1007
|
+
this.#activeExportJob = job;
|
|
1008
|
+
this.#emitStats();
|
|
1009
|
+
return job;
|
|
1010
|
+
}
|
|
1011
|
+
async #cancelExport(reason) {
|
|
1012
|
+
await this.#activeExportJob?.cancel(reason);
|
|
1013
|
+
}
|
|
1014
|
+
#acceptExportSnapshot(snapshot) {
|
|
1015
|
+
if (snapshot.state === 'completed')
|
|
1016
|
+
this.#exportJobsCompleted += 1;
|
|
1017
|
+
else if (snapshot.state === 'cancelled')
|
|
1018
|
+
this.#exportJobsCancelled += 1;
|
|
1019
|
+
else if (snapshot.state === 'failed')
|
|
1020
|
+
this.#exportJobsFailed += 1;
|
|
1021
|
+
this.#emitStats();
|
|
1022
|
+
}
|
|
1023
|
+
#recordErrorDiagnostics(error) {
|
|
1024
|
+
if (error === null || typeof error !== 'object')
|
|
1025
|
+
return 0;
|
|
1026
|
+
const diagnostics = Reflect.get(error, 'diagnostics');
|
|
1027
|
+
if (!Array.isArray(diagnostics))
|
|
1028
|
+
return 0;
|
|
1029
|
+
let recorded = 0;
|
|
1030
|
+
for (const diagnostic of diagnostics) {
|
|
1031
|
+
if (this.#isDiagnostic(diagnostic)) {
|
|
1032
|
+
this.#recordDiagnostic(diagnostic);
|
|
1033
|
+
recorded += 1;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
return recorded;
|
|
1037
|
+
}
|
|
1038
|
+
#acceptPlayerError(error) {
|
|
1039
|
+
if (this.#recordErrorDiagnostics(error) === 0) {
|
|
1040
|
+
this.#recordDiagnostic({
|
|
1041
|
+
code: 'PLAYER_RUNTIME_FAILED',
|
|
1042
|
+
severity: 'error',
|
|
1043
|
+
message: error instanceof Error ? error.message : 'Player runtime failed',
|
|
1044
|
+
recoverable: true,
|
|
1045
|
+
cause: error,
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
this.#emitStats();
|
|
1049
|
+
}
|
|
1050
|
+
#isDiagnostic(value) {
|
|
1051
|
+
return (value !== null &&
|
|
1052
|
+
typeof value === 'object' &&
|
|
1053
|
+
typeof Reflect.get(value, 'code') === 'string' &&
|
|
1054
|
+
typeof Reflect.get(value, 'message') === 'string' &&
|
|
1055
|
+
typeof Reflect.get(value, 'recoverable') === 'boolean');
|
|
1056
|
+
}
|
|
1057
|
+
#recordDiagnostic(diagnostic) {
|
|
1058
|
+
if (this.#diagnostics.length === this.#maxDiagnostics) {
|
|
1059
|
+
this.#diagnostics.shift();
|
|
1060
|
+
this.#droppedDiagnostics += 1;
|
|
1061
|
+
}
|
|
1062
|
+
this.#diagnostics.push(diagnostic);
|
|
1063
|
+
this.#emit({ type: 'diagnostic', diagnostic });
|
|
1064
|
+
}
|
|
1065
|
+
#setState(state) {
|
|
1066
|
+
if (this.#state === state)
|
|
1067
|
+
return;
|
|
1068
|
+
const previousState = this.#state;
|
|
1069
|
+
this.#state = state;
|
|
1070
|
+
this.#emit({ type: 'state-changed', previousState, state });
|
|
1071
|
+
}
|
|
1072
|
+
#emitStats() {
|
|
1073
|
+
this.#emit({ type: 'stats-changed', stats: this.getStats() });
|
|
1074
|
+
}
|
|
1075
|
+
#assertActive() {
|
|
1076
|
+
if (this.#state === 'disposed')
|
|
1077
|
+
throw new ReferenceError('AelionSession is disposed');
|
|
1078
|
+
}
|
|
1079
|
+
#assertLoadCurrent(generation) {
|
|
1080
|
+
if (this.#state === 'disposed')
|
|
1081
|
+
throw new ReferenceError('AelionSession is disposed');
|
|
1082
|
+
if (generation !== this.#loadGeneration) {
|
|
1083
|
+
throw new DOMException('AelionSession Project load was superseded', 'AbortError');
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
#requireRenderer() {
|
|
1087
|
+
this.#assertActive();
|
|
1088
|
+
this.#renderer ??= new RenderIrFrameRenderer({
|
|
1089
|
+
...(this.#options.maxPendingFrames === undefined
|
|
1090
|
+
? {}
|
|
1091
|
+
: { maxPendingFrames: this.#options.maxPendingFrames }),
|
|
1092
|
+
...(this.#options.runtimeAssets?.rendererWorker === undefined
|
|
1093
|
+
? {}
|
|
1094
|
+
: { workerUrl: this.#options.runtimeAssets.rendererWorker }),
|
|
1095
|
+
});
|
|
1096
|
+
return this.#renderer;
|
|
1097
|
+
}
|
|
1098
|
+
#requireCommands() {
|
|
1099
|
+
this.#assertTransactionAvailable();
|
|
1100
|
+
if (this.#commands === undefined)
|
|
1101
|
+
throw unloaded();
|
|
1102
|
+
return this.#commands;
|
|
1103
|
+
}
|
|
1104
|
+
#emit(event) {
|
|
1105
|
+
for (const listener of [...this.#listeners]) {
|
|
1106
|
+
try {
|
|
1107
|
+
listener(event);
|
|
1108
|
+
}
|
|
1109
|
+
catch {
|
|
1110
|
+
// Consumer callbacks must not corrupt SDK state or stop other subscribers.
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
}
|