diff --git a/.gitignore b/.gitignore index 57d3105..41482de 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /node_modules /build -/dist \ No newline at end of file +/dist +.DS_Store \ No newline at end of file diff --git a/api-extractor.json b/api-extractor.json index 90cc917..2b6166d 100644 --- a/api-extractor.json +++ b/api-extractor.json @@ -31,5 +31,6 @@ "logLevel": "warning" } } - } + }, + "newlineKind": "lf" } diff --git a/build.mjs b/build.mjs index d03d615..ffd622c 100644 --- a/build.mjs +++ b/build.mjs @@ -25,20 +25,20 @@ const esmConfig = { format: 'esm', }; -let ctxUmd = await esbuild.context({ +const ctxUmd = await esbuild.context({ ...umdConfig, outfile: 'dist/metamuxer.js', }); -let ctxEsm = await esbuild.context({ +const ctxEsm = await esbuild.context({ ...esmConfig, outfile: 'dist/metamuxer.mjs', }); -let ctxUmdMinified = await esbuild.context({ +const ctxUmdMinified = await esbuild.context({ ...umdConfig, outfile: 'dist/metamuxer.min.js', minify: true, }); -let ctxEsmMinified = await esbuild.context({ +const ctxEsmMinified = await esbuild.context({ ...esmConfig, outfile: 'dist/metamuxer.min.mjs', minify: true, diff --git a/package-lock.json b/package-lock.json index 35a1f14..975cb46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,12 +16,27 @@ "@eslint/js": "^9.22.0", "@microsoft/api-extractor": "^7.52.1", "@stylistic/eslint-plugin": "^4.2.0", + "@types/node": "^22.13.10", "esbuild": "^0.25.1", "eslint": "^9.22.0", + "ts-node": "^10.9.2", "typescript": "^5.8.2", "typescript-eslint": "^8.26.1" } }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.1.tgz", @@ -694,6 +709,34 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@microsoft/api-extractor": { "version": "7.52.1", "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.52.1.tgz", @@ -905,6 +948,34 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/argparse": { "version": "1.0.38", "resolved": "https://registry.npmjs.org/@types/argparse/-/argparse-1.0.38.tgz", @@ -937,6 +1008,16 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/node": { + "version": "22.13.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz", + "integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.26.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.26.1.tgz", @@ -1177,6 +1258,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/ajv": { "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", @@ -1242,6 +1336,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -1343,6 +1444,13 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1380,6 +1488,16 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/esbuild": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.1.tgz", @@ -2037,6 +2155,13 @@ "node": ">=10" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -2422,6 +2547,50 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -2471,6 +2640,13 @@ "typescript": ">=4.8.4 <5.9.0" } }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true, + "license": "MIT" + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -2490,6 +2666,13 @@ "punycode": "^2.1.0" } }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2521,6 +2704,16 @@ "dev": true, "license": "ISC" }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index a1dfe19..1f894e2 100644 --- a/package.json +++ b/package.json @@ -17,11 +17,12 @@ "dist" ], "scripts": { - "build": "node build.mjs && npm run lint && tsc && api-extractor run && node append-namespace.mjs", - "build-local": "node build.mjs && npm run lint && tsc && api-extractor run --local --verbose && node append-namespace.mjs", + "build": "node build.mjs && npm run lint && tsc -p src && api-extractor run && ts-node scripts/append-namespace.ts", + "build-local": "node build.mjs && npm run lint && tsc -p src && api-extractor run --local --verbose && ts-node scripts/append-namespace.ts", "watch": "node build.mjs --watch", "lint": "eslint .", - "check": "tsc --noEmit" + "check": "tsc -p src --noEmit && tsc -p scripts --noEmit", + "check-docblocks": "ts-node scripts/check-docblocks dist/metamuxer.d.ts" }, "author": "", "license": "MIT", @@ -33,8 +34,10 @@ "@eslint/js": "^9.22.0", "@microsoft/api-extractor": "^7.52.1", "@stylistic/eslint-plugin": "^4.2.0", + "@types/node": "^22.13.10", "esbuild": "^0.25.1", "eslint": "^9.22.0", + "ts-node": "^10.9.2", "typescript": "^5.8.2", "typescript-eslint": "^8.26.1" } diff --git a/append-namespace.mjs b/scripts/append-namespace.ts similarity index 90% rename from append-namespace.mjs rename to scripts/append-namespace.ts index 78d29d3..8edd70d 100644 --- a/append-namespace.mjs +++ b/scripts/append-namespace.ts @@ -1,3 +1,3 @@ import { appendFileSync } from 'fs'; -appendFileSync('dist/metamuxer.d.ts', '\nexport as namespace Metamuxer;'); \ No newline at end of file +appendFileSync('dist/metamuxer.d.ts', '\nexport as namespace Metamuxer;'); diff --git a/scripts/check-docblocks.ts b/scripts/check-docblocks.ts new file mode 100644 index 0000000..1fc285b --- /dev/null +++ b/scripts/check-docblocks.ts @@ -0,0 +1,206 @@ +import ts from 'typescript'; +import * as fs from 'node:fs'; + +function checkDocblocks(filePath: string): { + success: boolean; + missingDocblocks: { name: string; kind: string; line: number; reason: string }[]; +} { + const program = ts.createProgram([filePath], {}); + const sourceFile = program.getSourceFile(filePath); + const checker = program.getTypeChecker(); + + if (!sourceFile) { + throw new Error(`Could not find source file: ${filePath}`); + } + + const missingDocblocks: { name: string; kind: string; line: number; reason: string }[] = []; + + function checkNode(node: ts.Node) { + if ( + ts.isInterfaceDeclaration(node) + || ts.isClassDeclaration(node) + || ts.isMethodDeclaration(node) + || ts.isPropertyDeclaration(node) + || ts.isFunctionDeclaration(node) + || ts.isTypeAliasDeclaration(node) + || ts.isEnumDeclaration(node) + || ts.isPropertySignature(node) + || ts.isMethodSignature(node) + || ts.isVariableStatement(node) + || ts.isVariableDeclaration(node) + || (ts.isParameter(node) && ts.isPropertyDeclaration(node.parent)) + ) { + let symbol: ts.Symbol | undefined; + + try { + if (ts.isVariableStatement(node)) { + node.declarationList.declarations.forEach((declaration) => { + const declSymbol = checker.getSymbolAtLocation(declaration.name); + if (declSymbol) { + const docStatus = checkDocumentationContent(declSymbol, declaration); + if (docStatus.hasProblem) { + const name = declaration.name.getText(sourceFile); + const line = sourceFile!.getLineAndCharacterOfPosition(declaration.getStart()).line + 1; + missingDocblocks.push({ + name, + kind: 'variable', + line, + reason: docStatus.reason, + }); + } + } + }); + return; + } else if ('name' in node && node.name) { + symbol = checker.getSymbolAtLocation(node.name); + } + } catch { + symbol = undefined; + } + + let name = 'anonymous'; + const kind = ts.SyntaxKind[node.kind].replace(/Declaration|Statement/g, '').toLowerCase(); + + if ('name' in node && node.name) { + if (ts.isIdentifier(node.name)) { + name = node.name.text; + } else if ('getText' in node.name) { + name = node.name.getText(sourceFile); + } + } + + const line = sourceFile!.getLineAndCharacterOfPosition(node.getStart()).line + 1; + + if (!symbol) { + const jsDocNodes = ts.getJSDocCommentsAndTags(node); + if (jsDocNodes.length === 0) { + missingDocblocks.push({ + name, + kind, + line, + reason: 'No docblock found', + }); + } else { + const docStatus = checkJSDocContent(jsDocNodes); + if (docStatus.hasProblem) { + missingDocblocks.push({ + name, + kind, + line, + reason: docStatus.reason, + }); + } + } + } else { + const docStatus = checkDocumentationContent(symbol, node); + if (docStatus.hasProblem) { + missingDocblocks.push({ + name, + kind, + line, + reason: docStatus.reason, + }); + } + } + } + + ts.forEachChild(node, checkNode); + } + + function checkDocumentationContent(symbol: ts.Symbol, node: ts.Node): { hasProblem: boolean; reason: string } { + const docComments = symbol.getDocumentationComment(checker); + if (docComments.length === 0) { + const jsDocNodes = ts.getJSDocCommentsAndTags(node); + if (jsDocNodes.length === 0) { + return { hasProblem: true, reason: 'No docblock found' }; + } + return checkJSDocContent(jsDocNodes); + } + + // Get the raw text of the comment + const docText = docComments.map(comment => comment.text).join('').trim(); + + // Remove all @tags with regex + const cleanedText = docText.replace(/@\S+/g, '').trim(); + + // If nothing remains after removing tags, it's just modifiers + if (cleanedText.length === 0) { + return { hasProblem: true, reason: 'Docblock contains only modifiers' }; + } + + return { hasProblem: false, reason: '' }; + } + + function checkJSDocContent(jsDocNodes: readonly ts.Node[]): { hasProblem: boolean; reason: string } { + if (jsDocNodes.length === 0) { + return { hasProblem: true, reason: 'No docblock found' }; + } + + for (const node of jsDocNodes) { + if (ts.isJSDoc(node)) { + const commentText = node.comment ?? ''; + if (typeof commentText !== 'string') { + throw new Error('Can\'t handle this yet!'); + } + + // Remove all @tags with regex + const cleanedText = commentText.replace(/@\S+/g, '').trim(); + + // If there's content after removing tags, it's a meaningful docblock + if (cleanedText.length > 0) { + return { hasProblem: false, reason: '' }; + } + } + } + + return { hasProblem: true, reason: 'Docblock contains only modifiers' }; + } + + if (sourceFile) { + checkNode(sourceFile); + } + + return { + success: missingDocblocks.length === 0, + missingDocblocks, + }; +} + +function main() { + const args = process.argv.slice(2); + + if (args.length !== 1) { + console.error('Missing file argument.'); + process.exit(1); + } + + const filePath = args[0]!; + + if (!fs.existsSync(filePath)) { + console.error(`File not found: ${filePath}`); + process.exit(1); + } + + try { + const result = checkDocblocks(filePath); + + if (result.success) { + console.log(`✅ All symbols in ${filePath} have meaningful docblocks.`); + } else { + console.log( + `❌ Found ${result.missingDocblocks.length} symbols with insufficient docblocks in ${filePath}:`, + ); + + result.missingDocblocks.forEach((item) => { + console.log(` - ${item.kind} '${item.name}' at line ${item.line}: ${item.reason}`); + }); + + process.exit(1); + } + } catch (error) { + console.error('Error:', error); + process.exit(1); + } +} + +main(); diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 0000000..311dc28 --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "nodenext", + "isolatedModules": true, + }, + "include": [ + "**/*" + ], +} \ No newline at end of file diff --git a/src/codec.ts b/src/codec.ts index d925ad7..6ffb865 100644 --- a/src/codec.ts +++ b/src/codec.ts @@ -13,9 +13,10 @@ import { } from './misc'; import { SubtitleMetadata } from './subtitles'; -// Codecs are ordered by encoding preference: - -/** @public */ +/** + * List of known video codecs, ordered by encoding preference. + * @public + */ export const VIDEO_CODECS = [ 'avc', 'hevc', @@ -23,7 +24,10 @@ export const VIDEO_CODECS = [ 'av1', 'vp8', ] as const; -/** @public */ +/** + * List of known PCM (uncompressed) audio codecs, ordered by encoding preference. + * @public + */ export const PCM_AUDIO_CODECS = [ 'pcm-s16', // We don't prefix 'le' so we're compatible with the WebCodecs-registered PCM codec strings 'pcm-s16be', @@ -38,7 +42,10 @@ export const PCM_AUDIO_CODECS = [ 'ulaw', 'alaw', ] as const; -/** @public */ +/** + * List of known compressed audio codecs, ordered by encoding preference. + * @public + */ export const NON_PCM_AUDIO_CODECS = [ 'aac', 'opus', @@ -46,24 +53,42 @@ export const NON_PCM_AUDIO_CODECS = [ 'vorbis', 'flac', ] as const; -/** @public */ +/** + * List of known audio codecs, ordered by encoding preference. + * @public + */ export const AUDIO_CODECS = [ ...NON_PCM_AUDIO_CODECS, ...PCM_AUDIO_CODECS, ] as const; -/** @public */ +/** + * List of known subtitle codecs, ordered by encoding preference. + * @public + */ export const SUBTITLE_CODECS = [ 'webvtt', ] as const; // TODO add the rest -/** @public */ +/** + * Union type of known video codecs. + * @public + */ export type VideoCodec = typeof VIDEO_CODECS[number]; -/** @public */ +/** + * Union type of known audio codecs. + * @public + */ export type AudioCodec = typeof AUDIO_CODECS[number]; export type PcmAudioCodec = typeof PCM_AUDIO_CODECS[number]; -/** @public */ +/** + * Union type of known subtitle codecs. + * @public + */ export type SubtitleCodec = typeof SUBTITLE_CODECS[number]; -/** @public */ +/** + * Union type of known media codecs. + * @public + */ export type MediaCodec = VideoCodec | AudioCodec | SubtitleCodec; // https://en.wikipedia.org/wiki/Advanced_Video_Coding @@ -1079,7 +1104,10 @@ export const getAudioEncoderConfigExtension = (codec: AudioCodec) => { return {}; }; -/** @public */ +/** + * Represents a subjective media quality level. + * @public + */ export class Quality { /** @internal */ _factor: number; @@ -1159,15 +1187,30 @@ export class Quality { } } -/** @public */ +/** + * Represents a very low media quality. + * @public + */ export const QUALITY_VERY_LOW = new Quality(0.4); -/** @public */ +/** + * Represents a low media quality. + * @public + */ export const QUALITY_LOW = new Quality(0.6); -/** @public */ +/** + * Represents a medium media quality. + * @public + */ export const QUALITY_MEDIUM = new Quality(1); -/** @public */ +/** + * Represents a high media quality. + * @public + */ export const QUALITY_HIGH = new Quality(2); -/** @public */ +/** + * Represents a very high media quality. + * @public + */ export const QUALITY_VERY_HIGH = new Quality(4); const VALID_VIDEO_CODEC_STRING_PREFIXES = ['avc1', 'avc3', 'hev1', 'hvc1', 'vp8', 'vp09', 'av01']; @@ -1463,7 +1506,10 @@ export const validateSubtitleMetadata = (metadata: SubtitleMetadata | undefined) } }; -/** @public */ +/** + * Checks if the browser is able to encode the given codec. + * @public + */ export const canEncode = (codec: MediaCodec) => { if ((VIDEO_CODECS as readonly string[]).includes(codec)) { return canEncodeVideo(codec as VideoCodec); @@ -1476,7 +1522,10 @@ export const canEncode = (codec: MediaCodec) => { throw new TypeError(`Unknown codec '${codec}'.`); }; -/** @public */ +/** + * Checks if the browser is able to encode the given video codec with the given parameters. + * @public + */ export const canEncodeVideo = async (codec: VideoCodec, { width = 1280, height = 720, bitrate = 1e6 }: { width?: number; height?: number; @@ -1530,7 +1579,10 @@ export const canEncodeVideo = async (codec: VideoCodec, { width = 1280, height = return support.supported === true; }; -/** @public */ +/** + * Checks if the browser is able to encode the given audio codec with the given parameters. + * @public + */ export const canEncodeAudio = async (codec: AudioCodec, { numberOfChannels = 2, sampleRate = 48000, bitrate = 128e3 }: { numberOfChannels?: number; sampleRate?: number; @@ -1587,7 +1639,10 @@ export const canEncodeAudio = async (codec: AudioCodec, { numberOfChannels = 2, return support.supported === true; }; -/** @public */ +/** + * Checks if the browser is able to encode the given subtitle codec. + * @public + */ export const canEncodeSubtitles = async (codec: SubtitleCodec) => { if (!SUBTITLE_CODECS.includes(codec)) { return false; @@ -1596,7 +1651,10 @@ export const canEncodeSubtitles = async (codec: SubtitleCodec) => { return true; }; -/** @public */ +/** + * Returns the list of all media codecs that can be encoded by the browser. + * @public + */ export const getEncodableCodecs = async (): Promise => { const [videoCodecs, audioCodecs, subtitleCodecs] = await Promise.all([ getEncodableVideoCodecs(), @@ -1607,7 +1665,10 @@ export const getEncodableCodecs = async (): Promise => { return [...videoCodecs, ...audioCodecs, ...subtitleCodecs]; }; -/** @public */ +/** + * Returns the list of all video codecs that can be encoded by the browser. + * @public + */ export const getEncodableVideoCodecs = async ( checkedCodecs = VIDEO_CODECS as unknown as VideoCodec[], options?: { @@ -1620,7 +1681,10 @@ export const getEncodableVideoCodecs = async ( return checkedCodecs.filter((_, i) => bools[i]); }; -/** @public */ +/** + * Returns the list of all audio codecs that can be encoded by the browser. + * @public + */ export const getEncodableAudioCodecs = async ( checkedCodecs = AUDIO_CODECS as unknown as AudioCodec[], options?: { @@ -1633,7 +1697,10 @@ export const getEncodableAudioCodecs = async ( return checkedCodecs.filter((_, i) => bools[i]); }; -/** @public */ +/** + * Returns the list of all subtitle codecs that can be encoded by the browser. + * @public + */ export const getEncodableSubtitleCodecs = async ( checkedCodecs = SUBTITLE_CODECS as unknown as SubtitleCodec[], ): Promise => { diff --git a/src/conversion.ts b/src/conversion.ts index 75b4410..6cf1bf2 100644 --- a/src/conversion.ts +++ b/src/conversion.ts @@ -33,50 +33,101 @@ import { assert, clamp, normalizeRotation, promiseWithResolvers, Rotation } from import { Output, TrackType } from './output'; import { VideoSample } from './sample'; -/** @public */ +/** + * The options for media file conversion. + * @public + */ export type ConversionOptions = { + /** The input file. */ input: Input; + /** The output file. */ output: Output; + /** Video-specific options. */ video?: { + /** If true, all video tracks will be discarded and will not be present in the output. */ discard?: boolean; + /** The desired output video codec. */ codec?: VideoCodec; + /** The desired bitrate of the output video. */ bitrate?: VideoEncodingConfig['bitrate']; + /** + * The desired width of the output video, defaulting to the video's natural display width. If height is not set, + * it will be deduced automatically based on aspect ratio. + */ width?: number; + /** + * The desired height of the output video, defaulting to the video's natural display height. If width is not + * set, it will be deduced automatically based on aspect ratio. + */ height?: number; + /** + * The fitting algorithm in case both width and height are set. + * + * - 'fill' will stretch the image to fill the entire box, potentially altering aspect ratio. + * - 'contain' will contain the entire image within the box while preserving aspect ratio. This may lead to + * letterboxing. + * - 'cover' will scale the image until the entire box is filled, while preserving aspect ratio. + */ fit?: 'fill' | 'contain' | 'cover'; + /** + * The angle in degrees to rotate the input video by, clockwise. Rotation is applied before resizing. This + * rotation is _in addition to_ the natural rotation of the input video as specified in input file's metadata. + */ rotate?: Rotation; + /** When true, video will always be re-encoded instead of directly copying over the encoded samples. */ forceReencode?: boolean; }; + /** Audio-specific options. */ audio?: { + /** If true, all audio tracks will be discarded and will not be present in the output. */ discard?: boolean; + /** The desired output audio codec. */ codec?: AudioCodec; + /** The desired bitrate of the output audio. */ bitrate?: AudioEncodingConfig['bitrate']; + /** The desired channel count of the output audio. */ numberOfChannels?: number; + /** The desired sample rate of the output audio. */ sampleRate?: number; + /** When true, audio will always be re-encoded instead of directly copying over the encoded samples. */ forceReencode?: boolean; }; + /** Options to trim the input file. */ trim?: { + /** The time in the input file at which the output file should start. Must be less than `end`. */ start: number; + /** The time in the input file at which the output file should end. Must be greater than `start`. */ end: number; }; + /** + * When set to true, the current progress of the conversion will be computed and kept up to date in the `progress` + * field of the Conversion instance. + */ computeProgress?: boolean; }; const FALLBACK_NUMBER_OF_CHANNELS = 2; const FALLBACK_SAMPLE_RATE = 48000; -/** @public */ +/** + * Utility function to convert one media file into another. In addition to conversion, this function can be used to + * resize and rotate video, resample audio, drop tracks, or trim to a specific time range. + * @public + */ export const convert = async (options: ConversionOptions) => { const conversion = await Conversion.init(options); await conversion.execute(); return conversion; }; -/** @public */ +/** + * Represents a media file conversion process. + * @public + */ export class Conversion { /** @internal */ _options: ConversionOptions; @@ -120,11 +171,24 @@ export class Conversion { /** @internal */ _canceled = false; + /** + * A number between 0 and 1, indicating the completion of the conversion. If the `computeProgress` option is not + * enabled, this value will be stuck at 0. + */ progress = 0; + /** + * A callback that is fired whenever the conversion progresses. Only called if the `computeProgress` option + * is enabled. + */ onProgress?: () => unknown = undefined; + + /** The list of tracks that are included in the output file. */ utilizedTracks: InputTrack[] = []; + /** The list of tracks from the input file that have been discarded, alongside the discard reason. */ discardedTracks: { + /** The track that was discarded. */ track: InputTrack; + /** The reason for discarding the track. */ reason: | 'discardedByUser' | 'maxTrackCountReached' @@ -134,6 +198,7 @@ export class Conversion { | 'noEncodableTargetCodec'; }[] = []; + /** Initializes a new conversion process without starting the conversion. */ static async init(options: ConversionOptions) { const conversion = new Conversion(options); await conversion._init(); @@ -318,6 +383,7 @@ export class Conversion { } } + /** Starts the conversion process. */ async execute() { if (this._executed) { throw new Error('Conversion cannot be executed twice.'); @@ -342,6 +408,7 @@ export class Conversion { } } + /** Cancels the conversion process. Does nothing if the conversion is already complete. */ async cancel() { if (this._output.state === 'finalizing' || this._output.state === 'finalized') { return; diff --git a/src/custom-coder.ts b/src/custom-coder.ts index 715825a..b105e80 100644 --- a/src/custom-coder.ts +++ b/src/custom-coder.ts @@ -2,71 +2,119 @@ import { AudioCodec, VideoCodec } from './codec'; import { EncodedPacket } from './packet'; import { AudioSample, VideoSample } from './sample'; -/** @public */ +/** + * Base class for custom video decoders. To add your own custom video decoder, extend this class, implement the + * abstract methods and static `supports` method, and register the decoder using `registerDecoder`. + * @public + */ export abstract class CustomVideoDecoder { + /** The input video's codec. */ codec!: VideoCodec; + /** The input video's decoder config. */ config!: VideoDecoderConfig; + /** The callback to call when a decoded VideoSample is available. */ onSample!: (sample: VideoSample) => unknown; + /** Returns true iff the decoder can decode the given codec configuration. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars static supports(codec: VideoCodec, config: VideoDecoderConfig): boolean { return false; } + /** Called after decoder creation; can be used for custom initialization logic. */ abstract init(): void; + /** Decodes the provided encoded packet. */ abstract decode(packet: EncodedPacket): Promise | void; + /** Decodes all remaining packets and then resolves. */ abstract flush(): Promise | void; + /** Called when the decoder is no longer needed and its resources can be freed. */ abstract close(): Promise | void; } -/** @public */ +/** + * Base class for custom audio decoders. To add your own custom audio decoder, extend this class, implement the + * abstract methods and static `supports` method, and register the decoder using `registerDecoder`. + * @public + */ export abstract class CustomAudioDecoder { + /** The input audio's codec. */ codec!: AudioCodec; + /** The input audio's decoder config. */ config!: AudioDecoderConfig; + /** The callback to call when a decoded AudioSample is available. */ onSample!: (sample: AudioSample) => unknown; + /** Returns true iff the decoder can decode the given codec configuration. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars static supports(codec: AudioCodec, config: AudioDecoderConfig): boolean { return false; } + /** Called after decoder creation; can be used for custom initialization logic. */ abstract init(): void; + /** Decodes the provided encoded packet. */ abstract decode(packet: EncodedPacket): Promise | void; + /** Decodes all remaining packets and then resolves. */ abstract flush(): Promise | void; + /** Called when the decoder is no longer needed and its resources can be freed. */ abstract close(): Promise | void; } -/** @public */ +/** + * Base class for custom video encoders. To add your own custom video encoder, extend this class, implement the + * abstract methods and static `supports` method, and register the encoder using `registerEncoder`. + * @public + */ export abstract class CustomVideoEncoder { + /** The codec with which to encode the video. */ codec!: VideoCodec; + /** Config for the encoder. */ config!: VideoEncoderConfig; + /** The callback to call when an EncodedPacket is available. */ onPacket!: (packet: EncodedPacket, meta?: EncodedVideoChunkMetadata) => unknown; + /** Returns true iff the encoder can encode the given codec configuration. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars static supports(codec: VideoCodec, config: VideoEncoderConfig): boolean { return false; } + /** Called after encoder creation; can be used for custom initialization logic. */ abstract init(): void; + /** Encodes the provided video sample. */ abstract encode(videoSample: VideoSample, options: VideoEncoderEncodeOptions): Promise | void; + /** Encodes all remaining video samples and then resolves. */ abstract flush(): Promise | void; + /** Called when the encoder is no longer needed and its resources can be freed. */ abstract close(): Promise | void; } -/** @public */ +/** + * Base class for custom audio encoders. To add your own custom audio encoder, extend this class, implement the + * abstract methods and static `supports` method, and register the encoder using `registerEncoder`. + * @public + */ export abstract class CustomAudioEncoder { + /** The codec with which to encode the audio. */ codec!: AudioCodec; + /** Config for the encoder. */ config!: AudioEncoderConfig; + /** The callback to call when an EncodedPacket is available. */ onPacket!: (packet: EncodedPacket, meta?: EncodedAudioChunkMetadata) => unknown; + /** Returns true iff the encoder can encode the given codec configuration. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars static supports(codec: AudioCodec, config: AudioEncoderConfig): boolean { return false; } + /** Called after encoder creation; can be used for custom initialization logic. */ abstract init(): void; + /** Encodes the provided audio sample. */ abstract encode(audioSample: AudioSample): Promise | void; + /** Encodes all remaining audio samples and then resolves. */ abstract flush(): Promise | void; + /** Called when the encoder is no longer needed and its resources can be freed. */ abstract close(): Promise | void; } @@ -75,7 +123,11 @@ export const customAudioDecoders: typeof CustomAudioDecoder[] = []; export const customVideoEncoders: typeof CustomVideoEncoder[] = []; export const customAudioEncoders: typeof CustomAudioEncoder[] = []; -/** @public */ +/** + * Registers a custom video or audio decoder. Registered decoders will automatically be used for decoding whenever + * possible. + * @public + */ export const registerDecoder = (decoder: typeof CustomVideoDecoder | typeof CustomAudioDecoder) => { if (decoder.prototype instanceof CustomVideoDecoder) { customVideoDecoders.push(decoder as typeof CustomVideoDecoder); @@ -86,7 +138,11 @@ export const registerDecoder = (decoder: typeof CustomVideoDecoder | typeof Cust } }; -/** @public */ +/** + * Registers a custom video or audio encoder. Registered encoders will automatically be used for encoding whenever + * possible. + * @public + */ export const registerEncoder = (encoder: typeof CustomVideoEncoder | typeof CustomAudioEncoder) => { if (encoder.prototype instanceof CustomVideoEncoder) { customVideoEncoders.push(encoder as typeof CustomVideoEncoder); diff --git a/src/index.ts b/src/index.ts index 71b15ec..8d5af6d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,7 +22,7 @@ export { WaveOutputFormat, OggOutputFormat, TrackCountLimits, - InclusiveRange, + InclusiveIntegerRange, } from './output-format'; export { VideoEncodingConfig, @@ -96,6 +96,7 @@ export { VideoSampleInit, AudioSample, AudioSampleInit, + AudioSampleCopyToOptions, } from './sample'; export { PacketRetrievalOptions, diff --git a/src/input-format.ts b/src/input-format.ts index 0d83d10..515ec9e 100644 --- a/src/input-format.ts +++ b/src/input-format.ts @@ -11,7 +11,10 @@ import { OggReader } from './ogg/ogg-reader'; import { RiffReader } from './wave/riff-reader'; import { WaveDemuxer } from './wave/wave-demuxer'; -/** @public */ +/** + * Base class representing an input media file format. + * @public + */ export abstract class InputFormat { /** @internal */ abstract _canReadInput(input: Input): Promise; @@ -19,12 +22,18 @@ export abstract class InputFormat { /** @internal */ abstract _createDemuxer(input: Input): Demuxer; + /** Returns the name of the input format. */ abstract getName(): string; + /** Returns the typical base MIME type of the input format. */ abstract getMimeType(): string; } -/** @public */ +/** + * Format representing files compatible with the ISO base media file format (ISOBMFF), like MP4 or MOV files. + * @public + */ export abstract class IsobmffInputFormat extends InputFormat { + /** @internal */ protected async _getMajorBrand(input: Input) { const sourceSize = await input._mainReader.source._getSize(); if (sourceSize < 12) { @@ -48,7 +57,10 @@ export abstract class IsobmffInputFormat extends InputFormat { } } -/** @public */ +/** + * MPEG-4 Part 14 (MP4) file format. + * @public + */ export class Mp4InputFormat extends IsobmffInputFormat { /** @internal */ async _canReadInput(input: Input) { @@ -65,7 +77,10 @@ export class Mp4InputFormat extends IsobmffInputFormat { } } -/** @public */ +/** + * QuickTime File Format (QTFF), often called MOV. + * @public + */ export class QuickTimeInputFormat extends IsobmffInputFormat { /** @internal */ async _canReadInput(input: Input) { @@ -82,7 +97,10 @@ export class QuickTimeInputFormat extends IsobmffInputFormat { } } -/** @public */ +/** + * Matroska file format. + * @public + */ export class MatroskaInputFormat extends InputFormat { /** @internal */ protected async isSupportedEBMLOfDocType(input: Input, desiredDocType: string) { @@ -165,7 +183,10 @@ export class MatroskaInputFormat extends InputFormat { } } -/** @public */ +/** + * WebM file format, based on Matroska. + * @public + */ export class WebMInputFormat extends MatroskaInputFormat { /** @internal */ override _canReadInput(input: Input) { @@ -181,8 +202,12 @@ export class WebMInputFormat extends MatroskaInputFormat { } } -/** @public */ +/** + * MP3 file format. + * @public + */ export class Mp3InputFormat extends InputFormat { + /** @internal */ async _canReadInput(input: Input) { const sourceSize = await input._mainReader.source._getSize(); if (sourceSize < 4) { @@ -242,8 +267,12 @@ export class Mp3InputFormat extends InputFormat { } } -/** @public */ +/** + * WAVE file format, based on RIFF. + * @public + */ export class WaveInputFormat extends InputFormat { + /** @internal */ async _canReadInput(input: Input) { const sourceSize = await input._mainReader.source._getSize(); if (sourceSize < 12) { @@ -275,8 +304,12 @@ export class WaveInputFormat extends InputFormat { } } -/** @public */ +/** + * Ogg file format. + * @public + */ export class OggInputFormat extends InputFormat { + /** @internal */ async _canReadInput(input: Input) { const sourceSize = await input._mainReader.source._getSize(); if (sourceSize < 4) { @@ -301,20 +334,45 @@ export class OggInputFormat extends InputFormat { } } -/** @public */ +/** + * MP4 input format singleton. + * @public + */ export const MP4 = new Mp4InputFormat(); -/** @public */ +/** + * QuickTime File Format input format singleton. + * @public + */ export const QTFF = new QuickTimeInputFormat(); -/** @public */ +/** + * Matroska input format singleton. + * @public + */ export const MATROSKA = new MatroskaInputFormat(); -/** @public */ +/** + * WebM input format singleton. + * @public + */ export const WEBM = new WebMInputFormat(); -/** @public */ +/** + * MP3 input format singleton. + * @public + */ export const MP3 = new Mp3InputFormat(); -/** @public */ +/** + * WAVE input format singleton. + * @public + */ export const WAVE = new WaveInputFormat(); -/** @public */ +/** + * Ogg input format singleton. + * @public + */ export const OGG = new OggInputFormat(); -/** @public */ +/** + * List of all input format singletons. If you don't need to support all input formats, you should specify the + * formats individually for better tree shaking. + * @public + */ export const ALL_FORMATS: InputFormat[] = [MP4, QTFF, MATROSKA, WEBM, WAVE, OGG, MP3]; diff --git a/src/input-track.ts b/src/input-track.ts index 4b1ac25..c135f9a 100644 --- a/src/input-track.ts +++ b/src/input-track.ts @@ -5,10 +5,16 @@ import { assert, Rotation } from './misc'; import { TrackType } from './output'; import { EncodedPacket } from './packet'; -/** @public */ +/** + * Contains aggregate statistics about the encoded packets of a track. + * @public + */ export type PacketStats = { + /** The total number of packets. */ packetCount: number; + /** The average number of packets per second. For video tracks, this will equal the average frame rate (FPS). */ averagePacketRate: number; + /** The average number of bits per second. */ averageBitrate: number; }; @@ -27,7 +33,10 @@ export interface InputTrackBacking { getNextKeyPacket(packet: EncodedPacket, options: PacketRetrievalOptions): Promise; } -/** @public */ +/** + * Represents a media track in an input file. + * @public + */ export abstract class InputTrack { /** @internal */ _backing: InputTrackBacking; @@ -37,39 +46,58 @@ export abstract class InputTrack { this._backing = backing; } + /** The type of the track. */ abstract get type(): TrackType; + /** The codec of the track's packets. */ abstract get codec(): MediaCodec | null; - abstract getCodecMimeType(): Promise; + /** Returns the full codec parameter string for this track. */ + abstract getCodecParameterString(): Promise; + /** Checks if this track's packets can be decoded by the browser. */ abstract canDecode(): Promise; + /** Returns true iff this track is a video track. */ isVideoTrack(): this is InputVideoTrack { return this instanceof InputVideoTrack; } + /** Returns true iff this track is an audio track. */ isAudioTrack(): this is InputAudioTrack { return this instanceof InputAudioTrack; } + /** The unique ID of this track in the input file. */ get id() { return this._backing.getId(); } + /** The ISO 639-2 language code for this track. If the language is unknown, this field is 'und' (undetermined). */ get languageCode() { return this._backing.getLanguageCode(); } + /** + * A positive number x such that all timestamps and durations of all packets of this track are + * integer multiples of 1/x. + */ get timeResolution() { return this._backing.getTimeResolution(); } + /** + * Returns the start timestamp of the first packet of this track, in seconds. While often near zero, this value + * may be positive or even negative. A negative starting timestamp means the track's timing has been offset. Samples + * with a negative timestamp should not be presented. + */ getFirstTimestamp() { return this._backing.getFirstTimestamp(); } + /** Returns the end timestamp of the last packet of this track, in seconds. */ computeDuration() { return this._backing.computeDuration(); } + /** Computes aggregate packet statistics for this track, such as average packet rate or bitrate. */ async computePacketStats(): Promise { const sink = new EncodedPacketSink(this); @@ -107,7 +135,10 @@ export interface InputVideoTrackBacking extends InputTrackBacking { getDecoderConfig(): Promise; } -/** @public */ +/** + * Represents a video track in an input file. + * @public + */ export class InputVideoTrack extends InputTrack { /** @internal */ override _backing: InputVideoTrackBacking; @@ -127,32 +158,39 @@ export class InputVideoTrack extends InputTrack { return this._backing.getCodec(); } + /** The width of the track's coded samples, before any transformations or rotations. */ get codedWidth() { return this._backing.getCodedWidth(); } + /** The height of the track's coded samples, before any transformations or rotations. */ get codedHeight() { return this._backing.getCodedHeight(); } + /** The angle in degrees by which the track's frames should be rotated (clockwise). */ get rotation() { return this._backing.getRotation(); } + /** The width of the track's frames after rotation. */ get displayWidth() { const rotation = this._backing.getRotation(); return rotation % 180 === 0 ? this._backing.getCodedWidth() : this._backing.getCodedHeight(); } + /** The height of the track's frames after rotation. */ get displayHeight() { const rotation = this._backing.getRotation(); return rotation % 180 === 0 ? this._backing.getCodedHeight() : this._backing.getCodedWidth(); } + /** Returns the color space of the track's samples. */ getColorSpace() { return this._backing.getColorSpace(); } + /** Returns true iff the track's samples use a high dynamic range (HDR). */ async hasHighDynamicRange() { const colorSpace = await this._backing.getColorSpace(); @@ -161,11 +199,12 @@ export class InputVideoTrack extends InputTrack { || (colorSpace.matrix as string) === 'bt2020-ncl'; } + /** Returns the decoder configuration for decoding the track's packets using a VideoDecoder. */ getDecoderConfig() { return this._backing.getDecoderConfig(); } - async getCodecMimeType() { + async getCodecParameterString() { const decoderConfig = await this._backing.getDecoderConfig(); return decoderConfig?.codec ?? null; } @@ -204,7 +243,10 @@ export interface InputAudioTrackBacking extends InputTrackBacking { getDecoderConfig(): Promise; } -/** @public */ +/** + * Represents an audio track in an input file. + * @public + */ export class InputAudioTrack extends InputTrack { /** @internal */ override _backing: InputAudioTrackBacking; @@ -224,19 +266,22 @@ export class InputAudioTrack extends InputTrack { return this._backing.getCodec(); } + /** The number of audio channels in the track. */ get numberOfChannels() { return this._backing.getNumberOfChannels(); } + /** The track's audio sample rate in hertz. */ get sampleRate() { return this._backing.getSampleRate(); } + /** Returns the decoder configuration for decoding the track's packets using an AudioDecoder. */ getDecoderConfig() { return this._backing.getDecoderConfig(); } - async getCodecMimeType() { + async getCodecParameterString() { const decoderConfig = await this._backing.getDecoderConfig(); return decoderConfig?.codec ?? null; } diff --git a/src/input.ts b/src/input.ts index b1a40e3..ad022c4 100644 --- a/src/input.ts +++ b/src/input.ts @@ -4,13 +4,21 @@ import { assert } from './misc'; import { Reader } from './reader'; import { Source } from './source'; -/** @public */ +/** + * The options for creating an Input object. + * @public + */ export type InputOptions = { + /** A list of supported formats. If the source file is not of one of these formats, then it cannot be read. */ formats: InputFormat[]; + /** The source from which data will be read. */ source: Source; }; -/** @public */ +/** + * Represents an input media file. This is the root object from which all media read operations start. + * @public + */ export class Input { /** @internal */ _source: Source; @@ -56,42 +64,57 @@ export class Input { })(); } + /** + * Returns the format of the input file. You can compare this result directly to the InputFormat singletons or use + * `instanceof` checks for subset-aware logic (for example, `format instanceof MatroskaInputFormat` is true for + * both MKV and WebM). + */ async getFormat() { await this._getDemuxer(); assert(this._format!); return this._format; } + /** + * Computes the duration of the longest track in this input file, in seconds. More precisely, returns the largest + * end timestamp among all tracks. + */ async computeDuration() { const demuxer = await this._getDemuxer(); return demuxer.computeDuration(); } + /** Returns the list of all tracks of this input file. */ async getTracks() { const demuxer = await this._getDemuxer(); return demuxer.getTracks(); } + /** Returns the list of all video tracks of this input file. */ async getVideoTracks() { const tracks = await this.getTracks(); return tracks.filter(x => x.isVideoTrack()); } + /** Returns the primary video track of this input file, or null if there are no video tracks. */ async getPrimaryVideoTrack() { const tracks = await this.getTracks(); return tracks.find(x => x.isVideoTrack()) ?? null; } + /** Returns the list of all audio tracks of this input file. */ async getAudioTracks() { const tracks = await this.getTracks(); return tracks.filter(x => x.isAudioTrack()); } + /** Returns the primary audio track of this input file, or null if there are no audio tracks. */ async getPrimaryAudioTrack() { const tracks = await this.getTracks(); return tracks.find(x => x.isAudioTrack()) ?? null; } + /** Returns the full MIME type of this input file, including track codecs. */ async getMimeType() { const demuxer = await this._getDemuxer(); return demuxer.getMimeType(); diff --git a/src/isobmff/isobmff-demuxer.ts b/src/isobmff/isobmff-demuxer.ts index ccd0c0b..3e5bdd5 100644 --- a/src/isobmff/isobmff-demuxer.ts +++ b/src/isobmff/isobmff-demuxer.ts @@ -223,7 +223,7 @@ export class IsobmffDemuxer extends Demuxer { let string = base + (this.isQuickTime ? 'quicktime' : 'mp4'); if (this.tracks.length > 0) { - const codecMimeTypes = await Promise.all(this.tracks.map(x => x.inputTrack!.getCodecMimeType())); + const codecMimeTypes = await Promise.all(this.tracks.map(x => x.inputTrack!.getCodecParameterString())); const uniqueCodecMimeTypes = [...new Set(codecMimeTypes.filter(Boolean))]; string += `; codecs="${uniqueCodecMimeTypes.join(', ')}"`; @@ -2549,6 +2549,6 @@ const extractRotationFromMatrix = (matrix: TransformationMatrix) => { const cosTheta = m11 / scaleX; const sinTheta = m21 / scaleX; - // Invert the rotation beacuse matrices are post-multiplied in ISOBMFF + // Invert the rotation because matrices are post-multiplied in ISOBMFF return -Math.atan2(sinTheta, cosTheta) * (180 / Math.PI); }; diff --git a/src/matroska/matroska-demuxer.ts b/src/matroska/matroska-demuxer.ts index 908e366..9395cdc 100644 --- a/src/matroska/matroska-demuxer.ts +++ b/src/matroska/matroska-demuxer.ts @@ -190,7 +190,7 @@ export class MatroskaDemuxer extends Demuxer { const tracks = await this.getTracks(); if (tracks.length > 0) { - const codecMimeTypes = await Promise.all(tracks.map(x => x.getCodecMimeType())); + const codecMimeTypes = await Promise.all(tracks.map(x => x.getCodecParameterString())); const uniqueCodecMimeTypes = [...new Set(codecMimeTypes.filter(Boolean))]; string += `; codecs="${uniqueCodecMimeTypes.join(', ')}"`; @@ -822,7 +822,7 @@ export class MatroskaDemuxer extends Demuxer { if (this.currentTrack?.info?.type !== 'video') break; const rotation = reader.readFloat(size); - const flippedRotation = -rotation; // Convert clockwise to counter-clockwise + const flippedRotation = -rotation; // Convert counter-clockwise to clockwise try { this.currentTrack.info.rotation = normalizeRotation(flippedRotation); diff --git a/src/media-sink.ts b/src/media-sink.ts index b4b3bf5..e9c2c51 100644 --- a/src/media-sink.ts +++ b/src/media-sink.ts @@ -19,8 +19,15 @@ import { EncodedPacket } from './packet'; import { fromAlaw, fromUlaw } from './pcm'; import { AudioSample, VideoSample } from './sample'; -/** @public */ +/** + * Additional options for controlling packet retrieval. + * @public + */ export type PacketRetrievalOptions = { + /** + * When set to true, only packet metadata (like timestamp) will be retrieved - the actual packet data will not + * be loaded. + */ metadataOnly?: boolean; }; @@ -39,7 +46,10 @@ const validateTimestamp = (timestamp: number) => { } }; -/** @public */ +/** + * Sink for retrieving encoded packets from an input track. + * @public + */ export class EncodedPacketSink { /** @internal */ _track: InputTrack; @@ -52,17 +62,33 @@ export class EncodedPacketSink { this._track = track; } + /** + * Retrieves the track's first packet (in decode order), or null if it has no packets. The first packet is very + * likely to be a key packet. + */ getFirstPacket(options: PacketRetrievalOptions = {}) { validatePacketRetrievalOptions(options); return this._track._backing.getFirstPacket(options); } + /** + * Retrieves the packet corresponding to the given timestamp, in seconds. More specifically, returns the last packet + * (in presentation order) with a start timestamp less than or equal to the given timestamp. This method can be + * used to retrieve a track's last packet using `getPacket(Infinity)`. The method returns null if the timestamp + * is before the first packet in the track. + * + * @param timestamp - The timestamp used for retrieval, in seconds. + */ getPacket(timestamp: number, options: PacketRetrievalOptions = {}) { validateTimestamp(timestamp); validatePacketRetrievalOptions(options); return this._track._backing.getPacket(timestamp, options); } + /** + * Retrieves the packet following the given packet (in decode order), or null if the given packet is the + * last packet. + */ getNextPacket(packet: EncodedPacket, options: PacketRetrievalOptions = {}) { if (!(packet instanceof EncodedPacket)) { throw new TypeError('packet must be an EncodedPacket.'); @@ -71,12 +97,25 @@ export class EncodedPacketSink { return this._track._backing.getNextPacket(packet, options); } + /** + * Retrieves the key packet corresponding to the given timestamp, in seconds. More specifically, returns the last + * key packet (in presentation order) with a start timestamp less than or equal to the given timestamp. A key packet + * is a packet that doesn't require previous packets to be decoded. This method can be used to retrieve a track's + * last key packet using `getKeyPacket(Infinity)`. The method returns null if the timestamp is before the first + * key packet in the track. + * + * @param timestamp - The timestamp used for retrieval, in seconds. + */ getKeyPacket(timestamp: number, options: PacketRetrievalOptions = {}) { validateTimestamp(timestamp); validatePacketRetrievalOptions(options); return this._track._backing.getKeyPacket(timestamp, options); } + /** + * Retrieves the key packet following the given packet (in decode order), or null if the given packet is the last + * key packet. + */ getNextKeyPacket(packet: EncodedPacket, options: PacketRetrievalOptions = {}) { if (!(packet instanceof EncodedPacket)) { throw new TypeError('packet must be an EncodedPacket.'); @@ -85,6 +124,13 @@ export class EncodedPacketSink { return this._track._backing.getNextKeyPacket(packet, options); } + /** + * Creates an async iterator that yields the packets in this track in decode order. To enable fast iteration, this + * method will intelligently preload packets based on the speed of the consumer. + * + * @param startPacket - (optional) The packet from which iteration should begin. This packet will also be yielded. + * @param endTimestamp - The timestamp in seconds at which to stop iteration. This timestamp is exclusive. + */ packets( startPacket?: EncodedPacket, endTimestamp = Infinity, @@ -195,7 +241,10 @@ abstract class DecoderWrapper< abstract close(): void; } -/** @public */ +/** + * Base class for decoded media sample sinks. + * @public + */ export abstract class BaseMediaSampleSink< MediaSample extends VideoSample | AudioSample, > { @@ -688,7 +737,10 @@ class VideoDecoderWrapper extends DecoderWrapper { } } -/** @public */ +/** + * A sink that retrieves decoded video samples (video frames) from a video track. + * @public + */ export class VideoSampleSink extends BaseMediaSampleSink { /** @internal */ _videoTrack: InputVideoTrack; @@ -729,6 +781,13 @@ export class VideoSampleSink extends BaseMediaSampleSink { return new EncodedPacketSink(this._videoTrack); } + /** + * Retrieves the video sample (frame) corresponding to the given timestamp, in seconds. More specifically, returns + * the last video sample (in presentation order) with a start timestamp less than or equal to the given timestamp. + * Returns null if the timestamp is before the track's first timestamp. + * + * @param timestamp - The timestamp used for retrieval, in seconds. + */ async getSample(timestamp: number) { validateTimestamp(timestamp); @@ -738,32 +797,87 @@ export class VideoSampleSink extends BaseMediaSampleSink { throw new Error('Internal error: Iterator returned nothing.'); } + /** + * Creates an async iterator that yields the video samples (frames) of this track in presentation order. This method + * will intelligently pre-decode a few frames ahead to enable fast iteration. + * + * @param startTimestamp - The timestamp in seconds at which to start yielding samples (inclusive). + * @param endTimestamp - The timestamp in seconds at which to stop yielding samples (exclusive). + */ samples(startTimestamp = 0, endTimestamp = Infinity) { return this.mediaSamplesInRange(startTimestamp, endTimestamp); } + /** + * Creates an async iterator that yields a video sample (frame) for each timestamp in the argument. This method + * uses an optimized decoding pipeline if these timestamps are monotonically sorted, decoding each packet at most + * once, and is therefore more efficient than manually getting the sample for every timestamp. The iterator may + * yield null if no frame is available for a given timestamp. + * + * @param timestamps - An iterable or async iterable of timestamps in seconds. + */ samplesAtTimestamps(timestamps: AnyIterable) { return this.mediaSamplesAtTimestamps(timestamps); } } -/** @public */ +/** + * A canvas with additional timing information (timestamp & duration). + * @public + */ export type WrappedCanvas = { + /** A canvas element or offscreen canvas. */ canvas: HTMLCanvasElement | OffscreenCanvas; + /** The timestamp of the corresponding video sample, in seconds. */ timestamp: number; + /** The duration of the corresponding video sample, in seconds. */ duration: number; }; -/** @public */ +/** + * Options for constructing a CanvasSink. + * @public + */ export type CanvasSinkOptions = { + /** + * The width of the output canvas, defaulting to the display width of the video track. If height is not set, it + * will be deduced automatically based on aspect ratio. + */ width?: number; + /** + * The height of the output canvas, defaulting to the display height of the video track. If width is not set, it + * will be deduced automatically based on aspect ratio. + */ height?: number; + /** + * The fitting algorithm in case both width and height are set. + * + * - 'fill' will stretch the image to fill the entire box, potentially altering aspect ratio. + * - 'contain' will contain the entire image within the box while preserving aspect ratio. This may lead to + * letterboxing. + * - 'cover' will scale the image until the entire box is filled, while preserving aspect ratio. + */ fit?: 'fill' | 'contain' | 'cover'; + /** + * The clockwise rotation by which to rotate the raw video frame. Defaults to the rotation set in the file metadata. + * Rotation is applied before resizing. + */ rotation?: Rotation; + /** + * When set, specifies the number of canvases in the pool. These canvases will be reused in a ring buffer / + * round-robin type fashion. This keeps the amount of allocated VRAM constant and relieves the browser from + * constantly allocating/deallocating canvases. A pool size of 0 or `undefined` disables the pool and means a new + * canvas is created each time. + */ poolSize?: number; }; -/** @public */ +/** + * A sink that renders video samples (frames) of the given video track to canvases. This is often more useful than + * directly retrieving frames, as it comes with common preprocessing steps such as resizing or applying rotation + * metadata. + * @public + */ export class CanvasSink { /** @internal */ _videoTrack: InputVideoTrack; @@ -917,6 +1031,13 @@ export class CanvasSink { return result; } + /** + * Retrieves a canvas with the video frame corresponding to the given timestamp, in seconds. More specifically, + * returns the last video frame (in presentation order) with a start timestamp less than or equal to the given + * timestamp. Returns null if the timestamp is before the track's first timestamp. + * + * @param timestamp - The timestamp used for retrieval, in seconds. + */ async getCanvas(timestamp: number) { validateTimestamp(timestamp); @@ -924,6 +1045,13 @@ export class CanvasSink { return sample && this._videoSampleToWrappedCanvas(sample); } + /** + * Creates an async iterator that yields canvases with the video frames of this track in presentation order. This + * method will intelligently pre-decode a few frames ahead to enable fast iteration. + * + * @param startTimestamp - The timestamp in seconds at which to start yielding canvases (inclusive). + * @param endTimestamp - The timestamp in seconds at which to stop yielding canvases (exclusive). + */ canvases(startTimestamp = 0, endTimestamp = Infinity) { return mapAsyncGenerator( this._videoSampleSink.samples(startTimestamp, endTimestamp), @@ -931,6 +1059,14 @@ export class CanvasSink { ); } + /** + * Creates an async iterator that yields a canvas for each timestamp in the argument. This method uses an optimized + * decoding pipeline if these timestamps are monotonically sorted, decoding each packet at most once, and is + * therefore more efficient than manually getting the canvas for every timestamp. The iterator may yield null if + * no frame is available for a given timestamp. + * + * @param timestamps - An iterable or async iterable of timestamps in seconds. + */ canvasesAtTimestamps(timestamps: AnyIterable) { return mapAsyncGenerator( this._videoSampleSink.samplesAtTimestamps(timestamps), @@ -1187,7 +1323,10 @@ class PcmAudioDecoderWrapper extends DecoderWrapper { } } -/** @public */ +/** + * Sink for retrieving decoded audio samples from an audio track. + * @public + */ export class AudioSampleSink extends BaseMediaSampleSink { /** @internal */ _audioTrack: InputAudioTrack; @@ -1230,6 +1369,13 @@ export class AudioSampleSink extends BaseMediaSampleSink { return new EncodedPacketSink(this._audioTrack); } + /** + * Retrieves the audio sample corresponding to the given timestamp, in seconds. More specifically, returns + * the last audio sample (in presentation order) with a start timestamp less than or equal to the given timestamp. + * Returns null if the timestamp is before the track's first timestamp. + * + * @param timestamp - The timestamp used for retrieval, in seconds. + */ async getSample(timestamp: number) { validateTimestamp(timestamp); @@ -1239,23 +1385,48 @@ export class AudioSampleSink extends BaseMediaSampleSink { throw new Error('Internal error: Iterator returned nothing.'); } + /** + * Creates an async iterator that yields the audio samples of this track in presentation order. This method + * will intelligently pre-decode a few samples ahead to enable fast iteration. + * + * @param startTimestamp - The timestamp in seconds at which to start yielding samples (inclusive). + * @param endTimestamp - The timestamp in seconds at which to stop yielding samples (exclusive). + */ samples(startTimestamp = 0, endTimestamp = Infinity) { return this.mediaSamplesInRange(startTimestamp, endTimestamp); } + /** + * Creates an async iterator that yields an audio sample for each timestamp in the argument. This method + * uses an optimized decoding pipeline if these timestamps are monotonically sorted, decoding each packet at most + * once, and is therefore more efficient than manually getting the sample for every timestamp. The iterator may + * yield null if no sample is available for a given timestamp. + * + * @param timestamps - An iterable or async iterable of timestamps in seconds. + */ samplesAtTimestamps(timestamps: AnyIterable) { return this.mediaSamplesAtTimestamps(timestamps); } } -/** @public */ +/** + * An AudioBuffer with additional timing information (timestamp & duration). + * @public + */ export type WrappedAudioBuffer = { + /** An AudioBuffer. */ buffer: AudioBuffer; + /** The timestamp of the corresponding audio sample, in seconds. */ timestamp: number; + /** The duration of the corresponding audio sample, in seconds. */ duration: number; }; -/** @public */ +/** + * A sink that retrieves decoded audio samples from an audio track and converts them to AudioBuffers. This is often + * more useful than directly retrieving audio samples, as AudioBuffers can be directly used with the Web Audio API. + * @public + */ export class AudioBufferSink { /** @internal */ _audioSampleSink: AudioSampleSink; @@ -1277,6 +1448,13 @@ export class AudioBufferSink { }; } + /** + * Retrieves the audio buffer corresponding to the given timestamp, in seconds. More specifically, returns + * the last audio buffer (in presentation order) with a start timestamp less than or equal to the given timestamp. + * Returns null if the timestamp is before the track's first timestamp. + * + * @param timestamp - The timestamp used for retrieval, in seconds. + */ async getBuffer(timestamp: number) { validateTimestamp(timestamp); @@ -1284,6 +1462,13 @@ export class AudioBufferSink { return data && this._audioSampleToWrappedArrayBuffer(data); } + /** + * Creates an async iterator that yields audio buffers of this track in presentation order. This method + * will intelligently pre-decode a few buffers ahead to enable fast iteration. + * + * @param startTimestamp - The timestamp in seconds at which to start yielding buffers (inclusive). + * @param endTimestamp - The timestamp in seconds at which to stop yielding buffers (exclusive). + */ buffers(startTimestamp = 0, endTimestamp = Infinity) { return mapAsyncGenerator( this._audioSampleSink.samples(startTimestamp, endTimestamp), @@ -1291,6 +1476,14 @@ export class AudioBufferSink { ); } + /** + * Creates an async iterator that yields an audio buffer for each timestamp in the argument. This method + * uses an optimized decoding pipeline if these timestamps are monotonically sorted, decoding each packet at most + * once, and is therefore more efficient than manually getting the buffer for every timestamp. The iterator may + * yield null if no buffer is available for a given timestamp. + * + * @param timestamps - An iterable or async iterable of timestamps in seconds. + */ buffersAtTimestamps(timestamps: AnyIterable) { return mapAsyncGenerator( this._audioSampleSink.samplesAtTimestamps(timestamps), diff --git a/src/media-source.ts b/src/media-source.ts index ecd7470..ee4096f 100644 --- a/src/media-source.ts +++ b/src/media-source.ts @@ -28,7 +28,10 @@ import { import { EncodedPacket } from './packet'; import { AudioSample, VideoSample } from './sample'; -/** @public */ +/** + * Base class for media sources. Media sources are used to add media samples to an output file. + * @public + */ export abstract class MediaSource { /** @internal */ _connectedTrack: OutputTrack | null = null; @@ -67,6 +70,11 @@ export abstract class MediaSource { /** @internal */ async _flush() {} + /** + * Closes this source. This prevents future samples from being added and signals to the output file that no further + * samples will come in for this track. Calling `.close()` is optional but recommended after adding the + * last sample - for improved performance and reduced memory usage. + */ close() { if (this._closingPromise) { throw new Error('Source already closed.'); @@ -106,7 +114,10 @@ export abstract class MediaSource { } } -/** @public */ +/** + * Base class for video sources - sources for video tracks. + * @public + */ export abstract class VideoSource extends MediaSource { /** @internal */ override _connectedTrack: OutputVideoTrack | null = null; @@ -124,12 +135,24 @@ export abstract class VideoSource extends MediaSource { } } -/** @public */ +/** + * The most basic video source; can be used to directly pipe encoded packets into the output file. + * @public + */ export class EncodedVideoPacketSource extends VideoSource { constructor(codec: VideoCodec) { super(codec); } + /** + * Adds an encoded packet to the output video track. + * + * @param meta - Additional metadata from the encoder. You should pass this for the first call, including a valid + * decoder config. + * + * @returns A Promise that resolves once the output is ready to receive more samples. You should await this Promise + * to respect writer and encoder backpressure. + */ add(packet: EncodedPacket, meta?: EncodedVideoChunkMetadata) { if (!(packet instanceof EncodedPacket)) { throw new TypeError('packet must be an EncodedPacket.'); @@ -143,13 +166,29 @@ export class EncodedVideoPacketSource extends VideoSource { } } -/** @public */ +/** + * Configuration object that controls video encoding. Can be used to set codec, quality, and more. + * @public + */ export type VideoEncodingConfig = { + /** The video codec that should be used for encoding the video samples (frames). */ codec: VideoCodec; + /** + * The target bitrate for the encoded video, in bits per second. Alternatively, a subjective Quality can + * be provided. + */ bitrate: number | Quality; + /** The latency mode used by the encoder; controls the performance-quality tradeoff. */ latencyMode?: VideoEncoderConfig['latencyMode']; + /** + * The interval, in seconds, of how often frames are encoded as a key frame. The default is 5 seconds. Frequent key + * frames improve seeking behavior but increase file size. When using multiple video tracks, you should give them + * all the same key frame interval. + */ keyFrameInterval?: number; + /** Called for each successfully encoded packet. Both the packet and the encoding metadata are passed. */ onEncodedPacket?: (packet: EncodedPacket, meta: EncodedVideoChunkMetadata | undefined) => unknown; + /** Called when an error occurs during encoding. */ onEncodingError?: (error: Error) => unknown; }; @@ -223,7 +262,7 @@ class VideoEncoderWrapper { const keyFrameInterval = this.encodingConfig.keyFrameInterval ?? 5; const multipleOfKeyFrameInterval = Math.floor(videoSample.timestamp / keyFrameInterval); - // Ensure a key frame every KEY_FRAME_INTERVAL seconds. It is important that all video tracks follow the same + // Ensure a key frame every keyFrameInterval seconds. It is important that all video tracks follow the same // "key frame" rhythm, because aligned key frames are required to start new fragments in ISOBMFF or clusters // in Matroska. const finalEncodeOptions = { @@ -367,7 +406,11 @@ class VideoEncoderWrapper { } } -/** @public */ +/** + * This source can be used to add raw, unencoded video samples (frames) to an output video track. These frames will + * automatically be encoded and then piped into the output. + * @public + */ export class VideoSampleSource extends VideoSource { /** @internal */ private _encoder: VideoEncoderWrapper; @@ -379,6 +422,12 @@ export class VideoSampleSource extends VideoSource { this._encoder = new VideoEncoderWrapper(this, encodingConfig); } + /** + * Encodes a video sample (frame) and then adds it to the output. + * + * @returns A Promise that resolves once the output is ready to receive more samples. You should await this Promise + * to respect writer and encoder backpressure. + */ add(videoSample: VideoSample, encodeOptions?: VideoEncoderEncodeOptions) { if (!(videoSample instanceof VideoSample)) { throw new TypeError('videoSample must be a VideoSample.'); @@ -393,7 +442,11 @@ export class VideoSampleSource extends VideoSource { } } -/** @public */ +/** + * This source can be used to add video frames to the output track from a fixed canvas element. Since canvases are often + * used for rendering, this source provides a convenient wrapper around VideoSampleSource. + * @public + */ export class CanvasSource extends VideoSource { /** @internal */ private _encoder: VideoEncoderWrapper; @@ -414,6 +467,15 @@ export class CanvasSource extends VideoSource { this._canvas = canvas; } + /** + * Captures the current canvas state as a video sample (frame), encodes it and adds it to the output. + * + * @param timestamp - The timestamp of the sample, in seconds. + * @param duration - The duration of the sample, in seconds. + * + * @returns A Promise that resolves once the output is ready to receive more samples. You should await this Promise + * to respect writer and encoder backpressure. + */ add(timestamp: number, duration = 0, encodeOptions?: VideoEncoderEncodeOptions) { if (!Number.isFinite(timestamp) || timestamp < 0) { throw new TypeError('timestamp must be a non-negative number.'); @@ -432,7 +494,13 @@ export class CanvasSource extends VideoSource { } } -/** @public */ +/** + * Video source that encodes the frames of a MediaStreamVideoTrack and pipes them into the output. This is useful for + * capturing live or real-time data such as webcams or screen captures. Frames will automatically start being captured + * once the connected Output is started, and will keep being captured until the Output is finalized or this source + * is closed. + * @public + */ export class MediaStreamVideoTrackSource extends VideoSource { /** @internal */ private _encoder: VideoEncoderWrapper; @@ -498,7 +566,10 @@ export class MediaStreamVideoTrackSource extends VideoSource { } } -/** @public */ +/** + * Base class for audio sources - sources for audio tracks. + * @public + */ export abstract class AudioSource extends MediaSource { /** @internal */ override _connectedTrack: OutputAudioTrack | null = null; @@ -516,12 +587,24 @@ export abstract class AudioSource extends MediaSource { } } -/** @public */ +/** + * The most basic audio source; can be used to directly pipe encoded packets into the output file. + * @public + */ export class EncodedAudioPacketSource extends AudioSource { constructor(codec: AudioCodec) { super(codec); } + /** + * Adds an encoded packet to the output audio track. + * + * @param meta - Additional metadata from the encoder. You should pass this for the first call, including a valid + * decoder config. + * + * @returns A Promise that resolves once the output is ready to receive more samples. You should await this Promise + * to respect writer and encoder backpressure. + */ add(packet: EncodedPacket, meta?: EncodedAudioChunkMetadata) { if (!(packet instanceof EncodedPacket)) { throw new TypeError('packet must be an EncodedPacket.'); @@ -534,11 +617,22 @@ export class EncodedAudioPacketSource extends AudioSource { return this._connectedTrack!.output._muxer.addEncodedAudioPacket(this._connectedTrack!, packet, meta); } } -/** @public */ + +/** + * Configuration object that controls audio encoding. Can be used to set codec, quality, and more. + * @public + */ export type AudioEncodingConfig = { + /** The audio codec that should be used for encoding the audio samples. */ codec: AudioCodec; + /** + * The target bitrate for the encoded audio, in bits per second. Alternatively, a subjective Quality can + * be provided. Required for compressed audio codecs, unused for PCM codecs. + */ bitrate?: number | Quality; + /** Called for each successfully encoded packet. Both the packet and the encoding metadata are passed. */ onEncodedPacket?: (packet: EncodedPacket, meta: EncodedAudioChunkMetadata | undefined) => unknown; + /** Called when an error occurs during encoding. */ onEncodingError?: (error: Error) => unknown; }; @@ -901,7 +995,11 @@ class AudioEncoderWrapper { } } -/** @public */ +/** + * This source can be used to add raw, unencoded audio samples to an output audio track. These samples will + * automatically be encoded and then piped into the output. + * @public + */ export class AudioSampleSource extends AudioSource { /** @internal */ private _encoder: AudioEncoderWrapper; @@ -913,6 +1011,12 @@ export class AudioSampleSource extends AudioSource { this._encoder = new AudioEncoderWrapper(this, encodingConfig); } + /** + * Encodes an audio sample and then adds it to the output. + * + * @returns A Promise that resolves once the output is ready to receive more samples. You should await this Promise + * to respect writer and encoder backpressure. + */ add(audioSample: AudioSample) { if (!(audioSample instanceof AudioSample)) { throw new TypeError('audioSample must be an AudioSample.'); @@ -927,7 +1031,11 @@ export class AudioSampleSource extends AudioSource { } } -/** @public */ +/** + * This source can be used to add audio data from an AudioBuffer to the output track. This is useful when working with + * the Web Audio API. + * @public + */ export class AudioBufferSource extends AudioSource { /** @internal */ private _encoder: AudioEncoderWrapper; @@ -941,6 +1049,14 @@ export class AudioBufferSource extends AudioSource { this._encoder = new AudioEncoderWrapper(this, encodingConfig); } + /** + * Converts an AudioBuffer to audio samples, encodes them and adds them to the output. The first AudioBuffer will + * be played at timestamp 0, and any subsequent AudioBuffer will have a timestamp equal to the total duration of + * all previous AudioBuffers. + * + * @returns A Promise that resolves once the output is ready to receive more samples. You should await this Promise + * to respect writer and encoder backpressure. + */ add(audioBuffer: AudioBuffer) { if (!(audioBuffer instanceof AudioBuffer)) { throw new TypeError('audioBuffer must be an AudioBuffer.'); @@ -996,7 +1112,13 @@ export class AudioBufferSource extends AudioSource { } } -/** @public */ +/** + * Audio source that encodes the data of a MediaStreamAudioTrack and pipes it into the output. This is useful for + * capturing live or real-time audio such as microphones or audio from other media elements. Audio will automatically + * start being captured once the connected Output is started, and will keep being captured until the Output is + * finalized or this source is closed. + * @public + */ export class MediaStreamAudioTrackSource extends AudioSource { /** @internal */ private _encoder: AudioEncoderWrapper; @@ -1057,7 +1179,10 @@ export class MediaStreamAudioTrackSource extends AudioSource { } } -/** @public */ +/** + * Base class for subtitle sources - sources for subtitle tracks. + * @public + */ export abstract class SubtitleSource extends MediaSource { /** @internal */ override _connectedTrack: OutputSubtitleTrack | null = null; @@ -1075,7 +1200,10 @@ export abstract class SubtitleSource extends MediaSource { } } -/** @public */ +/** + * This source can be used to add subtitles from a subtitle text file. + * @public + */ export class TextSubtitleSource extends SubtitleSource { /** @internal */ private _parser: SubtitleParser; @@ -1090,6 +1218,13 @@ export class TextSubtitleSource extends SubtitleSource { }); } + /** + * Parses the subtitle text according to the specified codec and adds it to the output track. You don't have to + * add the entire subtitle file at once here; you can provide it in chunks. + * + * @returns A Promise that resolves once the output is ready to receive more samples. You should await this Promise + * to respect writer and encoder backpressure. + */ add(text: string) { if (typeof text !== 'string') { throw new TypeError('text must be a string.'); diff --git a/src/misc.ts b/src/misc.ts index e4d8b19..71bb447 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -4,7 +4,10 @@ export function assert(x: unknown): asserts x { } } -/** @public */ +/** + * Represents a clockwise rotation in degrees. + * @public + */ export type Rotation = 0 | 90 | 180 | 270; export const normalizeRotation = (rotation: number) => { @@ -243,7 +246,10 @@ export const findLastIndex = (arr: T[], predicate: (x: T) => boolean) => { return -1; }; -/** @public */ +/** + * Sync or async iterable. + * @public + */ export type AnyIterable = | Iterable | AsyncIterable; @@ -386,5 +392,8 @@ export const isIso639Dash2LanguageCode = (x: string) => { // 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); -/** @public */ +/** + * Sets all keys K of T to be required. + * @public + */ export type SetRequired = T & Required>; diff --git a/src/ogg/ogg-demuxer.ts b/src/ogg/ogg-demuxer.ts index e916c66..a9045d9 100644 --- a/src/ogg/ogg-demuxer.ts +++ b/src/ogg/ogg-demuxer.ts @@ -363,7 +363,7 @@ export class OggDemuxer extends Demuxer { let string = 'audio/ogg'; if (this.tracks.length > 0) { - const codecMimeTypes = await Promise.all(this.tracks.map(x => x.getCodecMimeType())); + const codecMimeTypes = await Promise.all(this.tracks.map(x => x.getCodecParameterString())); const uniqueCodecMimeTypes = [...new Set(codecMimeTypes.filter(Boolean))]; string += `; codecs="${uniqueCodecMimeTypes.join(', ')}"`; diff --git a/src/output-format.ts b/src/output-format.ts index 77480eb..9ecc6fd 100644 --- a/src/output-format.ts +++ b/src/output-format.ts @@ -17,37 +17,60 @@ import { OggMuxer } from './ogg/ogg-muxer'; import { Output, TrackType } from './output'; import { WaveMuxer } from './wave/wave-muxer'; -/** @public */ -export type InclusiveRange = { min: number; max: number }; -/** @public */ -export type TrackCountLimits = { - [K in TrackType]: InclusiveRange; -} & { - total: InclusiveRange; +/** + * Specifies an inclusive range of integers. + * @public + */ +export type InclusiveIntegerRange = { + /** The integer cannot be less than this. */ + min: number; + /** The integer cannot be greater than this. */ + max: number; }; -/** @public */ +/** + * Specifies the number of tracks (for each track type and in total) that an output format supports. + * @public + */ +export type TrackCountLimits = { + [K in TrackType]: InclusiveIntegerRange; +} & { + /** Specifies the overall allowed range of track counts for the output format. */ + total: InclusiveIntegerRange; +}; + +/** + * Base class representing an output media file format. + * @public + */ export abstract class OutputFormat { /** @internal */ abstract _createMuxer(output: Output): Muxer; /** @internal */ abstract get _name(): string; + /** The file extension used by this output format, beginning with a dot. */ abstract get fileExtension(): string; + /** Returns a list of media codecs that this output format can contain. */ abstract getSupportedCodecs(): MediaCodec[]; + /** Returns the number of tracks that this output format supports. */ abstract getSupportedTrackCounts(): TrackCountLimits; + /** Whether this output format supports video rotation metadata. */ abstract get supportsVideoRotationMetadata(): boolean; + /** Returns a list of video codecs that this output format can contain. */ getSupportedVideoCodecs() { return this.getSupportedCodecs() .filter(codec => (VIDEO_CODECS as readonly string[]).includes(codec)) as VideoCodec[]; } + /** Returns a list of audio codecs that this output format can contain. */ getSupportedAudioCodecs() { return this.getSupportedCodecs() .filter(codec => (AUDIO_CODECS as readonly string[]).includes(codec)) as AudioCodec[]; } + /** Returns a list of subtitle codecs that this output format can contain. */ getSupportedSubtitleCodecs() { return this.getSupportedCodecs() .filter(codec => (SUBTITLE_CODECS as readonly string[]).includes(codec)) as SubtitleCodec[]; @@ -60,12 +83,39 @@ export abstract class OutputFormat { } } -/** @public */ +/** + * Options controlling the format of an output ISOBMFF file. + * @public + */ export type IsobmffOutputFormatOptions = { + /** + * Controls the placement of metadata in the file. Placing metadata at the start of the file is known as "Fast + * Start", which results in better playback at the cost of more required processing or memory. + * + * Use `false` to disable Fast Start, placing the metadata at the end of the file. Fastest and uses the least + * memory. + * + * Use `'in-memory'` to produce a file with Fast Start by keeping all media chunks in memory until the file is + * finalized. This produces a high-quality and compact output at the cost of a more expensive finalization step and + * higher memory requirements. Data will be written monotonically (in order) when this option is set. + * + * Use `'fragmented'` to place metadata at the start of the file by creating a fragmented file. In a + * fragmented file, chunks of media and their metadata are written to the file in "fragments", eliminating the need + * to put all metadata in one place. Fragmented files are useful for streaming, as they allow for better random + * access. Furthermore, they remain lightweight to create even for very large files, as they don't require all media + * to be kept in memory. However, fragmented files are not as widely and wholly supported as regular MP4/MOV files. + * Data will be written monotonically (in order) when this option is set. + * + * When this field is not defined, either `false` or `'in-memory'` will be used, automatically determined based on + * the type of output target used. + */ fastStart?: false | 'in-memory' | 'fragmented'; }; -/** @public */ +/** + * Format representing files compatible with the ISO base media file format (ISOBMFF), like MP4 or MOV files. + * @public + */ export abstract class IsobmffOutputFormat extends OutputFormat { /** @internal */ _options: IsobmffOutputFormatOptions; @@ -102,7 +152,10 @@ export abstract class IsobmffOutputFormat extends OutputFormat { } } -/** @public */ +/** + * MPEG-4 Part 14 (MP4) file format. Supports all codecs except PCM audio codecs. + * @public + */ export class Mp4OutputFormat extends IsobmffOutputFormat { /** @internal */ get _name() { @@ -131,7 +184,10 @@ export class Mp4OutputFormat extends IsobmffOutputFormat { } } -/** @public */ +/** + * QuickTime File Format (QTFF), often called MOV. Supports all video and audio codecs, but not subtitle codecs. + * @public + */ export class MovOutputFormat extends IsobmffOutputFormat { /** @internal */ get _name() { @@ -159,12 +215,23 @@ export class MovOutputFormat extends IsobmffOutputFormat { } } -/** @public */ +/** + * Options controlling the format of an output Matroska file. + * @public + */ export type MkvOutputFormatOptions = { + /** + * Configures the output to only write data monotonically, useful for live-streaming the file as it's being muxed. + * When enabled, some features such as storing duration and seeking will be disabled or impacted, so don't use this + * option when you want to write out a file for later use. + */ streamable?: boolean; }; -/** @public */ +/** + * Matroska file format. + * @public + */ export class MkvOutputFormat extends OutputFormat { /** @internal */ _options: MkvOutputFormatOptions; @@ -220,10 +287,16 @@ export class MkvOutputFormat extends OutputFormat { } } -/** @public */ +/** + * Options controlling the format of an output WebM file. + * @public + */ export type WebMOutputFormatOptions = MkvOutputFormatOptions; -/** @public */ +/** + * WebM file format, based on Matroska. + * @public + */ export class WebMOutputFormat extends MkvOutputFormat { override getSupportedCodecs(): MediaCodec[] { return [ @@ -252,7 +325,10 @@ export class WebMOutputFormat extends MkvOutputFormat { } } -/** @public */ +/** + * MP3 file format. + * @public + */ export class Mp3OutputFormat extends OutputFormat { /** @internal */ _createMuxer(output: Output) { @@ -286,7 +362,10 @@ export class Mp3OutputFormat extends OutputFormat { } } -/** @public */ +/** + * WAVE file format, based on RIFF. + * @public + */ export class WaveOutputFormat extends OutputFormat { /** @internal */ _createMuxer(output: Output) { @@ -324,7 +403,10 @@ export class WaveOutputFormat extends OutputFormat { } } -/** @public */ +/** + * Ogg file format. + * @public + */ export class OggOutputFormat extends OutputFormat { /** @internal */ _createMuxer(output: Output) { diff --git a/src/output.ts b/src/output.ts index 6589d6e..418ae16 100644 --- a/src/output.ts +++ b/src/output.ts @@ -1,22 +1,33 @@ -import { AsyncMutex, isIso639Dash2LanguageCode } from './misc'; +import { AsyncMutex, isIso639Dash2LanguageCode, Rotation } from './misc'; import { Muxer } from './muxer'; import { OutputFormat } from './output-format'; import { AudioSource, MediaSource, SubtitleSource, VideoSource } from './media-source'; import { Target } from './target'; import { Writer } from './writer'; -/** @public */ +/** + * The options for creating an Output object. + * @public + */ export type OutputOptions< F extends OutputFormat = OutputFormat, T extends Target = Target, > = { + /** The format of the output file. */ format: F; + /** The target to which the file will be written. */ target: T; }; -/** @public */ +/** + * List of all track types. + * @public + */ export const ALL_TRACK_TYPES = ['video', 'audio', 'subtitle'] as const; -/** @public */ +/** + * Union type of all track types. + * @public + */ export type TrackType = typeof ALL_TRACK_TYPES[number]; export type OutputTrack = { @@ -41,19 +52,34 @@ export type OutputVideoTrack = OutputTrack & { type: 'video' }; export type OutputAudioTrack = OutputTrack & { type: 'audio' }; export type OutputSubtitleTrack = OutputTrack & { type: 'subtitle' }; -/** @public */ +/** + * Base track metadata, applicable to all tracks. + * @public + */ export type BaseTrackMetadata = { + /** The three-letter, ISO 639-2 language code specifying the language of this track. */ languageCode?: string; }; -/** @public */ +/** + * Additional metadata for video tracks. + * @public + */ export type VideoTrackMetadata = BaseTrackMetadata & { - rotation?: 0 | 90 | 180 | 270; + /** The angle in degrees by which the track's frames should be rotated (clockwise). */ + rotation?: Rotation; + /** The expected video frame rate. You should not exceed the value you set here. */ frameRate?: number; }; -/** @public */ +/** + * Additional metadata for audio tracks. + * @public + */ export type AudioTrackMetadata = BaseTrackMetadata & {}; -/** @public */ +/** + * Additional metadata for subtitle tracks. + * @public + */ export type SubtitleTrackMetadata = BaseTrackMetadata & {}; const validateBaseTrackMetadata = (metadata: BaseTrackMetadata) => { @@ -65,13 +91,19 @@ const validateBaseTrackMetadata = (metadata: BaseTrackMetadata) => { } }; -/** @public */ +/** + * Main class orchestrating the creation of a new media file. + * @public + */ export class Output< F extends OutputFormat = OutputFormat, T extends Target = Target, > { + /** The format of the output file. */ format: F; + /** The target to which the file will be written. */ target: T; + /** The current state of the output. */ state: 'pending' | 'started' | 'canceled' | 'finalizing' | 'finalized' = 'pending'; /** @internal */ @@ -112,6 +144,7 @@ export class Output< this._muxer = options.format._createMuxer(this); } + /** Adds a video track to the output with the given source. Must be called before output is started. */ addVideoTrack(source: VideoSource, metadata: VideoTrackMetadata = {}) { if (!(source instanceof VideoSource)) { throw new TypeError('source must be a VideoSource.'); @@ -135,6 +168,7 @@ export class Output< this._addTrack('video', source, metadata); } + /** Adds an audio track to the output with the given source. Must be called before output is started. */ addAudioTrack(source: AudioSource, metadata: AudioTrackMetadata = {}) { if (!(source instanceof AudioSource)) { throw new TypeError('source must be an AudioSource.'); @@ -144,6 +178,7 @@ export class Output< this._addTrack('audio', source, metadata); } + /** Adds a subtitle track to the output with the given source. Must be called before output is started. */ addSubtitleTrack(source: SubtitleSource, metadata: SubtitleTrackMetadata = {}) { if (!(source instanceof SubtitleSource)) { throw new TypeError('source must be a SubtitleSource.'); @@ -244,6 +279,12 @@ export class Output< source._connectedTrack = track; } + /** + * Starts the creation of the output file. This method should be called after all tracks have been added. Only after + * the output has started can media samples be added to the tracks. + * + * @returns A promise that resolves when the output has successfully started and is ready to receive media samples. + */ async start() { // Verify minimum track count constraints const supportedTrackCounts = this.format.getSupportedTrackCounts(); @@ -299,6 +340,12 @@ export class Output< })(); } + /** + * Cancels the creation of the output file, releasing internal resources like encoders and preventing further + * samples from being added. + * + * @returns A promise that resolves once all internal resources have been released. + */ async cancel() { if (this._cancelPromise) { console.warn('Output has already been canceled.'); @@ -322,6 +369,10 @@ export class Output< })(); } + /** + * Finalizes the output file. This method must be called after all media samples across all tracks have been added. + * Once the Promise returned by this method completes, the output file is ready. + */ async finalize() { if (this.state === 'pending') { throw new Error('Cannot finalize before starting.'); diff --git a/src/packet.ts b/src/packet.ts index 6c968ef..a889f8b 100644 --- a/src/packet.ts +++ b/src/packet.ts @@ -2,17 +2,43 @@ import { SECOND_TO_MICROSECOND_FACTOR } from './misc'; export const PLACEHOLDER_DATA = new Uint8Array(0); -/** @public */ +/** + * The type of a packet. Key packets can be decoded without previous packets, while delta packets depend on previous + * packets. + * @public + */ export type PacketType = 'key' | 'delta'; -/** @public */ +/** + * Represents an encoded chunk of media. Mainly used as an expressive wrapper around WebCodecs API's EncodedVideoChunk + * and EncodedAudioChunk, but can also be used standalone. + * @public + */ export class EncodedPacket { constructor( + /** The encoded data of this packet. */ public readonly data: Uint8Array, + /** The type of this packet. */ public readonly type: PacketType, + /** + * The timestamp of this packet in seconds. May be negative. Samples with negative end timestamps should not + * be presented. + */ public readonly timestamp: number, + /** The duration of this packet in seconds. */ public readonly duration: number, + /** + * The sequence number of this packet. The sequence number indicates the decode order of the packets. Packet A + * must be decoded before packet B if A has a lower sequence number than B. If two packets have the same + * sequence number, they are the same packet. Otherwise, sequence numbers are arbitrary and are not guaranteed + * to have any meaning besides their relative ordering. Negative sequence numbers mean the sequence number + * is undefined. + */ public readonly sequenceNumber = -1, + /** + * The actual byte length of the data in this packet. This field is useful for metadata-only packets where the + * `data` field contains no bytes. + */ public readonly byteLength = data.byteLength, ) { if (!(data instanceof Uint8Array)) { @@ -35,18 +61,22 @@ export class EncodedPacket { } } + /** If this packet is a metadata-only packet. Metadata-only packets don't contain their packet data. */ get isMetadataOnly() { return this.data === PLACEHOLDER_DATA; } + /** The timestamp of this packet in microseconds. */ get microsecondTimestamp() { return Math.trunc(SECOND_TO_MICROSECOND_FACTOR * this.timestamp); } + /** The duration of this packet in microseconds. */ get microsecondDuration() { return Math.trunc(SECOND_TO_MICROSECOND_FACTOR * this.duration); } + /** Converts this packet to an EncodedVideoChunk for use with the WebCodecs API. */ toEncodedVideoChunk() { if (this.isMetadataOnly) { throw new TypeError('Metadata-only packets cannot be converted to a video chunk.'); @@ -63,6 +93,7 @@ export class EncodedPacket { }); } + /** Converts this packet to an EncodedAudioChunk for use with the WebCodecs API. */ toEncodedAudioChunk() { if (this.isMetadataOnly) { throw new TypeError('Metadata-only packets cannot be converted to an audio chunk.'); @@ -79,6 +110,10 @@ export class EncodedPacket { }); } + /** + * Creates an EncodedPacket from an EncodedVideoChunk or EncodedAudioChunk. This method is useful for converting + * chunks from the WebCodecs API to EncodedPackets. + */ static fromEncodedChunk(chunk: EncodedVideoChunk | EncodedAudioChunk): EncodedPacket { if (!(chunk instanceof EncodedVideoChunk || chunk instanceof EncodedAudioChunk)) { throw new TypeError('chunk must be an EncodedVideoChunk or EncodedAudioChunk.'); @@ -95,7 +130,13 @@ export class EncodedPacket { ); } - clone(options?: { timestamp?: number; duration?: number }): EncodedPacket { + /** Clones this packet while optionally updating timing information. */ + clone(options?: { + /** The timestamp of the cloned packet in seconds. */ + timestamp?: number; + /** The duration of the cloned packet in seconds. */ + duration?: number; + }): EncodedPacket { if (options !== undefined && (typeof options !== 'object' || options === null)) { throw new TypeError('options, when provided, must be an object.'); } diff --git a/src/sample.ts b/src/sample.ts index fa3d577..8b14e87 100644 --- a/src/sample.ts +++ b/src/sample.ts @@ -9,44 +9,72 @@ import { SetRequired, } from './misc'; -/** @public */ +/** + * Metadata used for VideoSample initialization. + * @public + */ export type VideoSampleInit = { + /** The internal pixel format in which the frame is stored. */ format?: VideoPixelFormat; + /** The width of the frame in pixels. */ codedWidth?: number; + /** The height of the frame in pixels. */ codedHeight?: number; + /** The rotation of the frame in degrees, clockwise. */ rotation?: Rotation; + /** The timestamp of the frame in seconds. */ timestamp?: number; + /** The duration of the frame in seconds. */ duration?: number; + /** The color space of the frame. */ colorSpace?: VideoColorSpaceInit; }; -/** @public */ +/** + * Represents a raw, unencoded video sample (frame). Mainly used as an expressive wrapper around WebCodecs API's + * VideoFrame, but can also be used standalone. + * @public + */ export class VideoSample { /** @internal */ _data!: VideoFrame | OffscreenCanvas | Uint8Array | null; /** @internal */ _closed: boolean = false; + /** The internal pixel format in which the frame is stored. */ readonly format!: VideoPixelFormat | null; + /** The width of the frame in pixels. */ readonly codedWidth!: number; + /** The height of the frame in pixels. */ readonly codedHeight!: number; + /** The rotation of the frame in degrees, clockwise. */ readonly rotation!: Rotation; + /** + * The timestamp of the frame in seconds. May be negative. Frames with negative end timestamps should not + * be presented. + */ readonly timestamp!: number; + /** The duration of the frame in seconds. */ readonly duration!: number; + /** The color space of the frame. */ readonly colorSpace!: VideoColorSpace; + /** The width of the frame in pixels after rotation. */ get displayWidth() { return this.rotation % 180 === 0 ? this.codedWidth : this.codedHeight; } + /** The height of the frame in pixels after rotation. */ get displayHeight() { return this.rotation % 180 === 0 ? this.codedHeight : this.codedWidth; } + /** The timestamp of the frame in microseconds. */ get microsecondTimestamp() { return Math.trunc(SECOND_TO_MICROSECOND_FACTOR * this.timestamp); } + /** The duration of the frame in microseconds. */ get microsecondDuration() { return Math.trunc(SECOND_TO_MICROSECOND_FACTOR * this.duration); } @@ -188,6 +216,7 @@ export class VideoSample { } } + /** Clones this video sample. */ clone() { if (this._closed) { throw new Error('VideoSample is closed.'); @@ -218,6 +247,10 @@ export class VideoSample { } } + /** + * Closes this video sample, releasing held resources. Video samples should be closed as soon as they are not + * needed anymore. + */ close() { if (this._closed) { return; @@ -232,6 +265,7 @@ export class VideoSample { this._closed = true; } + /** Returns the number of bytes required to hold this video sample's pixel data. */ allocationSize() { if (this._closed) { throw new Error('VideoSample is closed.'); @@ -248,6 +282,7 @@ export class VideoSample { } } + /** Copies this video sample's pixel data to an ArrayBuffer or ArrayBufferView. */ async copyTo(destination: AllowSharedBufferSource) { if (!isAllowSharedBufferSource(destination)) { throw new TypeError('destination must be an ArrayBuffer or an ArrayBuffer view.'); @@ -275,6 +310,10 @@ export class VideoSample { } } + /** + * Converts this video sample to a VideoFrame for use with the WebCodecs API. The VideoFrame returned by this + * method *must* be closed separately from this video sample. + */ toVideoFrame() { if (this._closed) { throw new Error('VideoSample is closed.'); @@ -304,6 +343,14 @@ export class VideoSample { } } + /** + * Draws the video sample to a 2D canvas context. Rotation metadata will be taken into account. + * + * @param dx - The x-coordinate in the destination canvas at which to place the top-left corner of the source image. + * @param dy - The y-coordinate in the destination canvas at which to place the top-left corner of the source image. + * @param dWidth - The width to draw the image in the destination canvas. This allows scaling of the drawn image. + * @param dHeight - The height to draw the image in the destination canvas. This allows scaling of the drawn image. + */ draw( context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D, dx: number, @@ -364,6 +411,7 @@ export class VideoSample { context.restore(); } + /** Converts this video sample to a CanvasImageSource for drawing to a canvas. */ toCanvasImageSource() { if (this._closed) { throw new Error('VideoSample is closed.'); @@ -379,6 +427,7 @@ export class VideoSample { } } + /** Sets the rotation metadata of this video sample. */ setRotation(newRotation: Rotation) { if (![0, 90, 180, 270].includes(newRotation)) { throw new TypeError('newRotation must be 0, 90, 180, or 270.'); @@ -388,6 +437,7 @@ export class VideoSample { (this.rotation as Rotation) = newRotation; } + /** Sets the timestamp of this video sample, in seconds. */ setTimestamp(newTimestamp: number) { if (!Number.isFinite(newTimestamp)) { throw new TypeError('newTimestamp must be a number.'); @@ -397,6 +447,7 @@ export class VideoSample { (this.timestamp as number) = newTimestamp; } + /** Sets the duration of this video sample, in seconds. */ setDuration(newDuration: number) { if (!Number.isFinite(newDuration) || newDuration < 0) { throw new TypeError('newDuration must be a non-negative number.'); @@ -415,33 +466,78 @@ const AUDIO_SAMPLE_FORMATS = new Set( ['f32', 'f32-planar', 's16', 's16-planar', 's32', 's32-planar', 'u8', 'u8-planar'], ); -/** @public */ +/** + * Metadata used for AudioSample initialization. + * @public + */ export type AudioSampleInit = { + /** The audio data for this sample. */ data: AllowSharedBufferSource; + /** The audio sample format. */ format: AudioSampleFormat; + /** The number of audio channels. */ numberOfChannels: number; + /** The audio sample rate in hertz. */ sampleRate: number; + /** The timestamp of the sample in seconds. */ timestamp: number; }; -/** @public */ +/** + * Options used for copying audio sample data. + * @public + */ +export type AudioSampleCopyToOptions = { + /** + * The index identifying the plane to copy from. This must be 0 if using a non-planar (interleaved) output format. + */ + planeIndex: number; + /** The output format for the destination data. Defaults to the AudioSample's format. */ + format?: AudioSampleFormat; + /** An offset into the source plane data indicating which frame to begin copying from. Defaults to 0. */ + frameOffset?: number; + /** + * The number of frames to copy. If not provided, the copy will include all frames in the plane beginning + * with frameOffset. + */ + frameCount?: number; +}; + +/** + * Represents a raw, unencoded audio sample. Mainly used as an expressive wrapper around WebCodecs API's AudioData, + * but can also be used standalone. + * @public + */ export class AudioSample { /** @internal */ _data: AudioData | Uint8Array; /** @internal */ _closed: boolean = false; + /** The audio sample format. */ readonly format: AudioSampleFormat; + /** The audio sample rate in hertz. */ readonly sampleRate: number; + /** + * The number of audio frames in the sample, per channel. In other words, the length of this audio sample in frames. + */ readonly numberOfFrames: number; + /** The number of audio channels. */ readonly numberOfChannels: number; + /** The timestamp of the sample in seconds. */ readonly duration: number; + /** + * The timestamp of the sample in seconds. May be negative. Samples with negative end timestamps should not + * be presented. + */ readonly timestamp: number; + /** The timestamp of the sample in microseconds. */ get microsecondTimestamp() { return Math.trunc(SECOND_TO_MICROSECOND_FACTOR * this.timestamp); } + /** The duration of the sample in microseconds. */ get microsecondDuration() { return Math.trunc(SECOND_TO_MICROSECOND_FACTOR * this.duration); } @@ -510,12 +606,8 @@ export class AudioSample { } } - allocationSize(options: { - planeIndex: number; - format?: AudioSampleFormat; - frameOffset?: number; - frameCount?: number; - }) { + /** Returns the number of bytes required to hold the audio sample's data as specified by the given options. */ + allocationSize(options: AudioSampleCopyToOptions) { if (!options || typeof options !== 'object') { throw new TypeError('options must be an object.'); } @@ -563,15 +655,8 @@ export class AudioSample { return elementCount * bytesPerSample; } - copyTo( - destination: AllowSharedBufferSource, - options: { - planeIndex: number; - format?: AudioSampleFormat; - frameOffset?: number; - frameCount?: number; - }, - ) { + /** Copies the audio sample's data to an ArrayBuffer or ArrayBufferView as specified by the given options. */ + copyTo(destination: AllowSharedBufferSource, options: AudioSampleCopyToOptions) { if (!isAllowSharedBufferSource(destination)) { throw new TypeError('destination must be an ArrayBuffer or an ArrayBuffer view.'); } @@ -720,6 +805,7 @@ export class AudioSample { } } + /** Clones this audio sample. */ clone(): AudioSample { if (this._closed) { throw new Error('AudioSample is closed.'); @@ -740,6 +826,10 @@ export class AudioSample { } } + /** + * Closes this audio sample, releasing held resources. Audio samples should be closed as soon as they are not + * needed anymore. + */ close(): void { if (this._closed) { return; @@ -754,6 +844,10 @@ export class AudioSample { this._closed = true; } + /** + * Converts this audio sample to an AudioData for use with the WebCodecs API. The AudioData returned by this + * method *must* be closed separately from this audio sample. + */ toAudioData() { if (this._closed) { throw new Error('AudioSample is closed.'); @@ -812,6 +906,7 @@ export class AudioSample { } } + /** Convert this audio sample to an AudioBuffer for use with the Web Audio API. */ toAudioBuffer() { if (this._closed) { throw new Error('AudioSample is closed.'); @@ -833,6 +928,7 @@ export class AudioSample { return audioBuffer; } + /** Sets the timestamp of this audio sample, in seconds. */ setTimestamp(newTimestamp: number) { if (!Number.isFinite(newTimestamp)) { throw new TypeError('newTimestamp must be a number.'); diff --git a/src/source.ts b/src/source.ts index 28a06e5..9ca82d3 100644 --- a/src/source.ts +++ b/src/source.ts @@ -1,4 +1,7 @@ -/** @public */ +/** + * The source base class, representing a resource from which bytes can be read. + * @public + */ export abstract class Source { /** @internal */ abstract _read(start: number, end: number): Promise; @@ -13,10 +16,19 @@ export abstract class Source { return this._sizePromise ??= this._retrieveSize(); } - onread: ((range: { start: number; end: number }) => unknown) | null = null; + /** Called each time data is requested from the source. */ + onread: ((range: { + /** The start byte offset (inclusive). */ + start: number; + /** The end byte offset (exclusive). */ + end: number; + }) => unknown) | null = null; } -/** @public */ +/** + * A source backed by an ArrayBuffer or ArrayBufferView, with the entire file held in memory. + * @public + */ export class BufferSource extends Source { /** @internal */ _bytes: Uint8Array; @@ -42,13 +54,21 @@ export class BufferSource extends Source { } } -/** @public */ +/** + * Options for defining a StreamSource. + * @public + */ export type StreamSourceOptions = { + /** Called when data is requested. Should return or resolve to the bytes from the specified byte range. */ read: (start: number, end: number) => Uint8Array | Promise; + /** Called when the size of the entire file is requested. Should return or resolve to the size in bytes. */ getSize: () => number | Promise; }; -/** @public */ +/** + * A general-purpose, callback-driven source that can get its data from anywhere. + * @public + */ export class StreamSource extends Source { /** @internal */ _options: StreamSourceOptions; @@ -80,7 +100,10 @@ export class StreamSource extends Source { } } -/** @public */ +/** + * A source backed by a Blob. Since Files are also Blobs, this is the source to use when reading files off the disk. + * @public + */ export class BlobSource extends Source { /** @internal */ _blob: Blob; @@ -108,7 +131,11 @@ export class BlobSource extends Source { } } -/** @public */ +/** + * A source backed by a URL. This is useful for reading data from the network. Be careful using this source however, + * as it typically comes with increased latency. + * @public + */ export class UrlSource extends Source { /** @internal */ private _url: string; @@ -117,7 +144,13 @@ export class UrlSource extends Source { /** @internal */ private _fullData: ArrayBuffer | null = null; - constructor(url: string, options: { withCredentials?: boolean } = {}) { + constructor( + url: string, + options: { + /** If credentials are to be included in a cross-origin request. */ + withCredentials?: boolean; + } = {}, + ) { if (typeof url !== 'string') { throw new TypeError('url must be a string.'); } @@ -202,32 +235,44 @@ export class UrlSource extends Source { } const xhr = new XMLHttpRequest(); - xhr.open('HEAD', this._url, true); + xhr.open('GET', this._url, true); + xhr.responseType = 'arraybuffer'; xhr.withCredentials = this._withCredentials; + xhr.setRequestHeader('Range', 'bytes=0-0'); await new Promise((resolve, reject) => { xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(); } else { - reject(new Error(`Error fetching ${this._url} (HEAD): ${xhr.status} ${xhr.statusText}`)); + reject(new Error(`Error fetching ${this._url} (Range): ${xhr.status} ${xhr.statusText}`)); } }; xhr.onerror = () => { - resolve(); + reject(new Error('Network error occurred.')); }; xhr.send(); }); - const contentLength = xhr.getResponseHeader('Content-Length'); - if (!contentLength) { - // If Content-Length is not available, make a GET request to get the full size - const { response } = await this._makeRequest(); - return response.byteLength; + // Check for Content-Range header (e.g., "bytes 0-0/1234" where 1234 is the total size) + const contentRange = xhr.getResponseHeader('Content-Range'); + if (contentRange) { + const match = contentRange.match(/bytes \d+-\d+\/(\d+)/); + if (match && match[1]) { + return parseInt(match[1], 10); + } } - return parseInt(contentLength, 10); + // If Content-Range is not available, check Content-Length + const contentLength = xhr.getResponseHeader('Content-Length'); + if (contentLength) { + return parseInt(contentLength, 10); + } + + // If neither header is available, make a full GET request + const { response } = await this._makeRequest(); + return response.byteLength; } } diff --git a/src/target.ts b/src/target.ts index 5101ef1..cd1c99c 100644 --- a/src/target.ts +++ b/src/target.ts @@ -1,7 +1,10 @@ import { BufferTargetWriter, ChunkedStreamTargetWriter, StreamTargetWriter, Writer } from './writer'; import { Output } from './output'; -/** @public */ +/** + * Base class for targets, specifying where output files are written. + * @public + */ export abstract class Target { /** @internal */ _output: Output | null = null; @@ -10,8 +13,13 @@ export abstract class Target { abstract _createWriter(): Writer; } -/** @public */ +/** + * 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. + * @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 */ @@ -20,20 +28,40 @@ export class BufferTarget extends Target { } } -/** @public */ +/** + * A data chunk for StreamTarget. + * @public + */ export type StreamTargetChunk = { + /** The operation type. */ type: 'write'; // This ensures automatic compatibility with FileSystemWritableFileStream + /** The data to write. */ data: Uint8Array; + /** The byte offset in the output file at which to write the data. */ position: number; }; -/** @public */ +/** + * Options for StreamTarget. + * @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; }; -/** @public */ +/** + * This target writes data to a WritableStream, making it a general-purpose target for writing data anywhere. It is + * also compatible with FileSystemWritableFileStream for use with the File System Access API. The WritableStream can + * also apply backpressure, which will propagate to the output and throttle the encoders. + * @public + */ export class StreamTarget extends Target { /** @internal */ _writable: WritableStream; diff --git a/src/tsconfig.json b/src/tsconfig.json new file mode 100644 index 0000000..bc6694d --- /dev/null +++ b/src/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "../build", + "declaration": true, + "stripInternal": true, + "noEmit": false + }, + "include": [ + "**/*" + ] +} \ No newline at end of file diff --git a/todo.txt b/todo.txt index 6afd86e..07318e7 100644 --- a/todo.txt +++ b/todo.txt @@ -2,4 +2,5 @@ - https://github.com/Vanilagy/mp4-muxer/issues/83 tell him it's possible now - is this fixed? https://github.com/Vanilagy/webm-muxer/issues/50 - cross-track offset for streaming sources -- configurable fragmented mp4 fragment size, like the mp4-muxer PR \ No newline at end of file +- configurable fragmented mp4 fragment size, like the mp4-muxer PR +- textsubtitlesource, chunked piping \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 40dbf0f..c95bf30 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,14 +6,8 @@ "noImplicitOverride": true, "noUncheckedIndexedAccess": true, "noPropertyAccessFromIndexSignature": true, - "rootDir": "src", - "outDir": "build", - "declaration": true, - "stripInternal": true, "skipLibCheck": true, - "allowJs": true - }, - "include": [ - "src/**/*" - ] + "allowJs": true, + "noEmit": true + } } \ No newline at end of file