@aelionsdk/media 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.
@@ -0,0 +1,769 @@
1
+ import { throwIfAborted } from '@aelionsdk/core';
2
+ import { ALL_FORMATS, AudioSampleSink, BufferSource, CustomSource, EncodedPacketSink, Input, MP4, VideoSampleSink, WEBM, } from 'mediabunny';
3
+ import { resolveVideoSeek } from './seek.js';
4
+ const MICROSECONDS_PER_SECOND = 1_000_000;
5
+ let activeVideoDecoders = 0;
6
+ let retainedVideoFrames = 0;
7
+ function inputFromReader(reader, signal) {
8
+ return new Input({
9
+ source: new CustomSource({
10
+ getSize: () => reader.size(signal),
11
+ read: async (start, end) => {
12
+ const result = await reader.read({ offset: start, length: end - start }, signal);
13
+ return result.bytes;
14
+ },
15
+ maxCacheSize: 8 * 1_024 * 1_024,
16
+ prefetchProfile: reader.kind === 'network' ? 'network' : 'fileSystem',
17
+ }),
18
+ formats: ALL_FORMATS,
19
+ });
20
+ }
21
+ export function videoDecoderResourceSnapshot() {
22
+ return { activeDecoders: activeVideoDecoders, retainedFrames: retainedVideoFrames };
23
+ }
24
+ function secondsToUs(value, context) {
25
+ const microseconds = Math.round(value * MICROSECONDS_PER_SECOND);
26
+ if (!Number.isSafeInteger(microseconds)) {
27
+ throw new RangeError(`${context} is outside the safe microsecond range`);
28
+ }
29
+ return microseconds;
30
+ }
31
+ function diagnostic(code, message) {
32
+ return {
33
+ code,
34
+ severity: 'warning',
35
+ message,
36
+ recoverable: true,
37
+ };
38
+ }
39
+ function adapterLimitDiagnostics() {
40
+ return [
41
+ diagnostic('MEDIA_RAW_DTS_UNAVAILABLE', 'The container adapter exposes decode order and PTS, but not raw container DTS.'),
42
+ diagnostic('MEDIA_SAMPLE_OFFSET_UNAVAILABLE', 'The container adapter exposes encoded sample size, but not a stable physical byte offset.'),
43
+ ];
44
+ }
45
+ function codecFamily(codec) {
46
+ return codec ?? 'unknown';
47
+ }
48
+ function description(config) {
49
+ const source = config?.description;
50
+ if (source === undefined)
51
+ return undefined;
52
+ if (source instanceof ArrayBuffer)
53
+ return new Uint8Array(source.slice(0));
54
+ return new Uint8Array(source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength));
55
+ }
56
+ function copyVideoDecoderConfig(config) {
57
+ const configDescription = description(config);
58
+ return {
59
+ ...config,
60
+ ...(configDescription === undefined ? {} : { description: configDescription }),
61
+ };
62
+ }
63
+ async function videoTrackInfo(track) {
64
+ const [codec, codecString, width, height, rotation, timeResolution, config] = await Promise.all([
65
+ track.getCodec(),
66
+ track.getCodecParameterString(),
67
+ track.getCodedWidth(),
68
+ track.getCodedHeight(),
69
+ track.getRotation(),
70
+ track.getTimeResolution(),
71
+ track.getDecoderConfig(),
72
+ ]);
73
+ const configDescription = description(config);
74
+ return {
75
+ kind: 'video',
76
+ id: track.id,
77
+ codec: codecString ?? codec ?? 'unknown',
78
+ codecFamily: codecFamily(codec),
79
+ codedWidth: width,
80
+ codedHeight: height,
81
+ rotation,
82
+ timeBase: { numerator: 1, denominator: timeResolution },
83
+ ...(configDescription === undefined ? {} : { description: configDescription }),
84
+ };
85
+ }
86
+ async function audioTrackInfo(track) {
87
+ if (!track.isAudioTrack())
88
+ throw new TypeError('Track is not audio');
89
+ const [codec, codecString, sampleRate, channelCount, timeResolution, config] = await Promise.all([
90
+ track.getCodec(),
91
+ track.getCodecParameterString(),
92
+ track.getSampleRate(),
93
+ track.getNumberOfChannels(),
94
+ track.getTimeResolution(),
95
+ track.getDecoderConfig(),
96
+ ]);
97
+ const configDescription = description(config);
98
+ return {
99
+ kind: 'audio',
100
+ id: track.id,
101
+ codec: codecString ?? codec ?? 'unknown',
102
+ codecFamily: codecFamily(codec),
103
+ sampleRate,
104
+ channelCount,
105
+ timeBase: { numerator: 1, denominator: timeResolution },
106
+ ...(configDescription === undefined ? {} : { description: configDescription }),
107
+ };
108
+ }
109
+ function packetEntry(track, packet, decodeOrder, normalizedDecodeTimeUs) {
110
+ return {
111
+ trackId: track.id,
112
+ sampleIndex: decodeOrder,
113
+ kind: track.isVideoTrack() ? 'video' : 'audio',
114
+ decodeOrder,
115
+ presentationOrder: -1,
116
+ sourceSequenceNumber: packet.sequenceNumber,
117
+ presentationTimestampUs: packet.microsecondTimestamp,
118
+ durationUs: packet.microsecondDuration,
119
+ // Normalize the decode timeline to zero and accumulate durations in
120
+ // strict decode order. Raw container DTS origin remains adapter-private.
121
+ normalizedDecodeTimeUs,
122
+ isSync: packet.type === 'key',
123
+ byteLength: packet.byteLength,
124
+ };
125
+ }
126
+ function assignPresentationOrder(samples) {
127
+ const order = samples
128
+ .map((_, index) => index)
129
+ .sort((left, right) => {
130
+ const leftSample = samples[left];
131
+ const rightSample = samples[right];
132
+ return ((leftSample?.presentationTimestampUs ?? 0) - (rightSample?.presentationTimestampUs ?? 0) ||
133
+ left - right);
134
+ });
135
+ const position = new Map(order.map((sampleIndex, presentationOrder) => [sampleIndex, presentationOrder]));
136
+ return {
137
+ samples: samples.map(sample => ({
138
+ ...sample,
139
+ presentationOrder: position.get(sample.sampleIndex) ?? -1,
140
+ })),
141
+ presentationOrder: order,
142
+ };
143
+ }
144
+ async function indexTrack(track, signal) {
145
+ const sink = new EncodedPacketSink(track);
146
+ const samples = [];
147
+ let normalizedDecodeTimeUs = 0;
148
+ for await (const packet of sink.packets(undefined, undefined, { metadataOnly: true })) {
149
+ throwIfAborted(signal, 'media sample indexing');
150
+ samples.push(packetEntry(track, packet, samples.length, normalizedDecodeTimeUs));
151
+ normalizedDecodeTimeUs += packet.microsecondDuration;
152
+ }
153
+ return assignPresentationOrder(samples);
154
+ }
155
+ export async function createSampleIndex(bytes, options = {}) {
156
+ throwIfAborted(options.signal, 'media probe');
157
+ const input = new Input({
158
+ source: new BufferSource(bytes),
159
+ formats: ALL_FORMATS,
160
+ });
161
+ try {
162
+ const [format, durationSeconds, tracks] = await Promise.all([
163
+ input.getFormat(),
164
+ input.computeDuration(),
165
+ input.getTracks(),
166
+ ]);
167
+ throwIfAborted(options.signal, 'media probe');
168
+ const trackInfos = await Promise.all(tracks.map(track => (track.isVideoTrack() ? videoTrackInfo(track) : audioTrackInfo(track))));
169
+ const diagnostics = [...adapterLimitDiagnostics()];
170
+ const samples = {};
171
+ const presentationOrder = {};
172
+ if (options.includeSamples ?? true) {
173
+ for (const track of tracks) {
174
+ const indexed = await indexTrack(track, options.signal);
175
+ samples[track.id] = indexed.samples;
176
+ presentationOrder[track.id] = indexed.presentationOrder;
177
+ }
178
+ }
179
+ return {
180
+ schemaVersion: '1.0.0',
181
+ container: format === MP4 ? 'mp4' : format === WEBM ? 'webm' : 'unknown',
182
+ durationUs: secondsToUs(durationSeconds, 'media duration'),
183
+ tracks: trackInfos,
184
+ capabilities: {
185
+ timingAndSize: true,
186
+ rawDecodeTimestamps: false,
187
+ byteOffsets: false,
188
+ },
189
+ samples,
190
+ presentationOrder,
191
+ diagnostics,
192
+ };
193
+ }
194
+ finally {
195
+ input.dispose();
196
+ }
197
+ }
198
+ export async function createSampleIndexFromReader(reader, options = {}) {
199
+ throwIfAborted(options.signal, 'media range probe');
200
+ const input = inputFromReader(reader, options.signal);
201
+ try {
202
+ const [format, durationSeconds, tracks] = await Promise.all([
203
+ input.getFormat(),
204
+ input.computeDuration(),
205
+ input.getTracks(),
206
+ ]);
207
+ const trackInfos = await Promise.all(tracks.map(track => (track.isVideoTrack() ? videoTrackInfo(track) : audioTrackInfo(track))));
208
+ const samples = {};
209
+ const presentationOrder = {};
210
+ if (options.includeSamples ?? true) {
211
+ for (const track of tracks) {
212
+ const indexed = await indexTrack(track, options.signal);
213
+ samples[track.id] = indexed.samples;
214
+ presentationOrder[track.id] = indexed.presentationOrder;
215
+ }
216
+ }
217
+ return {
218
+ schemaVersion: '1.0.0',
219
+ container: format === MP4 ? 'mp4' : format === WEBM ? 'webm' : 'unknown',
220
+ durationUs: secondsToUs(durationSeconds, 'media duration'),
221
+ tracks: trackInfos,
222
+ capabilities: {
223
+ timingAndSize: true,
224
+ rawDecodeTimestamps: false,
225
+ byteOffsets: false,
226
+ },
227
+ samples,
228
+ presentationOrder,
229
+ diagnostics: adapterLimitDiagnostics(),
230
+ };
231
+ }
232
+ finally {
233
+ input.dispose();
234
+ }
235
+ }
236
+ export async function probeSampleIndex(bytes, options = {}) {
237
+ try {
238
+ return { ok: true, index: await createSampleIndex(bytes, options) };
239
+ }
240
+ catch (cause) {
241
+ return {
242
+ ok: false,
243
+ diagnostics: [
244
+ {
245
+ code: 'MEDIA_INPUT_INVALID',
246
+ severity: 'error',
247
+ message: cause instanceof Error ? cause.message : 'Input media is unsupported or corrupt',
248
+ recoverable: false,
249
+ cause,
250
+ },
251
+ ],
252
+ };
253
+ }
254
+ }
255
+ export async function decodeAudioPcmRange(bytes, startUs, durationUs, options = {}) {
256
+ return decodeAudioPcmRangeFromInput(new Input({ source: new BufferSource(bytes), formats: ALL_FORMATS }), startUs, durationUs, options);
257
+ }
258
+ /** Decode an audio interval without first loading the complete media resource. */
259
+ export async function decodeAudioPcmRangeFromReader(reader, startUs, durationUs, options = {}) {
260
+ return decodeAudioPcmRangeFromInput(inputFromReader(reader, options.signal), startUs, durationUs, options);
261
+ }
262
+ async function decodeAudioPcmRangeFromInput(input, startUs, durationUs, options) {
263
+ throwIfAborted(options.signal, 'audio PCM decode');
264
+ if (!Number.isSafeInteger(startUs) ||
265
+ !Number.isSafeInteger(durationUs) ||
266
+ startUs < 0 ||
267
+ durationUs <= 0) {
268
+ throw new RangeError('Audio decode range must use non-negative safe integer microseconds');
269
+ }
270
+ try {
271
+ const tracks = await input.getAudioTracks();
272
+ const track = tracks[options.streamIndex ?? 0];
273
+ if (track === undefined)
274
+ throw new RangeError('Requested audio stream does not exist');
275
+ const sampleRate = await track.getSampleRate();
276
+ const channelCount = await track.getNumberOfChannels();
277
+ const frameCount = Math.ceil((durationUs * sampleRate) / MICROSECONDS_PER_SECOND);
278
+ const output = new Float32Array(frameCount * channelCount);
279
+ const sink = new AudioSampleSink(track);
280
+ const endUs = startUs + durationUs;
281
+ for await (const sample of sink.samples(startUs / MICROSECONDS_PER_SECOND, endUs / MICROSECONDS_PER_SECOND)) {
282
+ throwIfAborted(options.signal, 'audio PCM decode');
283
+ try {
284
+ const sampleStartFrame = Math.round(((sample.microsecondTimestamp - startUs) * sampleRate) / MICROSECONDS_PER_SECOND);
285
+ const sourceOffset = Math.max(0, -sampleStartFrame);
286
+ const destinationOffset = Math.max(0, sampleStartFrame);
287
+ const frames = Math.min(sample.numberOfFrames - sourceOffset, frameCount - destinationOffset);
288
+ if (frames <= 0)
289
+ continue;
290
+ const copied = new Float32Array(frames * channelCount);
291
+ sample.copyTo(copied, {
292
+ planeIndex: 0,
293
+ format: 'f32',
294
+ frameOffset: sourceOffset,
295
+ frameCount: frames,
296
+ });
297
+ output.set(copied, destinationOffset * channelCount);
298
+ }
299
+ finally {
300
+ sample.close();
301
+ }
302
+ }
303
+ return {
304
+ sampleRate,
305
+ channelCount,
306
+ startUs,
307
+ durationUs,
308
+ frameCount,
309
+ interleaved: output,
310
+ };
311
+ }
312
+ finally {
313
+ input.dispose();
314
+ }
315
+ }
316
+ async function waitForDecodeCapacity(decoder, maxDecodeQueueSize, signal) {
317
+ while (decoder.decodeQueueSize >= maxDecodeQueueSize) {
318
+ throwIfAborted(signal, 'video exact seek');
319
+ await new Promise((resolve, reject) => {
320
+ const onAbort = () => {
321
+ decoder.removeEventListener('dequeue', onDequeue);
322
+ reject(new DOMException('Video decode wait was aborted', 'AbortError'));
323
+ };
324
+ const onDequeue = () => {
325
+ signal?.removeEventListener('abort', onAbort);
326
+ resolve();
327
+ };
328
+ decoder.addEventListener('dequeue', onDequeue, { once: true });
329
+ signal?.addEventListener('abort', onAbort, { once: true });
330
+ });
331
+ }
332
+ }
333
+ function positiveSessionInteger(value, name) {
334
+ if (!Number.isSafeInteger(value) || value <= 0) {
335
+ throw new RangeError(`${name} must be a positive safe integer`);
336
+ }
337
+ return value;
338
+ }
339
+ function videoFrameBytes(frame) {
340
+ try {
341
+ return frame.allocationSize();
342
+ }
343
+ catch {
344
+ return frame.codedWidth * frame.codedHeight * 4;
345
+ }
346
+ }
347
+ function decodeResultFromFrame(frame, options) {
348
+ let closed = false;
349
+ retainedVideoFrames += 1;
350
+ return {
351
+ frame,
352
+ ...options,
353
+ close: () => {
354
+ if (closed)
355
+ return;
356
+ closed = true;
357
+ frame.close();
358
+ retainedVideoFrames -= 1;
359
+ },
360
+ };
361
+ }
362
+ function operationWithSignal(promise, signal) {
363
+ if (signal === undefined)
364
+ return promise;
365
+ throwIfAborted(signal, 'persistent video decode');
366
+ return new Promise((resolve, reject) => {
367
+ const onAbort = () => {
368
+ reject(signal.reason instanceof Error
369
+ ? signal.reason
370
+ : new DOMException('Persistent video decode was aborted', 'AbortError'));
371
+ };
372
+ signal.addEventListener('abort', onAbort, { once: true });
373
+ void promise.then(value => {
374
+ signal.removeEventListener('abort', onAbort);
375
+ resolve(value);
376
+ }, (error) => {
377
+ signal.removeEventListener('abort', onAbort);
378
+ reject(error instanceof Error ? error : new Error('Persistent video decode failed'));
379
+ });
380
+ });
381
+ }
382
+ /**
383
+ * A bounded, persistent video decoder session.
384
+ *
385
+ * Sequential requests share one Input, decoder and packet iterator. Backward
386
+ * seeks and large forward jumps restart at the nearest verified key packet.
387
+ * Returned frames are caller-owned; cached frames remain session-owned.
388
+ */
389
+ export class VideoFrameDecodeSession {
390
+ #reader;
391
+ #index;
392
+ #streamIndex;
393
+ #videoInfo;
394
+ #maxCachedFrames;
395
+ #maxCachedBytes;
396
+ #maxSequentialGapUs;
397
+ #cache = new Map();
398
+ #input;
399
+ #iterator;
400
+ #current;
401
+ #lookahead;
402
+ #inputAbort;
403
+ #tail = Promise.resolve();
404
+ #clock = 0;
405
+ #cachedBytes = 0;
406
+ #cacheHits = 0;
407
+ #cacheMisses = 0;
408
+ #seeks = 0;
409
+ #sequentialFrames = 0;
410
+ #resets = 0;
411
+ #decoderActive = false;
412
+ #disposed = false;
413
+ constructor(reader, index, options = {}) {
414
+ this.#reader = reader;
415
+ this.#index = index;
416
+ this.#streamIndex = options.streamIndex ?? 0;
417
+ if (!Number.isSafeInteger(this.#streamIndex) || this.#streamIndex < 0) {
418
+ throw new RangeError('streamIndex must be a non-negative safe integer');
419
+ }
420
+ const videoInfo = index.tracks.filter(track => track.kind === 'video')[this.#streamIndex];
421
+ if (videoInfo === undefined)
422
+ throw new RangeError('Requested video stream does not exist');
423
+ this.#videoInfo = videoInfo;
424
+ this.#maxCachedFrames = positiveSessionInteger(options.maxCachedFrames ?? 24, 'maxCachedFrames');
425
+ this.#maxCachedBytes = positiveSessionInteger(options.maxCachedBytes ?? 96 * 1_024 * 1_024, 'maxCachedBytes');
426
+ this.#maxSequentialGapUs = positiveSessionInteger(options.maxSequentialGapUs ?? 3_000_000, 'maxSequentialGapUs');
427
+ }
428
+ frameAt(targetUs, signal) {
429
+ if (this.#disposed) {
430
+ return Promise.reject(new ReferenceError('VideoFrameDecodeSession is disposed'));
431
+ }
432
+ if (!Number.isSafeInteger(targetUs) || targetUs < 0) {
433
+ return Promise.reject(new RangeError('targetUs must be a non-negative safe integer'));
434
+ }
435
+ const operation = this.#tail.then(() => this.#frameAt(targetUs, signal));
436
+ this.#tail = operation.then(() => undefined, () => undefined);
437
+ return operation;
438
+ }
439
+ snapshot() {
440
+ return {
441
+ streamIndex: this.#streamIndex,
442
+ cachedFrames: this.#cache.size,
443
+ cachedBytes: this.#cachedBytes,
444
+ maxCachedFrames: this.#maxCachedFrames,
445
+ maxCachedBytes: this.#maxCachedBytes,
446
+ maxSequentialGapUs: this.#maxSequentialGapUs,
447
+ cacheHits: this.#cacheHits,
448
+ cacheMisses: this.#cacheMisses,
449
+ seeks: this.#seeks,
450
+ sequentialFrames: this.#sequentialFrames,
451
+ resets: this.#resets,
452
+ active: this.#decoderActive,
453
+ disposed: this.#disposed,
454
+ };
455
+ }
456
+ dispose() {
457
+ if (this.#disposed)
458
+ return;
459
+ this.#disposed = true;
460
+ void this.#reset(false);
461
+ for (const cached of this.#cache.values()) {
462
+ cached.frame.close();
463
+ retainedVideoFrames -= 1;
464
+ }
465
+ this.#cache.clear();
466
+ this.#cachedBytes = 0;
467
+ }
468
+ async #frameAt(targetUs, signal) {
469
+ if (this.#disposed)
470
+ throw new ReferenceError('VideoFrameDecodeSession is disposed');
471
+ throwIfAborted(signal, 'persistent video decode');
472
+ const seek = resolveVideoSeek(this.#index, this.#videoInfo.id, targetUs);
473
+ const cached = this.#cache.get(seek.presentationUs);
474
+ if (cached !== undefined) {
475
+ cached.access = ++this.#clock;
476
+ this.#cacheHits += 1;
477
+ return decodeResultFromFrame(cached.frame.clone(), {
478
+ timestampUs: seek.presentationUs,
479
+ durationUs: cached.frame.duration ?? 0,
480
+ decodedPackets: 0,
481
+ plannedPackets: seek.samplesToDecode,
482
+ decodeStartUs: seek.decodeStartUs,
483
+ targetUs,
484
+ });
485
+ }
486
+ this.#cacheMisses += 1;
487
+ const currentUs = this.#current?.microsecondTimestamp;
488
+ if (currentUs === undefined ||
489
+ seek.presentationUs < currentUs ||
490
+ seek.presentationUs - currentUs > this.#maxSequentialGapUs) {
491
+ await this.#start(seek.presentationUs, signal);
492
+ }
493
+ let decodedSamples = 0;
494
+ while (this.#current !== undefined &&
495
+ this.#current.microsecondTimestamp < seek.presentationUs) {
496
+ const next = await this.#next(signal);
497
+ if (next === undefined)
498
+ break;
499
+ this.#current.close();
500
+ this.#current = next;
501
+ decodedSamples += 1;
502
+ this.#sequentialFrames += 1;
503
+ }
504
+ if (this.#current === undefined || this.#current.microsecondTimestamp !== seek.presentationUs) {
505
+ // A container/decoder may have produced an unexpected order. Restarting
506
+ // from the exact timestamp preserves the strict seek contract.
507
+ await this.#start(seek.presentationUs, signal);
508
+ }
509
+ if (this.#current === undefined || this.#current.microsecondTimestamp !== seek.presentationUs) {
510
+ throw new Error(`Persistent seek expected PTS ${seek.presentationUs}, received ${this.#current?.microsecondTimestamp ?? 'end-of-stream'}`);
511
+ }
512
+ const decodedFrame = this.#current.toVideoFrame();
513
+ const result = decodeResultFromFrame(decodedFrame.clone(), {
514
+ timestampUs: seek.presentationUs,
515
+ durationUs: this.#current.microsecondDuration,
516
+ decodedPackets: Math.max(1, decodedSamples),
517
+ plannedPackets: seek.samplesToDecode,
518
+ decodeStartUs: seek.decodeStartUs,
519
+ targetUs,
520
+ });
521
+ this.#cacheFrame(seek.presentationUs, decodedFrame);
522
+ return result;
523
+ }
524
+ async #start(targetUs, signal) {
525
+ await this.#reset();
526
+ throwIfAborted(signal, 'persistent video seek');
527
+ this.#inputAbort = new AbortController();
528
+ this.#input = inputFromReader(this.#reader, this.#inputAbort.signal);
529
+ try {
530
+ const tracks = await operationWithSignal(this.#input.getVideoTracks(), signal);
531
+ const track = tracks.find(candidate => candidate.id === this.#videoInfo.id);
532
+ if (track === undefined)
533
+ throw new Error('Indexed video track is missing from input');
534
+ const sink = new VideoSampleSink(track);
535
+ this.#iterator = sink.samples(targetUs / MICROSECONDS_PER_SECOND)[Symbol.asyncIterator]();
536
+ this.#decoderActive = true;
537
+ activeVideoDecoders += 1;
538
+ this.#seeks += 1;
539
+ const first = await this.#next(signal);
540
+ if (first === undefined)
541
+ throw new Error('Video stream ended before the requested timestamp');
542
+ this.#current = first;
543
+ }
544
+ catch (error) {
545
+ await this.#reset();
546
+ throw error;
547
+ }
548
+ }
549
+ async #next(signal) {
550
+ if (this.#lookahead !== undefined) {
551
+ const sample = this.#lookahead;
552
+ this.#lookahead = undefined;
553
+ return sample;
554
+ }
555
+ const iterator = this.#iterator;
556
+ if (iterator === undefined)
557
+ return undefined;
558
+ try {
559
+ const result = await operationWithSignal(iterator.next(), signal);
560
+ return result.done ? undefined : result.value;
561
+ }
562
+ catch (error) {
563
+ if (signal?.aborted ?? false)
564
+ await this.#reset();
565
+ throw error;
566
+ }
567
+ }
568
+ async #reset(countReset = true) {
569
+ const iterator = this.#iterator;
570
+ this.#iterator = undefined;
571
+ this.#inputAbort?.abort(new DOMException('Video decode session reset', 'AbortError'));
572
+ this.#inputAbort = undefined;
573
+ this.#current?.close();
574
+ this.#current = undefined;
575
+ this.#lookahead?.close();
576
+ this.#lookahead = undefined;
577
+ this.#input?.dispose();
578
+ this.#input = undefined;
579
+ if (this.#decoderActive) {
580
+ this.#decoderActive = false;
581
+ activeVideoDecoders -= 1;
582
+ }
583
+ if (iterator?.return !== undefined) {
584
+ try {
585
+ await iterator.return();
586
+ }
587
+ catch {
588
+ // The Input has already been disposed above.
589
+ }
590
+ }
591
+ if (countReset)
592
+ this.#resets += 1;
593
+ }
594
+ #cacheFrame(timestampUs, frame) {
595
+ const byteLength = videoFrameBytes(frame);
596
+ if (byteLength > this.#maxCachedBytes) {
597
+ frame.close();
598
+ return;
599
+ }
600
+ const previous = this.#cache.get(timestampUs);
601
+ if (previous !== undefined) {
602
+ previous.frame.close();
603
+ retainedVideoFrames -= 1;
604
+ this.#cachedBytes -= previous.byteLength;
605
+ }
606
+ this.#cache.set(timestampUs, {
607
+ frame,
608
+ byteLength,
609
+ access: ++this.#clock,
610
+ });
611
+ retainedVideoFrames += 1;
612
+ this.#cachedBytes += byteLength;
613
+ while (this.#cache.size > this.#maxCachedFrames || this.#cachedBytes > this.#maxCachedBytes) {
614
+ let oldestKey;
615
+ let oldestAccess = Number.POSITIVE_INFINITY;
616
+ for (const [key, value] of this.#cache) {
617
+ if (value.access < oldestAccess) {
618
+ oldestKey = key;
619
+ oldestAccess = value.access;
620
+ }
621
+ }
622
+ if (oldestKey === undefined)
623
+ break;
624
+ const oldest = this.#cache.get(oldestKey);
625
+ if (oldest === undefined)
626
+ break;
627
+ this.#cache.delete(oldestKey);
628
+ this.#cachedBytes -= oldest.byteLength;
629
+ oldest.frame.close();
630
+ retainedVideoFrames -= 1;
631
+ }
632
+ }
633
+ }
634
+ export function createVideoFrameDecodeSessionFromReader(reader, index, options = {}) {
635
+ return new VideoFrameDecodeSession(reader, index, options);
636
+ }
637
+ export async function decodeVideoFrameAt(bytes, targetUs, options = {}) {
638
+ const index = options.sampleIndex ??
639
+ (await createSampleIndex(bytes, options.signal === undefined ? {} : { signal: options.signal }));
640
+ return decodeVideoFrameAtFromInput(new Input({ source: new BufferSource(bytes), formats: ALL_FORMATS }), index, targetUs, options);
641
+ }
642
+ /** Decode an exact video frame using demand-driven byte-range reads. */
643
+ export async function decodeVideoFrameAtFromReader(reader, targetUs, options = {}) {
644
+ const index = options.sampleIndex ??
645
+ (await createSampleIndexFromReader(reader, options.signal === undefined ? {} : { signal: options.signal }));
646
+ return decodeVideoFrameAtFromInput(inputFromReader(reader, options.signal), index, targetUs, options);
647
+ }
648
+ async function decodeVideoFrameAtFromInput(input, index, targetUs, options) {
649
+ throwIfAborted(options.signal, 'video exact seek');
650
+ if (!Number.isSafeInteger(targetUs) || targetUs < 0) {
651
+ throw new RangeError('targetUs must be a non-negative safe integer');
652
+ }
653
+ if (typeof VideoDecoder !== 'function')
654
+ throw new Error('VideoDecoder is unavailable');
655
+ const streamIndex = options.streamIndex ?? 0;
656
+ if (!Number.isSafeInteger(streamIndex) || streamIndex < 0) {
657
+ throw new RangeError('Video stream index must be a non-negative safe integer');
658
+ }
659
+ const videoInfo = index.tracks.filter(track => track.kind === 'video')[streamIndex];
660
+ if (videoInfo === undefined)
661
+ throw new RangeError('Requested video stream does not exist');
662
+ const seek = resolveVideoSeek(index, videoInfo.id, targetUs);
663
+ const targetSample = index.samples[videoInfo.id]?.[seek.presentationSample];
664
+ const decodeStartSample = index.samples[videoInfo.id]?.[seek.decodeStartSample];
665
+ if (targetSample === undefined || decodeStartSample === undefined) {
666
+ throw new Error('SampleIndex returned an invalid seek plan');
667
+ }
668
+ let decoder;
669
+ let selectedFrame;
670
+ let selectedTimestampUs = Number.MIN_SAFE_INTEGER;
671
+ let selectedDurationUs = 0;
672
+ let decodedPackets = 0;
673
+ let decoderCounted = false;
674
+ try {
675
+ const track = (await input.getVideoTracks()).find(candidate => candidate.id === videoInfo.id);
676
+ if (track === undefined)
677
+ throw new Error('Indexed video track is missing from input');
678
+ const config = await track.getDecoderConfig();
679
+ if (config === null)
680
+ throw new Error('Video decoder config is unavailable');
681
+ const supported = await VideoDecoder.isConfigSupported(config);
682
+ if (!supported.supported) {
683
+ throw new Error(`Video decoder config is unsupported: ${videoInfo.codec}`);
684
+ }
685
+ let decodeFailure;
686
+ decoder = new VideoDecoder({
687
+ output: frame => {
688
+ const frameTimestamp = frame.timestamp;
689
+ if (frameTimestamp <= targetUs && frameTimestamp >= selectedTimestampUs) {
690
+ selectedFrame?.close();
691
+ selectedFrame = frame;
692
+ selectedTimestampUs = frameTimestamp;
693
+ selectedDurationUs = frame.duration ?? 0;
694
+ }
695
+ else {
696
+ frame.close();
697
+ }
698
+ },
699
+ error: error => {
700
+ decodeFailure = error;
701
+ },
702
+ });
703
+ activeVideoDecoders += 1;
704
+ decoderCounted = true;
705
+ decoder.configure(copyVideoDecoderConfig(config));
706
+ const sink = new EncodedPacketSink(track);
707
+ const startPacket = await sink.getKeyPacket(targetUs / MICROSECONDS_PER_SECOND, {
708
+ verifyKeyPackets: true,
709
+ });
710
+ if (startPacket === null)
711
+ throw new Error('No sync packet exists at or before target');
712
+ const stablePacketSequence = startPacket.sequenceNumber === decodeStartSample.sourceSequenceNumber;
713
+ const maxDecodeQueueSize = options.maxDecodeQueueSize ?? 16;
714
+ if (!Number.isSafeInteger(maxDecodeQueueSize) || maxDecodeQueueSize <= 0) {
715
+ throw new RangeError('maxDecodeQueueSize must be a positive safe integer');
716
+ }
717
+ for await (const packet of sink.packets(startPacket)) {
718
+ throwIfAborted(options.signal, 'video exact seek');
719
+ await waitForDecodeCapacity(decoder, maxDecodeQueueSize, options.signal);
720
+ decoder.decode(packet.toEncodedVideoChunk());
721
+ decodedPackets += 1;
722
+ // Some legal segmented containers assign accessor-local sequence numbers
723
+ // or conservatively verify a key packet before the indexed sync point.
724
+ // In that case, decode through the exact target PTS rather than trusting
725
+ // metadata-only sequence identity.
726
+ if ((stablePacketSequence && packet.sequenceNumber === targetSample.sourceSequenceNumber) ||
727
+ (!stablePacketSequence &&
728
+ packet.microsecondTimestamp === targetSample.presentationTimestampUs)) {
729
+ break;
730
+ }
731
+ }
732
+ await decoder.flush();
733
+ if (decodeFailure !== undefined)
734
+ throw decodeFailure;
735
+ if (selectedFrame === undefined ||
736
+ selectedTimestampUs !== targetSample.presentationTimestampUs) {
737
+ throw new Error(`Exact seek expected PTS ${targetSample.presentationTimestampUs}, received ${selectedTimestampUs}`);
738
+ }
739
+ let closed = false;
740
+ const frame = selectedFrame;
741
+ retainedVideoFrames += 1;
742
+ return {
743
+ frame,
744
+ timestampUs: selectedTimestampUs,
745
+ durationUs: selectedDurationUs,
746
+ decodedPackets,
747
+ plannedPackets: stablePacketSequence ? seek.samplesToDecode : decodedPackets,
748
+ decodeStartUs: seek.decodeStartUs,
749
+ targetUs,
750
+ close: () => {
751
+ if (closed)
752
+ return;
753
+ closed = true;
754
+ frame.close();
755
+ retainedVideoFrames -= 1;
756
+ },
757
+ };
758
+ }
759
+ catch (error) {
760
+ selectedFrame?.close();
761
+ throw error;
762
+ }
763
+ finally {
764
+ decoder?.close();
765
+ if (decoderCounted)
766
+ activeVideoDecoders -= 1;
767
+ input.dispose();
768
+ }
769
+ }