UNPKG

mediabunny

Version:

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

613 lines (612 loc) 23.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 * as nodeAlias from './node.js'; import { assert, EventEmitter } from './misc.js'; const node = typeof nodeAlias !== 'undefined' ? nodeAlias // Aliasing it prevents some bundler warnings : undefined; /** * Base class for targets, specifying where output files are written. * @group Output targets * @public */ export class Target extends EventEmitter { constructor() { super(...arguments); /** @internal */ this._writerAcquired = false; /** @internal */ this._monotonicity = null; // null = unknown /** * 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. */ this.onwrite = null; } /** @internal */ _setMonotonicity(monotonicity) { if (this._monotonicity !== false) { this._monotonicity = monotonicity; } else { // Once false, it's locked } } /** @internal */ _dispatchWrite(start, end) { // 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) { 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; /** * 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 { /** Creates a new {@link BufferTarget}. The buffer holding the data will be created and managed internally. */ constructor(options = {}) { super(); /** Stores the final output buffer. Until the output is finalized, this will be `null`. */ this.buffer = null; /** @internal */ this._maxPos = 0; 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) { 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, pos) { 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, end) { return this._bytes.slice(start, end); } } const DEFAULT_CHUNK_SIZE = 2 ** 24; const MAX_CHUNKS_AT_ONCE = 2; /** * 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 { /** Creates a new {@link StreamTarget} which writes to the specified `writable`. */ constructor(writable, options = {}) { super(); /** @internal */ this._sections = []; /** @internal */ this._lastWriteEnd = 0; /** @internal */ this._lastFlushEnd = 0; /** @internal */ this._streamWriter = null; /** @internal */ this._writeError = null; /** * 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 */ this._chunks = []; 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, pos) { 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 = []; 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, position) { // 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 = { 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, section) { 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) { const start = Math.floor(includesPosition / this._chunkSize) * this._chunkSize; const 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 { constructor(writable) { super(); /** @internal */ this._writer = null; /** @internal */ this._nextWritePos = 0; 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() { this._streamTarget._start(); } /** @internal */ _write(data, pos) { this._streamTarget._write(data, pos); } /** @internal */ _flush() { return this._streamTarget._flush(); } /** @internal */ _finalize() { return this._streamTarget._finalize(); } /** @internal */ _close() { return this._streamTarget._close(); } /** @internal */ _setMonotonicity(monotonicity) { super._setMonotonicity(monotonicity); this._streamTarget._setMonotonicity(monotonicity); } } /** * 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 { /** Creates a new {@link FilePathTarget} that writes to the file at the specified file path. */ constructor(filePath, options = {}) { 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(); /** @internal */ this._fileHandle = null; // Let's back this target with a StreamTarget, makes the implementation very simple const writable = new WritableStream({ 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, pos) { 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 */ _setMonotonicity(monotonicity) { 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, pos) { 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 */ constructor(baseTarget, offset) { super(); this._baseTarget = baseTarget; this._offset = offset; } /** @internal */ _start() { } /** @internal */ _write(data, pos) { 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 */ _setMonotonicity(monotonicity) { 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 { /** 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. */ rootPath, /** The callback that is called for each file that needs to be written; must return a {@link Target}. */ getTarget) { this.rootPath = rootPath; this.getTarget = getTarget; if (typeof rootPath !== 'string') { throw new TypeError('rootPath must be a string.'); } if (typeof getTarget !== 'function') { throw new TypeError('getTarget must be a function.'); } } }