UNPKG

mediabunny

Version:

Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.

872 lines (738 loc) 24.2 kB
/*! * Copyright (c) 2026-present, Vanilagy and contributors * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import type { FileHandle } from 'node:fs/promises'; import * as nodeAlias from './node'; import { assert, EventEmitter, FilePath, MaybePromise } from './misc'; const node = typeof nodeAlias !== 'undefined' ? nodeAlias // Aliasing it prevents some bundler warnings : undefined!; /** * The events emitted by a {@link Target}. * @group Output targets * @public */ export type TargetEvents = { /** Emitted each time data is written to the target. */ write: { /** The start of the written range, inclusive. */ start: number; /** The end of the written range, exclusive. */ end: number; }; /** Emitted when the target is finalized. */ finalized: void; }; /** * Base class for targets, specifying where output files are written. * @group Output targets * @public */ export abstract class Target extends EventEmitter<TargetEvents> { /** @internal */ _writerAcquired = false; /** @internal */ _monotonicity: boolean | null = null; // null = unknown /** @internal */ abstract _start(): void; /** @internal */ abstract _write(data: Uint8Array, pos: number): void; /** @internal */ abstract _flush(): Promise<void>; /** @internal */ abstract _finalize(): Promise<void>; /** @internal */ abstract _close(): Promise<void>; /** * Called each time data is written to the target. Will be called with the byte range into which data was written. * * Use this callback to track the size of the output file as it grows. But be warned, this function is chatty and * gets called *extremely* often. * * @deprecated Use `target.on('write', ({ start, end }) => ...)` instead. */ onwrite: ((start: number, end: number) => unknown) | null = null; /** @internal */ _setMonotonicity(monotonicity: boolean) { if (this._monotonicity !== false) { this._monotonicity = monotonicity; } else { // Once false, it's locked } } /** @internal */ _dispatchWrite(start: number, end: number) { // eslint-disable-next-line @typescript-eslint/no-deprecated this.onwrite?.(start, end); this._emit('write', { start, end }); } /** * Returns a new {@link RangedTarget} that writes data to this target using the given offset. * * Useful for writing a file into a section of a larger file. */ slice(offset: number) { if (!Number.isInteger(offset) || offset < 0) { throw new TypeError('offset must be a non-negative integer.'); } return new RangedTarget(this, offset); } } const ARRAY_BUFFER_INITIAL_SIZE = 2 ** 16; const ARRAY_BUFFER_MAX_SIZE = 2 ** 32; /** * Options for {@link BufferTarget}. * @group Output targets * @public */ export type BufferTargetOptions = { /** * Called once the target has been finalized, with the complete output buffer. If you return a promise, it will be * used to apply backpressure internally. * * One use for this callback is for uploading to a server where the full buffer must be known before * sending (e.g. S3 PutObject) and stream-uploading is not an option. */ onFinalize?: (buffer: ArrayBuffer) => MaybePromise<unknown>; }; /** * A target that writes data directly into an ArrayBuffer in memory. Great for performance, but not suitable for very * large files. The buffer will be available once the output has been finalized. * @group Output targets * @public */ export class BufferTarget extends Target { /** Stores the final output buffer. Until the output is finalized, this will be `null`. */ buffer: ArrayBuffer | null = null; /** @internal */ _buffer: ArrayBuffer; /** @internal */ _bytes: Uint8Array; /** @internal */ _maxPos = 0; /** @internal */ _supportsResize: boolean; /** @internal */ _options: BufferTargetOptions; /** Creates a new {@link BufferTarget}. The buffer holding the data will be created and managed internally. */ constructor(options: BufferTargetOptions = {}) { super(); if (!options || typeof options !== 'object') { throw new TypeError('BufferTarget options, when provided, must be an object.'); } if (options.onFinalize !== undefined && typeof options.onFinalize !== 'function') { throw new TypeError('options.onFinalize, when provided, must be a function.'); } this._options = options; this._supportsResize = 'resize' in new ArrayBuffer(0); if (this._supportsResize) { try { // @ts-expect-error Don't want to bump "lib" in tsconfig this._buffer = new ArrayBuffer(ARRAY_BUFFER_INITIAL_SIZE, { maxByteLength: ARRAY_BUFFER_MAX_SIZE }); } catch { this._buffer = new ArrayBuffer(ARRAY_BUFFER_INITIAL_SIZE); this._supportsResize = false; } } else { this._buffer = new ArrayBuffer(ARRAY_BUFFER_INITIAL_SIZE); } this._bytes = new Uint8Array(this._buffer); } /** @internal */ _ensureSize(size: number) { let newLength = this._buffer.byteLength; while (newLength < size) newLength *= 2; if (newLength === this._buffer.byteLength) return; if (newLength > ARRAY_BUFFER_MAX_SIZE) { throw new Error( `ArrayBuffer exceeded maximum size of ${ARRAY_BUFFER_MAX_SIZE} bytes. Please consider using another` + ` target.`, ); } if (this._supportsResize) { // Use resize if it exists // @ts-expect-error Don't want to bump "lib" in tsconfig // eslint-disable-next-line @typescript-eslint/no-unsafe-call this._buffer.resize(newLength); // The Uint8Array scales automatically } else { const newBuffer = new ArrayBuffer(newLength); const newBytes = new Uint8Array(newBuffer); newBytes.set(this._bytes, 0); this._buffer = newBuffer; this._bytes = newBytes; } } /** @internal */ _start() {} /** @internal */ _write(data: Uint8Array, pos: number) { this._ensureSize(pos + data.byteLength); this._bytes.set(data, pos); this._maxPos = Math.max(this._maxPos, pos + data.byteLength); this._dispatchWrite(pos, pos + data.byteLength); } /** @internal */ async _flush() {} /** @internal */ async _finalize() { this.buffer = this._buffer.slice(0, this._maxPos); if (this._options.onFinalize) { await this._options.onFinalize(this.buffer); } this._emit('finalized'); } /** @internal */ async _close() {} /** @internal */ _getSlice(start: number, end: number) { return this._bytes.slice(start, end); } } /** * A data chunk for {@link StreamTarget}. * @group Output targets * @public */ export type StreamTargetChunk = { /** The operation type. */ type: 'write'; // This ensures automatic compatibility with FileSystemWritableFileStream /** The data to write. */ data: Uint8Array<ArrayBuffer>; /** The byte offset in the output file at which to write the data. */ position: number; }; /** * Options for {@link StreamTarget}. * @group Output targets * @public */ export type StreamTargetOptions = { /** * When setting this to true, data created by the output will first be accumulated and only written out * once it has reached sufficient size, using a default chunk size of 16 MiB. This is useful for reducing the total * amount of writes, at the cost of latency. */ chunked?: boolean; /** When using `chunked: true`, this specifies the maximum size of each chunk. Defaults to 16 MiB. */ chunkSize?: number; }; const DEFAULT_CHUNK_SIZE = 2 ** 24; const MAX_CHUNKS_AT_ONCE = 2; type Chunk = { start: number; written: ChunkSection[]; data: Uint8Array<ArrayBuffer>; shouldFlush: boolean; }; type ChunkSection = { start: number; end: number; }; /** * This target writes data to a [`WritableStream`](https://developer.mozilla.org/en-US/docs/Web/API/WritableStream), * making it a general-purpose target for writing data anywhere. It is also compatible with * [`FileSystemWritableFileStream`](https://developer.mozilla.org/en-US/docs/Web/API/FileSystemWritableFileStream) for * use with the [File System Access API](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API). The * `WritableStream` can also apply backpressure, which will propagate to the output and throttle the encoders. * @group Output targets * @public */ export class StreamTarget extends Target { /** @internal */ _writable: WritableStream<StreamTargetChunk>; /** @internal */ _options: StreamTargetOptions; /** @internal */ _sections: { data: Uint8Array; start: number; }[] = []; /** @internal */ _lastWriteEnd = 0; /** @internal */ _lastFlushEnd = 0; /** @internal */ _streamWriter: WritableStreamDefaultWriter<StreamTargetChunk> | null = null; /** @internal */ _writeError: unknown = null; // These variables regard chunked mode: /** @internal */ _chunked: boolean; /** @internal */ _chunkSize: number; /** * The data is divided up into fixed-size chunks, whose contents are first filled in RAM and then flushed out. * A chunk is flushed if all of its contents have been written. */ /** @internal */ _chunks: Chunk[] = []; /** Creates a new {@link StreamTarget} which writes to the specified `writable`. */ constructor( writable: WritableStream<StreamTargetChunk>, options: StreamTargetOptions = {}, ) { super(); if (!(writable instanceof WritableStream)) { throw new TypeError('StreamTarget requires a WritableStream instance.'); } if (options != null && typeof options !== 'object') { throw new TypeError('StreamTarget options, when provided, must be an object.'); } if (options.chunked !== undefined && typeof options.chunked !== 'boolean') { throw new TypeError('options.chunked, when provided, must be a boolean.'); } if (options.chunkSize !== undefined && (!Number.isInteger(options.chunkSize) || options.chunkSize < 1024)) { throw new TypeError('options.chunkSize, when provided, must be an integer and not smaller than 1024.'); } this._writable = writable; this._options = options; this._chunked = options.chunked ?? false; this._chunkSize = options.chunkSize ?? DEFAULT_CHUNK_SIZE; } /** @internal */ _start() { this._streamWriter = this._writable.getWriter(); } /** @internal */ _write(data: Uint8Array, pos: number) { if (pos > this._lastWriteEnd) { const paddingBytesNeeded = pos - this._lastWriteEnd; this._write(new Uint8Array(paddingBytesNeeded), this._lastWriteEnd); } this._sections.push({ data: data.slice(), start: pos, }); this._lastWriteEnd = Math.max(this._lastWriteEnd, pos + data.byteLength); this._dispatchWrite(pos, pos + data.byteLength); } /** @internal */ async _flush() { if (this._writeError !== null) { // eslint-disable-next-line @typescript-eslint/only-throw-error throw this._writeError; } assert(this._streamWriter); if (this._sections.length === 0) { return; } const chunks: { start: number; size: number; data?: Uint8Array<ArrayBuffer>; }[] = []; const sorted = [...this._sections].sort((a, b) => a.start - b.start); chunks.push({ start: sorted[0]!.start, size: sorted[0]!.data.byteLength, }); // Figure out how many contiguous chunks we have for (let i = 1; i < sorted.length; i++) { const lastChunk = chunks[chunks.length - 1]!; const section = sorted[i]!; if (section.start <= lastChunk.start + lastChunk.size) { lastChunk.size = Math.max(lastChunk.size, section.start + section.data.byteLength - lastChunk.start); } else { chunks.push({ start: section.start, size: section.data.byteLength, }); } } for (const chunk of chunks) { chunk.data = new Uint8Array(chunk.size); // Make sure to write the data in the correct order for correct overwriting for (const section of this._sections) { // Check if the section is in the chunk if (chunk.start <= section.start && section.start < chunk.start + chunk.size) { chunk.data.set(section.data, section.start - chunk.start); } } if (this._streamWriter.desiredSize !== null && this._streamWriter.desiredSize <= 0) { await this._streamWriter.ready; // Allow the writer to apply backpressure } if (this._chunked) { // Let's first gather the data into bigger chunks before writing it this._writeDataIntoChunks(chunk.data, chunk.start); this._tryToFlushChunks(); } else { if (this._monotonicity === true && chunk.start !== this._lastFlushEnd) { throw new Error('Internal error: Monotonicity violation.'); } void this._streamWriter.write({ type: 'write', data: chunk.data, position: chunk.start, }).catch((error) => { this._writeError ??= error; }); this._lastFlushEnd = chunk.start + chunk.data.byteLength; } } this._sections.length = 0; } /** @internal */ _writeDataIntoChunks(data: Uint8Array, position: number) { // First, find the chunk to write the data into, or create one if none exists let chunkIndex = this._chunks.findIndex(x => x.start <= position && position < x.start + this._chunkSize); if (chunkIndex === -1) chunkIndex = this._createChunk(position); const chunk = this._chunks[chunkIndex]!; // Figure out how much to write to the chunk, and then write to the chunk const relativePosition = position - chunk.start; const toWrite = data.subarray(0, Math.min(this._chunkSize - relativePosition, data.byteLength)); chunk.data.set(toWrite, relativePosition); // Create a section describing the region of data that was just written to const section: ChunkSection = { start: relativePosition, end: relativePosition + toWrite.byteLength, }; this._insertSectionIntoChunk(chunk, section); // Queue chunk for flushing to target if it has been fully written to if (chunk.written[0]!.start === 0 && chunk.written[0]!.end === this._chunkSize) { chunk.shouldFlush = true; } // Make sure we don't hold too many chunks in memory at once to keep memory usage down if (this._chunks.length > MAX_CHUNKS_AT_ONCE) { // Flush all but the last chunk for (let i = 0; i < this._chunks.length - 1; i++) { this._chunks[i]!.shouldFlush = true; } this._tryToFlushChunks(); } // If the data didn't fit in one chunk, recurse with the remaining data if (toWrite.byteLength < data.byteLength) { this._writeDataIntoChunks(data.subarray(toWrite.byteLength), position + toWrite.byteLength); } } /** @internal */ _insertSectionIntoChunk(chunk: Chunk, section: ChunkSection) { let low = 0; let high = chunk.written.length - 1; let index = -1; // Do a binary search to find the last section with a start not larger than `section`'s start while (low <= high) { const mid = Math.floor(low + (high - low + 1) / 2); if (chunk.written[mid]!.start <= section.start) { low = mid + 1; index = mid; } else { high = mid - 1; } } // Insert the new section chunk.written.splice(index + 1, 0, section); if (index === -1 || chunk.written[index]!.end < section.start) index++; // Merge overlapping sections while (index < chunk.written.length - 1 && chunk.written[index]!.end >= chunk.written[index + 1]!.start) { chunk.written[index]!.end = Math.max(chunk.written[index]!.end, chunk.written[index + 1]!.end); chunk.written.splice(index + 1, 1); } } /** @internal */ _createChunk(includesPosition: number) { const start = Math.floor(includesPosition / this._chunkSize) * this._chunkSize; const chunk: Chunk = { start, data: new Uint8Array(this._chunkSize), written: [], shouldFlush: false, }; this._chunks.push(chunk); this._chunks.sort((a, b) => a.start - b.start); return this._chunks.indexOf(chunk); } /** @internal */ _tryToFlushChunks(force = false) { assert(this._streamWriter); for (let i = 0; i < this._chunks.length; i++) { const chunk = this._chunks[i]!; if (!chunk.shouldFlush && !force) continue; for (const section of chunk.written) { const position = chunk.start + section.start; if (this._monotonicity === true && position !== this._lastFlushEnd) { throw new Error('Internal error: Monotonicity violation.'); } void this._streamWriter.write({ type: 'write', data: chunk.data.subarray(section.start, section.end), position, }).catch((error) => { this._writeError ??= error; }); this._lastFlushEnd = chunk.start + section.end; } this._chunks.splice(i--, 1); } } /** @internal */ async _finalize() { if (this._chunked) { this._tryToFlushChunks(true); } if (this._writeError !== null) { // eslint-disable-next-line @typescript-eslint/only-throw-error throw this._writeError; } assert(this._streamWriter); await this._streamWriter.ready; await this._streamWriter.close(); this._emit('finalized'); } /** @internal */ async _close() { return this._streamWriter?.close(); } } /** * This target writes to a `WritableStream<Uint8Array>`, meaning all writes are necessarily append-only and involve no * seeking. Great for streaming data to a source that can only accept sequential data, like an HTTP server processing * an incoming upload. * * Note that using this target *requires* that the underlying format write data sequentially. Not all formats do this, * and this target will throw for the formats that don't. Check the guide for more. * * @group Output targets * @public */ export class AppendOnlyStreamTarget extends Target { /** @internal */ _writable: WritableStream<Uint8Array>; /** @internal */ _streamTarget: StreamTarget; /** @internal */ _writer: WritableStreamDefaultWriter<Uint8Array> | null = null; /** @internal */ _nextWritePos = 0; constructor(writable: WritableStream<Uint8Array>) { super(); this._writable = writable; this._streamTarget = new StreamTarget(new WritableStream({ start: () => { this._writer = this._writable.getWriter(); }, write: (chunk) => { if (this._monotonicity !== true) { throw new Error( 'AppendOnlyStreamTarget requires that data be written monotonically (always appended to the' + ' end). You must use a format that guarantees this behavior.', ); } assert(chunk.position === this._nextWritePos); this._nextWritePos += chunk.data.byteLength; assert(this._writer); return this._writer.write(chunk.data); }, close: () => { return this._writer?.close(); }, })); } /** @internal */ _start(): void { this._streamTarget._start(); } /** @internal */ _write(data: Uint8Array, pos: number): void { this._streamTarget._write(data, pos); } /** @internal */ _flush(): Promise<void> { return this._streamTarget._flush(); } /** @internal */ _finalize(): Promise<void> { return this._streamTarget._finalize(); } /** @internal */ _close(): Promise<void> { return this._streamTarget._close(); } /** @internal */ override _setMonotonicity(monotonicity: boolean): void { super._setMonotonicity(monotonicity); this._streamTarget._setMonotonicity(monotonicity); } } /** * Options for {@link FilePathTarget}. * @group Output targets * @public */ export type FilePathTargetOptions = StreamTargetOptions; /** * A target that writes to a file at the specified path. Intended for server-side usage in Node, Bun, or Deno. * * Writing is chunked by default. The internally held file handle will be closed when `.finalize()` or `.cancel()` are * called on the corresponding {@link Output}. * @group Output targets * @public */ export class FilePathTarget extends Target { /** @internal */ _streamTarget: StreamTarget; /** @internal */ _fileHandle: FileHandle | null = null; /** Creates a new {@link FilePathTarget} that writes to the file at the specified file path. */ constructor(filePath: string, options: FilePathTargetOptions = {}) { if (typeof filePath !== 'string') { throw new TypeError('filePath must be a string.'); } if (!options || typeof options !== 'object') { throw new TypeError('options must be an object.'); } if (!node.fs) { throw new Error( 'FilePathTarget is only available in server-side environments (Node.js, Bun, Deno).', ); } super(); // Let's back this target with a StreamTarget, makes the implementation very simple const writable = new WritableStream<StreamTargetChunk>({ start: async () => { this._fileHandle = await node.fs.open(filePath, 'w'); }, write: async (chunk) => { assert(this._fileHandle); await this._fileHandle.write(chunk.data, 0, chunk.data.byteLength, chunk.position); }, close: async () => { if (this._fileHandle) { await this._fileHandle.close(); this._fileHandle = null; } }, }); this._streamTarget = new StreamTarget(writable, { chunked: true, ...options, }); } /** @internal */ _start() { this._streamTarget._start(); } /** @internal */ _write(data: Uint8Array, pos: number) { this._streamTarget._write(data, pos); this._dispatchWrite(pos, pos + data.byteLength); } /** @internal */ async _flush() { return this._streamTarget._flush(); } /** @internal */ async _finalize() { await this._streamTarget._finalize(); this._emit('finalized'); } /** @internal */ async _close() { return this._streamTarget._close(); } /** @internal */ override _setMonotonicity(monotonicity: boolean): void { super._setMonotonicity(monotonicity); this._streamTarget._setMonotonicity(monotonicity); } } /** * This target just discards all incoming data. It is useful for when you need an {@link Output} but extract data from * it differently, for example through format-specific callbacks (`onMoof`, `onMdat`, ...) or encoder events. * @group Output targets * @public */ export class NullTarget extends Target { /** @internal */ _start() {} /** @internal */ _write(data: Uint8Array, pos: number) { this._dispatchWrite(pos, pos + data.byteLength); } /** @internal */ async _flush() {} /** @internal */ async _finalize() { this._emit('finalized'); } /** @internal */ async _close() {} } /** * A target that writes to a subrange (defined by an offset) of another, underlying target. Useful for writing a file * into a section of a larger file. * @group Output targets * @public */ export class RangedTarget extends Target { /** @internal */ _baseTarget: Target; /** @internal */ _offset: number; /** @internal */ constructor(baseTarget: Target, offset: number) { super(); this._baseTarget = baseTarget; this._offset = offset; } /** @internal */ _start() {} /** @internal */ _write(data: Uint8Array, pos: number): void { this._baseTarget._write(data, this._offset + pos); this._dispatchWrite(pos, pos + data.byteLength); } /** @internal */ _flush() { return this._baseTarget._flush(); } /** @internal */ async _finalize() { this._emit('finalized'); } /** @internal */ async _close() {} /** @internal */ override _setMonotonicity(monotonicity: boolean): void { super._setMonotonicity(monotonicity); this._baseTarget._setMonotonicity(monotonicity); } } /** * A special target for writing multi-file media where each file is uniquely identified by a path. * @group Output targets * @public */ export class PathedTarget<T extends Target> { /** Creates a new {@link PathedTarget} from a root path and a callback. */ constructor( /** The path that points to the root file; the entry file of the media. */ public readonly rootPath: FilePath, /** The callback that is called for each file that needs to be written; must return a {@link Target}. */ public readonly getTarget: (request: TargetRequest) => MaybePromise<T>, ) { if (typeof rootPath !== 'string') { throw new TypeError('rootPath must be a string.'); } if (typeof getTarget !== 'function') { throw new TypeError('getTarget must be a function.'); } } } /** * A request for a {@link Target} at the given path. * @group Output targets * @public */ export type TargetRequest = { /** The requested file path. */ path: FilePath; /** Whether the to-be-written file will be the root file. */ isRoot: boolean; /** The MIME type of the to-be-written file. */ mimeType: string; };