@aelionsdk/export 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 +7 -0
- package/dist/audio-export.d.ts +23 -0
- package/dist/audio-export.d.ts.map +1 -0
- package/dist/audio-export.js +120 -0
- package/dist/checkpoint.d.ts +58 -0
- package/dist/checkpoint.d.ts.map +1 -0
- package/dist/checkpoint.js +119 -0
- package/dist/image-export.d.ts +40 -0
- package/dist/image-export.d.ts.map +1 -0
- package/dist/image-export.js +235 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/memory-sink.d.ts +17 -0
- package/dist/memory-sink.d.ts.map +1 -0
- package/dist/memory-sink.js +60 -0
- package/dist/mux-export-worker.d.ts +2 -0
- package/dist/mux-export-worker.d.ts.map +1 -0
- package/dist/mux-export-worker.js +120 -0
- package/dist/opfs-sink.d.ts +20 -0
- package/dist/opfs-sink.d.ts.map +1 -0
- package/dist/opfs-sink.js +113 -0
- package/dist/profiles.d.ts +66 -0
- package/dist/profiles.d.ts.map +1 -0
- package/dist/profiles.js +284 -0
- package/dist/remote-export.d.ts +56 -0
- package/dist/remote-export.d.ts.map +1 -0
- package/dist/remote-export.js +83 -0
- package/dist/resumable-muxed-export.d.ts +84 -0
- package/dist/resumable-muxed-export.d.ts.map +1 -0
- package/dist/resumable-muxed-export.js +533 -0
- package/dist/session.d.ts +47 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +483 -0
- package/dist/sink-completion.d.ts +8 -0
- package/dist/sink-completion.d.ts.map +1 -0
- package/dist/sink-completion.js +13 -0
- package/dist/webm-export.d.ts +97 -0
- package/dist/webm-export.d.ts.map +1 -0
- package/dist/webm-export.js +408 -0
- package/dist/worker-export.d.ts +25 -0
- package/dist/worker-export.d.ts.map +1 -0
- package/dist/worker-export.js +202 -0
- package/dist/worker-protocol.d.ts +66 -0
- package/dist/worker-protocol.d.ts.map +1 -0
- package/dist/worker-protocol.js +1 -0
- package/package.json +46 -0
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
import { frameStartUs, throwIfAborted } from '@aelionsdk/core';
|
|
2
|
+
import { Mp4OutputFormat, WebMOutputFormat } from 'mediabunny';
|
|
3
|
+
import { av1CodecString, hevcCodecString, negotiateAvcCodecString, preferredAvcCodecString, } from './profiles.js';
|
|
4
|
+
import { exportMuxed, } from './webm-export.js';
|
|
5
|
+
function cloneUnit(unit) {
|
|
6
|
+
return {
|
|
7
|
+
index: unit.index,
|
|
8
|
+
...(unit.init === undefined ? {} : { init: unit.init.slice() }),
|
|
9
|
+
media: unit.media.slice(),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export class MemoryResumableMuxedExportStore {
|
|
13
|
+
#manifests = new Map();
|
|
14
|
+
#units = new Map();
|
|
15
|
+
loadManifest(key, signal) {
|
|
16
|
+
throwIfAborted(signal, 'Load resumable export manifest');
|
|
17
|
+
const value = this.#manifests.get(key);
|
|
18
|
+
return Promise.resolve(value === undefined ? undefined : structuredClone(value));
|
|
19
|
+
}
|
|
20
|
+
loadUnit(key, index, signal) {
|
|
21
|
+
throwIfAborted(signal, 'Load resumable export unit');
|
|
22
|
+
const value = this.#units.get(`${key}:${index.toString()}`);
|
|
23
|
+
return Promise.resolve(value === undefined ? undefined : cloneUnit(value));
|
|
24
|
+
}
|
|
25
|
+
commitUnit(key, unit, manifest, signal) {
|
|
26
|
+
throwIfAborted(signal, 'Commit resumable export unit');
|
|
27
|
+
this.#units.set(`${key}:${unit.index.toString()}`, cloneUnit(unit));
|
|
28
|
+
this.#manifests.set(key, structuredClone(manifest));
|
|
29
|
+
return Promise.resolve();
|
|
30
|
+
}
|
|
31
|
+
delete(key, signal) {
|
|
32
|
+
throwIfAborted(signal, 'Delete resumable export');
|
|
33
|
+
const manifest = this.#manifests.get(key);
|
|
34
|
+
if (manifest !== undefined) {
|
|
35
|
+
for (let index = 0; index < manifest.totalUnits; index += 1) {
|
|
36
|
+
this.#units.delete(`${key}:${index.toString()}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
this.#manifests.delete(key);
|
|
40
|
+
return Promise.resolve();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function requestResult(request) {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
request.addEventListener('success', () => resolve(request.result), { once: true });
|
|
46
|
+
request.addEventListener('error', () => reject(request.error ?? new Error('IndexedDB request failed')), { once: true });
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
function transactionCompletion(transaction) {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
transaction.addEventListener('complete', () => resolve(), { once: true });
|
|
52
|
+
transaction.addEventListener('abort', () => reject(transaction.error ?? new Error('IndexedDB transaction aborted')), { once: true });
|
|
53
|
+
transaction.addEventListener('error', () => reject(transaction.error ?? new Error('IndexedDB transaction failed')), { once: true });
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* IndexedDB-backed binary checkpoint store. Unit bytes and their prefix
|
|
58
|
+
* manifest advance in the same read/write transaction, so a reload observes
|
|
59
|
+
* either the previous committed prefix or the complete next unit.
|
|
60
|
+
*/
|
|
61
|
+
export class IndexedDbResumableMuxedExportStore {
|
|
62
|
+
#databaseName;
|
|
63
|
+
#namespace;
|
|
64
|
+
#database;
|
|
65
|
+
constructor(options = {}) {
|
|
66
|
+
this.#databaseName = options.databaseName ?? 'aelion-export-checkpoints';
|
|
67
|
+
this.#namespace = options.namespace ?? 'default';
|
|
68
|
+
}
|
|
69
|
+
async loadManifest(key, signal) {
|
|
70
|
+
throwIfAborted(signal, 'Load resumable export manifest');
|
|
71
|
+
const database = await this.#open();
|
|
72
|
+
throwIfAborted(signal, 'Load resumable export manifest');
|
|
73
|
+
const transaction = database.transaction('manifests', 'readonly');
|
|
74
|
+
const record = await requestResult(transaction.objectStore('manifests').get(this.#manifestId(key)));
|
|
75
|
+
await transactionCompletion(transaction);
|
|
76
|
+
return record?.value;
|
|
77
|
+
}
|
|
78
|
+
async loadUnit(key, index, signal) {
|
|
79
|
+
throwIfAborted(signal, 'Load resumable export unit');
|
|
80
|
+
const database = await this.#open();
|
|
81
|
+
throwIfAborted(signal, 'Load resumable export unit');
|
|
82
|
+
const transaction = database.transaction('units', 'readonly');
|
|
83
|
+
const record = await requestResult(transaction.objectStore('units').get(this.#unitId(key, index)));
|
|
84
|
+
await transactionCompletion(transaction);
|
|
85
|
+
return record === undefined
|
|
86
|
+
? undefined
|
|
87
|
+
: {
|
|
88
|
+
index: record.index,
|
|
89
|
+
...(record.init === undefined ? {} : { init: new Uint8Array(record.init) }),
|
|
90
|
+
media: new Uint8Array(record.media),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
async commitUnit(key, unit, manifest, signal) {
|
|
94
|
+
throwIfAborted(signal, 'Commit resumable export unit');
|
|
95
|
+
const database = await this.#open();
|
|
96
|
+
throwIfAborted(signal, 'Commit resumable export unit');
|
|
97
|
+
const transaction = database.transaction(['units', 'manifests'], 'readwrite');
|
|
98
|
+
transaction.objectStore('units').put({
|
|
99
|
+
id: this.#unitId(key, unit.index),
|
|
100
|
+
index: unit.index,
|
|
101
|
+
...(unit.init === undefined ? {} : { init: unit.init.slice().buffer }),
|
|
102
|
+
media: unit.media.slice().buffer,
|
|
103
|
+
});
|
|
104
|
+
transaction.objectStore('manifests').put({
|
|
105
|
+
id: this.#manifestId(key),
|
|
106
|
+
value: manifest,
|
|
107
|
+
});
|
|
108
|
+
await transactionCompletion(transaction);
|
|
109
|
+
}
|
|
110
|
+
async delete(key, signal) {
|
|
111
|
+
throwIfAborted(signal, 'Delete resumable export');
|
|
112
|
+
const manifest = await this.loadManifest(key, signal);
|
|
113
|
+
const database = await this.#open();
|
|
114
|
+
const transaction = database.transaction(['units', 'manifests'], 'readwrite');
|
|
115
|
+
transaction.objectStore('manifests').delete(this.#manifestId(key));
|
|
116
|
+
for (let index = 0; index < (manifest?.totalUnits ?? 0); index += 1) {
|
|
117
|
+
transaction.objectStore('units').delete(this.#unitId(key, index));
|
|
118
|
+
}
|
|
119
|
+
await transactionCompletion(transaction);
|
|
120
|
+
}
|
|
121
|
+
async #open() {
|
|
122
|
+
this.#database ??= new Promise((resolve, reject) => {
|
|
123
|
+
const request = indexedDB.open(this.#databaseName, 1);
|
|
124
|
+
request.addEventListener('upgradeneeded', () => {
|
|
125
|
+
const database = request.result;
|
|
126
|
+
if (!database.objectStoreNames.contains('manifests')) {
|
|
127
|
+
database.createObjectStore('manifests', { keyPath: 'id' });
|
|
128
|
+
}
|
|
129
|
+
if (!database.objectStoreNames.contains('units')) {
|
|
130
|
+
database.createObjectStore('units', { keyPath: 'id' });
|
|
131
|
+
}
|
|
132
|
+
}, { once: true });
|
|
133
|
+
request.addEventListener('success', () => resolve(request.result), { once: true });
|
|
134
|
+
request.addEventListener('error', () => reject(request.error ?? new Error('Unable to open export checkpoint database')), { once: true });
|
|
135
|
+
});
|
|
136
|
+
return this.#database;
|
|
137
|
+
}
|
|
138
|
+
#manifestId(key) {
|
|
139
|
+
if (key.length === 0)
|
|
140
|
+
throw new TypeError('Checkpoint key must not be empty');
|
|
141
|
+
return `${this.#namespace}:manifest:${key}`;
|
|
142
|
+
}
|
|
143
|
+
#unitId(key, index) {
|
|
144
|
+
return `${this.#namespace}:unit:${key}:${index.toString()}`;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function concatBytes(parts) {
|
|
148
|
+
const length = parts.reduce((total, part) => total + part.byteLength, 0);
|
|
149
|
+
const result = new Uint8Array(length);
|
|
150
|
+
let offset = 0;
|
|
151
|
+
for (const part of parts) {
|
|
152
|
+
result.set(part, offset);
|
|
153
|
+
offset += part.byteLength;
|
|
154
|
+
}
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
function sortedBytes(parts) {
|
|
158
|
+
return concatBytes([...parts].sort((left, right) => left.position - right.position).map(part => part.data));
|
|
159
|
+
}
|
|
160
|
+
async function sha256(bytes) {
|
|
161
|
+
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
|
162
|
+
return [...new Uint8Array(digest)].map(value => value.toString(16).padStart(2, '0')).join('');
|
|
163
|
+
}
|
|
164
|
+
function stableConfigurationId(options, videoCodecString, audioCodecString, framesPerUnit) {
|
|
165
|
+
return JSON.stringify({
|
|
166
|
+
profile: options.profile,
|
|
167
|
+
durationUs: options.durationUs,
|
|
168
|
+
width: options.width,
|
|
169
|
+
height: options.height,
|
|
170
|
+
frameRate: options.frameRate,
|
|
171
|
+
sampleRate: options.sampleRate,
|
|
172
|
+
channelCount: options.channelCount,
|
|
173
|
+
videoBitrate: options.videoBitrate,
|
|
174
|
+
audioBitrate: options.audioBitrate,
|
|
175
|
+
videoCodecString,
|
|
176
|
+
audioCodecString,
|
|
177
|
+
framesPerUnit,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
function compatibleManifest(value, expected) {
|
|
181
|
+
return (value?.version === 1 &&
|
|
182
|
+
value.contentId === expected.contentId &&
|
|
183
|
+
value.configurationId === expected.configurationId &&
|
|
184
|
+
value.profile === expected.profile &&
|
|
185
|
+
value.durationUs === expected.durationUs &&
|
|
186
|
+
value.totalUnits === expected.totalUnits &&
|
|
187
|
+
value.completedUnits >= 0 &&
|
|
188
|
+
value.completedUnits <= expected.totalUnits &&
|
|
189
|
+
value.units.length === value.completedUnits &&
|
|
190
|
+
value.units.every((unit, index) => unit.index === index));
|
|
191
|
+
}
|
|
192
|
+
function patchMp4FragmentSequences(bytes, firstSequence) {
|
|
193
|
+
let sequence = firstSequence;
|
|
194
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
195
|
+
for (let index = 4; index + 12 <= bytes.length; index += 1) {
|
|
196
|
+
if (bytes[index] === 0x6d &&
|
|
197
|
+
bytes[index + 1] === 0x66 &&
|
|
198
|
+
bytes[index + 2] === 0x68 &&
|
|
199
|
+
bytes[index + 3] === 0x64) {
|
|
200
|
+
view.setUint32(index + 8, sequence, false);
|
|
201
|
+
sequence += 1;
|
|
202
|
+
index += 11;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return sequence;
|
|
206
|
+
}
|
|
207
|
+
function greatestCommonDivisor(left, right) {
|
|
208
|
+
let a = Math.abs(left);
|
|
209
|
+
let b = Math.abs(right);
|
|
210
|
+
while (b !== 0) {
|
|
211
|
+
const remainder = a % b;
|
|
212
|
+
a = b;
|
|
213
|
+
b = remainder;
|
|
214
|
+
}
|
|
215
|
+
return a;
|
|
216
|
+
}
|
|
217
|
+
function directMp4Boxes(bytes, start, end) {
|
|
218
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
219
|
+
const boxes = [];
|
|
220
|
+
let position = start;
|
|
221
|
+
while (position + 8 <= end) {
|
|
222
|
+
let size = view.getUint32(position, false);
|
|
223
|
+
const type = String.fromCharCode(bytes[position + 4] ?? 0, bytes[position + 5] ?? 0, bytes[position + 6] ?? 0, bytes[position + 7] ?? 0);
|
|
224
|
+
let headerSize = 8;
|
|
225
|
+
if (size === 1) {
|
|
226
|
+
if (position + 16 > end)
|
|
227
|
+
break;
|
|
228
|
+
const largeSize = view.getBigUint64(position + 8, false);
|
|
229
|
+
if (largeSize > BigInt(Number.MAX_SAFE_INTEGER))
|
|
230
|
+
break;
|
|
231
|
+
size = Number(largeSize);
|
|
232
|
+
headerSize = 16;
|
|
233
|
+
}
|
|
234
|
+
else if (size === 0) {
|
|
235
|
+
size = end - position;
|
|
236
|
+
}
|
|
237
|
+
if (size < headerSize || position + size > end)
|
|
238
|
+
break;
|
|
239
|
+
boxes.push({
|
|
240
|
+
type,
|
|
241
|
+
start: position,
|
|
242
|
+
end: position + size,
|
|
243
|
+
contentStart: position + headerSize,
|
|
244
|
+
});
|
|
245
|
+
position += size;
|
|
246
|
+
}
|
|
247
|
+
return boxes;
|
|
248
|
+
}
|
|
249
|
+
function patchMp4DecodeTimes(bytes, range, frameRate) {
|
|
250
|
+
const divisor = greatestCommonDivisor(frameRate.numerator, frameRate.denominator);
|
|
251
|
+
const videoOffset = BigInt(range.videoStartFrame) * BigInt(frameRate.denominator / divisor);
|
|
252
|
+
const audioOffset = BigInt(range.audioStartFrame);
|
|
253
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
254
|
+
for (const moof of directMp4Boxes(bytes, 0, bytes.length).filter(box => box.type === 'moof')) {
|
|
255
|
+
for (const traf of directMp4Boxes(bytes, moof.contentStart, moof.end).filter(box => box.type === 'traf')) {
|
|
256
|
+
const children = directMp4Boxes(bytes, traf.contentStart, traf.end);
|
|
257
|
+
const tfhd = children.find(box => box.type === 'tfhd');
|
|
258
|
+
const tfdt = children.find(box => box.type === 'tfdt');
|
|
259
|
+
if (tfhd === undefined || tfdt === undefined || tfhd.contentStart + 8 > tfhd.end) {
|
|
260
|
+
throw new Error('Fragmented MP4 unit is missing tfhd/tfdt timing boxes');
|
|
261
|
+
}
|
|
262
|
+
const trackId = view.getUint32(tfhd.contentStart + 4, false);
|
|
263
|
+
const offset = trackId === 1 ? videoOffset : trackId === 2 ? audioOffset : undefined;
|
|
264
|
+
if (offset === undefined)
|
|
265
|
+
throw new Error(`Unexpected fragmented MP4 track ${trackId}`);
|
|
266
|
+
const version = view.getUint8(tfdt.contentStart);
|
|
267
|
+
if (version !== 1 || tfdt.contentStart + 12 > tfdt.end) {
|
|
268
|
+
throw new Error('Fragmented MP4 tfdt must use a 64-bit base decode time');
|
|
269
|
+
}
|
|
270
|
+
const current = view.getBigUint64(tfdt.contentStart + 4, false);
|
|
271
|
+
view.setBigUint64(tfdt.contentStart + 4, current + offset, false);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
async function codecStrings(options) {
|
|
276
|
+
const frameRate = options.frameRate.numerator / options.frameRate.denominator;
|
|
277
|
+
if (options.profile === 'webm-vp9-opus') {
|
|
278
|
+
return {
|
|
279
|
+
video: options.videoCodecString ?? 'vp09.00.10.08',
|
|
280
|
+
audio: options.audioCodecString ?? 'opus',
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
if (options.profile === 'mp4-av1-aac') {
|
|
284
|
+
return {
|
|
285
|
+
video: options.videoCodecString ?? av1CodecString(options.width, options.height, frameRate),
|
|
286
|
+
audio: options.audioCodecString ?? 'mp4a.40.2',
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
if (options.profile === 'mp4-hevc-aac') {
|
|
290
|
+
return {
|
|
291
|
+
video: options.videoCodecString ?? hevcCodecString(options.width, options.height, frameRate),
|
|
292
|
+
audio: options.audioCodecString ?? 'mp4a.40.2',
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
const negotiated = options.videoCodecString === undefined
|
|
296
|
+
? await negotiateAvcCodecString({
|
|
297
|
+
width: options.width,
|
|
298
|
+
height: options.height,
|
|
299
|
+
framerate: frameRate,
|
|
300
|
+
bitrate: options.videoBitrate,
|
|
301
|
+
})
|
|
302
|
+
: undefined;
|
|
303
|
+
return {
|
|
304
|
+
video: options.videoCodecString ??
|
|
305
|
+
negotiated?.selected ??
|
|
306
|
+
preferredAvcCodecString(options.width, options.height, frameRate),
|
|
307
|
+
audio: options.audioCodecString ?? 'mp4a.40.2',
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
function profileAndFormat(profile, init, media, minimumDurationSeconds) {
|
|
311
|
+
if (profile === 'webm-vp9-opus') {
|
|
312
|
+
return {
|
|
313
|
+
id: profile,
|
|
314
|
+
operationName: 'Resumable WebM export',
|
|
315
|
+
format: new WebMOutputFormat({
|
|
316
|
+
appendOnly: true,
|
|
317
|
+
minimumClusterDuration: minimumDurationSeconds,
|
|
318
|
+
onEbmlHeader: (data, position) => init.push({ data: data.slice(), position }),
|
|
319
|
+
onSegmentHeader: (data, position) => init.push({ data: data.slice(), position }),
|
|
320
|
+
onCluster: (data, position) => media.push({ data: sizedWebmCluster(data), position }),
|
|
321
|
+
}),
|
|
322
|
+
videoCodec: 'vp9',
|
|
323
|
+
fullVideoCodecString: 'vp09.00.10.08',
|
|
324
|
+
audioCodec: 'opus',
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
const videoCodec = profile === 'mp4-av1-aac' ? 'av1' : profile === 'mp4-hevc-aac' ? 'hevc' : 'avc';
|
|
328
|
+
return {
|
|
329
|
+
id: profile,
|
|
330
|
+
operationName: 'Resumable fragmented MP4 export',
|
|
331
|
+
format: new Mp4OutputFormat({
|
|
332
|
+
fastStart: 'fragmented',
|
|
333
|
+
minimumFragmentDuration: minimumDurationSeconds,
|
|
334
|
+
onFtyp: (data, position) => init.push({ data: data.slice(), position }),
|
|
335
|
+
onMoov: (data, position) => init.push({ data: data.slice(), position }),
|
|
336
|
+
onMoof: (data, position) => media.push({ data: data.slice(), position }),
|
|
337
|
+
onMdat: (data, position) => media.push({ data: data.slice(), position }),
|
|
338
|
+
}),
|
|
339
|
+
videoCodec,
|
|
340
|
+
fullVideoCodecString: '',
|
|
341
|
+
audioCodec: 'aac',
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
function sizedWebmCluster(data) {
|
|
345
|
+
if (data.length < 5 ||
|
|
346
|
+
data[0] !== 0x1f ||
|
|
347
|
+
data[1] !== 0x43 ||
|
|
348
|
+
data[2] !== 0xb6 ||
|
|
349
|
+
data[3] !== 0x75 ||
|
|
350
|
+
data[4] !== 0xff) {
|
|
351
|
+
throw new Error('Append-only WebM cluster does not use the expected unknown-size header');
|
|
352
|
+
}
|
|
353
|
+
const payloadLength = BigInt(data.length - 5);
|
|
354
|
+
if (payloadLength >= 1n << 56n)
|
|
355
|
+
throw new RangeError('WebM cluster exceeds EBML limits');
|
|
356
|
+
const result = new Uint8Array(data.length + 7);
|
|
357
|
+
result.set(data.subarray(0, 4), 0);
|
|
358
|
+
result[4] = 0x01;
|
|
359
|
+
let remaining = payloadLength;
|
|
360
|
+
for (let index = 11; index >= 5; index -= 1) {
|
|
361
|
+
result[index] = Number(remaining & 0xffn);
|
|
362
|
+
remaining >>= 8n;
|
|
363
|
+
}
|
|
364
|
+
result.set(data.subarray(5), 12);
|
|
365
|
+
return result;
|
|
366
|
+
}
|
|
367
|
+
function unitRange(unitIndex, framesPerUnit, fullVideoFrames, fullAudioFrames, options) {
|
|
368
|
+
const videoStartFrame = unitIndex * framesPerUnit;
|
|
369
|
+
const videoEndFrameExclusive = Math.min(fullVideoFrames, videoStartFrame + framesPerUnit);
|
|
370
|
+
const startUs = frameStartUs(videoStartFrame, options.frameRate);
|
|
371
|
+
const endUs = videoEndFrameExclusive === fullVideoFrames
|
|
372
|
+
? options.durationUs
|
|
373
|
+
: frameStartUs(videoEndFrameExclusive, options.frameRate);
|
|
374
|
+
return {
|
|
375
|
+
videoStartFrame,
|
|
376
|
+
videoEndFrameExclusive,
|
|
377
|
+
audioStartFrame: Math.floor((startUs * options.sampleRate) / 1_000_000),
|
|
378
|
+
audioEndFrameExclusive: videoEndFrameExclusive === fullVideoFrames
|
|
379
|
+
? fullAudioFrames
|
|
380
|
+
: Math.floor((endUs * options.sampleRate) / 1_000_000),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
async function writeCompletedOutput(options, manifest) {
|
|
384
|
+
const writer = options.sink.getWriter();
|
|
385
|
+
let position = 0;
|
|
386
|
+
let mp4Sequence = 1;
|
|
387
|
+
try {
|
|
388
|
+
for (let index = 0; index < manifest.totalUnits; index += 1) {
|
|
389
|
+
throwIfAborted(options.signal, 'Assemble resumable export');
|
|
390
|
+
const unit = await options.store.loadUnit(options.key, index, options.signal);
|
|
391
|
+
const metadata = manifest.units[index];
|
|
392
|
+
if (unit === undefined || metadata === undefined || unit.index !== index) {
|
|
393
|
+
throw new Error(`Committed export unit ${index.toString()} is missing`);
|
|
394
|
+
}
|
|
395
|
+
const hash = await sha256(concatBytes(unit.init === undefined ? [unit.media] : [unit.init, unit.media]));
|
|
396
|
+
if (hash !== metadata.sha256) {
|
|
397
|
+
throw new Error(`Committed export unit ${index.toString()} failed SHA-256 verification`);
|
|
398
|
+
}
|
|
399
|
+
if (unit.init !== undefined) {
|
|
400
|
+
await writer.write({ type: 'write', position, data: unit.init });
|
|
401
|
+
position += unit.init.byteLength;
|
|
402
|
+
}
|
|
403
|
+
const media = unit.media.slice();
|
|
404
|
+
if (options.profile !== 'webm-vp9-opus') {
|
|
405
|
+
mp4Sequence = patchMp4FragmentSequences(media, mp4Sequence);
|
|
406
|
+
}
|
|
407
|
+
await writer.write({ type: 'write', position, data: media });
|
|
408
|
+
position += media.byteLength;
|
|
409
|
+
}
|
|
410
|
+
await writer.close();
|
|
411
|
+
}
|
|
412
|
+
catch (error) {
|
|
413
|
+
await writer.abort(error).catch(() => undefined);
|
|
414
|
+
throw error;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Encodes frame-aligned WebM clusters or fMP4 fragments and checkpoints every
|
|
419
|
+
* complete unit. Resume reuses the committed prefix, verifies every unit hash,
|
|
420
|
+
* and re-encodes only the first missing unit onward.
|
|
421
|
+
*/
|
|
422
|
+
export async function exportResumableMuxed(options) {
|
|
423
|
+
if (options.key.length === 0 || options.contentId.length === 0) {
|
|
424
|
+
throw new TypeError('key and contentId must not be empty');
|
|
425
|
+
}
|
|
426
|
+
const segmentDurationUs = options.segmentDurationUs ?? 2_000_000;
|
|
427
|
+
if (!Number.isSafeInteger(segmentDurationUs) || segmentDurationUs <= 0) {
|
|
428
|
+
throw new RangeError('segmentDurationUs must be a positive safe integer');
|
|
429
|
+
}
|
|
430
|
+
throwIfAborted(options.signal, 'Resumable muxed export');
|
|
431
|
+
const fullVideoFrames = Math.ceil((options.durationUs * options.frameRate.numerator) /
|
|
432
|
+
(1_000_000 * options.frameRate.denominator));
|
|
433
|
+
const fullAudioFrames = Math.floor((options.durationUs * options.sampleRate) / 1_000_000);
|
|
434
|
+
const framesPerUnit = Math.max(1, Math.round((segmentDurationUs * options.frameRate.numerator) /
|
|
435
|
+
(1_000_000 * options.frameRate.denominator)));
|
|
436
|
+
const totalUnits = Math.ceil(fullVideoFrames / framesPerUnit);
|
|
437
|
+
const codecs = await codecStrings(options);
|
|
438
|
+
const configurationId = stableConfigurationId(options, codecs.video, codecs.audio, framesPerUnit);
|
|
439
|
+
const expected = {
|
|
440
|
+
contentId: options.contentId,
|
|
441
|
+
configurationId,
|
|
442
|
+
profile: options.profile,
|
|
443
|
+
durationUs: options.durationUs,
|
|
444
|
+
totalUnits,
|
|
445
|
+
};
|
|
446
|
+
let manifest = await options.store.loadManifest(options.key, options.signal);
|
|
447
|
+
if (!compatibleManifest(manifest, expected)) {
|
|
448
|
+
await options.store.delete(options.key, options.signal);
|
|
449
|
+
manifest = {
|
|
450
|
+
version: 1,
|
|
451
|
+
...expected,
|
|
452
|
+
completedUnits: 0,
|
|
453
|
+
units: [],
|
|
454
|
+
updatedAtMs: (options.now ?? Date.now)(),
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
const reusedUnits = manifest.completedUnits;
|
|
458
|
+
options.onProgress?.(manifest.completedUnits / totalUnits);
|
|
459
|
+
for (let index = manifest.completedUnits; index < totalUnits; index += 1) {
|
|
460
|
+
throwIfAborted(options.signal, 'Resumable muxed export');
|
|
461
|
+
const range = unitRange(index, framesPerUnit, fullVideoFrames, fullAudioFrames, options);
|
|
462
|
+
const init = [];
|
|
463
|
+
const media = [];
|
|
464
|
+
const profile = profileAndFormat(options.profile, init, media, segmentDurationUs / 1_000_000 + 1);
|
|
465
|
+
const discardSink = new WritableStream();
|
|
466
|
+
const exportRange = options.profile === 'webm-vp9-opus' ? range : { ...range, timestampBase: 'range' };
|
|
467
|
+
const result = await exportMuxed({
|
|
468
|
+
durationUs: options.durationUs,
|
|
469
|
+
width: options.width,
|
|
470
|
+
height: options.height,
|
|
471
|
+
frameRate: options.frameRate,
|
|
472
|
+
sampleRate: options.sampleRate,
|
|
473
|
+
channelCount: options.channelCount,
|
|
474
|
+
videoBitrate: options.videoBitrate,
|
|
475
|
+
audioBitrate: options.audioBitrate,
|
|
476
|
+
videoCodecString: codecs.video,
|
|
477
|
+
audioCodecString: codecs.audio,
|
|
478
|
+
sink: discardSink,
|
|
479
|
+
renderFrame: options.renderFrame,
|
|
480
|
+
renderAudio: options.renderAudio,
|
|
481
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
482
|
+
onProgress: progress => options.onProgress?.((index + progress) / totalUnits),
|
|
483
|
+
}, { ...profile, fullVideoCodecString: codecs.video }, exportRange);
|
|
484
|
+
const encodedMedia = sortedBytes(media);
|
|
485
|
+
if (options.profile !== 'webm-vp9-opus') {
|
|
486
|
+
patchMp4DecodeTimes(encodedMedia, range, options.frameRate);
|
|
487
|
+
}
|
|
488
|
+
const unitInit = index === 0 ? sortedBytes(init) : undefined;
|
|
489
|
+
const unit = {
|
|
490
|
+
index,
|
|
491
|
+
...(unitInit === undefined ? {} : { init: unitInit }),
|
|
492
|
+
media: encodedMedia,
|
|
493
|
+
};
|
|
494
|
+
if (unit.media.byteLength === 0 || (index === 0 && unit.init?.byteLength === 0)) {
|
|
495
|
+
throw new Error(`Muxer did not emit a complete resumable unit ${index.toString()}`);
|
|
496
|
+
}
|
|
497
|
+
const metadata = {
|
|
498
|
+
index,
|
|
499
|
+
...range,
|
|
500
|
+
byteLength: (unit.init?.byteLength ?? 0) + unit.media.byteLength,
|
|
501
|
+
sha256: await sha256(concatBytes(unit.init === undefined ? [unit.media] : [unit.init, unit.media])),
|
|
502
|
+
};
|
|
503
|
+
manifest = {
|
|
504
|
+
...manifest,
|
|
505
|
+
completedUnits: index + 1,
|
|
506
|
+
units: [...manifest.units, metadata],
|
|
507
|
+
mimeType: result.mimeType,
|
|
508
|
+
encoderConfiguration: result.encoderConfiguration,
|
|
509
|
+
updatedAtMs: (options.now ?? Date.now)(),
|
|
510
|
+
};
|
|
511
|
+
await options.store.commitUnit(options.key, unit, manifest, options.signal);
|
|
512
|
+
options.onUnitCommitted?.(manifest.completedUnits, totalUnits);
|
|
513
|
+
options.onProgress?.(manifest.completedUnits / totalUnits);
|
|
514
|
+
}
|
|
515
|
+
if (manifest.mimeType === undefined || manifest.encoderConfiguration === undefined) {
|
|
516
|
+
throw new Error('Completed resumable export manifest is missing encoder metadata');
|
|
517
|
+
}
|
|
518
|
+
await writeCompletedOutput(options, manifest);
|
|
519
|
+
if (options.deleteCheckpointOnSuccess === true) {
|
|
520
|
+
await options.store.delete(options.key, options.signal);
|
|
521
|
+
}
|
|
522
|
+
return {
|
|
523
|
+
mimeType: manifest.mimeType,
|
|
524
|
+
videoFrames: fullVideoFrames,
|
|
525
|
+
audioFrames: fullAudioFrames,
|
|
526
|
+
durationUs: options.durationUs,
|
|
527
|
+
encoderConfiguration: manifest.encoderConfiguration,
|
|
528
|
+
checkpointKey: options.key,
|
|
529
|
+
totalUnits,
|
|
530
|
+
reusedUnits,
|
|
531
|
+
encodedUnits: totalUnits - reusedUnits,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type Diagnostic, type JsonValue } from '@aelionsdk/core';
|
|
2
|
+
import { type RenderIr } from '@aelionsdk/render-ir';
|
|
3
|
+
import { type Mp4ExportResult, type WebMExportOptions, type WebMExportResult } from './webm-export.js';
|
|
4
|
+
import { type ExportProfileId } from './profiles.js';
|
|
5
|
+
export type ExportPreflightIssue = Diagnostic;
|
|
6
|
+
export interface ExportPreflightReport {
|
|
7
|
+
readonly ok: boolean;
|
|
8
|
+
readonly revision: bigint;
|
|
9
|
+
readonly issues: readonly ExportPreflightIssue[];
|
|
10
|
+
readonly encoderConfiguration?: {
|
|
11
|
+
readonly videoCodecString?: string;
|
|
12
|
+
readonly audioCodecString?: string;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export interface FrozenWebMExportOptions extends Omit<WebMExportOptions, 'durationUs' | 'width' | 'height' | 'frameRate' | 'sampleRate' | 'channelCount'> {
|
|
16
|
+
readonly ir: RenderIr;
|
|
17
|
+
readonly projectRevision: bigint;
|
|
18
|
+
readonly materialBackendAvailable?: (materialId: string, parameters: Readonly<Record<string, JsonValue>>) => boolean;
|
|
19
|
+
/**
|
|
20
|
+
* WebM defaults to Worker. MP4 defaults to inline because current Chromium
|
|
21
|
+
* advertises AAC in DedicatedWorker but fails during encode; `worker` remains opt-in.
|
|
22
|
+
*/
|
|
23
|
+
readonly execution?: 'worker' | 'inline';
|
|
24
|
+
/** Host-resolved Export Worker URL for non-Vite or CDN deployments. */
|
|
25
|
+
readonly workerUrl?: string | URL;
|
|
26
|
+
}
|
|
27
|
+
export type FrozenMp4ExportOptions = FrozenWebMExportOptions;
|
|
28
|
+
export interface FrozenProfilePreflightOptions {
|
|
29
|
+
readonly ir: RenderIr;
|
|
30
|
+
readonly projectRevision: bigint;
|
|
31
|
+
readonly profile: ExportProfileId;
|
|
32
|
+
readonly sink: WebMExportOptions['sink'];
|
|
33
|
+
readonly videoBitrate?: number;
|
|
34
|
+
readonly audioBitrate?: number;
|
|
35
|
+
readonly materialBackendAvailable?: FrozenWebMExportOptions['materialBackendAvailable'];
|
|
36
|
+
}
|
|
37
|
+
export declare function preflightWebMExport(options: FrozenWebMExportOptions): Promise<ExportPreflightReport>;
|
|
38
|
+
export declare function preflightMp4Export(options: FrozenMp4ExportOptions): Promise<ExportPreflightReport>;
|
|
39
|
+
export declare function preflightAv1Mp4Export(options: FrozenMp4ExportOptions): Promise<ExportPreflightReport>;
|
|
40
|
+
export declare function preflightHevcMp4Export(options: FrozenMp4ExportOptions): Promise<ExportPreflightReport>;
|
|
41
|
+
/** Profile-wide preflight used by the SDK before any sink writer is acquired. */
|
|
42
|
+
export declare function preflightProfileExport(options: FrozenProfilePreflightOptions): Promise<ExportPreflightReport>;
|
|
43
|
+
export declare function exportFrozenRenderIrWebM(options: FrozenWebMExportOptions): Promise<WebMExportResult>;
|
|
44
|
+
export declare function exportFrozenRenderIrMp4(options: FrozenMp4ExportOptions): Promise<Mp4ExportResult>;
|
|
45
|
+
export declare function exportFrozenRenderIrAv1Mp4(options: FrozenMp4ExportOptions): Promise<Mp4ExportResult>;
|
|
46
|
+
export declare function exportFrozenRenderIrHevcMp4(options: FrozenMp4ExportOptions): Promise<Mp4ExportResult>;
|
|
47
|
+
//# sourceMappingURL=session.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,KAAK,UAAU,EAAE,KAAK,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC/E,OAAO,EAGL,KAAK,QAAQ,EACd,MAAM,sBAAsB,CAAC;AAE9B,OAAO,EAKL,KAAK,eAAe,EACpB,KAAK,iBAAiB,EACtB,KAAK,gBAAgB,EACtB,MAAM,kBAAkB,CAAC;AAE1B,OAAO,EAIL,KAAK,eAAe,EACrB,MAAM,eAAe,CAAC;AAEvB,MAAM,MAAM,oBAAoB,GAAG,UAAU,CAAC;AAE9C,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAC;IACrB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,SAAS,oBAAoB,EAAE,CAAC;IACjD,QAAQ,CAAC,oBAAoB,CAAC,EAAE;QAC9B,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;QACnC,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;KACpC,CAAC;CACH;AAED,MAAM,WAAW,uBACf,SAAQ,IAAI,CACV,iBAAiB,EACjB,YAAY,GAAG,OAAO,GAAG,QAAQ,GAAG,WAAW,GAAG,YAAY,GAAG,cAAc,CAChF;IACD,QAAQ,CAAC,EAAE,EAAE,QAAQ,CAAC;IACtB,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,wBAAwB,CAAC,EAAE,CAClC,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC,KAC5C,OAAO,CAAC;IACb;;;OAGG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAC;IACzC,uEAAuE;IACvE,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,GAAG,CAAC;CACnC;AAED,MAAM,MAAM,sBAAsB,GAAG,uBAAuB,CAAC;AAE7D,MAAM,WAAW,6BAA6B;IAC5C,QAAQ,CAAC,EAAE,EAAE,QAAQ,CAAC;IACtB,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAC;IAClC,QAAQ,CAAC,IAAI,EAAE,iBAAiB,CAAC,MAAM,CAAC,CAAC;IACzC,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,wBAAwB,CAAC,EAAE,uBAAuB,CAAC,0BAA0B,CAAC,CAAC;CACzF;AA4PD,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,uBAAuB,GAC/B,OAAO,CAAC,qBAAqB,CAAC,CAOhC;AAED,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,qBAAqB,CAAC,CAShC;AAED,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,qBAAqB,CAAC,CAYhC;AAED,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,qBAAqB,CAAC,CAahC;AAED,iFAAiF;AACjF,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,6BAA6B,GACrC,OAAO,CAAC,qBAAqB,CAAC,CAmFhC;AAED,wBAAsB,wBAAwB,CAC5C,OAAO,EAAE,uBAAuB,GAC/B,OAAO,CAAC,gBAAgB,CAAC,CAkC3B;AAED,wBAAsB,uBAAuB,CAC3C,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,eAAe,CAAC,CAgC1B;AA2CD,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,eAAe,CAAC,CAE1B;AAED,wBAAgB,2BAA2B,CACzC,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,eAAe,CAAC,CAE1B"}
|