UNPKG

mediabunny

Version:

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

1,321 lines (1,098 loc) 35 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 { Bitstream } from '../shared/bitstream'; export function assert(x: unknown): asserts x { if (!x) { throw new Error('Assertion failed.'); } } /** * Represents a clockwise rotation in degrees. * @group Miscellaneous * @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; }; /** 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.constructor === Uint8Array) { // We want a true Uint8Array, not something that extends it like Buffer return source; } else if (ArrayBuffer.isView(source)) { return new Uint8Array(source.buffer, source.byteOffset, source.byteLength); } else { return new Uint8Array(source); } }; export const toDataView = (source: AllowSharedBufferSource): DataView => { if (source.constructor === DataView) { return source; } else if (ArrayBuffer.isView(source)) { return new DataView(source.buffer, source.byteOffset, source.byteLength); } else { return new DataView(source); } }; export const textDecoder = /* #__PURE__ */ new TextDecoder(); export const textEncoder = /* #__PURE__ */ new TextEncoder(); export const isIso88591Compatible = (text: string) => { for (let i = 0; i < text.length; i++) { const code = text.charCodeAt(i); if (code > 255) { return false; } } return true; }; 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 = /* #__PURE__ */ 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 'pq': 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 = /* #__PURE__ */ 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 = /* #__PURE__ */ 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(); pending = 0; async acquire() { let resolver: () => void; const nextPromise = new Promise<void>((resolve) => { let resolved = false; resolver = () => { if (resolved) { return; } resolve(); this.pending--; resolved = true; }; }); const currentPromiseAlias = this.currentPromise; this.currentPromise = nextPromise; this.pending++; await currentPromiseAlias; return resolver!; } } export const HEX_STRING_REGEX = /^[0-9a-fA-F]+$/; export const bytesToHexString = (bytes: Uint8Array) => { return [...bytes].map(x => x.toString(16).padStart(2, '0')).join(''); }; export const hexStringToBytes = (hexString: string) => { assert(hexString.length % 2 === 0); const bytes = new Uint8Array(hexString.length / 2); for (let i = 0; i < hexString.length; i += 2) { bytes[i / 2] = parseInt(hexString.slice(i, i + 2), 16); } return bytes; }; 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. * @group Miscellaneous * @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 roundIfAlmostInteger = (value: number) => { const rounded = Math.round(value); if (Math.abs(value / rounded - 1) < 10 * Number.EPSILON) { return rounded; } else { return value; } }; export const roundToMultiple = (value: number, multiple: number) => { return Math.round(value / multiple) * multiple; }; export const roundToDivisor = (value: number, multiple: number) => { return Math.round(value * multiple) / multiple; }; export const floorToMultiple = (value: number, multiple: number) => { return Math.floor(value / multiple) * multiple; }; export const floorToDivisor = (value: number, multiple: number) => { return Math.floor(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. * @group Miscellaneous * @public */ export type SetRequired<T, K extends keyof T> = T & Required<Pick<T, K>>; /** * Sets all keys K of T to be optional. * @group Miscellaneous * @public */ export type SetOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>; /** * Merges two RequestInit objects with special handling for headers. * Headers are merged case-insensitively, but original casing is preserved. * init2 headers take precedence and will override case-insensitive matches from init1. */ export const mergeRequestInit = (init1: RequestInit, init2: RequestInit): RequestInit => { const merged: RequestInit = { ...init1, ...init2 }; // Special handling for headers if (init1.headers || init2.headers) { const headers1 = init1.headers ? normalizeHeaders(init1.headers) : {}; const headers2 = init2.headers ? normalizeHeaders(init2.headers) : {}; const mergedHeaders = { ...headers1 }; // For each header in headers2, check if a case-insensitive match exists in mergedHeaders Object.entries(headers2).forEach(([key2, value2]) => { const existingKey = Object.keys(mergedHeaders).find( key1 => key1.toLowerCase() === key2.toLowerCase(), ); if (existingKey) { delete mergedHeaders[existingKey]; } mergedHeaders[key2] = value2; }); merged.headers = mergedHeaders; } return merged; }; /** Normalizes HeadersInit to a Record<string, string> format. */ const normalizeHeaders = (headers: HeadersInit): Record<string, string> => { if (headers instanceof Headers) { const result: Record<string, string> = {}; headers.forEach((value, key) => { result[key] = value; }); return result; } if (Array.isArray(headers)) { const result: Record<string, string> = {}; headers.forEach(([key, value]) => { result[key] = value; }); return result; } return headers; }; export const retriedFetch = async ( fetchFn: typeof fetch, url: string | URL | Request, requestInit: RequestInit, getRetryDelay: (previousAttempts: number, error: unknown, url: string | URL | Request) => number | null, shouldStop: () => boolean, ) => { let attempts = 0; while (true) { try { return await fetchFn(url, requestInit); } catch (error) { if (shouldStop()) { throw error; } attempts++; const retryDelayInSeconds = getRetryDelay(attempts, error, url); 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 wait(1000 * retryDelayInSeconds); } if (shouldStop()) { throw error; } } } }; export const computeRationalApproximation = (x: number, maxDenominator: number): Rational => { // 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 { num: sign * currNumerator, den: currDenominator, }; } prevNumerator = currNumerator; prevDenominator = currDenominator; currNumerator = nextNumerator; currDenominator = nextDenominator; remainder = 1 / (remainder - integer); // Guard against precision issues if (!isFinite(remainder)) { break; } } return { num: sign * currNumerator, den: currDenominator, }; }; export class CallSerializer { currentPromise = Promise.resolve(); call(fn: () => Promise<void> | void) { return this.currentPromise = this.currentPromise.then(fn); } } let isWebKitCache: boolean | null = null; export const isWebKit = () => { if (isWebKitCache !== null) { return isWebKitCache; } // This even returns true for WebKit-wrapping browsers such as Chrome on iOS return isWebKitCache = !!( typeof navigator !== 'undefined' && ( // eslint-disable-next-line @typescript-eslint/no-deprecated navigator.vendor?.match(/apple/i) // Or, in workers: || (/AppleWebKit/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent)) || /\b(iPad|iPhone|iPod)\b/.test(navigator.userAgent) ) ); }; let isFirefoxCache: boolean | null = null; export const isFirefox = () => { if (isFirefoxCache !== null) { return isFirefoxCache; } return isFirefoxCache = typeof navigator !== 'undefined' && navigator.userAgent?.includes('Firefox'); }; let isChromiumCache: boolean | null = null; export const isChromium = () => { if (isChromiumCache !== null) { return isChromiumCache; } return isChromiumCache = !!( typeof navigator !== 'undefined' // eslint-disable-next-line @typescript-eslint/no-deprecated && (navigator.vendor?.includes('Google Inc') || /Chrome/.test(navigator.userAgent)) ); }; let chromiumVersionCache: number | null = null; export const getChromiumVersion = () => { if (chromiumVersionCache !== null) { return chromiumVersionCache; } if (typeof navigator === 'undefined') { return null; } const match = /\bChrome\/(\d+)/.exec(navigator.userAgent); if (!match) { return null; } return chromiumVersionCache = Number(match[1]!); }; /** * T or a promise that resolves to T. * @group Miscellaneous * @public */ export type MaybePromise<T> = T | Promise<T>; /** Acts like `??` except the condition is -1 and not null/undefined. */ export const coalesceIndex = (a: number, b: number) => { return a !== -1 ? a : b; }; export const closedIntervalsOverlap = (startA: number, endA: number, startB: number, endB: number) => { return startA <= endB && startB <= endA; }; type KeyValuePair<T extends Record<string, unknown>> = { [K in keyof T]-?: { key: K; value: T[K] extends infer R | undefined ? R : T[K]; } }[keyof T]; export const keyValueIterator = function* <T extends Record<string, unknown>>(object: T) { for (const key in object) { const value = object[key]; if (value === undefined) { continue; } yield { key, value } as KeyValuePair<T>; } }; export const imageMimeTypeToExtension = (mimeType: string) => { switch (mimeType.toLowerCase()) { case 'image/jpeg': case 'image/jpg': return '.jpg'; case 'image/png': return '.png'; case 'image/gif': return '.gif'; case 'image/webp': return '.webp'; case 'image/bmp': return '.bmp'; case 'image/svg+xml': return '.svg'; case 'image/tiff': return '.tiff'; case 'image/avif': return '.avif'; case 'image/x-icon': case 'image/vnd.microsoft.icon': return '.ico'; default: return null; } }; export const base64ToBytes = (base64: string) => { const decoded = atob(base64); const bytes = new Uint8Array(decoded.length); for (let i = 0; i < decoded.length; i++) { bytes[i] = decoded.charCodeAt(i); } return bytes; }; export const bytesToBase64 = (bytes: Uint8Array) => { let string = ''; for (let i = 0; i < bytes.length; i++) { string += String.fromCharCode(bytes[i]!); } return btoa(string); }; export const uint8ArraysAreEqual = (a: Uint8Array, b: Uint8Array) => { if (a.length !== b.length) { return false; } for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) { return false; } } return true; }; export const polyfillSymbolDispose = () => { // https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html // @ts-expect-error Readonly Symbol.dispose ??= Symbol('Symbol.dispose'); }; export const isNumber = (x: unknown) => { return typeof x === 'number' && !Number.isNaN(x); }; /** * A path to a file. File paths can be relative or absolute, and be local paths or full URLs. Paths must be POSIX-like, * using `/` as the separator. * * Examples of valid paths: * - `'video.mp4'` * - `'path/to/video.mp4'` * - `'./video.mp4'` * - `'../video.mp4'` * - `'/path/to/video.mp4'` * - `'https://example.com/video.mp4'` * - `'file:///home/user/video.mp4'` * - `'video.mp4?key=foo'` * * @group Miscellaneous * @public */ export type FilePath = string; export const joinPaths = (basePath: FilePath, relativePath: FilePath) => { // If relativePath is a full URL with protocol, return it as-is if (relativePath.includes('://')) { return relativePath; } // Strip query parameters from URL base paths so their contents don't mess up the join if (basePath.includes('://')) { const queryIndex = basePath.indexOf('?'); if (queryIndex !== -1) { basePath = basePath.slice(0, queryIndex); } } let result: string; if (relativePath.startsWith('/')) { const protocolIndex = basePath.indexOf('://'); if (protocolIndex === -1) { result = relativePath; } else { const pathStart = basePath.indexOf('/', protocolIndex + 3); if (pathStart === -1) { result = basePath + relativePath; } else { result = basePath.slice(0, pathStart) + relativePath; } } } else { const lastSlash = basePath.lastIndexOf('/'); if (lastSlash === -1) { result = relativePath; } else { result = basePath.slice(0, lastSlash + 1) + relativePath; } } // Normalize ./ and ../ let prefix = ''; const protocolIndex = result.indexOf('://'); if (protocolIndex !== -1) { const pathStart = result.indexOf('/', protocolIndex + 3); if (pathStart !== -1) { prefix = result.slice(0, pathStart); result = result.slice(pathStart); } } const segments = result.split('/'); const normalized: string[] = []; for (const segment of segments) { if (segment === '..') { normalized.pop(); } else if (segment !== '.') { normalized.push(segment); } } return prefix + normalized.join('/'); }; export const arrayCount = <T>(array: T[], predicate: (item: T) => boolean) => { let count = 0; for (let i = 0; i < array.length; i++) { if (predicate(array[i]!)) { count++; } } return count; }; export const arrayArgmin = <T>(array: T[], getValue: (item: T) => number): number => { let minIndex = -1; let minValue = Infinity; for (let i = 0; i < array.length; i++) { const value = getValue(array[i]!); if (value < minValue) { minValue = value; minIndex = i; } } return minIndex; }; export const arrayArgmax = <T>(array: T[], getValue: (item: T) => number): number => { let maxIndex = -1; let maxValue = -Infinity; for (let i = 0; i < array.length; i++) { const value = getValue(array[i]!); if (value > maxValue) { maxValue = value; maxIndex = i; } } return maxIndex; }; /** * A rational number; a ratio of two integers. * @group Miscellaneous * @public */ export type Rational = { /** The numerator of the rational number. */ num: number; /** The denominator of the rational number. */ den: number; }; export const simplifyRational = (rational: Rational): Rational => { assert(Number.isInteger(rational.num)); assert(Number.isInteger(rational.den)); assert(rational.den !== 0); let a = Math.abs(rational.num); let b = Math.abs(rational.den); // Euclidean algorithm while (b !== 0) { const t = a % b; a = b; b = t; } const gcd = a || 1; return { num: rational.num / gcd, den: rational.den / gcd, }; }; /** * Specifies a rectangular region where all quantities must be non-negative integers. * @group Miscellaneous * @public */ export type Rectangle = { /** The distance in pixels to the left edge of the rectangle. */ left: number; /** The distance in pixels to the top edge of the rectangle. */ top: number; /** The width in pixels of the rectangle. */ width: number; /** The height in pixels of the rectangle. */ height: number; }; export const validateRectangle = (rect: Rectangle, propertyPath: string) => { if (typeof rect !== 'object' || !rect) { throw new TypeError(`${propertyPath} must be an object.`); } if (!Number.isInteger(rect.left) || rect.left < 0) { throw new TypeError(`${propertyPath}.left must be a non-negative integer.`); } if (!Number.isInteger(rect.top) || rect.top < 0) { throw new TypeError(`${propertyPath}.top must be a non-negative integer.`); } if (!Number.isInteger(rect.width) || rect.width < 0) { throw new TypeError(`${propertyPath}.width must be a non-negative integer.`); } if (!Number.isInteger(rect.height) || rect.height < 0) { throw new TypeError(`${propertyPath}.height must be a non-negative integer.`); } }; export type NonFunctionKeys<T> = { [K in keyof T]-?: T[K] extends ((...args: never[]) => unknown) ? never : K }[keyof T]; export type UnthrottledTimerHandle = { id: ReturnType<typeof setTimeout> | number; }; type UnthrottledTimerMessage = | { type: 'set-timeout'; timerId: number; delay: number } | { type: 'set-interval'; timerId: number; delay: number } | { type: 'clear-timeout'; timerId: number } | { type: 'clear-interval'; timerId: number }; type UnthrottledTimerEvent = { type: 'fire'; timerId: number }; let unthrottledTimerWorker: Worker | undefined; let nextUnthrottledTimerId = 1; const unthrottledTimeoutCallbacks = new Map<number, () => void>(); const unthrottledIntervalCallbacks = new Map<number, () => void>(); const shouldUseNativeTimers = () => { return typeof window === 'undefined'; }; const unthrottledTimerWorkerMain = () => { const timeoutHandles = new Map<number, ReturnType<typeof setTimeout>>(); const intervalHandles = new Map<number, ReturnType<typeof setInterval>>(); self.onmessage = (event: MessageEvent<UnthrottledTimerMessage>) => { const message = event.data; switch (message.type) { case 'set-timeout': { const handle = setTimeout(() => { timeoutHandles.delete(message.timerId); self.postMessage({ type: 'fire', timerId: message.timerId }); }, message.delay); timeoutHandles.set(message.timerId, handle); }; break; case 'set-interval': { const handle = setInterval(() => { self.postMessage({ type: 'fire', timerId: message.timerId }); }, message.delay); intervalHandles.set(message.timerId, handle); }; break; case 'clear-timeout': { const handle = timeoutHandles.get(message.timerId); if (handle !== undefined) { clearTimeout(handle); timeoutHandles.delete(message.timerId); } }; break; case 'clear-interval': { const handle = intervalHandles.get(message.timerId); if (handle !== undefined) { clearInterval(handle); intervalHandles.delete(message.timerId); } }; break; } }; }; const getUnthrottledTimerWorker = () => { if (unthrottledTimerWorker) { return unthrottledTimerWorker; } const workerSource = `(${unthrottledTimerWorkerMain.toString()})();`; const workerURL = URL.createObjectURL(new Blob([workerSource], { type: 'text/javascript' })); unthrottledTimerWorker = new Worker(workerURL); URL.revokeObjectURL(workerURL); unthrottledTimerWorker.onmessage = (event: MessageEvent<UnthrottledTimerEvent>) => { const message = event.data; const timeoutCallback = unthrottledTimeoutCallbacks.get(message.timerId); if (timeoutCallback) { unthrottledTimeoutCallbacks.delete(message.timerId); timeoutCallback(); return; } const intervalCallback = unthrottledIntervalCallbacks.get(message.timerId); if (intervalCallback) { intervalCallback(); } }; return unthrottledTimerWorker; }; export const setTimeoutUnthrottled = ( // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type callback: Function, delay: number, ): UnthrottledTimerHandle => { if (shouldUseNativeTimers()) { return { id: setTimeout(callback, delay) }; } const timerId = nextUnthrottledTimerId++; unthrottledTimeoutCallbacks.set(timerId, () => { (callback as () => void)(); }); getUnthrottledTimerWorker().postMessage({ type: 'set-timeout', timerId, delay, } satisfies UnthrottledTimerMessage); return { id: timerId }; }; export const clearTimeoutUnthrottled = (timer: UnthrottledTimerHandle) => { if (shouldUseNativeTimers()) { clearTimeout(timer.id); return; } assert(typeof timer.id === 'number'); unthrottledTimeoutCallbacks.delete(timer.id); getUnthrottledTimerWorker().postMessage({ type: 'clear-timeout', timerId: timer.id, } satisfies UnthrottledTimerMessage); }; export const setIntervalUnthrottled = ( // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type callback: Function, delay: number, ): UnthrottledTimerHandle => { if (shouldUseNativeTimers()) { return { id: setInterval(callback, delay) }; } const timerId = nextUnthrottledTimerId++; unthrottledIntervalCallbacks.set(timerId, () => { (callback as () => void)(); }); getUnthrottledTimerWorker().postMessage({ type: 'set-interval', timerId, delay, } satisfies UnthrottledTimerMessage); return { id: timerId }; }; export const clearIntervalUnthrottled = (timer: UnthrottledTimerHandle) => { if (shouldUseNativeTimers()) { clearInterval(timer.id); return; } assert(typeof timer.id === 'number'); unthrottledIntervalCallbacks.delete(timer.id); getUnthrottledTimerWorker().postMessage({ type: 'clear-interval', timerId: timer.id, } satisfies UnthrottledTimerMessage); }; export const wait = (ms: number) => { return new Promise(resolve => setTimeout(resolve, ms)); }; export const rejectAfter = (ms: number, message = 'Promise rejected') => { return new Promise((_, reject) => { setTimeout(() => reject(new Error(message)), ms); }); }; export const toArray = <T>(x: T | T[]) => { if (Array.isArray(x)) { return x; } else { return [x]; } }; /** * Options for {@link EventEmitter.on}. * * @group Miscellaneous * @public */ export type EventListenerOptions = { /** If `true`, the listener will be automatically removed after being called once. Defaults to `false`. */ once?: boolean; }; /** * A class that manages event listeners and dispatches events to them. * * @group Miscellaneous * @public */ export class EventEmitter<TEvents extends Record<string, unknown>> { /** @internal */ _listeners = new Map<keyof TEvents, Set<{ fn: (data: never) => unknown; once: boolean }>>(); /** Registers a listener for the given event. */ on<K extends keyof TEvents>( event: K, listener: (data: TEvents[K]) => unknown, options?: EventListenerOptions, ): () => void { if (!this._listeners.has(event)) { this._listeners.set(event, new Set()); } const entry = { fn: listener as (data: never) => void, once: options?.once ?? false }; this._listeners.get(event)!.add(entry); return () => { this._listeners.get(event)?.delete(entry); }; } /** @internal */ _emit<K extends keyof TEvents>( ...args: TEvents[K] extends void ? [event: K] : [event: K, data: TEvents[K]] ): void { const [event, data] = args; const listeners = this._listeners.get(event); if (!listeners) { return; } for (const entry of listeners) { try { (entry.fn as (data: unknown) => void)(data); } catch (error) { console.error(error); } if (entry.once) { listeners.delete(entry); } } } } export const ceilToMultipleOfTwo = (value: number) => Math.ceil(value / 2) * 2; /** * Utility class for running async functions in parallel up to a certain level of parallelism. Can be used to apply * backpressure only if the concurrency level would be exceeded. * * @group Miscellaneous * @public */ export class ConcurrentRunner { /** @internal */ _queue: Promise<unknown>[] = []; /** @internal */ _errored = false; /** * The maximum number of in-flight promises. You can also think of it as the "high water mark". * You can set this value to dynamically change the level of parallelism. */ parallelism: number; constructor(parallelism: number) { this.parallelism = parallelism; } /** Whether any function has errored. The runner is effectively bricked if this is `true`, by design. */ get errored() { return this._errored; } /** The number of tasks currently running. */ get inFlightCount() { return this._queue.length; } /** * Schedules an async function to be run. If the maximum allowed level of parallelism has not yet been reached, * the function will be executed immediately and `run()` will resolve immediately. Otherwise, the function will be * called as soon as any currently-running function finishes, and `run()` will only resolve then. * * Throws if the runner is errored. */ async run(fn: () => Promise<unknown>) { if (this._errored) { await Promise.race(this._queue); // Will surface the error } while (this._queue.length >= this.parallelism) { await Promise.race(this._queue); } const promise = fn(); this._queue.push(promise); void promise .then(() => removeItem(this._queue, promise)) .catch(() => this._errored = true); } /** Waits for all currently running functions to finish. Throws if the runner is errored. */ async flush() { await Promise.all(this._queue); } } export const isRecordStringString = (value: unknown): value is Record<string, string> => { return value !== null && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype && Object.values(value).every(x => typeof x === 'string'); };