From 608c769a7ebd834b6b1407b8564ec3adc0966a0d Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Sun, 21 Sep 2025 20:18:59 +0200 Subject: [PATCH] Add explicit disposability to Inputs (#126) --- dev/demux.html | 11 +- docs/guide/reading-media-files.md | 30 ++++ scripts/generate-api-docs.ts | 198 +++++++++++++++++++-- src/adts/adts-demuxer.ts | 2 +- src/flac/flac-demuxer.ts | 2 +- src/index.ts | 1 + src/input-track.ts | 14 +- src/input.ts | 61 ++++++- src/isobmff/isobmff-demuxer.ts | 4 +- src/matroska/matroska-demuxer.ts | 4 +- src/media-sink.ts | 113 ++++++++---- src/mp3/mp3-demuxer.ts | 2 +- src/ogg/ogg-demuxer.ts | 2 +- src/reader.ts | 9 + src/source.ts | 96 ++++++---- src/tags.ts | 6 +- src/wave/wave-demuxer.ts | 2 +- test/browser/flac.test.ts | 4 +- test/browser/url-source-short-file.test.ts | 2 +- test/node/flac.test.ts | 10 +- test/node/metadata-tags.test.ts | 20 +-- test/node/read-mp4.test.ts | 2 +- 22 files changed, 479 insertions(+), 116 deletions(-) diff --git a/dev/demux.html b/dev/demux.html index 53817d5..799b92f 100644 --- a/dev/demux.html +++ b/dev/demux.html @@ -14,7 +14,16 @@ source: new Mediabunny.BlobSource(file), }); - console.log(await input.getMetadataTags()); + window.doDispose = () => input.dispose(); + + const videoTrack = await input.getPrimaryVideoTrack(); + const sink = new Mediabunny.EncodedPacketSink(videoTrack); + + for await (const sample of sink.packets()) { + console.log(sample.timestamp) + //sample.close(); + await new Promise(resolve => setTimeout(resolve, 0)) + } /* console.log(await input.computeDuration()); diff --git a/docs/guide/reading-media-files.md b/docs/guide/reading-media-files.md index dc8fd42..8cbc1ef 100644 --- a/docs/guide/reading-media-files.md +++ b/docs/guide/reading-media-files.md @@ -394,6 +394,29 @@ await decoder.flush(); As you can see, media sinks are incredibly versatile and allow for efficient, sparse reading of media data within the input file. +## Disposing inputs + +When you're not using them anymore, `Input` instances will automatically get cleaned up by garbage collection. However, sometimes you may want to prematurely dispose of an `Input` and all its connected resources, or cancel any ongoing operation. You can do it like so: + +```ts +input.dispose(); +``` + +When an `Input` is disposed, ongoing read operations will be canceled, all future read operations will fail, any open decoders will be closed, and all ongoing media sink operations will be canceled. Disallowed and canceled operations will throw an `InputDisposedError`. + +You are expected not to use an `Input` after disposing it. While some operations may still work, it is not specified and may change in any future update. + +`Input` also implements `Disposable`, meaning you can use it with JavaScript Explicit Resource Management features such as the [`using` keyword](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/using): +```ts +{ + using input = new Input(...); + + // `input` will automatically be disposed +} +``` + +Using `using` is recommended over `const` if you only need the `Input` momentarily within a single scope. + ## Input sources The _input source_ determines where the `Input` reads data from. @@ -511,6 +534,10 @@ type FilePathSourceOptions = { }; ``` +::: warning +When using this source, make sure to manually [dispose of the Input](#disposing-inputs) when you are done with it to properly close the internal file handle held by this source. +::: + ### `StreamSource` This is a general-purpose input source you can use to read data from anywhere. @@ -540,6 +567,7 @@ The options of `StreamSource` have the following type: type StreamSourceOptions = { getSize: () => MaybePromise; read: (start: number, end: number) => MaybePromise>; + dispose?: () => unknown; maxCacheSize?: number; prefetchProfile?: 'none' | 'fileSystem' | 'network'; }; @@ -551,6 +579,8 @@ type MaybePromise = T | Promise; Called when the size of the entire file is requested. Must return or resolve to the size in bytes. This function is guaranteed to be called before `read`. - `read`\ Called when data is requested. Must return or resolve to the bytes from the specified byte range, or a stream that yields these bytes. +- `dispose`\ + Called when the `Input` driven by this source is disposed. - `maxCacheSize`\ The maximum number of bytes the cache is allowed to hold in memory. Defaults to 8 MiB. - `prefetchProfile`\ diff --git a/scripts/generate-api-docs.ts b/scripts/generate-api-docs.ts index 7205b16..250df45 100644 --- a/scripts/generate-api-docs.ts +++ b/scripts/generate-api-docs.ts @@ -157,8 +157,70 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) // Helper to find all potential type references in a type string const findAllTypeReferences = (typeString: string): string[] => { - // Match PascalCase identifiers that could be type names - const matches = typeString.match(/\b[A-Z][a-zA-Z0-9_]*\b/g) || []; + const matches: string[] = []; + let i = 0; + + while (i < typeString.length) { + const char = typeString[i]!; + + // Skip string literals (both single and double quotes) + if (char === '"' || char === '\'') { + const quote = char; + i++; // Skip opening quote + + // Find closing quote, handling escaped quotes + while (i < typeString.length) { + if (typeString[i] === '\\') { + i += 2; // Skip escaped character + } else if (typeString[i] === quote) { + i++; // Skip closing quote + break; + } else { + i++; + } + } + continue; + } + + // Skip template literals + if (char === '`') { + i++; // Skip opening backtick + while (i < typeString.length && typeString[i] !== '`') { + if (typeString[i] === '\\') { + i += 2; // Skip escaped character + } else { + i++; + } + } + if (i < typeString.length) i++; // Skip closing backtick + continue; + } + + // Check for PascalCase identifier at current position + if (/[A-Z]/.test(char)) { + let match = ''; + let j = i; + + // Collect the full identifier + while (j < typeString.length && /[a-zA-Z0-9_]/.test(typeString[j]!)) { + match += typeString[j]; + j++; + } + + // Ensure it's a word boundary (not part of a larger word) + const prevChar = i > 0 ? typeString[i - 1]! : ' '; + const nextChar = j < typeString.length ? typeString[j]! : ' '; + + if (!/[a-zA-Z0-9_]/.test(prevChar) && !/[a-zA-Z0-9_]/.test(nextChar)) { + matches.push(match); + } + + i = j; + } else { + i++; + } + } + return [...new Set(matches)]; // Remove duplicates }; @@ -167,6 +229,96 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) return references.filter(ref => exportedTypes.has(ref) && ref !== currentTypeName); }; + // Helper to split union types while respecting bracket depth and string literals + const splitUnionType = (typeString: string): string[] => { + const parts: string[] = []; + let current = ''; + let depth = 0; + let i = 0; + + while (i < typeString.length) { + const char = typeString[i]; + + // Skip string literals (both single and double quotes) + if (char === '"' || char === '\'') { + const quote = char; + current += char; + i++; // Skip opening quote + + // Find closing quote, handling escaped quotes + while (i < typeString.length) { + current += typeString[i]; + if (typeString[i] === '\\') { + i++; // Skip escaped character + if (i < typeString.length) { + current += typeString[i]; + i++; + } + } else if (typeString[i] === quote) { + i++; // Skip closing quote + break; + } else { + i++; + } + } + continue; + } + + // Skip template literals + if (char === '`') { + current += char; + i++; // Skip opening backtick + while (i < typeString.length && typeString[i] !== '`') { + current += typeString[i]; + if (typeString[i] === '\\') { + i++; // Skip escaped character + if (i < typeString.length) { + current += typeString[i]; + } + } + i++; + } + if (i < typeString.length) { + current += typeString[i]; // Add closing backtick + i++; + } + continue; + } + + // Track bracket depth + if (char === '(' || char === '{' || char === '[' || char === '<') { + depth++; + current += char; + } else if (char === ')' || char === '}' || char === ']' || char === '>') { + depth--; + current += char; + } else if (char === '|' && depth === 0) { + // Found a top-level union separator + // Check if it's part of ' | ' pattern + if (i > 0 && typeString[i - 1] === ' ' && i < typeString.length - 1 && typeString[i + 1] === ' ') { + // This is a union separator + parts.push(current.trim()); + current = ''; + i += 2; // Skip ' | ' + continue; + } else { + // Just a pipe character, not a union separator + current += char; + } + } else { + current += char; + } + i++; + } + + // Add the final part + if (current.trim()) { + parts.push(current.trim()); + } + + return parts.length > 1 ? parts : [typeString]; + }; + // Helper to process {@link} tags in JSDoc comments const processLinkTags = (text: string, currentTypeName?: string): string => { // Updated regex to handle member links and optional link text, e.g., {@link Type.member | text} @@ -319,8 +471,10 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) // Format long union types with line breaks if they exceed 80 characters // Only apply to top-level unions, not unions nested within intersections, object types, or generic types if (cleanedType.includes(' | ') && cleanedType.length > 80 && !cleanedType.includes('&') && !cleanedType.includes('{') && !cleanedType.includes('<')) { - const unionMembers = cleanedType.split(' | '); - cleanedType = '\n\t| ' + unionMembers.join('\n\t| '); + const unionMembers = splitUnionType(cleanedType); + if (unionMembers.length > 1) { + cleanedType = '\n\t| ' + unionMembers.join('\n\t| '); + } } return cleanedType; @@ -614,6 +768,7 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) const staticMethods: string[] = []; let constructor: string | null = null; let extendsClause = ''; + let implementsClause = ''; let typeParameters: string | null = null; // Get class description from JSDoc (or from superclass if none) @@ -729,11 +884,32 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) ); if (extendsClauseNode && extendsClauseNode.types[0]) { const superClassName = extendsClauseNode.types[0].expression.getText(); - extendsClause = `\n\n**Extends:** [\`${superClassName}\`](./${superClassName}.md)\n`; - - // In tandem: update usage map if (exportedTypes.has(superClassName)) { + extendsClause = `\n\n**Extends:** [\`${superClassName}\`](./${superClassName}.md)\n`; + // In tandem: update usage map addUsage(superClassName, className, className, 'extends'); + } else { + extendsClause = `\n\n**Extends:** \`${superClassName}\`\n`; + } + } + + // Check for implements clause (only for classes) + if (ts.isClassDeclaration(declaration)) { + const implementsClauseNode = declaration.heritageClauses.find( + clause => clause.token === ts.SyntaxKind.ImplementsKeyword, + ); + if (implementsClauseNode && implementsClauseNode.types.length > 0) { + const implementedInterfaces = implementsClauseNode.types.map((type) => { + const interfaceName = type.expression.getText(); + if (exportedTypes.has(interfaceName)) { + // In tandem: update usage map + addUsage(interfaceName, className, className, 'extends'); + return `[\`${interfaceName}\`](./${interfaceName}.md)`; + } else { + return `\`${interfaceName}\``; + } + }); + implementsClause = `\n\n**Implements:** ${implementedInterfaces.join(', ')}\n`; } } } @@ -1351,7 +1527,7 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) markdown += `\n\n`; } - markdown += `# ${className}\n\n${description ? `${description}\n` : ''}${extendsClause}`; + markdown += `# ${className}\n\n${description ? `${description}\n` : ''}${extendsClause}${implementsClause}`; // Add subclasses section for classes that have subclasses if (ts.isClassDeclaration(declaration) && classHierarchy.has(className)) { @@ -1445,8 +1621,10 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) // Convert string literals from double quotes to single quotes typeText = typeText.replace(/"([^"]*)"/g, '\'$1\''); if (typeText.includes(' | ')) { - const unionMembers = typeText.split(' | '); - typeText = '\n\t| ' + unionMembers.join('\n\t| '); + const unionMembers = splitUnionType(typeText); + if (unionMembers.length > 1) { + typeText = '\n\t| ' + unionMembers.join('\n\t| '); + } } } else { // For complex types, use the original text diff --git a/src/adts/adts-demuxer.ts b/src/adts/adts-demuxer.ts index 5985df0..209365e 100644 --- a/src/adts/adts-demuxer.ts +++ b/src/adts/adts-demuxer.ts @@ -63,7 +63,7 @@ export class AdtsDemuxer extends Demuxer { assert(this.firstFrameHeader); // Create the single audio track - this.tracks = [new InputAudioTrack(new AdtsAudioTrackBacking(this))]; + this.tracks = [new InputAudioTrack(this.input, new AdtsAudioTrackBacking(this))]; })(); } diff --git a/src/flac/flac-demuxer.ts b/src/flac/flac-demuxer.ts index f42bafe..bf71ee4 100644 --- a/src/flac/flac-demuxer.ts +++ b/src/flac/flac-demuxer.ts @@ -189,7 +189,7 @@ export class FlacDemuxer extends Demuxer { description, }; - this.track = new InputAudioTrack(new FlacAudioTrackBacking(this)); + this.track = new InputAudioTrack(this.input, new FlacAudioTrackBacking(this)); break; } case FlacBlockType.VORBIS_COMMENT: { diff --git a/src/index.ts b/src/index.ts index 9e6e655..9ec8163 100644 --- a/src/index.ts +++ b/src/index.ts @@ -147,6 +147,7 @@ export { export { Input, InputOptions, + InputDisposedError, } from './input'; export { InputTrack, diff --git a/src/input-track.ts b/src/input-track.ts index 7f3e56e..e0680cd 100644 --- a/src/input-track.ts +++ b/src/input-track.ts @@ -9,6 +9,7 @@ import { AudioCodec, MediaCodec, VideoCodec } from './codec'; import { determineVideoPacketType } from './codec-data'; import { customAudioDecoders, customVideoDecoders } from './custom-coder'; +import { Input } from './input'; import { EncodedPacketSink, PacketRetrievalOptions } from './media-sink'; import { assert, Rotation } from './misc'; import { TrackType } from './output'; @@ -51,11 +52,14 @@ export interface InputTrackBacking { * @public */ export abstract class InputTrack { + /** The input file this track belongs to. */ + readonly input: Input; /** @internal */ _backing: InputTrackBacking; /** @internal */ - constructor(backing: InputTrackBacking) { + constructor(input: Input, backing: InputTrackBacking) { + this.input = input; this._backing = backing; } @@ -201,8 +205,8 @@ export class InputVideoTrack extends InputTrack { override _backing: InputVideoTrackBacking; /** @internal */ - constructor(backing: InputVideoTrackBacking) { - super(backing); + constructor(input: Input, backing: InputVideoTrackBacking) { + super(input, backing); this._backing = backing; } @@ -329,8 +333,8 @@ export class InputAudioTrack extends InputTrack { override _backing: InputAudioTrackBacking; /** @internal */ - constructor(backing: InputAudioTrackBacking) { - super(backing); + constructor(input: Input, backing: InputAudioTrackBacking) { + super(input, backing); this._backing = backing; } diff --git a/src/input.ts b/src/input.ts index 1b9a0c0..875aeb6 100644 --- a/src/input.ts +++ b/src/input.ts @@ -24,12 +24,16 @@ export type InputOptions = { source: S; }; +// https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html +// @ts-expect-error Readonly +Symbol.dispose ??= Symbol('Symbol.dispose'); + /** * Represents an input media file. This is the root object from which all media read operations start. * @group Input files & tracks * @public */ -export class Input { +export class Input implements Disposable { /** @internal */ _source: S; /** @internal */ @@ -40,6 +44,13 @@ export class Input { _format: InputFormat | null = null; /** @internal */ _reader: Reader; + /** @internal */ + _disposed = false; + + /** True if the input has been disposed. */ + get disposed() { + return this._disposed; + } /** * Creates a new input file from the specified options. No reading operations will be performed until methods are @@ -55,6 +66,9 @@ export class Input { if (!(options.source instanceof Source)) { throw new TypeError('options.source must be a Source.'); } + if (options.source._disposed) { + throw new Error('options.source must not be disposed.'); + } this._formats = options.formats; this._source = options.source; @@ -142,9 +156,52 @@ export class Input { return demuxer.getMimeType(); } - /** Returns descriptive metadata tags about the media file, such as title, author, date, or cover art. */ + /** + * Returns descriptive metadata tags about the media file, such as title, author, date, cover art, or other + * attached files. + */ async getMetadataTags() { const demuxer = await this._getDemuxer(); return demuxer.getMetadataTags(); } + + /** + * Disposes this input and frees connected resources. When an input is disposed, ongoing read operations will be + * canceled, all future read operations will fail, any open decoders will be closed, and all ongoing media sink + * operations will be canceled. Disallowed and canceled operations will throw an {@link InputDisposedError}. + * + * You are expected not to use an input after disposing it. While some operations may still work, it is not + * specified and may change in any future update. + */ + dispose() { + if (this._disposed) { + return; + } + + this._disposed = true; + + this._source._disposed = true; + this._source._dispose(); + } + + /** + * Calls `.dispose()` on the input, implementing the `Disposable` interface for use with + * JavaScript Explicit Resource Management features. + */ + [Symbol.dispose]() { + this.dispose(); + } +} + +/** + * Thrown when an operation was prevented because the corresponding {@link Input} has been disposed. + * @group Input files & tracks + * @public + */ +export class InputDisposedError extends Error { + /** Creates a new {@link InputDisposedError}. */ + constructor(message = 'Input has been disposed.') { + super(message); + this.name = 'InputDisposedError'; + } } diff --git a/src/isobmff/isobmff-demuxer.ts b/src/isobmff/isobmff-demuxer.ts index 044fa28..d0027f4 100644 --- a/src/isobmff/isobmff-demuxer.ts +++ b/src/isobmff/isobmff-demuxer.ts @@ -710,11 +710,11 @@ export class IsobmffDemuxer extends Demuxer { if (track.id !== -1 && track.timescale !== -1 && track.info !== null) { if (track.info.type === 'video' && track.info.width !== -1) { const videoTrack = track as InternalVideoTrack; - track.inputTrack = new InputVideoTrack(new IsobmffVideoTrackBacking(videoTrack)); + track.inputTrack = new InputVideoTrack(this.input, new IsobmffVideoTrackBacking(videoTrack)); this.tracks.push(track); } else if (track.info.type === 'audio' && track.info.numberOfChannels !== -1) { const audioTrack = track as InternalAudioTrack; - track.inputTrack = new InputAudioTrack(new IsobmffAudioTrackBacking(audioTrack)); + track.inputTrack = new InputAudioTrack(this.input, new IsobmffAudioTrackBacking(audioTrack)); this.tracks.push(track); } } diff --git a/src/matroska/matroska-demuxer.ts b/src/matroska/matroska-demuxer.ts index 06ee690..7f1b030 100644 --- a/src/matroska/matroska-demuxer.ts +++ b/src/matroska/matroska-demuxer.ts @@ -1027,7 +1027,7 @@ export class MatroskaDemuxer extends Demuxer { } const videoTrack = this.currentTrack as InternalVideoTrack; - const inputTrack = new InputVideoTrack(new MatroskaVideoTrackBacking(videoTrack)); + const inputTrack = new InputVideoTrack(this.input, new MatroskaVideoTrackBacking(videoTrack)); this.currentTrack.inputTrack = inputTrack; this.currentSegment.tracks.push(this.currentTrack); } else if ( @@ -1082,7 +1082,7 @@ export class MatroskaDemuxer extends Demuxer { } const audioTrack = this.currentTrack as InternalAudioTrack; - const inputTrack = new InputAudioTrack(new MatroskaAudioTrackBacking(audioTrack)); + const inputTrack = new InputAudioTrack(this.input, new MatroskaAudioTrackBacking(audioTrack)); this.currentTrack.inputTrack = inputTrack; this.currentSegment.tracks.push(this.currentTrack); } diff --git a/src/media-sink.ts b/src/media-sink.ts index 335dfcc..1dc6dc0 100644 --- a/src/media-sink.ts +++ b/src/media-sink.ts @@ -9,6 +9,7 @@ import { parsePcmCodec, PCM_AUDIO_CODECS, PcmAudioCodec, VideoCodec, AudioCodec } from './codec'; import { extractHevcNalUnits, extractNalUnitTypeForHevc, HevcNalUnitType } from './codec-data'; import { CustomVideoDecoder, customVideoDecoders, CustomAudioDecoder, customAudioDecoders } from './custom-coder'; +import { InputDisposedError } from './input'; import { InputAudioTrack, InputTrack, InputVideoTrack } from './input-track'; import { AnyIterable, @@ -123,6 +124,10 @@ export class EncodedPacketSink { getFirstPacket(options: PacketRetrievalOptions = {}) { validatePacketRetrievalOptions(options); + if (this._track.input._disposed) { + throw new InputDisposedError(); + } + return maybeFixPacketType(this._track, this._track._backing.getFirstPacket(options), options); } @@ -138,6 +143,10 @@ export class EncodedPacketSink { validateTimestamp(timestamp); validatePacketRetrievalOptions(options); + if (this._track.input._disposed) { + throw new InputDisposedError(); + } + return maybeFixPacketType(this._track, this._track._backing.getPacket(timestamp, options), options); } @@ -151,6 +160,10 @@ export class EncodedPacketSink { } validatePacketRetrievalOptions(options); + if (this._track.input._disposed) { + throw new InputDisposedError(); + } + return maybeFixPacketType(this._track, this._track._backing.getNextPacket(packet, options), options); } @@ -169,6 +182,10 @@ export class EncodedPacketSink { validateTimestamp(timestamp); validatePacketRetrievalOptions(options); + if (this._track.input._disposed) { + throw new InputDisposedError(); + } + if (!options.verifyKeyPackets) { return this._track._backing.getKeyPacket(timestamp, options); } @@ -199,6 +216,10 @@ export class EncodedPacketSink { } validatePacketRetrievalOptions(options); + if (this._track.input._disposed) { + throw new InputDisposedError(); + } + if (!options.verifyKeyPackets) { return this._track._backing.getNextKeyPacket(packet, options); } @@ -240,6 +261,10 @@ export class EncodedPacketSink { } validatePacketRetrievalOptions(options); + if (this._track.input._disposed) { + throw new InputDisposedError(); + } + const packetQueue: EncodedPacket[] = []; let { promise: queueNotEmpty, resolve: onQueueNotEmpty } = promiseWithResolvers(); @@ -260,7 +285,7 @@ export class EncodedPacketSink { (async () => { let packet = startPacket ?? await this.getFirstPacket(options); - while (packet && !terminated) { + while (packet && !terminated && !this._track.input._disposed) { if (endPacket && packet.sequenceNumber >= endPacket?.sequenceNumber) { break; } @@ -288,10 +313,14 @@ export class EncodedPacketSink { } }); + const track = this._track; + return { async next() { while (true) { - if (terminated) { + if (track.input._disposed) { + throw new InputDisposedError(); + } else if (terminated) { return { value: undefined, done: true }; } else if (outOfBandError) { throw outOfBandError; @@ -353,6 +382,9 @@ abstract class DecoderWrapper< export abstract class BaseMediaSampleSink< MediaSample extends VideoSample | AudioSample, > { + /** @internal */ + abstract _track: InputTrack; + /** @internal */ abstract _createDecoder( onSample: (sample: MediaSample) => unknown, @@ -459,7 +491,7 @@ export abstract class BaseMediaSampleSink< const packets = packetSink.packets(keyPacket, endPacket); await packets.next(); // Skip the start packet as we already have it - while (currentPacket && !ended) { + while (currentPacket && !ended && !this._track.input._disposed) { const maxQueueSize = computeMaxQueueSize(sampleQueue.length); if (sampleQueue.length + decoder.getDecodeQueueSize() > maxQueueSize) { ({ promise: queueDequeue, resolve: onQueueDequeue } = promiseWithResolvers()); @@ -479,7 +511,9 @@ export abstract class BaseMediaSampleSink< await packets.return(); - if (!terminated) await decoder.flush(); + if (!terminated && !this._track.input._disposed) { + await decoder.flush(); + } decoder.close(); if (!firstSampleQueued && lastSample) { @@ -495,12 +529,24 @@ export abstract class BaseMediaSampleSink< } }); + const track = this._track; + const closeSamples = () => { + lastSample?.close(); + for (const sample of sampleQueue) { + sample.close(); + } + }; + return { async next() { while (true) { - if (terminated) { + if (track.input._disposed) { + closeSamples(); + throw new InputDisposedError(); + } else if (terminated) { return { value: undefined, done: true }; } else if (outOfBandError) { + closeSamples(); throw outOfBandError; } else if (sampleQueue.length > 0) { const value = sampleQueue.shift()!; @@ -518,12 +564,7 @@ export abstract class BaseMediaSampleSink< ended = true; onQueueDequeue(); onQueueNotEmpty(); - - lastSample?.close(); - - for (const sample of sampleQueue) { - sample.close(); - } + closeSamples(); return { value: undefined, done: true }; }, @@ -647,7 +688,7 @@ export abstract class BaseMediaSampleSink< for await (const timestamp of timestampIterator) { validateTimestamp(timestamp); - if (terminated) { + if (terminated || this._track.input._disposed) { break; } @@ -684,7 +725,7 @@ export abstract class BaseMediaSampleSink< lastKeyPacket = keyPacket; } - if (!terminated) { + if (!terminated && !this._track.input._disposed) { if (maxSequenceNumber !== -1) { // We still need to decode packets await decodePackets(); @@ -703,12 +744,23 @@ export abstract class BaseMediaSampleSink< } }); + const track = this._track; + const closeSamples = () => { + for (const sample of sampleQueue) { + sample?.close(); + } + }; + return { async next() { while (true) { - if (terminated) { + if (track.input._disposed) { + closeSamples(); + throw new InputDisposedError(); + } else if (terminated) { return { value: undefined, done: true }; } else if (outOfBandError) { + closeSamples(); throw outOfBandError; } else if (sampleQueue.length > 0) { const value = sampleQueue.shift(); @@ -726,10 +778,7 @@ export abstract class BaseMediaSampleSink< terminated = true; onQueueDequeue(); onQueueNotEmpty(); - - for (const sample of sampleQueue) { - sample?.close(); - } + closeSamples(); return { value: undefined, done: true }; }, @@ -930,7 +979,7 @@ class VideoDecoderWrapper extends DecoderWrapper { */ export class VideoSampleSink extends BaseMediaSampleSink { /** @internal */ - _videoTrack: InputVideoTrack; + _track: InputVideoTrack; /** Creates a new {@link VideoSampleSink} for the given {@link InputVideoTrack}. */ constructor(videoTrack: InputVideoTrack) { @@ -940,7 +989,7 @@ export class VideoSampleSink extends BaseMediaSampleSink { super(); - this._videoTrack = videoTrack; + this._track = videoTrack; } /** @internal */ @@ -948,17 +997,17 @@ export class VideoSampleSink extends BaseMediaSampleSink { onSample: (sample: VideoSample) => unknown, onError: (error: DOMException) => unknown, ) { - if (!(await this._videoTrack.canDecode())) { + if (!(await this._track.canDecode())) { throw new Error( 'This video track cannot be decoded by this browser. Make sure to check decodability before using' + ' a track.', ); } - const codec = this._videoTrack.codec; - const rotation = this._videoTrack.rotation; - const decoderConfig = await this._videoTrack.getDecoderConfig(); - const timeResolution = this._videoTrack.timeResolution; + const codec = this._track.codec; + const rotation = this._track.rotation; + const decoderConfig = await this._track.getDecoderConfig(); + const timeResolution = this._track.timeResolution; assert(codec && decoderConfig); return new VideoDecoderWrapper(onSample, onError, codec, decoderConfig, rotation, timeResolution); @@ -966,7 +1015,7 @@ export class VideoSampleSink extends BaseMediaSampleSink { /** @internal */ _createPacketSink() { - return new EncodedPacketSink(this._videoTrack); + return new EncodedPacketSink(this._track); } /** @@ -1580,7 +1629,7 @@ class PcmAudioDecoderWrapper extends DecoderWrapper { */ export class AudioSampleSink extends BaseMediaSampleSink { /** @internal */ - _audioTrack: InputAudioTrack; + _track: InputAudioTrack; /** Creates a new {@link AudioSampleSink} for the given {@link InputAudioTrack}. */ constructor(audioTrack: InputAudioTrack) { @@ -1590,7 +1639,7 @@ export class AudioSampleSink extends BaseMediaSampleSink { super(); - this._audioTrack = audioTrack; + this._track = audioTrack; } /** @internal */ @@ -1598,15 +1647,15 @@ export class AudioSampleSink extends BaseMediaSampleSink { onSample: (sample: AudioSample) => unknown, onError: (error: DOMException) => unknown, ) { - if (!(await this._audioTrack.canDecode())) { + if (!(await this._track.canDecode())) { throw new Error( 'This audio track cannot be decoded by this browser. Make sure to check decodability before using' + ' a track.', ); } - const codec = this._audioTrack.codec; - const decoderConfig = await this._audioTrack.getDecoderConfig(); + const codec = this._track.codec; + const decoderConfig = await this._track.getDecoderConfig(); assert(codec && decoderConfig); if ((PCM_AUDIO_CODECS as readonly string[]).includes(decoderConfig.codec)) { @@ -1618,7 +1667,7 @@ export class AudioSampleSink extends BaseMediaSampleSink { /** @internal */ _createPacketSink() { - return new EncodedPacketSink(this._audioTrack); + return new EncodedPacketSink(this._track); } /** diff --git a/src/mp3/mp3-demuxer.ts b/src/mp3/mp3-demuxer.ts index b467c47..5d3c1a4 100644 --- a/src/mp3/mp3-demuxer.ts +++ b/src/mp3/mp3-demuxer.ts @@ -64,7 +64,7 @@ export class Mp3Demuxer extends Demuxer { throw new Error('No valid MP3 frame found.'); } - this.tracks = [new InputAudioTrack(new Mp3AudioTrackBacking(this))]; + this.tracks = [new InputAudioTrack(this.input, new Mp3AudioTrackBacking(this))]; })(); } diff --git a/src/ogg/ogg-demuxer.ts b/src/ogg/ogg-demuxer.ts index 1675f9f..495865d 100644 --- a/src/ogg/ogg-demuxer.ts +++ b/src/ogg/ogg-demuxer.ts @@ -138,7 +138,7 @@ export class OggDemuxer extends Demuxer { } if (bitstream.codecInfo.codec !== null) { - this.tracks.push(new InputAudioTrack(new OggAudioTrackBacking(bitstream, this))); + this.tracks.push(new InputAudioTrack(this.input, new OggAudioTrackBacking(bitstream, this))); } } })(); diff --git a/src/reader.ts b/src/reader.ts index 2cb5c00..5624343 100644 --- a/src/reader.ts +++ b/src/reader.ts @@ -6,6 +6,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { InputDisposedError } from './input'; import { assert, clamp, getUint24, MaybePromise, toDataView } from './misc'; import { Source } from './source'; @@ -15,6 +16,10 @@ export class Reader { constructor(public source: Source) {} requestSlice(start: number, length: number): MaybePromise { + if (this.source._disposed) { + throw new InputDisposedError(); + } + if (this.fileSize !== null && start + length > this.fileSize) { return null; } @@ -40,6 +45,10 @@ export class Reader { } requestSliceRange(start: number, minLength: number, maxLength: number): MaybePromise { + if (this.source._disposed) { + throw new InputDisposedError(); + } + if (this.fileSize !== null) { return this.requestSlice( start, diff --git a/src/source.ts b/src/source.ts index 6f65adb..28876ce 100644 --- a/src/source.ts +++ b/src/source.ts @@ -19,6 +19,7 @@ import { toUint8Array, } from './misc'; import * as nodeAlias from './node'; +import { InputDisposedError } from './input'; const node = nodeAlias; // Aliasing it prevents some bundler warnings @@ -40,7 +41,9 @@ export abstract class Source { /** @internal */ abstract _read(start: number, end: number): MaybePromise; /** @internal */ - abstract get _supportsRandomAccess(): boolean; + abstract _dispose(): void; + /** @internal */ + _disposed = false; /** @internal */ private _sizePromise: Promise | null = null; @@ -52,6 +55,10 @@ export abstract class Source { * Returns null if the source is unsized. */ async getSizeOrNull() { + if (this._disposed) { + throw new InputDisposedError(); + } + return this._sizePromise ??= Promise.resolve(this._retrieveSize()); } @@ -62,6 +69,10 @@ export abstract class Source { * Throws an error if the source is unsized. */ async getSize() { + if (this._disposed) { + throw new InputDisposedError(); + } + const result = await this.getSizeOrNull(); if (result === null) { throw new Error('Cannot determine the size of an unsized source.'); @@ -120,9 +131,7 @@ export class BufferSource extends Source { } /** @internal */ - get _supportsRandomAccess() { - return true; - } + _dispose() {} } /** @@ -222,8 +231,8 @@ export class BlobSource extends Source { } /** @internal */ - get _supportsRandomAccess() { - return true; + _dispose() { + this._orchestrator.dispose(); } } @@ -431,6 +440,13 @@ export class UrlSource extends Source { const reader = response.body.getReader(); while (true) { + if (worker.currentPos >= worker.targetPos || worker.aborted) { + abortController.abort(); + worker.running = false; + + return; + } + let readResult: ReadableStreamReadResult; try { @@ -464,13 +480,6 @@ export class UrlSource extends Source { this.onread?.(worker.currentPos, worker.currentPos + value.length); this._orchestrator.supplyWorkerData(worker, value); - - if (worker.currentPos >= worker.targetPos || worker.aborted) { - abortController.abort(); - - worker.running = false; - return; - } } } @@ -505,8 +514,8 @@ export class UrlSource extends Source { } /** @internal */ - get _supportsRandomAccess() { - return true; + _dispose() { + this._orchestrator.dispose(); } } @@ -528,6 +537,8 @@ export type FilePathSourceOptions = { export class FilePathSource extends Source { /** @internal */ _streamSource: StreamSource; + /** @internal */ + _fileHandle: FileHandle | null = null; /** Creates a new {@link FilePathSource} backed by the file at the specified file path. */ constructor(filePath: string, options: BlobSourceOptions = {}) { @@ -546,21 +557,19 @@ export class FilePathSource extends Source { super(); - let fileHandle: FileHandle | null = null; - // Let's back this source with a StreamSource, makes the implementation very simple this._streamSource = new StreamSource({ getSize: async () => { - fileHandle = await node.fs.open(filePath, 'r'); + this._fileHandle = await node.fs.open(filePath, 'r'); - const stats = await fileHandle.stat(); + const stats = await this._fileHandle.stat(); return stats.size; }, read: async (start, end) => { - assert(fileHandle); + assert(this._fileHandle); const buffer = new Uint8Array(end - start); - await fileHandle.read(buffer, 0, end - start, start); + await this._fileHandle.read(buffer, 0, end - start, start); return buffer; }, @@ -580,8 +589,10 @@ export class FilePathSource extends Source { } /** @internal */ - get _supportsRandomAccess() { - return true; + _dispose() { + this._streamSource._dispose(); + void this._fileHandle?.close(); + this._fileHandle = null; } } @@ -603,6 +614,11 @@ export type StreamSourceOptions = { */ read: (start: number, end: number) => MaybePromise>; + /** + * Called when the {@link Input} driven by this source is disposed. + */ + dispose?: () => unknown; + /** The maximum number of bytes the cache is allowed to hold in memory. Defaults to 8 MiB. */ maxCacheSize?: number; @@ -636,11 +652,14 @@ export class StreamSource extends Source { if (!options || typeof options !== 'object') { throw new TypeError('options must be an object.'); } + if (typeof options.getSize !== 'function') { + throw new TypeError('options.getSize must be a function.'); + } if (typeof options.read !== 'function') { throw new TypeError('options.read must be a function.'); } - if (typeof options.getSize !== 'function') { - throw new TypeError('options.getSize must be a function.'); + if (options.dispose !== undefined && typeof options.dispose !== 'function') { + throw new TypeError('options.dispose, when provided, must be a function.'); } if ( options.maxCacheSize !== undefined @@ -718,7 +737,7 @@ export class StreamSource extends Source { } else if (data instanceof ReadableStream) { const reader = data.getReader(); - while (true) { + while (worker.currentPos < originalTargetPos && !worker.aborted) { const { done, value } = await reader.read(); if (done) { if (worker.currentPos < originalTargetPos) { @@ -740,10 +759,6 @@ export class StreamSource extends Source { this.onread?.(worker.currentPos, worker.currentPos + value.length); this._orchestrator.supplyWorkerData(worker, value); - - if (worker.currentPos >= originalTargetPos || worker.aborted) { - break; - } } } else { throw new TypeError('options.read must return or resolve to a Uint8Array or a ReadableStream.'); @@ -754,8 +769,9 @@ export class StreamSource extends Source { } /** @internal */ - get _supportsRandomAccess() { - return true; + _dispose() { + this._orchestrator.dispose(); + this._options.dispose?.(); } } @@ -949,7 +965,7 @@ export class ReadableStreamSource extends Source { // This is the loop that keeps pulling data from the stream until a target index is reached, filling requests // in the process - while (this._currentIndex < this._targetIndex) { + while (this._currentIndex < this._targetIndex && !this._disposed) { const { done, value } = await this._reader.read(); if (done) { for (const pendingSlice of this._pendingSlices) { @@ -1018,8 +1034,9 @@ export class ReadableStreamSource extends Source { } /** @internal */ - get _supportsRandomAccess() { - return false; + _dispose() { + this._pendingSlices.length = 0; + this._cache.length = 0; } } @@ -1542,4 +1559,13 @@ class ReadOrchestrator { this.currentCacheSize -= oldestEntry.bytes.length; } } + + dispose() { + for (const worker of this.workers) { + worker.aborted = true; + } + + this.workers.length = 0; + this.cache.length = 0; + } } diff --git a/src/tags.ts b/src/tags.ts index 4e53a92..cc58e97 100644 --- a/src/tags.ts +++ b/src/tags.ts @@ -7,9 +7,9 @@ */ /** - * Represents descriptive (non-technical) metadata about a media file, such as title, author, date, or cover art. - * Common tags are normalized by Mediabunny into a uniform format, while the `raw` field can be used to directly read or - * write the underlying metadata tags (which differ by format). + * Represents descriptive (non-technical) metadata about a media file, such as title, author, date, cover art, or other + * attached files. Common tags are normalized by Mediabunny into a uniform format, while the `raw` field can be used to + * directly read or write the underlying metadata tags (which differ by format). * * - For MP4/QuickTime files, the metadata refers to the data in `'moov'`-level `'udta'` and `'meta'` atoms. * - For Matroska files, the metadata refers to the Tags and Attachments elements whose target is 50 (MOVIE). diff --git a/src/wave/wave-demuxer.ts b/src/wave/wave-demuxer.ts index 974cf25..92669d3 100644 --- a/src/wave/wave-demuxer.ts +++ b/src/wave/wave-demuxer.ts @@ -123,7 +123,7 @@ export class WaveDemuxer extends Demuxer { const blockSize = this.audioInfo.blockSizeInBytes; this.dataSize = Math.floor(this.dataSize / blockSize) * blockSize; - this.tracks.push(new InputAudioTrack(new WaveAudioTrackBacking(this))); + this.tracks.push(new InputAudioTrack(this.input, new WaveAudioTrackBacking(this))); })(); } diff --git a/test/browser/flac.test.ts b/test/browser/flac.test.ts index 6bb17b3..2aed0e3 100644 --- a/test/browser/flac.test.ts +++ b/test/browser/flac.test.ts @@ -10,7 +10,7 @@ import { BufferTarget } from '../../src/target.js'; import { Conversion } from '../../src/conversion.js'; test('can decode samples from a FLAC file', async () => { - const input = new Input({ + using input = new Input({ source: new UrlSource('/sample.flac'), formats: [FLAC], }); @@ -25,7 +25,7 @@ test('can decode samples from a FLAC file', async () => { }); test('can convert a .flac to .wav', async () => { - const input = new Input({ + using input = new Input({ source: new UrlSource('/sample.flac'), formats: [FLAC], }); diff --git a/test/browser/url-source-short-file.test.ts b/test/browser/url-source-short-file.test.ts index d9d003a..d610223 100644 --- a/test/browser/url-source-short-file.test.ts +++ b/test/browser/url-source-short-file.test.ts @@ -5,7 +5,7 @@ import { Input } from '../../src/input.js'; test('Should be able to load a very small video file via URL (<512 kB)', async () => { const source = new UrlSource('/frames.webm'); - const input = new Input({ + using input = new Input({ source, formats: ALL_FORMATS, }); diff --git a/test/node/flac.test.ts b/test/node/flac.test.ts index c7e6000..a7efd4e 100644 --- a/test/node/flac.test.ts +++ b/test/node/flac.test.ts @@ -14,7 +14,7 @@ const __dirname = new URL('.', import.meta.url).pathname; test('can loop over all samples', async () => { const filePath = path.join(__dirname, '..', 'public/sample.flac'); - const input = new Input({ + using input = new Input({ source: new FilePathSource(filePath), formats: ALL_FORMATS, }); @@ -56,7 +56,7 @@ test('can loop over all samples', async () => { test('can do random access', async () => { const filePath = path.join(__dirname, '..', 'public/sample.flac'); - const input = new Input({ + using input = new Input({ source: new FilePathSource(filePath), formats: ALL_FORMATS, }); @@ -89,7 +89,7 @@ test('can do random access', async () => { test('can get metadata-only packets', async () => { const filePath = path.join(__dirname, '..', 'public/sample.flac'); - const input = new Input({ + using input = new Input({ source: new FilePathSource(filePath), formats: ALL_FORMATS, }); @@ -108,7 +108,7 @@ test('can get metadata-only packets', async () => { test('can get metadata', async () => { const filePath = path.join(__dirname, '..', 'public/sample.flac'); - const input = new Input({ + using input = new Input({ source: new FilePathSource(filePath), formats: ALL_FORMATS, }); @@ -145,7 +145,7 @@ test('can get metadata', async () => { test('can re-mux a .flac', async () => { const filePath = path.join(__dirname, '..', 'public/sample.flac'); - const input = new Input({ + using input = new Input({ source: new FilePathSource(filePath), formats: ALL_FORMATS, }); diff --git a/test/node/metadata-tags.test.ts b/test/node/metadata-tags.test.ts index 6957ec6..ee8a64f 100644 --- a/test/node/metadata-tags.test.ts +++ b/test/node/metadata-tags.test.ts @@ -107,7 +107,7 @@ test('Read and write metadata, MP4', async () => { await dummyTrack.addPacket(); await output.finalize(); - const input = new Input({ + using input = new Input({ source: new BufferSource(output.target.buffer!), formats: ALL_FORMATS, }); @@ -156,7 +156,7 @@ test('Read and write metadata, QuickTime', async () => { await dummyTrack.addPacket(); await output.finalize(); - const input = new Input({ + using input = new Input({ source: new BufferSource(output.target.buffer!), formats: ALL_FORMATS, }); @@ -187,7 +187,7 @@ test('Read and write metadata, QuickTime', async () => { }); test('Read MOV metadata tags, ilst with keys', async () => { - const input = new Input({ + using input = new Input({ source: new FilePathSource(path.join(__dirname, '../public/trunc-buck-bunny.mov')), formats: ALL_FORMATS, }); @@ -231,7 +231,7 @@ test('Read and write metadata, Matroska', async () => { await dummyTrack.addPacket(); await output.finalize(); - const input = new Input({ + using input = new Input({ source: new BufferSource(output.target.buffer!), formats: ALL_FORMATS, }); @@ -294,7 +294,7 @@ test('Read and write metadata, MP3', async () => { await dummyTrack.addPacket(); await output.finalize(); - const input = new Input({ + using input = new Input({ source: new BufferSource(output.target.buffer!), formats: ALL_FORMATS, }); @@ -346,7 +346,7 @@ test('Read and write metadata, Ogg', async () => { await dummyTrack.addPacket(); await output.finalize(); - const input = new Input({ + using input = new Input({ source: new BufferSource(output.target.buffer!), formats: ALL_FORMATS, }); @@ -396,7 +396,7 @@ test('Read and write metadata, FLAC', async () => { await dummyTrack.addPacket(); await output.finalize(); - const input = new Input({ + using input = new Input({ source: new BufferSource(output.target.buffer!), formats: ALL_FORMATS, }); @@ -445,7 +445,7 @@ test('Read and write metadata, WAVE', async () => { await dummyTrack.addPacket(); await output.finalize(); - const input = new Input({ + using input = new Input({ source: new BufferSource(output.target.buffer!), formats: ALL_FORMATS, }); @@ -484,7 +484,7 @@ test('Conversion metadata tags, default case', async () => { await dummyTrack.addPacket(); await output.finalize(); - const input = new Input({ + using input = new Input({ source: new BufferSource(output.target.buffer!), formats: ALL_FORMATS, }); @@ -534,7 +534,7 @@ test('Conversion metadata tags, modified', async () => { await dummyTrack.addPacket(); await output.finalize(); - const input = new Input({ + using input = new Input({ source: new BufferSource(output.target.buffer!), formats: ALL_FORMATS, }); diff --git a/test/node/read-mp4.test.ts b/test/node/read-mp4.test.ts index 1332cc5..d986ed3 100644 --- a/test/node/read-mp4.test.ts +++ b/test/node/read-mp4.test.ts @@ -6,7 +6,7 @@ const __dirname = new URL('.', import.meta.url).pathname; test('Should be able to get packets from a .MP4 file', async () => { const filePath = path.join(__dirname, '..', 'public/video.mp4'); - const input = new Input({ + using input = new Input({ source: new FilePathSource(filePath), formats: ALL_FORMATS, });