UNPKG

mediabunny

Version:

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

619 lines (508 loc) 16.5 kB
/*! * Copyright (c) 2025-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/. */ export function assert(x: unknown): asserts x { if (!x) { throw new Error('Assertion failed.'); } } /** * Represents a clockwise rotation in degrees. * @public */ export type Rotation = 0 | 90 | 180 | 270; export const normalizeRotation = (rotation: number) => { const mappedRotation = (rotation % 360 + 360) % 360; if (mappedRotation === 0 || mappedRotation === 90 || mappedRotation === 180 || mappedRotation === 270) { return mappedRotation as Rotation; } else { throw new Error(`Invalid rotation ${rotation}.`); } }; export type TransformationMatrix = [number, number, number, number, number, number, number, number, number]; export const last = <T>(arr: T[]) => { return arr && arr[arr.length - 1]; }; export const isU32 = (value: number) => { return value >= 0 && value < 2 ** 32; }; export class Bitstream { /** Current offset in bits. */ pos = 0; constructor(public bytes: Uint8Array) {} seekToByte(byteOffset: number) { this.pos = 8 * byteOffset; } private readBit() { const byteIndex = Math.floor(this.pos / 8); const byte = this.bytes[byteIndex] ?? 0; const bitIndex = 0b111 - (this.pos & 0b111); const bit = (byte & (1 << bitIndex)) >> bitIndex; this.pos++; return bit; } readBits(n: number) { if (n === 1) { return this.readBit(); } let result = 0; for (let i = 0; i < n; i++) { result <<= 1; result |= this.readBit(); } return result; } readAlignedByte() { // Ensure we're byte-aligned if (this.pos % 8 !== 0) { throw new Error('Bitstream is not byte-aligned.'); } const byteIndex = this.pos / 8; const byte = this.bytes[byteIndex] ?? 0; this.pos += 8; return byte; } skipBits(n: number) { this.pos += n; } getBitsLeft() { return this.bytes.length * 8 - this.pos; } clone() { const clone = new Bitstream(this.bytes); clone.pos = this.pos; return clone; } } /** Reads an exponential-Golomb universal code from a Bitstream. */ export const readExpGolomb = (bitstream: Bitstream) => { let leadingZeroBits = 0; while (bitstream.readBits(1) === 0 && leadingZeroBits < 32) { leadingZeroBits++; } if (leadingZeroBits >= 32) { throw new Error('Invalid exponential-Golomb code.'); } const result = (1 << leadingZeroBits) - 1 + bitstream.readBits(leadingZeroBits); return result; }; /** Reads a signed exponential-Golomb universal code from a Bitstream. */ export const readSignedExpGolomb = (bitstream: Bitstream) => { const codeNum = readExpGolomb(bitstream); return ((codeNum & 1) === 0) ? -(codeNum >> 1) : ((codeNum + 1) >> 1); }; export const writeBits = (bytes: Uint8Array, start: number, end: number, value: number) => { for (let i = start; i < end; i++) { const byteIndex = Math.floor(i / 8); let byte = bytes[byteIndex]!; const bitIndex = 0b111 - (i & 0b111); byte &= ~(1 << bitIndex); byte |= ((value & (1 << (end - i - 1))) >> (end - i - 1)) << bitIndex; bytes[byteIndex] = byte; } }; export const toUint8Array = (source: AllowSharedBufferSource): Uint8Array => { if (source instanceof Uint8Array) { return source; } else if (source instanceof ArrayBuffer) { return new Uint8Array(source); } else { return new Uint8Array(source.buffer, source.byteOffset, source.byteLength); } }; export const toDataView = (source: AllowSharedBufferSource) => { if (source instanceof DataView) { return source; } else if (source instanceof ArrayBuffer) { return new DataView(source); } else { return new DataView(source.buffer, source.byteOffset, source.byteLength); } }; export const textEncoder = new TextEncoder(); const invertObject = <K extends PropertyKey, V extends PropertyKey>(object: Record<K, V>) => { return Object.fromEntries(Object.entries(object).map(([key, value]) => [value, key])) as Record<V, K>; }; // For the color space mappings, see Rec. ITU-T H.273. export const COLOR_PRIMARIES_MAP = { bt709: 1, // ITU-R BT.709 bt470bg: 5, // ITU-R BT.470BG smpte170m: 6, // ITU-R BT.601 525 - SMPTE 170M bt2020: 9, // ITU-R BT.202 smpte432: 12, // SMPTE EG 432-1 }; export const COLOR_PRIMARIES_MAP_INVERSE = invertObject(COLOR_PRIMARIES_MAP); export const TRANSFER_CHARACTERISTICS_MAP = { 'bt709': 1, // ITU-R BT.709 'smpte170m': 6, // SMPTE 170M 'linear': 8, // Linear transfer characteristics 'iec61966-2-1': 13, // IEC 61966-2-1 'pg': 16, // Rec. ITU-R BT.2100-2 perceptual quantization (PQ) system 'hlg': 18, // Rec. ITU-R BT.2100-2 hybrid loggamma (HLG) system }; export const TRANSFER_CHARACTERISTICS_MAP_INVERSE = invertObject(TRANSFER_CHARACTERISTICS_MAP); export const MATRIX_COEFFICIENTS_MAP = { 'rgb': 0, // Identity 'bt709': 1, // ITU-R BT.709 'bt470bg': 5, // ITU-R BT.470BG 'smpte170m': 6, // SMPTE 170M 'bt2020-ncl': 9, // ITU-R BT.2020-2 (non-constant luminance) }; export const MATRIX_COEFFICIENTS_MAP_INVERSE = invertObject(MATRIX_COEFFICIENTS_MAP); export type RequiredNonNull<T> = { [K in keyof T]-?: NonNullable<T[K]>; }; export const colorSpaceIsComplete = ( colorSpace: VideoColorSpaceInit | undefined, ): colorSpace is RequiredNonNull<VideoColorSpaceInit> => { return ( !!colorSpace && !!colorSpace.primaries && !!colorSpace.transfer && !!colorSpace.matrix && colorSpace.fullRange !== undefined ); }; export const isAllowSharedBufferSource = (x: unknown) => { return ( x instanceof ArrayBuffer || (typeof SharedArrayBuffer !== 'undefined' && x instanceof SharedArrayBuffer) || ArrayBuffer.isView(x) ); }; export class AsyncMutex { currentPromise = Promise.resolve(); async acquire() { let resolver: () => void; const nextPromise = new Promise<void>((resolve) => { resolver = resolve; }); const currentPromiseAlias = this.currentPromise; this.currentPromise = nextPromise; await currentPromiseAlias; return resolver!; } } export const bytesToHexString = (bytes: Uint8Array) => { return [...bytes].map(x => x.toString(16).padStart(2, '0')).join(''); }; export const reverseBitsU32 = (x: number): number => { x = ((x >> 1) & 0x55555555) | ((x & 0x55555555) << 1); x = ((x >> 2) & 0x33333333) | ((x & 0x33333333) << 2); x = ((x >> 4) & 0x0f0f0f0f) | ((x & 0x0f0f0f0f) << 4); x = ((x >> 8) & 0x00ff00ff) | ((x & 0x00ff00ff) << 8); x = ((x >> 16) & 0x0000ffff) | ((x & 0x0000ffff) << 16); return x >>> 0; // Ensure it's treated as an unsigned 32-bit integer }; /** Returns the smallest index i such that val[i] === key, or -1 if no such index exists. */ export const binarySearchExact = <T>(arr: T[], key: number, valueGetter: (x: T) => number): number => { let low = 0; let high = arr.length - 1; let ans = -1; while (low <= high) { const mid = (low + high) >> 1; const midVal = valueGetter(arr[mid]!); if (midVal === key) { ans = mid; high = mid - 1; // Continue searching left to find the lowest index } else if (midVal < key) { low = mid + 1; } else { high = mid - 1; } } return ans; }; /** Returns the largest index i such that val[i] <= key, or -1 if no such index exists. */ export const binarySearchLessOrEqual = <T>(arr: T[], key: number, valueGetter: (x: T) => number) => { let low = 0; let high = arr.length - 1; let ans = -1; while (low <= high) { const mid = (low + (high - low + 1) / 2) | 0; const midVal = valueGetter(arr[mid]!); if (midVal <= key) { ans = mid; low = mid + 1; } else { high = mid - 1; } } return ans; }; /** Assumes the array is already sorted. */ export const insertSorted = <T>(arr: T[], item: T, valueGetter: (x: T) => number) => { const insertionIndex = binarySearchLessOrEqual(arr, valueGetter(item), valueGetter); arr.splice(insertionIndex + 1, 0, item); // This even behaves correctly for the -1 case }; export const promiseWithResolvers = <T = void>() => { let resolve: (value: T) => void; let reject: (reason: unknown) => void; const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve: resolve!, reject: reject! }; }; export const removeItem = <T>(arr: T[], item: T) => { const index = arr.indexOf(item); if (index !== -1) { arr.splice(index, 1); } }; export const findLast = <T>(arr: T[], predicate: (x: T) => boolean) => { for (let i = arr.length - 1; i >= 0; i--) { if (predicate(arr[i]!)) { return arr[i]; } } return undefined; }; export const findLastIndex = <T>(arr: T[], predicate: (x: T) => boolean) => { for (let i = arr.length - 1; i >= 0; i--) { if (predicate(arr[i]!)) { return i; } } return -1; }; /** * Sync or async iterable. * @public */ export type AnyIterable<T> = | Iterable<T> | AsyncIterable<T>; export const toAsyncIterator = async function* <T>(source: AnyIterable<T>): AsyncGenerator<T, void, unknown> { if (Symbol.iterator in source) { // @ts-expect-error Trust me yield* source[Symbol.iterator](); } else { // @ts-expect-error Trust me yield* source[Symbol.asyncIterator](); } }; export const validateAnyIterable = (iterable: AnyIterable<unknown>) => { if (!(Symbol.iterator in iterable) && !(Symbol.asyncIterator in iterable)) { throw new TypeError('Argument must be an iterable or async iterable.'); } }; export const assertNever = (x: never) => { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Unexpected value: ${x}`); }; export const getUint24 = (view: DataView, byteOffset: number, littleEndian: boolean) => { const byte1 = view.getUint8(byteOffset); const byte2 = view.getUint8(byteOffset + 1); const byte3 = view.getUint8(byteOffset + 2); if (littleEndian) { return byte1 | (byte2 << 8) | (byte3 << 16); } else { return (byte1 << 16) | (byte2 << 8) | byte3; } }; export const getInt24 = (view: DataView, byteOffset: number, littleEndian: boolean) => { // The left shift pushes the most significant bit into the sign bit region, and the subsequent right shift // then correctly interprets the sign bit. return getUint24(view, byteOffset, littleEndian) << 8 >> 8; }; export const setUint24 = (view: DataView, byteOffset: number, value: number, littleEndian: boolean) => { // Ensure the value is within 24-bit unsigned range (0 to 16777215) value = value >>> 0; // Convert to unsigned 32-bit value = value & 0xFFFFFF; // Mask to 24 bits if (littleEndian) { view.setUint8(byteOffset, value & 0xFF); view.setUint8(byteOffset + 1, (value >>> 8) & 0xFF); view.setUint8(byteOffset + 2, (value >>> 16) & 0xFF); } else { view.setUint8(byteOffset, (value >>> 16) & 0xFF); view.setUint8(byteOffset + 1, (value >>> 8) & 0xFF); view.setUint8(byteOffset + 2, value & 0xFF); } }; export const setInt24 = (view: DataView, byteOffset: number, value: number, littleEndian: boolean) => { // Ensure the value is within 24-bit signed range (-8388608 to 8388607) value = clamp(value, -8388608, 8388607); // Convert negative values to their 24-bit representation if (value < 0) { value = (value + 0x1000000) & 0xFFFFFF; } setUint24(view, byteOffset, value, littleEndian); }; export const setInt64 = (view: DataView, byteOffset: number, value: number, littleEndian: boolean) => { if (littleEndian) { view.setUint32(byteOffset + 0, value, true); view.setInt32(byteOffset + 4, Math.floor(value / 2 ** 32), true); } else { view.setInt32(byteOffset + 0, Math.floor(value / 2 ** 32), true); view.setUint32(byteOffset + 4, value, true); } }; /** * Calls a function on each value spat out by an async generator. The reason for writing this manually instead of * using a generator function is that the generator function queues return() calls - here, we forward them immediately. */ export const mapAsyncGenerator = <T, U>( generator: AsyncGenerator<T, void, unknown>, map: (t: T) => U, ): AsyncGenerator<U, void, unknown> => { return { async next() { const result = await generator.next(); if (result.done) { return { value: undefined, done: true }; } else { return { value: map(result.value), done: false }; } }, return() { return generator.return() as ReturnType<AsyncGenerator<U, void, unknown>['return']>; }, throw(error) { return generator.throw(error) as ReturnType<AsyncGenerator<U, void, unknown>['throw']>; }, [Symbol.asyncIterator]() { return this; }, }; }; export const clamp = (value: number, min: number, max: number) => { return Math.max(min, Math.min(max, value)); }; export const UNDETERMINED_LANGUAGE = 'und'; export const roundToPrecision = (value: number, digits: number) => { const factor = 10 ** digits; return Math.round(value * factor) / factor; }; export const roundToMultiple = (value: number, multiple: number) => { return Math.round(value / multiple) * multiple; }; export const ilog = (x: number) => { let ret = 0; while (x) { ret++; x >>= 1; } return ret; }; const ISO_639_2_REGEX = /^[a-z]{3}$/; export const isIso639Dash2LanguageCode = (x: string) => { return ISO_639_2_REGEX.test(x); }; // Since the result will be truncated, add a bit of eps to compensate for floating point errors export const SECOND_TO_MICROSECOND_FACTOR = 1e6 * (1 + Number.EPSILON); /** * Sets all keys K of T to be required. * @public */ export type SetRequired<T, K extends keyof T> = T & Required<Pick<T, K>>; export const mergeObjectsDeeply = <T extends object, S extends object>(a: T, b: S): T & S => { const result = { ...a } as T & S; for (const key in b) { if ( typeof a[key as unknown as keyof T] === 'object' && a[key as unknown as keyof T] !== null && typeof b[key] === 'object' && b[key] !== null ) { result[key] = mergeObjectsDeeply( a[key as unknown as keyof T] as object, b[key], ) as (T & S)[Extract<keyof S, string>]; } else { result[key] = b[key] as (T & S)[Extract<keyof S, string>]; } } return result; }; export const retriedFetch = async ( url: string | URL, requestInit: RequestInit, getRetryDelay: (previousAttempts: number) => number | null, ) => { let attempts = 0; while (true) { try { return await fetch(url, requestInit); } catch (error) { attempts++; const retryDelayInSeconds = getRetryDelay(attempts); if (retryDelayInSeconds === null) { throw error; } console.error('Retrying failed fetch. Error:', error); if (!Number.isFinite(retryDelayInSeconds) || retryDelayInSeconds < 0) { throw new TypeError('Retry delay must be a non-negative finite number.'); } if (retryDelayInSeconds > 0) { await new Promise(resolve => setTimeout(resolve, 1000 * retryDelayInSeconds)); } } } }; export const computeRationalApproximation = (x: number, maxDenominator: number) => { // Handle negative numbers const sign = x < 0 ? -1 : 1; x = Math.abs(x); let prevNumerator = 0, prevDenominator = 1; let currNumerator = 1, currDenominator = 0; // Continued fraction algorithm let remainder = x; while (true) { const integer = Math.floor(remainder); // Calculate next convergent const nextNumerator = integer * currNumerator + prevNumerator; const nextDenominator = integer * currDenominator + prevDenominator; if (nextDenominator > maxDenominator) { return { numerator: sign * currNumerator, denominator: currDenominator, }; } prevNumerator = currNumerator; prevDenominator = currDenominator; currNumerator = nextNumerator; currDenominator = nextDenominator; remainder = 1 / (remainder - integer); // Guard against precision issues if (!isFinite(remainder)) { break; } } return { numerator: sign * currNumerator, denominator: currDenominator, }; }; export class CallSerializer { currentPromise = Promise.resolve(); call(fn: () => Promise<void> | void) { return this.currentPromise = this.currentPromise.then(fn); } } let isSafariCache: boolean | null = null; export const isSafari = () => { if (isSafariCache !== null) { return isSafariCache; } const result = !!( typeof navigator !== 'undefined' && navigator.vendor?.match(/apple/i) && !navigator.userAgent?.match(/crios/i) && !navigator.userAgent?.match(/fxios/i) && !navigator.userAgent?.match(/Opera|OPT\//) ); isSafariCache = result; return result; }; /** * T or a promise that resolves to T. * @public */ export type MaybePromise<T> = T | Promise<T>;