From a2a30c3e920d107aca3dcfaa7bfb511dd2179802 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Wed, 3 Sep 2025 18:11:43 +0200 Subject: [PATCH] Add API doc generator, improve documentation across the board --- .gitignore | 1 + README.md | 1 + docs/.vitepress/config.mts | 97 +- docs/api-config.json | 22 + docs/index.md | 3 + package-lock.json | 15 + package.json | 3 +- packages/mp3-encoder/src/index.ts | 1 + scripts/check-docblocks.ts | 28 +- scripts/generate-api-docs.ts | 1445 +++++++++++++++++++++++++++++ src/codec.ts | 113 +-- src/conversion.ts | 82 +- src/custom-coder.ts | 55 +- src/encode.ts | 222 ++++- src/index.ts | 149 +-- src/input-format.ts | 43 + src/input-track.ts | 30 +- src/input.ts | 24 +- src/media-sink.ts | 31 +- src/media-source.ts | 64 +- src/misc.ts | 4 + src/output-format.ts | 50 +- src/output.ts | 12 + src/packet.ts | 9 +- src/sample.ts | 67 +- src/source.ts | 60 +- src/target.ts | 25 +- tsdoc.json | 13 + 28 files changed, 2281 insertions(+), 388 deletions(-) create mode 100644 docs/api-config.json create mode 100644 scripts/generate-api-docs.ts create mode 100644 tsdoc.json diff --git a/.gitignore b/.gitignore index 3c737af..355e7e1 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,6 @@ node_modules /dist-docs .DS_Store /docs/.vitepress/cache +/docs/api packages/mp3-encoder/dist \ No newline at end of file diff --git a/README.md b/README.md index 5218bec..53a5c3b 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,7 @@ npm run build # Production build with type definitions npm run check # Type checking npm run lint # ESLint +npm run docs:generate # Generates API docs npm run docs:dev # Start docs development server npm run dev # Start examples development server diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index bcb0330..723737b 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -3,6 +3,8 @@ import footnote from 'markdown-it-footnote'; import tailwindcss from '@tailwindcss/vite'; import llmstxt from 'vitepress-plugin-llms'; import { HeadConfig } from 'vitepress'; +// @ts-expect-error This file gets generated once docs:generate is run +import apiRoutes from '../api/index.json'; const DESCRIPTION = 'A JavaScript library for reading, writing, and converting media files. Directly in the browser,' + ' and faster than anybunny else.'; @@ -32,56 +34,61 @@ export default withMermaid({ // https://vitepress.dev/reference/default-theme-config nav: [ { text: 'Guide', link: '/guide/introduction', activeMatch: '/guide' }, + { text: 'API', link: '/api', activeMatch: '/api' }, { text: 'Examples', link: '/examples', activeMatch: '/examples' }, { text: 'Sponsors', link: '/#sponsors', activeMatch: '/#sponsors' }, { text: 'License', link: 'https://github.com/Vanilagy/mediabunny#license' }, ], - sidebar: [ - { - text: 'Getting started', - items: [ - { text: 'Introduction', link: '/guide/introduction' }, - { text: 'Installation', link: '/guide/installation' }, - { text: 'Quick start', link: '/guide/quick-start' }, - ], - }, - { - text: 'Reading', - items: [ - { text: 'Reading media files', link: '/guide/reading-media-files' }, - { text: 'Media sinks', link: '/guide/media-sinks' }, - { text: 'Input formats', link: '/guide/input-formats' }, - ], - }, - { - text: 'Writing', - items: [ - { text: 'Writing media files', link: '/guide/writing-media-files' }, - { text: 'Media sources', link: '/guide/media-sources' }, - { text: 'Output formats', link: '/guide/output-formats' }, - ], - }, - { - text: 'Conversion', - items: [ - { text: 'Converting media files', link: '/guide/converting-media-files' }, - ], - }, - { - text: 'Miscellaneous', - items: [ - { text: 'Packets & samples', link: '/guide/packets-and-samples' }, - { text: 'Supported formats & codecs', link: '/guide/supported-formats-and-codecs' }, - ], - }, - { - text: 'Extensions', - items: [ - { text: 'mp3-encoder', link: '/guide/extensions/mp3-encoder' }, - ], - }, - ], + sidebar: { + '/guide': [ + { + text: 'Getting started', + items: [ + { text: 'Introduction', link: '/guide/introduction' }, + { text: 'Installation', link: '/guide/installation' }, + { text: 'Quick start', link: '/guide/quick-start' }, + ], + }, + { + text: 'Reading', + items: [ + { text: 'Reading media files', link: '/guide/reading-media-files' }, + { text: 'Media sinks', link: '/guide/media-sinks' }, + { text: 'Input formats', link: '/guide/input-formats' }, + ], + }, + { + text: 'Writing', + items: [ + { text: 'Writing media files', link: '/guide/writing-media-files' }, + { text: 'Media sources', link: '/guide/media-sources' }, + { text: 'Output formats', link: '/guide/output-formats' }, + ], + }, + { + text: 'Conversion', + items: [ + { text: 'Converting media files', link: '/guide/converting-media-files' }, + ], + }, + { + text: 'Miscellaneous', + items: [ + { text: 'Packets & samples', link: '/guide/packets-and-samples' }, + { text: 'Supported formats & codecs', link: '/guide/supported-formats-and-codecs' }, + ], + }, + { + text: 'Extensions', + items: [ + { text: 'mp3-encoder', link: '/guide/extensions/mp3-encoder' }, + ], + }, + ], + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + '/api': apiRoutes, + }, socialLinks: [ { icon: 'github', link: 'https://github.com/Vanilagy/mediabunny' }, diff --git a/docs/api-config.json b/docs/api-config.json new file mode 100644 index 0000000..e160b55 --- /dev/null +++ b/docs/api-config.json @@ -0,0 +1,22 @@ +{ + "heading": "Mediabunny API reference", + "intro": "Here you can find detailed documentation for all classes, functions, constants and types exposed by Mediabunny's public API.", + + "Samples": "Raw, unencoded chunks of media data, such as video frames or sections of audio.", + "Packets": "Chunks of encoded media data.", + "Input files & tracks": "Read input files and their tracks; demuxer API.", + "Input formats": "Container formats that Mediabunny can read.", + "Input sources": "The sources that can provide data to an `Input`.", + "Output files": "Create and write new media files; muxer API.", + "Output formats": "Container formats that Mediabunny can write.", + "Output targets": "The targets where `Output` writes data to.", + "Media sinks": "Methods for extracting media data from input files.", + "Media sources": "Methods for adding media data to output files.", + "Conversion": "A simple API for converting and transforming media files.", + "Codecs": "Codecs understood by Mediabunny.", + "Encoding": "Encoder configuration and encodability checks.", + "Custom coders": "API for adding custom encoders and decoders.", + "Miscellaneous": "Whatever's left.", + + "@mediabunny/mp3-encoder": "Adds MP3 encoder support to Mediabunny." +} diff --git a/docs/index.md b/docs/index.md index f35a333..050d8dd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,6 +14,9 @@ hero: - theme: brand text: Hop in link: /guide/introduction + - theme: alt + text: API + link: /api - theme: alt text: Examples link: /examples diff --git a/package-lock.json b/package-lock.json index 3bec6d7..74440a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9424,6 +9424,21 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index 1d6512f..2514ca1 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,9 @@ "check": "tsc -p src --noEmit && tsc -p packages/mp3-encoder/src --noEmit && tsc -p scripts --noEmit && tsc -p tsconfig.vite.json --noEmit && rm tsconfig.vite.tsbuildinfo", "check-docblocks": "tsx scripts/check-docblocks.ts dist/mediabunny.d.ts", "docs:dev": "vitepress dev docs", - "docs:build": "vitepress build docs && npm run examples:build", + "docs:build": "npm run docs:generate && vitepress build docs && npm run examples:build", "docs:preview": "vitepress preview docs", + "docs:generate": "tsx scripts/generate-api-docs.ts src/index.ts packages/mp3-encoder/src/index.ts docs/api-config.json", "dev": "vite", "examples:build": "vite build", "fix-build-import-paths": "tsx scripts/add-import-extensions.ts", diff --git a/packages/mp3-encoder/src/index.ts b/packages/mp3-encoder/src/index.ts index 33e4a38..7319e2d 100644 --- a/packages/mp3-encoder/src/index.ts +++ b/packages/mp3-encoder/src/index.ts @@ -200,6 +200,7 @@ class Mp3Encoder extends CustomAudioEncoder { * } * ``` * + * @group \@mediabunny/mp3-encoder * @public */ export const registerMp3Encoder = () => { diff --git a/scripts/check-docblocks.ts b/scripts/check-docblocks.ts index 493d121..5589a9c 100644 --- a/scripts/check-docblocks.ts +++ b/scripts/check-docblocks.ts @@ -16,6 +16,7 @@ const checkDocblocks = (filePath: string) => { if ( ts.isInterfaceDeclaration(node) || ts.isClassDeclaration(node) + || ts.isConstructorDeclaration(node) || ts.isMethodDeclaration(node) || ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node) @@ -60,7 +61,13 @@ const checkDocblocks = (filePath: string) => { let name = 'anonymous'; const kind = ts.SyntaxKind[node.kind].replace(/Declaration|Statement/g, '').toLowerCase(); - if ('name' in node && node.name) { + if (ts.isConstructorDeclaration(node)) { + // For constructors, use the parent class name + const parent = node.parent; + if (ts.isClassDeclaration(parent) && parent.name) { + name = parent.name.text; + } + } else if ('name' in node && node.name) { if (ts.isIdentifier(node.name)) { name = node.name.text; } else if ('getText' in node.name) { @@ -137,9 +144,22 @@ const checkDocblocks = (filePath: string) => { 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!'); + let commentText = ''; + + if (typeof node.comment === 'string') { + commentText = node.comment; + } else if (Array.isArray(node.comment)) { + // Handle JSDoc comment parts (including @link tags) + commentText = node.comment + .map((part) => { + if (typeof part === 'string') { + return part; + } else if (part && typeof part === 'object' && 'text' in part) { + return part.text || ''; + } + return ''; + }) + .join(''); } // Remove all @tags with regex diff --git a/scripts/generate-api-docs.ts b/scripts/generate-api-docs.ts new file mode 100644 index 0000000..27af5a2 --- /dev/null +++ b/scripts/generate-api-docs.ts @@ -0,0 +1,1445 @@ +// This script has been 100% vibe-coded with Claude, meaning I literally haven't looked at any of the code. It's +// probably a mess, but it solves a one-off problem where only the output matters, and the output is indeed good, which +// is the point of a custom script for this: full, precise control. + +/* eslint-disable @typescript-eslint/restrict-template-expressions */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable @stylistic/max-len */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @stylistic/brace-style */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ + +import * as ts from 'typescript'; +import * as fs from 'fs'; +import * as path from 'path'; + +const generateDocs = (entryFiles: string[], apiConfigFile: string) => { + const program = ts.createProgram(entryFiles, { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Node10, + allowJs: false, + declaration: true, + esModuleInterop: true, + skipLibCheck: true, + strict: true, + }); + + const sourceFiles = entryFiles.map((entryFile) => { + const sourceFile = program.getSourceFile(entryFile); + if (!sourceFile) { + throw new Error(`Could not find source file: ${entryFile}`); + } + return sourceFile; + }); + + const typeChecker = program.getTypeChecker(); + const outputDir = path.resolve(process.cwd(), 'docs/api'); + + // Load API config + const apiConfigPath = path.resolve(process.cwd(), apiConfigFile); + if (!fs.existsSync(apiConfigPath)) { + throw new Error(`API config file not found: ${apiConfigPath}`); + } + const apiConfig: Record = JSON.parse(fs.readFileSync(apiConfigPath, 'utf-8')); + + // Extract special fields + const headingText = apiConfig['heading'] || 'API Reference'; + const introText = apiConfig['intro']; + + // Create a copy without the special fields for group processing + const groupConfig = { ...apiConfig }; + delete groupConfig['heading']; + delete groupConfig['intro']; + + // Clear and recreate output directory + if (fs.existsSync(outputDir)) { + fs.rmSync(outputDir, { recursive: true }); + } + fs.mkdirSync(outputDir, { recursive: true }); + + // Collect all exported types for cross-referencing + const exportedTypes = new Set(); + const classHierarchy = new Map(); // Maps parent class to array of subclasses + const classInstances = new Map(); // Maps class name to array of instance variable names + + const collectExportedTypes = (module: ts.Symbol, visited = new Set()): void => { + if (visited.has(module)) return; + visited.add(module); + + const exports = typeChecker.getExportsOfModule(module); + exports.forEach((exportSymbol) => { + const declaration = exportSymbol.valueDeclaration || exportSymbol.declarations?.[0]; + if (!declaration) return; + + // Collect classes, interfaces, types, enums, variables (only if @public) + if (ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration) + || ts.isTypeAliasDeclaration(declaration) || ts.isEnumDeclaration(declaration) + || ts.isVariableDeclaration(declaration)) { + const hasPublicTag = ts.getJSDocTags(declaration).some(tag => tag.tagName.text === 'public'); + if (hasPublicTag) { + exportedTypes.add(exportSymbol.getName()); + } + } + + // Follow reexports + else if (exportSymbol.flags & ts.SymbolFlags.Alias) { + const aliasedSymbol = typeChecker.getAliasedSymbol(exportSymbol); + const aliasedDeclaration = aliasedSymbol.valueDeclaration || aliasedSymbol.declarations?.[0]; + if (aliasedDeclaration) { + // Check if the aliased symbol has @public tag + const hasPublicTag = ts.getJSDocTags(aliasedDeclaration).some(tag => tag.tagName.text === 'public'); + if (hasPublicTag) { + exportedTypes.add(exportSymbol.getName()); + } + + // Also recursively collect from the source module + const sourceFile = aliasedDeclaration.getSourceFile(); + const moduleSymbol = typeChecker.getSymbolAtLocation(sourceFile); + if (moduleSymbol) { + collectExportedTypes(moduleSymbol, visited); + } + } + } + }); + }; + + // Get all exported symbols recursively + const getAllExportedSymbols = (module: ts.Symbol, visited = new Set()): ts.Symbol[] => { + if (visited.has(module)) return []; + visited.add(module); + + const exports = typeChecker.getExportsOfModule(module); + const symbols: ts.Symbol[] = []; + + exports.forEach((exportSymbol) => { + const declaration = exportSymbol.valueDeclaration || exportSymbol.declarations?.[0]; + if (!declaration) return; + + // If it's a reexport, follow it recursively + if (exportSymbol.flags & ts.SymbolFlags.Alias) { + const aliasedSymbol = typeChecker.getAliasedSymbol(exportSymbol); + const aliasedDeclaration = aliasedSymbol.valueDeclaration || aliasedSymbol.declarations?.[0]; + if (aliasedDeclaration) { + const sourceFile = aliasedDeclaration.getSourceFile(); + const moduleSymbol = typeChecker.getSymbolAtLocation(sourceFile); + if (moduleSymbol) { + symbols.push(...getAllExportedSymbols(moduleSymbol, visited)); + } + } + } + // Otherwise, add any symbol with @public tag (we'll filter by type later) + else { + const hasPublicTag = ts.getJSDocTags(declaration).some(tag => tag.tagName.text === 'public'); + if (hasPublicTag) { + symbols.push(exportSymbol); + } + } + }); + + return symbols; + }; + + // Collect all exported types from all source files + const allModuleSymbols: ts.Symbol[] = []; + sourceFiles.forEach((sourceFile) => { + const moduleSymbol = typeChecker.getSymbolAtLocation(sourceFile); + if (moduleSymbol) { + allModuleSymbols.push(moduleSymbol); + collectExportedTypes(moduleSymbol); + } + }); + + // Helper to find all potential type references in a type string + const findAllTypeReferences = (typeString: string): string[] => { + // Match PascalCase identifiers that could be type names + const matches = typeString.match(/\b[A-Z][a-zA-Z0-9_]*\b/g) || []; + return [...new Set(matches)]; // Remove duplicates + }; + + // Helper to filter references to only exported types (excluding current type) + const filterToExportedTypes = (references: string[], currentTypeName?: string): string[] => { + return references.filter(ref => exportedTypes.has(ref) && ref !== currentTypeName); + }; + + // Helper to process {@link} tags in JSDoc comments + const processLinkTags = (text: string, currentTypeName?: string): string => { + // Replace {@link TypeName} with [TypeName](./TypeName.md) if TypeName is exported + // or just TypeName if not exported + // If TypeName is the current type, just use code formatting without link + return text.replace(/\{@link\s+([^}]+)\}/g, (_, typeName) => { + const cleanTypeName = typeName.trim(); + if (cleanTypeName === currentTypeName) { + return `\`${cleanTypeName}\``; + } + if (exportedTypes.has(cleanTypeName)) { + return `[\`${cleanTypeName}\`](./${cleanTypeName}.md)`; + } + return `\`${cleanTypeName}\``; + }); + }; + + // Helper to extract linked types from {@link} tags in text + const extractLinkedTypes = (text: string): string[] => { + if (!text) return []; + const linkMatches = text.match(/\{@link\s+([^}]+)\}/g) || []; + return linkMatches.map((match) => { + const typeName = match.replace(/\{@link\s+([^}]+)\}/, '$1').trim(); + return typeName; + }); + }; + + // Helper to format references with proper "and" and period + // Optionally filters out references that were already mentioned in @link tags + const formatReferences = (references: string[], linkedTypes: string[] = []): string => { + if (references.length === 0) return ''; + + // Filter out references that were already linked in the description + const filteredReferences = references.filter(ref => !linkedTypes.includes(ref)); + + if (filteredReferences.length === 0) return ''; + + const refLinks = filteredReferences.map(ref => `[\`${ref}\`](./${ref}.md)`); + const formatter = new Intl.ListFormat('en', { style: 'long', type: 'conjunction' }); + return `\n\nSee ${formatter.format(refLinks)}.`; + }; + + // Helper to format object types with proper indentation + const formatObjectType = (typeText: string): string => { + // Remove JSDoc comments but preserve original structure + let lines = typeText.split('\n'); + const result: string[] = []; + + // First remove all JSDoc comments from the entire text + const cleanText = typeText.replace(/\/\*\*[\s\S]*?\*\//g, ''); + lines = cleanText.split('\n'); + + for (const line of lines) { + // Skip empty lines + if (line.trim() === '') continue; + + result.push(line); + } + + return result.join('\n'); + }; + + // Helper to get better type string representation + const getTypeString = (type: ts.Type): string => { + // Handle array types specially + if (typeChecker.isArrayType(type)) { + const elementType = typeChecker.getTypeArguments(type as ts.TypeReference)[0]; + if (elementType) { + return `${getTypeString(elementType)}[]`; + } + } + + // Check if it's an array-like type by checking the symbol name + const typeString = typeChecker.typeToString(type); + if (typeString === 'Array' && type.symbol && type.symbol.getName() === 'Array') { + // Try to get type arguments from the type reference + if ((type as any).typeArguments && (type as any).typeArguments.length > 0) { + const elementType = (type as any).typeArguments[0]; + return `${getTypeString(elementType)}[]`; + } + // If we can't determine the element type, try looking at the declaration + if (type.symbol.declarations && type.symbol.declarations[0]) { + const declaration = type.symbol.declarations[0]; + if ( + ts.isTypeReferenceNode(declaration) + && declaration.typeArguments + && declaration.typeArguments.length > 0 + ) { + return `${declaration.typeArguments[0]!.getText()}[]`; + } + } + } + + return typeString; + }; + + // Helper to clean up optional parameter types + const cleanOptionalType = (type: string, isOptional: boolean) => { + let cleanedType = type; + if (isOptional) { + // Remove "| undefined" from union types for optional parameters + cleanedType = cleanedType.replace(/\s*\|\s*undefined$/, '').replace(/^undefined\s*\|\s*/, ''); + } + // Convert string literals from double quotes to single quotes + cleanedType = cleanedType.replace(/"([^"]*)"/g, '\'$1\''); + + // Format long union types with line breaks if they exceed 80 characters + // Only apply to top-level unions, not unions nested within intersections, object types, or generic types + if (cleanedType.includes(' | ') && cleanedType.length > 80 && !cleanedType.includes('&') && !cleanedType.includes('{') && !cleanedType.includes('<')) { + const unionMembers = cleanedType.split(' | '); + cleanedType = '\n\t| ' + unionMembers.join('\n\t| '); + } + + return cleanedType; + }; + + // Get all exported symbols from all modules + const allSymbols: ts.Symbol[] = []; + allModuleSymbols.forEach((moduleSymbol) => { + allSymbols.push(...getAllExportedSymbols(moduleSymbol)); + }); + const indexEntries: Array<{ name: string; type: string; group: string; order: number }> = []; + + // Create a map to track the order of symbols based on their appearance in the entry file + const symbolOrderMap = new Map(); + let orderIndex = 0; + + // Walk through all source files to establish order based on declaration/export order + const establishSymbolOrder = (node: ts.Node): void => { + if (ts.isExportDeclaration(node)) { + // Handle export declarations like "export { Foo } from './foo'" + if (node.exportClause && ts.isNamedExports(node.exportClause)) { + node.exportClause.elements.forEach((element) => { + const exportName = (element.propertyName || element.name).getText(); + if (!symbolOrderMap.has(exportName)) { + symbolOrderMap.set(exportName, orderIndex++); + } + }); + } + } else if (ts.isVariableStatement(node)) { + // Handle variable statements like "export const foo = ..." + if (node.modifiers?.some(mod => mod.kind === ts.SyntaxKind.ExportKeyword)) { + node.declarationList.declarations.forEach((declaration) => { + const name = declaration.name.getText(); + if (name && !symbolOrderMap.has(name)) { + symbolOrderMap.set(name, orderIndex++); + } + }); + } + } else if (ts.isClassDeclaration(node) || ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node) || ts.isFunctionDeclaration(node)) { + // Handle direct declarations + if (node.modifiers?.some(mod => mod.kind === ts.SyntaxKind.ExportKeyword)) { + const name = (node as any).name?.getText(); + if (name && !symbolOrderMap.has(name)) { + symbolOrderMap.set(name, orderIndex++); + } + } + } + + ts.forEachChild(node, establishSymbolOrder); + }; + + // Establish symbol order from all source files + sourceFiles.forEach((sourceFile) => { + establishSymbolOrder(sourceFile); + }); + + // Phase 1: Collect class hierarchy information + allSymbols.forEach((exportSymbol) => { + const declaration = exportSymbol.valueDeclaration || exportSymbol.declarations?.[0]; + if (!declaration) return; + + const hasPublicTag = ts.getJSDocTags(declaration).some(tag => tag.tagName.text === 'public'); + if (!hasPublicTag) return; + + // Collect inheritance information for classes + if (ts.isClassDeclaration(declaration) && declaration.heritageClauses) { + const symbolName = declaration.name?.getText(); + if (!symbolName) return; + + const extendsClauseNode = declaration.heritageClauses.find( + clause => clause.token === ts.SyntaxKind.ExtendsKeyword, + ); + if (extendsClauseNode && extendsClauseNode.types[0]) { + const superClassName = extendsClauseNode.types[0].expression.getText(); + if (!classHierarchy.has(superClassName)) { + classHierarchy.set(superClassName, []); + } + classHierarchy.get(superClassName)!.push(symbolName); + } + } + }); + + // Collect class instances + allSymbols.forEach((exportSymbol) => { + const declaration = exportSymbol.valueDeclaration || exportSymbol.declarations?.[0]; + if (!declaration) return; + + const hasPublicTag = ts.getJSDocTags(declaration).some(tag => tag.tagName.text === 'public'); + if (!hasPublicTag) return; + + if (ts.isVariableDeclaration(declaration)) { + const variableName = declaration.name.getText(); + let className: string | undefined; + // Check explicit type annotation + if (declaration.type && ts.isTypeReferenceNode(declaration.type)) { + className = declaration.type.typeName.getText(); + } + // Check initializer for constructor calls + else if (declaration.initializer && ts.isNewExpression(declaration.initializer)) { + className = declaration.initializer.expression.getText(); + } + if (className && exportedTypes.has(className)) { + if (!classInstances.has(className)) { + classInstances.set(className, []); + } + classInstances.get(className)!.push(variableName); + } + } + }); + + // Phase 2: Generate documentation for each symbol + allSymbols.forEach((exportSymbol) => { + const declaration = exportSymbol.valueDeclaration || exportSymbol.declarations?.[0]; + if (!declaration) return; + + // Check if it's a supported symbol type + const nodeKind = ts.SyntaxKind[declaration.kind]; + const symbolName = (declaration as any).name?.getText() || exportSymbol.getName(); + + // Only process symbols with @public tag + const hasPublicTag = ts.getJSDocTags(declaration).some(tag => tag.tagName.text === 'public'); + if (!hasPublicTag) return; + + // Check for @group tag (handle re-exports by looking at the original declaration) + let targetDeclaration = declaration; + if (exportSymbol.flags & ts.SymbolFlags.Alias) { + const aliasedSymbol = typeChecker.getAliasedSymbol(exportSymbol); + const aliasedDeclaration = aliasedSymbol.valueDeclaration || aliasedSymbol.declarations?.[0]; + if (aliasedDeclaration) { + targetDeclaration = aliasedDeclaration; + } + } + + const groupTag = ts.getJSDocTags(targetDeclaration).find(tag => tag.tagName.text === 'group'); + if (!groupTag || typeof groupTag.comment !== 'string') { + throw new Error(`Symbol '${symbolName}' is missing @group JSDoc tag`); + } + const groupName = groupTag.comment.trim().replace(/\\(.)/g, '$1'); + + // Validate that the group exists in the API config + if (!Object.prototype.hasOwnProperty.call(groupConfig, groupName)) { + throw new Error(`Symbol '${symbolName}' has @group '${groupName}' which is not defined in API config`); + } + + // Check if it's a supported type + if (ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration) || ts.isTypeAliasDeclaration(declaration) || ts.isVariableDeclaration(declaration)) { + // Supported types - continue processing + } else { + // Unsupported type - throw error with type info + throw new Error(`Unsupported symbol type: ${nodeKind} for symbol '${symbolName}'`); + } + + if (!declaration.name) return; + + // Handle variable declarations separately + if (ts.isVariableDeclaration(declaration)) { + const variableName = declaration.name.getText(); + + // Get variable description from JSDoc + const jsDocComment = ts.getJSDocCommentsAndTags(declaration)[0]; + let description = ''; + if (jsDocComment && ts.isJSDoc(jsDocComment)) { + const commentText = jsDocComment.comment; + if (typeof commentText === 'string') { + description = processLinkTags(commentText.trim(), variableName); + } + } + + // Check if it's a function type + const variableType = typeChecker.getTypeAtLocation(declaration); + const isFunctionType = variableType.getCallSignatures().length > 0; + + // Add to index + const order = symbolOrderMap.get(variableName); + if (order === undefined) { + throw new Error(`Symbol '${variableName}' not found in entry files export order`); + } + indexEntries.push({ name: variableName, type: isFunctionType ? 'Function' : 'Constant', group: groupName, order }); + + if (isFunctionType) { + // Handle function variables like methods + const signature = variableType.getCallSignatures()[0]; + if (signature) { + const parameters = signature.getParameters(); + const params = parameters.map((param) => { + const paramDecl = param.valueDeclaration; + if (paramDecl && ts.isParameter(paramDecl)) { + const paramName = param.getName(); + const hasQuestionToken = paramDecl.questionToken !== undefined; + const hasDefault = paramDecl.initializer !== undefined; + const isRest = paramDecl.dotDotDotToken !== undefined; + const rawParamType = paramDecl.type ? paramDecl.type.getText() : typeChecker.typeToString(typeChecker.getTypeOfSymbolAtLocation(param, paramDecl)); + const paramType = cleanOptionalType(rawParamType, hasQuestionToken); + + if (hasDefault) { + const defaultValue = paramDecl.initializer.getText(); + return `\t${isRest ? '...' : ''}${paramName}: ${paramType} = ${defaultValue}`; + } else { + return `\t${isRest ? '...' : ''}${paramName}${hasQuestionToken ? '?' : ''}: ${paramType}`; + } + } + return `\t${param.getName()}: unknown`; + }); + + const returnType = getTypeString(signature.getReturnType()); + const functionSig = params.length > 0 + ? `${variableName}(\n${params.join(',\n')},\n): ${returnType};` + : `${variableName}(): ${returnType};`; + + let markdown = `\n\n\n\n# ${variableName}\n\n\`\`\`ts\n${functionSig}\n\`\`\`${description ? `\n\n${description}` : ''}`; + + // Find referenced types in all parameters and return type + const allTypeStrings = params.map(p => p.replace(/\t.*?:\s*/, '')).concat([returnType]); + const allReferences = filterToExportedTypes([...new Set(allTypeStrings.flatMap(findAllTypeReferences))], variableName); + markdown += formatReferences(allReferences); + + const outputPath = path.join(outputDir, `${variableName}.md`); + fs.writeFileSync(outputPath, markdown); + console.log(`Generated: ${outputPath}`); + } + } else { + // Handle regular variables + let markdown = `\n\n\n\n# ${variableName}\n\n${description ? `${description}\n\n` : ''}`; + const variableValue = declaration.initializer ? declaration.initializer.getText() : 'undefined'; + const variableDefinition = `const ${variableName} = ${variableValue};`; + markdown += `\`\`\`ts\n${variableDefinition}\n\`\`\``; + + // Find referenced types in the variable value + const references = filterToExportedTypes(findAllTypeReferences(variableValue), variableName); + markdown += formatReferences(references); + + const outputPath = path.join(outputDir, `${variableName}.md`); + fs.writeFileSync(outputPath, markdown); + console.log(`Generated: ${outputPath}`); + } + + return; + } + + { + const className = declaration.name.text; + const isAbstract = ts.isClassDeclaration(declaration) && declaration.modifiers?.some(mod => mod.kind === ts.SyntaxKind.AbstractKeyword); + + // Add to index + const order = symbolOrderMap.get(className); + if (order === undefined) { + throw new Error(`Symbol '${className}' not found in entry files export order`); + } + if (isAbstract) { + indexEntries.push({ name: className, type: 'Abstract class', group: groupName, order }); + } else if (ts.isClassDeclaration(declaration)) { + indexEntries.push({ name: className, type: 'Class', group: groupName, order }); + } else if (ts.isTypeAliasDeclaration(declaration)) { + indexEntries.push({ name: className, type: 'Type', group: groupName, order }); + } else if (ts.isInterfaceDeclaration(declaration)) { + indexEntries.push({ name: className, type: 'Interface', group: groupName, order }); + } + + const properties: string[] = []; + const events: string[] = []; + const methods: string[] = []; + const staticMethods: string[] = []; + let constructor: string | null = null; + let extendsClause = ''; + let typeParameters: string | null = null; + + // Get class description from JSDoc (or from superclass if none) + let description = ''; + const jsDocComment = ts.getJSDocCommentsAndTags(declaration)[0]; + + if (jsDocComment && ts.isJSDoc(jsDocComment)) { + // First try to get the comment from the parsed JSDoc + const commentText = jsDocComment.comment; + if (typeof commentText === 'string' && commentText.trim()) { + description = processLinkTags(commentText.trim(), className); + } else { + // If no comment text, extract from raw source text + const sourceFile = declaration.getSourceFile(); + const sourceText = sourceFile.getFullText(); + const start = jsDocComment.getStart(); + const end = jsDocComment.getEnd(); + const rawJsDoc = sourceText.substring(start, end); + + // Extract the content between /** and */ + const match = rawJsDoc.match(/\/\*\*(.*?)\*\//s); + if (match && match[1]) { + const content = match[1] + .split('\n') + .map(line => line.replace(/^\s*\*\s?/, '')) // Remove leading * and spaces + .join('\n') + .trim(); + + // Filter out @tags but keep the description + const lines = content.split('\n'); + const descLines = lines.filter(line => !line.trim().startsWith('@')); + const rawDesc = descLines.join('\n').trim(); + + if (rawDesc) { + description = processLinkTags(rawDesc, className); + } + } + } + } + + // If no description, check superclass (only for classes/interfaces) + if (!description && (ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration)) && declaration.heritageClauses) { + const classType = typeChecker.getTypeAtLocation(declaration); + const baseTypes = classType.getBaseTypes(); + if (baseTypes && baseTypes.length > 0) { + const baseSymbol = baseTypes[0]!.getSymbol(); + if (baseSymbol && baseSymbol.valueDeclaration) { + const baseJsDoc = ts.getJSDocCommentsAndTags(baseSymbol.valueDeclaration)[0]; + if (baseJsDoc && ts.isJSDoc(baseJsDoc) && typeof baseJsDoc.comment === 'string') { + description = processLinkTags(baseJsDoc.comment.trim(), className); + } + } + } + } + + // Check for type parameters (only for classes/interfaces) + if ((ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration)) && declaration.typeParameters && declaration.typeParameters.length > 0) { + const typeParamStrings = declaration.typeParameters.map((tp) => { + const name = tp.name.text; + const constraint = tp.constraint ? ` extends ${tp.constraint.getText()}` : ''; + const defaultType = tp.default ? ` = ${tp.default.getText()}` : ''; + return `\t${name}${constraint}${defaultType}`; + }); + + const typeParamSig = `${className}<\n${typeParamStrings.join(',\n')},\n>`; + + // Get type parameter descriptions + const typeParamDocs: string[] = []; + const classJsDoc = ts.getJSDocCommentsAndTags(declaration)[0]; + if (classJsDoc && ts.isJSDoc(classJsDoc)) { + const templateTags = classJsDoc.tags?.filter((tag: any) => tag.tagName.text === 'template') || []; + templateTags.forEach((tag: any) => { + if (typeof tag.comment === 'string') { + const parts = tag.comment.trim().split(/\s+/); + const paramName = parts[0]; + const paramDesc = parts.slice(1).join(' ').replace(/^-\s*/, ''); + if (paramDesc) { + typeParamDocs.push(`- **${paramName}**: ${paramDesc}`); + } + } + }); + } + + typeParameters = `## Type parameters\n\n\`\`\`ts\n${typeParamSig}\n\`\`\``; + if (typeParamDocs.length > 0) { + typeParameters += `\n\n${typeParamDocs.join('\n')}`; + } + + // Find referenced types in type parameters for classes/interfaces + const typeParamRefs: string[] = []; + declaration.typeParameters.forEach((tp) => { + if (tp.constraint) { + typeParamRefs.push(...findAllTypeReferences(tp.constraint.getText())); + } + if (tp.default) { + typeParamRefs.push(...findAllTypeReferences(tp.default.getText())); + } + }); + const typeParamReferences = filterToExportedTypes([...new Set(typeParamRefs)], className); + const typeParamReferencesText = formatReferences(typeParamReferences); + if (typeParamReferencesText) { + typeParameters += typeParamReferencesText; + } + } + + // Check for extends clause (only for classes/interfaces) + if ((ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration)) && declaration.heritageClauses) { + const extendsClauseNode = declaration.heritageClauses.find( + clause => clause.token === ts.SyntaxKind.ExtendsKeyword, + ); + if (extendsClauseNode && extendsClauseNode.types[0]) { + const superClassName = extendsClauseNode.types[0].expression.getText(); + extendsClause = `\n\n**Extends:** [\`${superClassName}\`](./${superClassName}.md)\n`; + } + } + + // Helper to get JSDoc description with superclass fallback (recursive) + const getDescriptionWithFallback = (member: ts.ClassElement | ts.TypeElement, memberName: string): string => { + const jsDoc = ts.getJSDocCommentsAndTags(member)[0]; + if (jsDoc && ts.isJSDoc(jsDoc)) { + // First try the parsed comment + if (typeof jsDoc.comment === 'string' && jsDoc.comment.trim()) { + return processLinkTags(jsDoc.comment.trim(), className); + } else { + // If no parsed comment, extract from raw source (same logic as class descriptions) + const sourceFile = member.getSourceFile(); + const sourceText = sourceFile.getFullText(); + const start = jsDoc.getStart(); + const end = jsDoc.getEnd(); + const rawJsDoc = sourceText.substring(start, end); + + const match = rawJsDoc.match(/\/\*\*(.*?)\*\//s); + if (match && match[1]) { + const content = match[1] + .split('\n') + .map(line => line.replace(/^\s*\*\s?/, '')) // Remove leading * and spaces + .join('\n') + .trim(); + + // Filter out @tags but keep the description + const lines = content.split('\n'); + const descLines = lines.filter(line => !line.trim().startsWith('@')); + const rawDesc = descLines.join('\n').trim(); + + if (rawDesc) { + return processLinkTags(rawDesc, className); + } + } + } + } + + // Recursively check superclass hierarchy for this member's documentation + const findInHierarchy = (currentDeclaration: ts.ClassDeclaration): string => { + const currentType = typeChecker.getTypeAtLocation(currentDeclaration); + const baseTypes = currentType.getBaseTypes(); + + if (baseTypes && baseTypes.length > 0) { + const baseSymbol = baseTypes[0]!.getSymbol(); + if (baseSymbol && baseSymbol.valueDeclaration && ts.isClassDeclaration(baseSymbol.valueDeclaration)) { + const baseMember = baseSymbol.valueDeclaration.members.find(m => + m.name && m.name.getText() === memberName, + ); + if (baseMember) { + const baseJsDoc = ts.getJSDocCommentsAndTags(baseMember)[0]; + if (baseJsDoc && ts.isJSDoc(baseJsDoc) && typeof baseJsDoc.comment === 'string') { + return processLinkTags(baseJsDoc.comment.trim(), className); + } + } + // Recursively check further up the hierarchy + return findInHierarchy(baseSymbol.valueDeclaration); + } + } + return ''; + }; + + if (ts.isClassDeclaration(declaration)) { + return findInHierarchy(declaration); + } + + return ''; + }; + + // Helper to extract linked types from a member's JSDoc + const getLinkedTypesFromMember = (member: ts.ClassElement | ts.TypeElement): string[] => { + const jsDoc = ts.getJSDocCommentsAndTags(member)[0]; + if (jsDoc && ts.isJSDoc(jsDoc)) { + // First try the parsed comment + if (typeof jsDoc.comment === 'string' && jsDoc.comment.trim()) { + return extractLinkedTypes(jsDoc.comment.trim()); + } else { + // If no parsed comment, extract from raw source + const sourceFile = member.getSourceFile(); + const sourceText = sourceFile.getFullText(); + const start = jsDoc.getStart(); + const end = jsDoc.getEnd(); + const rawJsDoc = sourceText.substring(start, end); + + const match = rawJsDoc.match(/\/\*\*(.*?)\*\//s); + if (match && match[1]) { + const content = match[1] + .split('\n') + .map(line => line.replace(/^\s*\*\s?/, '')) // Remove leading * and spaces + .join('\n') + .trim(); + + // Filter out @tags but keep the description + const lines = content.split('\n'); + const descLines = lines.filter(line => !line.trim().startsWith('@')); + const rawDesc = descLines.join('\n').trim(); + + return extractLinkedTypes(rawDesc); + } + } + } + return []; + }; + + // Helper to process members (both own and inherited) + const processMember = (member: ts.ClassElement | ts.TypeElement, _isInherited = false) => { + // Check if member has @internal in JSDoc + const hasInternalTag = ts.getJSDocTags(member).some(tag => tag.tagName.text === 'internal'); + if (hasInternalTag) return; + + if (ts.isConstructorDeclaration(member) && !isAbstract) { + // Skip private constructors + const isPrivate = member.modifiers?.some(mod => mod.kind === ts.SyntaxKind.PrivateKeyword); + if (isPrivate) return; + + // Only process the first constructor we encounter to build the constructor section + // We'll collect all overloads separately + if (!constructor) { + // Collect all constructor overloads from the class + const constructorOverloads: ts.ConstructorDeclaration[] = []; + if (ts.isClassDeclaration(declaration)) { + declaration.members.forEach((m) => { + if (ts.isConstructorDeclaration(m) && !m.modifiers?.some(mod => mod.kind === ts.SyntaxKind.PrivateKeyword)) { + constructorOverloads.push(m); + } + }); + } + + // Build individual constructor blocks with their own descriptions + const constructorBlocks: string[] = []; + const allReferencedTypes: string[] = []; + + constructorOverloads.forEach((ctor) => { + // Skip the implementation (the one with a body) unless it's the only constructor + if (ctor.body && constructorOverloads.length > 1) { + // Process constructor parameters with visibility modifiers as properties (only from implementation) + ctor.parameters.forEach((param) => { + // Check if parameter has visibility modifier (public, private, protected, readonly) + const hasVisibilityModifier = param.modifiers?.some(mod => + mod.kind === ts.SyntaxKind.PublicKeyword + || mod.kind === ts.SyntaxKind.PrivateKeyword + || mod.kind === ts.SyntaxKind.ProtectedKeyword + || mod.kind === ts.SyntaxKind.ReadonlyKeyword, + ); + + if (!hasVisibilityModifier) return; + + // Skip private parameters + const isPrivate = param.modifiers?.some(mod => mod.kind === ts.SyntaxKind.PrivateKeyword); + if (isPrivate) return; + + const paramName = param.name.getText(); + const hasQuestionToken = param.questionToken !== undefined; + const isReadonly = param.modifiers?.some(mod => mod.kind === ts.SyntaxKind.ReadonlyKeyword); + const rawParamType = param.type ? param.type.getText() : typeChecker.typeToString(typeChecker.getTypeAtLocation(param)); + const paramType = cleanOptionalType(rawParamType, hasQuestionToken); + + const propertyDef = `${isReadonly ? 'readonly ' : ''}${paramName}${hasQuestionToken ? '?' : ''}: ${paramType};`; + + // Get description from JSDoc comment on the parameter + const paramJsDoc = ts.getJSDocCommentsAndTags(param)[0]; + let desc = ''; + if (paramJsDoc && ts.isJSDoc(paramJsDoc) && typeof paramJsDoc.comment === 'string') { + desc = processLinkTags(paramJsDoc.comment.trim(), className); + } + + // Find referenced types + const references = filterToExportedTypes(findAllTypeReferences(paramType), className); + const referencesText = formatReferences(references); + + const propertyContent = `### \`${paramName}\`\n\n\`\`\`ts\n${propertyDef}\n\`\`\`${desc ? `\n\n${desc}` : ''}${referencesText}`; + properties.push(propertyContent); + }); + return; + } + + // Build parameter list for this overload + const params = ctor.parameters.map((param) => { + const paramName = param.name.getText(); + const hasQuestionToken = param.questionToken !== undefined; + const hasDefault = param.initializer !== undefined; + const isRest = param.dotDotDotToken !== undefined; + const rawParamType = param.type ? param.type.getText() : typeChecker.typeToString(typeChecker.getTypeAtLocation(param)); + const paramType = cleanOptionalType(rawParamType, hasQuestionToken); + + if (hasDefault) { + const defaultValue = param.initializer.getText(); + return `\t${isRest ? '...' : ''}${paramName}: ${paramType} = ${defaultValue}`; + } else { + return `\t${isRest ? '...' : ''}${paramName}${hasQuestionToken ? '?' : ''}: ${paramType}`; + } + }); + + const constructorSig = params.length > 0 + ? `constructor(\n${params.join(',\n')},\n): ${className};` + : `constructor(): ${className};`; + + // Collect referenced types from this overload + const ctorTypeStrings = params.map(p => p.replace(/\t.*?:\s*/, '')); + allReferencedTypes.push(...ctorTypeStrings.flatMap(findAllTypeReferences)); + + // Get description for this specific overload + const constructorDesc = getDescriptionWithFallback(ctor, 'constructor'); + const linkedTypes = getLinkedTypesFromMember(ctor); + + // Get parameter descriptions for this specific overload + const paramDocs: string[] = []; + const jsDoc = ts.getJSDocCommentsAndTags(ctor)[0]; + if (jsDoc && ts.isJSDoc(jsDoc)) { + const paramTags = jsDoc.tags?.filter((tag: any) => tag.tagName.text === 'param') || []; + paramTags.forEach((tag: any) => { + if (ts.isJSDocParameterTag(tag) && tag.name && typeof tag.comment === 'string') { + const paramName = tag.name.getText(); + const paramDesc = tag.comment.trim().replace(/^-\s*/, ''); + paramDocs.push(`- **${paramName}**: ${paramDesc}`); + } + }); + } + + // Build this constructor block + let constructorBlock = `\`\`\`ts\n${constructorSig}\n\`\`\``; + if (constructorDesc) { + constructorBlock += `\n\n${constructorDesc}`; + } + if (paramDocs.length > 0) { + constructorBlock += `\n\n**Parameters:**\n\n${paramDocs.join('\n')}`; + } + + // Find referenced types for this overload + const overloadReferences = filterToExportedTypes([...new Set(ctorTypeStrings.flatMap(findAllTypeReferences))], className); + constructorBlock += formatReferences(overloadReferences, linkedTypes); + + constructorBlocks.push(constructorBlock); + }); + + // Build the constructor section + const headingText = constructorBlocks.length > 1 ? 'Constructors' : 'Constructor'; + const separator = constructorBlocks.length > 1 ? '\n\n---\n\n' : '\n\n'; + constructor = `## ${headingText}\n\n${constructorBlocks.join(separator)}`; + } + } else if ((ts.isPropertyDeclaration(member) || ts.isPropertySignature(member)) && member.name) { + const name = member.name.getText(); + const isReadonly = member.modifiers?.some(mod => mod.kind === ts.SyntaxKind.ReadonlyKeyword); + const isOptional = member.questionToken !== undefined; + // Prefer explicit type annotation if available, otherwise use type checker + let rawType: string; + if (member.type) { + rawType = member.type.getText(); + // For optional properties, check if the type is a reference to an exported type + // If so, don't apply undefined removal logic to preserve the type reference + if (isOptional && exportedTypes.has(rawType)) { + // Keep the original type reference for exported types + } else { + rawType = cleanOptionalType(rawType, isOptional); + } + } else { + const memberType = typeChecker.getTypeAtLocation(member); + const typeName = memberType.getSymbol()?.getName(); + // If the type has a symbol name and it's in our exported types, use that instead of expanding + if (typeName && exportedTypes.has(typeName)) { + rawType = typeName; + } else { + rawType = getTypeString(memberType); + rawType = cleanOptionalType(rawType, isOptional); + } + } + const type = rawType; + const propertyDef = `${isReadonly ? 'readonly ' : ''}${name}${isOptional ? '?' : ''}: ${type};`; + + // Get description from JSDoc + const desc = getDescriptionWithFallback(member, name); + const linkedTypes = getLinkedTypesFromMember(member); + + // Find referenced types + const references = filterToExportedTypes(findAllTypeReferences(type), className); + const referencesText = formatReferences(references, linkedTypes); + + // Check if this is an event handler (starts with "on" and can be a function) + const isEventHandler = name.startsWith('on') && ( + type.includes('=>') + || type.includes('Function') + || type.includes('() =>') + || (type.includes('(') && type.includes(') =>')) + ); + + const inheritedBadge = ''; + const propertyContent = `### \`${name}\`${inheritedBadge}\n\n\`\`\`ts\n${propertyDef}\n\`\`\`${desc ? `\n\n${desc}` : ''}${referencesText}`; + + if (isEventHandler) { + events.push(propertyContent); + } else { + properties.push(propertyContent); + } + } else if (ts.isGetAccessorDeclaration(member) && member.name) { + const name = member.name.getText(); + // For getters, prefer the explicit return type annotation if available + let rawType: string; + if (member.type) { + rawType = member.type.getText(); + } else { + const memberType = typeChecker.getTypeAtLocation(member); + const typeName = memberType.getSymbol()?.getName(); + // If the type has a symbol name and it's in our exported types, use that instead of expanding + if (typeName && exportedTypes.has(typeName)) { + rawType = typeName; + } else { + rawType = getTypeString(memberType); + } + } + const type = cleanOptionalType(rawType, false); + const accessorDef = `get ${name}(): ${type};`; + + // Get description from JSDoc + const desc = getDescriptionWithFallback(member, name); + const linkedTypes = getLinkedTypesFromMember(member); + + // Find referenced types + const references = filterToExportedTypes(findAllTypeReferences(type), className); + const referencesText = formatReferences(references, linkedTypes); + + const inheritedBadge = ''; + properties.push(`### \`${name}\`${inheritedBadge}\n\n\`\`\`ts\n${accessorDef}\n\`\`\`${desc ? `\n\n${desc}` : ''}${referencesText}`); + } else if (ts.isSetAccessorDeclaration(member) && member.name) { + const name = member.name.getText(); + const param = member.parameters[0]; + const isOptional = param?.questionToken !== undefined; + const rawParamType = param?.type + ? typeChecker.typeToString(typeChecker.getTypeAtLocation(param)) + : 'any'; + const paramType = cleanOptionalType(rawParamType, isOptional); + const accessorDef = `set ${name}(value: ${paramType});`; + + // Get description from JSDoc + const desc = getDescriptionWithFallback(member, name); + const linkedTypes = getLinkedTypesFromMember(member); + + // Find referenced types + const references = filterToExportedTypes(findAllTypeReferences(paramType), className); + const referencesText = formatReferences(references, linkedTypes); + + const inheritedBadge = ''; + properties.push(`### \`${name}\`${inheritedBadge}\n\n\`\`\`ts\n${accessorDef}\n\`\`\`${desc ? `\n\n${desc}` : ''}${referencesText}`); + } else if ((ts.isMethodDeclaration(member) || ts.isMethodSignature(member)) && member.name) { + const name = member.name.getText(); + const isStatic = member.modifiers?.some(mod => mod.kind === ts.SyntaxKind.StaticKeyword); + + // For method overloads, skip the implementation if it has no JSDoc + // (the overload signatures should have the documentation) + const methodJsDoc = ts.getJSDocCommentsAndTags(member)[0]; + const hasJsDoc = methodJsDoc && ts.isJSDoc(methodJsDoc) && methodJsDoc.comment; + + // If this is a method declaration (has a body) and has no JSDoc, check if there are overloads + if (ts.isMethodDeclaration(member) && member.body && !hasJsDoc && ts.isClassDeclaration(declaration)) { + // Check if there are other methods with the same name (overloads) + const sameNameMethods = declaration.members.filter(m => + (ts.isMethodDeclaration(m) || ts.isMethodSignature(m)) + && m.name && m.name.getText() === name, + ); + + // If there are multiple methods with the same name, this is likely an overloaded method + // Skip the implementation (the one with a body) if it has no JSDoc + if (sameNameMethods.length > 1) { + return; + } + } + + // Build parameter list with each on its own line + const params = member.parameters.map((param) => { + const paramName = param.name.getText(); + const hasQuestionToken = param.questionToken !== undefined; + const hasDefault = param.initializer !== undefined; + const isRest = param.dotDotDotToken !== undefined; + const rawParamType = param.type ? param.type.getText() : typeChecker.typeToString(typeChecker.getTypeAtLocation(param)); + const paramType = cleanOptionalType(rawParamType, hasQuestionToken); + + if (hasDefault) { + const defaultValue = param.initializer.getText(); + return `\t${isRest ? '...' : ''}${paramName}: ${paramType} = ${defaultValue}`; + } else { + return `\t${isRest ? '...' : ''}${paramName}${hasQuestionToken ? '?' : ''}: ${paramType}`; + } + }); + + // Get return type + const signature = typeChecker.getSignatureFromDeclaration(member); + const returnType = signature ? getTypeString(signature.getReturnType()) : 'void'; + + // Format method signature + const methodSig = params.length > 0 + ? `${isStatic ? 'static ' : ''}${name}(\n${params.join(',\n')},\n): ${returnType};` + : `${isStatic ? 'static ' : ''}${name}(): ${returnType};`; + + // Get method description from JSDoc + const desc = getDescriptionWithFallback(member, name); + const linkedTypes = getLinkedTypesFromMember(member); + + // Get parameter and return descriptions + const paramDocs: string[] = []; + let returnDoc = ''; + const jsDoc = ts.getJSDocCommentsAndTags(member)[0]; + if (jsDoc && ts.isJSDoc(jsDoc)) { + const paramTags = jsDoc.tags?.filter((tag: any) => tag.tagName.text === 'param') || []; + paramTags.forEach((tag: any) => { + if (ts.isJSDocParameterTag(tag) && tag.name && typeof tag.comment === 'string') { + const paramName = tag.name.getText(); + const paramDesc = tag.comment.trim().replace(/^-\s*/, ''); + paramDocs.push(`- **${paramName}**: ${paramDesc}`); + } + }); + + const returnTags = jsDoc.tags?.filter((tag: any) => tag.tagName.text === 'returns' || tag.tagName.text === 'return') || []; + if (returnTags.length > 0 && returnTags[0] && typeof returnTags[0].comment === 'string') { + returnDoc = returnTags[0].comment.trim().replace(/^-\s*/, ''); + } + } + + const inheritedBadge = ''; + let methodContent = `### \`${name}()\`${inheritedBadge}\n\n\`\`\`ts\n${methodSig}\n\`\`\``; + if (desc) { + methodContent += `\n\n${desc}`; + } + if (paramDocs.length > 0) { + methodContent += `\n\n**Parameters:**\n\n${paramDocs.join('\n')}`; + } + if (returnDoc) { + methodContent += `\n\n**Returns:** ${returnDoc}`; + } + + // Find referenced types in all parameters and return type + const allTypeStrings = params.map(p => p.replace(/\t.*?:\s*/, '')).concat([returnType]); + const allReferences = filterToExportedTypes([...new Set(allTypeStrings.flatMap(findAllTypeReferences))], className); + methodContent += formatReferences(allReferences, linkedTypes); + + if (isStatic) { + staticMethods.push(methodContent); + } else { + methods.push(methodContent); + } + } + }; + + // Track names of own members to avoid duplicates with inherited + const ownMemberNames = new Set(); + + // Process own members first + if (ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration)) { + declaration.members.forEach((member) => { + if (member.name) { + ownMemberNames.add(member.name.getText()); + } + processMember(member); + }); + } else if (ts.isTypeAliasDeclaration(declaration)) { + // For type aliases, check if it's a simple union type (like string literals) + const resolvedType = typeChecker.getTypeAtLocation(declaration); + + // If it's a primitive type or simple union, skip property processing + const isPrimitive = !!(resolvedType.flags & (ts.TypeFlags.String | ts.TypeFlags.Number | ts.TypeFlags.Boolean | ts.TypeFlags.StringLiteral | ts.TypeFlags.NumberLiteral | ts.TypeFlags.BooleanLiteral)); + const isSimpleUnion = resolvedType.isUnion() && resolvedType.types.every(t => + t.flags & (ts.TypeFlags.StringLiteral | ts.TypeFlags.NumberLiteral | ts.TypeFlags.BooleanLiteral | ts.TypeFlags.String | ts.TypeFlags.Number | ts.TypeFlags.Boolean), + ); + + if (!isPrimitive && !isSimpleUnion) { + const typeProperties = typeChecker.getPropertiesOfType(resolvedType); + + typeProperties.forEach((prop) => { + // Create a synthetic property signature for each resolved property + const propName = prop.getName(); + const propType = typeChecker.getTypeOfSymbolAtLocation(prop, declaration); + const propTypeString = getTypeString(propType); + const isOptional = (prop.flags & ts.SymbolFlags.Optional) !== 0; + + // Get JSDoc from the original declaration + let desc = ''; + const propDeclaration = prop.valueDeclaration || prop.declarations?.[0]; + if (propDeclaration) { + const jsDoc = ts.getJSDocCommentsAndTags(propDeclaration)[0]; + if (jsDoc && ts.isJSDoc(jsDoc) && typeof jsDoc.comment === 'string') { + desc = processLinkTags(jsDoc.comment.trim(), className); + } + } + + // Check if this property's original type annotation references an exported type + let cleanedType = propTypeString; + if (propDeclaration && ts.isPropertySignature(propDeclaration) && propDeclaration.type) { + const originalType = propDeclaration.type.getText(); + if (exportedTypes.has(originalType)) { + cleanedType = originalType; + } else { + cleanedType = cleanOptionalType(propTypeString, isOptional); + } + } else { + cleanedType = cleanOptionalType(propTypeString, isOptional); + } + + const propertyDef = `${propName}${isOptional ? '?' : ''}: ${cleanedType};`; + + // Find referenced types + const references = filterToExportedTypes(findAllTypeReferences(cleanedType), className); + const referencesText = formatReferences(references); + + // Check if this is an event handler (starts with "on" and can be a function) + const isEventHandler = propName.startsWith('on') && ( + cleanedType.includes('=>') + || cleanedType.includes('Function') + || cleanedType.includes('() =>') + || (cleanedType.includes('(') && cleanedType.includes(') =>')) + ); + + // Type alias properties are never inherited + const propertyContent = `### \`${propName}\`\n\n\`\`\`ts\n${propertyDef}\n\`\`\`${desc ? `\n\n${desc}` : ''}${referencesText}`; + + if (isEventHandler) { + events.push(propertyContent); + } else { + properties.push(propertyContent); + } + }); + } + } + + // Process inherited members (skip if overridden, only for classes) + if (ts.isClassDeclaration(declaration)) { + const classType = typeChecker.getTypeAtLocation(declaration); + const baseTypes = classType.getBaseTypes(); + if (baseTypes && baseTypes.length > 0) { + baseTypes.forEach((baseType) => { + const baseSymbol = baseType.getSymbol(); + if (baseSymbol && baseSymbol.valueDeclaration && ts.isClassDeclaration(baseSymbol.valueDeclaration)) { + baseSymbol.valueDeclaration.members.forEach((member) => { + // Skip if this member is overridden in the derived class + if (member.name && ownMemberNames.has(member.name.getText())) { + return; + } + // Never process inherited constructors - always use the derived class constructor + if (ts.isConstructorDeclaration(member)) { + return; + } + processMember(member, true); + }); + } + }); + } + } + + // Sort properties and methods alphabetically (but not for type aliases - keep source order) + if (!ts.isTypeAliasDeclaration(declaration)) { + properties.sort((a, b) => { + const nameA = a.match(/### (.+)/)?.[1] || ''; + const nameB = b.match(/### (.+)/)?.[1] || ''; + return nameA.localeCompare(nameB); + }); + } + + staticMethods.sort((a, b) => { + const nameA = a.match(/### (.+)/)?.[1] || ''; + const nameB = b.match(/### (.+)/)?.[1] || ''; + return nameA.localeCompare(nameB); + }); + + let markdown = ''; + + // Add VPBadge import and badge for all types + markdown += `\n\n`; + + if (isAbstract) { + markdown += `\n\n`; + } else if (ts.isClassDeclaration(declaration)) { + markdown += `\n\n`; + } else if (ts.isTypeAliasDeclaration(declaration)) { + markdown += `\n\n`; + } else if (ts.isInterfaceDeclaration(declaration)) { + markdown += `\n\n`; + } + + markdown += `# ${className}\n\n${description ? `${description}\n` : ''}${extendsClause}`; + + // Add subclasses section for classes that have subclasses + if (ts.isClassDeclaration(declaration) && classHierarchy.has(className)) { + const subclasses = classHierarchy.get(className)!; + // Sort by definition order instead of alphabetically + subclasses.sort((a, b) => { + const orderA = symbolOrderMap.get(a); + const orderB = symbolOrderMap.get(b); + if (orderA === undefined) throw new Error(`Symbol '${a}' not found in entry files export order`); + if (orderB === undefined) throw new Error(`Symbol '${b}' not found in entry files export order`); + return orderA - orderB; + }); + markdown += `\n## Subclasses\n\n`; + subclasses.forEach((sub) => { + markdown += `- [\`${sub}\`](./${sub}.md)\n`; + }); + } + + // Add instances section for classes that have instances + if (ts.isClassDeclaration(declaration) && classInstances.has(className)) { + const instances = classInstances.get(className)!; + // Sort by definition order instead of alphabetically + instances.sort((a, b) => { + const orderA = symbolOrderMap.get(a); + const orderB = symbolOrderMap.get(b); + if (orderA === undefined) throw new Error(`Symbol '${a}' not found in entry files export order`); + if (orderB === undefined) throw new Error(`Symbol '${b}' not found in entry files export order`); + return orderA - orderB; + }); + markdown += `\n## Instances\n\n`; + instances.forEach((instance) => { + markdown += `- [\`${instance}\`](./${instance}.md)\n`; + }); + } + + // Add type definition for type aliases + if (ts.isTypeAliasDeclaration(declaration) && declaration.type) { + const resolvedType = typeChecker.getTypeAtLocation(declaration); + const isPrimitive = !!(resolvedType.flags & (ts.TypeFlags.String | ts.TypeFlags.Number | ts.TypeFlags.Boolean | ts.TypeFlags.StringLiteral | ts.TypeFlags.NumberLiteral | ts.TypeFlags.BooleanLiteral)); + const isSimpleUnion = resolvedType.isUnion() && resolvedType.types.every(t => + t.flags & (ts.TypeFlags.StringLiteral | ts.TypeFlags.NumberLiteral | ts.TypeFlags.BooleanLiteral | ts.TypeFlags.String | ts.TypeFlags.Number | ts.TypeFlags.Boolean), + ); + + // Build the type name with generic parameters if they exist + let typeName = className; + if (declaration.typeParameters && declaration.typeParameters.length > 0) { + const typeParamStrings = declaration.typeParameters.map((tp) => { + const name = tp.name.text; + const constraint = tp.constraint ? ` extends ${tp.constraint.getText()}` : ''; + const defaultType = tp.default ? ` = ${tp.default.getText()}` : ''; + return `${name}${constraint}${defaultType}`; + }); + typeName = `${className}<${typeParamStrings.join(', ')}>`; + } + + let typeText; + if (isPrimitive || isSimpleUnion) { + // For primitive types or simple unions, use resolved type string + const resolvedTypeString = typeChecker.typeToString(resolvedType); + if (resolvedTypeString === className) { + // If resolved type is just the alias name, use original text + typeText = declaration.type.getText(); + } else { + typeText = resolvedTypeString; + } + // Convert string literals from double quotes to single quotes + typeText = typeText.replace(/"([^"]*)"/g, '\'$1\''); + if (typeText.includes(' | ')) { + const unionMembers = typeText.split(' | '); + typeText = '\n\t| ' + unionMembers.join('\n\t| '); + } + } else { + // For complex types, use the original text + typeText = declaration.type.getText(); + // Format object types with proper line breaks + if (typeText.includes('{')) { + typeText = formatObjectType(typeText); + } + } + const typeDefinition = `type ${typeName} = ${typeText};`; + + // Find referenced types in the type definition and generic parameters + const allTypeRefs = findAllTypeReferences(typeText); + // Also find references in type parameters (extends clauses and default types) + if (declaration.typeParameters && declaration.typeParameters.length > 0) { + declaration.typeParameters.forEach((tp) => { + if (tp.constraint) { + allTypeRefs.push(...findAllTypeReferences(tp.constraint.getText())); + } + if (tp.default) { + allTypeRefs.push(...findAllTypeReferences(tp.default.getText())); + } + }); + } + const typeReferences = filterToExportedTypes([...new Set(allTypeRefs)], className); + const typeReferencesText = formatReferences(typeReferences); + + markdown += `\n\`\`\`ts\n${typeDefinition}\n\`\`\`${typeReferencesText}`; + } + + if (typeParameters) { + markdown += `\n${typeParameters}\n\n`; + } + + if (constructor) { + markdown += `\n${constructor}\n\n`; + } + + if (staticMethods.length > 0) { + markdown += `\n## Static methods\n\n${staticMethods.join('\n\n')}\n\n`; + } + + if (properties.length > 0) { + markdown += `\n## Properties\n\n${properties.join('\n\n')}\n\n`; + } + + if (events.length > 0) { + markdown += `\n## Events\n\n${events.join('\n\n')}\n\n`; + } + + if (methods.length > 0) { + markdown += `\n## Methods\n\n${methods.join('\n\n')}\n`; + } + const outputPath = path.join(outputDir, `${className}.md`); + fs.writeFileSync(outputPath, markdown); + console.log(`Generated: ${outputPath}`); + } + }); + + // Generate index.md with all exported symbols grouped by group + const entriesByGroup = new Map>(); + + indexEntries.forEach((entry) => { + if (!entriesByGroup.has(entry.group)) { + entriesByGroup.set(entry.group, []); + } + entriesByGroup.get(entry.group)!.push({ name: entry.name, type: entry.type, order: entry.order }); + }); + + // Sort groups according to API config order + const configGroups = Object.keys(groupConfig); + const sortedGroups = configGroups.filter(group => entriesByGroup.has(group)); + + // Check for groups in entries that aren't in config + const missingGroups = Array.from(entriesByGroup.keys()).filter(group => !configGroups.includes(group)); + if (missingGroups.length > 0) { + throw new Error(`Groups found in code but not in API config: ${missingGroups.join(', ')}`); + } + + let indexMarkdown = `# ${headingText}\n\n`; + + // Add intro text if provided + if (introText) { + indexMarkdown += `${introText}\n\n`; + } + + sortedGroups.forEach((group) => { + const entries = entriesByGroup.get(group)!; + // Sort entries by definition order + entries.sort((a, b) => a.order - b.order); + + indexMarkdown += `## ${group}\n\n`; + const groupDescription = groupConfig[group]; + if (groupDescription) { + indexMarkdown += `${groupDescription}\n\n`; + } + entries.forEach((entry) => { + indexMarkdown += `- [${entry.name}](./${entry.name}.md)\n`; + }); + indexMarkdown += '\n'; + }); + + const indexPath = path.join(outputDir, 'index.md'); + fs.writeFileSync(indexPath, indexMarkdown); + console.log(`Generated: ${indexPath}`); + + // Generate index.json with sidebar config structure + const sidebarConfig = sortedGroups.map((group) => { + const entries = entriesByGroup.get(group)!; + // Sort entries by definition order + entries.sort((a, b) => a.order - b.order); + + return { + text: group, + collapsed: true, + items: entries.map(entry => ({ + text: entry.name, + link: `/api/${entry.name}`, + })), + }; + }); + + const jsonPath = path.join(outputDir, 'index.json'); + fs.writeFileSync(jsonPath, JSON.stringify(sidebarConfig, null, 2)); + console.log(`Generated: ${jsonPath}`); +}; + +const main = () => { + const args = process.argv.slice(2); + if (args.length < 2) { + console.error('Usage: npm run generate-docs [entry-file2 ...] '); + console.error(' entry-files: One or more TypeScript entry files'); + console.error(' api-config-file: JSON config file defining groups'); + process.exit(1); + } + + // Last argument is the config file, everything else are entry files + const apiConfigFile = args[args.length - 1]!; + const entryFiles = args.slice(0, -1); + + generateDocs(entryFiles, apiConfigFile); +}; + +main(); diff --git a/src/codec.ts b/src/codec.ts index 8e4740e..119f7cb 100644 --- a/src/codec.ts +++ b/src/codec.ts @@ -28,6 +28,7 @@ import { SubtitleMetadata } from './subtitles'; /** * List of known video codecs, ordered by encoding preference. + * @group Codecs * @public */ export const VIDEO_CODECS = [ @@ -39,6 +40,7 @@ export const VIDEO_CODECS = [ ] as const; /** * List of known PCM (uncompressed) audio codecs, ordered by encoding preference. + * @group Codecs * @public */ export const PCM_AUDIO_CODECS = [ @@ -59,6 +61,7 @@ export const PCM_AUDIO_CODECS = [ ] as const; /** * List of known compressed audio codecs, ordered by encoding preference. + * @group Codecs * @public */ export const NON_PCM_AUDIO_CODECS = [ @@ -70,6 +73,7 @@ export const NON_PCM_AUDIO_CODECS = [ ] as const; /** * List of known audio codecs, ordered by encoding preference. + * @group Codecs * @public */ export const AUDIO_CODECS = [ @@ -78,6 +82,7 @@ export const AUDIO_CODECS = [ ] as const; /** * List of known subtitle codecs, ordered by encoding preference. + * @group Codecs * @public */ export const SUBTITLE_CODECS = [ @@ -86,22 +91,26 @@ export const SUBTITLE_CODECS = [ /** * Union type of known video codecs. + * @group Codecs * @public */ export type VideoCodec = typeof VIDEO_CODECS[number]; /** * Union type of known audio codecs. + * @group Codecs * @public */ export type AudioCodec = typeof AUDIO_CODECS[number]; export type PcmAudioCodec = typeof PCM_AUDIO_CODECS[number]; /** * Union type of known subtitle codecs. + * @group Codecs * @public */ export type SubtitleCodec = typeof SUBTITLE_CODECS[number]; /** * Union type of known media codecs. + * @group Codecs * @public */ export type MediaCodec = VideoCodec | AudioCodec | SubtitleCodec; @@ -726,110 +735,6 @@ export const getAudioEncoderConfigExtension = (codec: AudioCodec) => { return {}; }; -/** - * Represents a subjective media quality level. - * @public - */ -export class Quality { - /** @internal */ - _factor: number; - - /** @internal */ - constructor(factor: number) { - this._factor = factor; - } - - /** @internal */ - _toVideoBitrate(codec: VideoCodec, width: number, height: number) { - const pixels = width * height; - - const codecEfficiencyFactors = { - avc: 1.0, // H.264/AVC (baseline) - hevc: 0.6, // H.265/HEVC (~40% more efficient than AVC) - vp9: 0.6, // Similar to HEVC - av1: 0.4, // ~60% more efficient than AVC - vp8: 1.2, // Slightly less efficient than AVC - }; - - const referencePixels = 1920 * 1080; - const referenceBitrate = 3000000; - const scaleFactor = Math.pow(pixels / referencePixels, 0.95); // Slight non-linear scaling - const baseBitrate = referenceBitrate * scaleFactor; - - const codecAdjustedBitrate = baseBitrate * codecEfficiencyFactors[codec]; - const finalBitrate = codecAdjustedBitrate * this._factor; - - return Math.ceil(finalBitrate / 1000) * 1000; - } - - /** @internal */ - _toAudioBitrate(codec: AudioCodec) { - if ((PCM_AUDIO_CODECS as readonly string[]).includes(codec) || codec === 'flac') { - return undefined; - } - - const baseRates = { - aac: 128000, // 128kbps base for AAC - opus: 64000, // 64kbps base for Opus - mp3: 160000, // 160kbps base for MP3 - vorbis: 64000, // 64kbps base for Vorbis - }; - - const baseBitrate = baseRates[codec as keyof typeof baseRates]; - if (!baseBitrate) { - throw new Error(`Unhandled codec: ${codec}`); - } - - let finalBitrate = baseBitrate * this._factor; - - if (codec === 'aac') { - // AAC only works with specific bitrates, let's find the closest - const validRates = [96000, 128000, 160000, 192000]; - finalBitrate = validRates.reduce((prev, curr) => - Math.abs(curr - finalBitrate) < Math.abs(prev - finalBitrate) ? curr : prev, - ); - } else if (codec === 'opus' || codec === 'vorbis') { - finalBitrate = Math.max(6000, finalBitrate); - } else if (codec === 'mp3') { - const validRates = [ - 8000, 16000, 24000, 32000, 40000, 48000, 64000, 80000, - 96000, 112000, 128000, 160000, 192000, 224000, 256000, 320000, - ]; - finalBitrate = validRates.reduce((prev, curr) => - Math.abs(curr - finalBitrate) < Math.abs(prev - finalBitrate) ? curr : prev, - ); - } - - return Math.round(finalBitrate / 1000) * 1000; - } -} - -/** - * Represents a very low media quality. - * @public - */ -export const QUALITY_VERY_LOW = new Quality(0.3); -/** - * Represents a low media quality. - * @public - */ -export const QUALITY_LOW = new Quality(0.6); -/** - * Represents a medium media quality. - * @public - */ -export const QUALITY_MEDIUM = new Quality(1); -/** - * Represents a high media quality. - * @public - */ -export const QUALITY_HIGH = new Quality(2); -/** - * 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']; const AVC_CODEC_STRING_REGEX = /^(avc1|avc3)\.[0-9a-fA-F]{6}$/; const HEVC_CODEC_STRING_REGEX = /^(hev1|hvc1)\.(?:[ABC]?\d+)\.[0-9a-fA-F]{1,8}\.[LH]\d+(?:\.[0-9a-fA-F]{1,2}){0,6}$/; diff --git a/src/conversion.ts b/src/conversion.ts index 014a6bd..c3c1a0a 100644 --- a/src/conversion.ts +++ b/src/conversion.ts @@ -10,15 +10,14 @@ import { AUDIO_CODECS, AudioCodec, NON_PCM_AUDIO_CODECS, - Quality, - QUALITY_HIGH, VIDEO_CODECS, VideoCodec, } from './codec'; import { - AudioEncodingConfig, getEncodableAudioCodecs, getFirstEncodableVideoCodec, + Quality, + QUALITY_HIGH, VideoEncodingConfig, } from './encode'; import { Input } from './input'; @@ -51,6 +50,7 @@ import { AudioSample, VideoSample } from './sample'; /** * The options for media file conversion. + * @group Conversion * @public */ export type ConversionOptions = { @@ -62,8 +62,8 @@ export type ConversionOptions = { /** * Video-specific options. When passing an object, the same options are applied to all video tracks. When passing a * function, it will be invoked for each video track and is expected to return or resolve to the options - * for that specific track. The function is passed an instance of `InputVideoTrack` as well as a number `n`, which - * is the 1-based index of the track in the list of all video tracks. + * for that specific track. The function is passed an instance of {@link InputVideoTrack} as well as a number `n`, + * which is the 1-based index of the track in the list of all video tracks. */ video?: ConversionVideoOptions | ((track: InputVideoTrack, n: number) => MaybePromise); @@ -71,8 +71,8 @@ export type ConversionOptions = { /** * Audio-specific options. When passing an object, the same options are applied to all audio tracks. When passing a * function, it will be invoked for each audio track and is expected to return or resolve to the options - * for that specific track. The function is passed an instance of `InputAudioTrack` as well as a number `n`, which - * is the 1-based index of the track in the list of all audio tracks. + * for that specific track. The function is passed an instance of {@link InputAudioTrack} as well as a number `n`, + * which is the 1-based index of the track in the list of all audio tracks. */ audio?: ConversionAudioOptions | ((track: InputAudioTrack, n: number) => MaybePromise); @@ -88,10 +88,11 @@ export type ConversionOptions = { /** * Video-specific options. + * @group Conversion * @public */ export type ConversionVideoOptions = { - /** If true, all video tracks will be discarded and will not be present in the output. */ + /** If `true`, all video tracks will be discarded and will not be present in the output. */ discard?: boolean; /** * The desired width of the output video in pixels, defaulting to the video's natural display width. If height @@ -106,10 +107,10 @@ export type ConversionVideoOptions = { /** * The fitting algorithm in case both width and height are set, or if the input video changes its size over time. * - * - '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 + * - `'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. + * - `'cover'` will scale the image until the entire box is filled, while preserving aspect ratio. */ fit?: 'fill' | 'contain' | 'cover'; /** @@ -125,17 +126,18 @@ export type ConversionVideoOptions = { /** The desired output video codec. */ codec?: VideoCodec; /** The desired bitrate of the output video. */ - bitrate?: VideoEncodingConfig['bitrate']; - /** When true, video will always be re-encoded instead of directly copying over the encoded samples. */ + bitrate?: number | Quality; + /** When `true`, video will always be re-encoded instead of directly copying over the encoded samples. */ forceTranscode?: boolean; }; /** * Audio-specific options. + * @group Conversion * @public */ export type ConversionAudioOptions = { - /** If true, all audio tracks will be discarded and will not be present in the output. */ + /** If `true`, all audio tracks will be discarded and will not be present in the output. */ discard?: boolean; /** The desired channel count of the output audio. */ numberOfChannels?: number; @@ -144,8 +146,8 @@ export type ConversionAudioOptions = { /** The desired output audio codec. */ codec?: AudioCodec; /** The desired bitrate of the output audio. */ - bitrate?: AudioEncodingConfig['bitrate']; - /** When true, audio will always be re-encoded instead of directly copying over the encoded samples. */ + bitrate?: number | Quality; + /** When `true`, audio will always be re-encoded instead of directly copying over the encoded samples. */ forceTranscode?: boolean; }; @@ -246,9 +248,41 @@ const validateAudioOptions = (audioOptions: ConversionAudioOptions | undefined) const FALLBACK_NUMBER_OF_CHANNELS = 2; const FALLBACK_SAMPLE_RATE = 48000; +/** + * An input track that was discarded (excluded) from a {@link Conversion} alongside the discard reason. + * @group Conversion + * @public + */ +export type DiscardedTrack = { + /** The track that was discarded. */ + track: InputTrack; + /** + * The reason for discarding the track. + * + * - `'discarded_by_user'`: You discarded this track by setting `discard: true`. + * - `'max_track_count_reached'`: The output had no more room for another track. + * - `'max_track_count_of_type_reached'`: The output had no more room for another track of this type, or the output + * doesn't support this track type at all. + * - `'unknown_source_codec'`: We don't know the codec of the input track and therefore don't know what to do + * with it. + * - `'undecodable_source_codec'`: The input track's codec is known, but we are unable to decode it. + * - `'no_encodable_target_codec'`: We can't find a codec that we are able to encode and that can be contained + * within the output format. This reason can be hit if the environment doesn't support the necessary encoders, or if + * you requested a codec that cannot be contained within the output format. + */ + reason: + | 'discarded_by_user' + | 'max_track_count_reached' + | 'max_track_count_of_type_reached' + | 'unknown_source_codec' + | 'undecodable_source_codec' + | 'no_encodable_target_codec'; +}; + /** * Represents a media file conversion process, used to convert one media file into another. In addition to conversion, * this class can be used to resize and rotate video, resample audio, drop tracks, or trim to a specific time range. + * @group Conversion * @public */ export class Conversion { @@ -298,7 +332,7 @@ export class Conversion { /** * A callback that is fired whenever the conversion progresses. Returns a number between 0 and 1, indicating the * completion of the conversion. Note that a progress of 1 doesn't necessarily mean the conversion is complete; - * the conversion is complete once `execute` resolves. + * the conversion is complete once `execute()` resolves. * * In order for progress to be computed, this property must be set before `execute` is called. */ @@ -311,18 +345,7 @@ export class Conversion { /** The list of tracks that are included in the output file. */ readonly utilizedTracks: InputTrack[] = []; /** The list of tracks from the input file that have been discarded, alongside the discard reason. */ - readonly discardedTracks: { - /** The track that was discarded. */ - track: InputTrack; - /** The reason for discarding the track. */ - reason: - | 'discarded_by_user' - | 'max_track_count_reached' - | 'max_track_count_of_type_reached' - | 'unknown_source_codec' - | 'undecodable_source_codec' - | 'no_encodable_target_codec'; - }[] = []; + readonly discardedTracks: DiscardedTrack[] = []; /** Initializes a new conversion process without starting the conversion. */ static async init(options: ConversionOptions) { @@ -332,6 +355,7 @@ export class Conversion { return conversion; } + /** Creates a new Conversion instance (duh). */ private constructor(options: ConversionOptions) { if (!options || typeof options !== 'object') { throw new TypeError('options must be an object.'); diff --git a/src/custom-coder.ts b/src/custom-coder.ts index 4c3bccf..ddc422b 100644 --- a/src/custom-coder.ts +++ b/src/custom-coder.ts @@ -7,12 +7,14 @@ */ import { AudioCodec, VideoCodec } from './codec'; +import { MaybePromise } from './misc'; import { EncodedPacket } from './packet'; import { AudioSample, VideoSample } from './sample'; /** * 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`. + * abstract methods and static `supports` method, and register the decoder using {@link registerDecoder}. + * @group Custom coders * @public */ export abstract class CustomVideoDecoder { @@ -23,25 +25,26 @@ export abstract class CustomVideoDecoder { /** The callback to call when a decoded VideoSample is available. */ readonly onSample!: (sample: VideoSample) => unknown; - /** Returns true iff the decoder can decode the given codec configuration. */ + /** Returns true if and only if 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(): Promise | void; + abstract init(): MaybePromise; /** Decodes the provided encoded packet. */ - abstract decode(packet: EncodedPacket): Promise | void; + abstract decode(packet: EncodedPacket): MaybePromise; /** Decodes all remaining packets and then resolves. */ - abstract flush(): Promise | void; + abstract flush(): MaybePromise; /** Called when the decoder is no longer needed and its resources can be freed. */ - abstract close(): Promise | void; + abstract close(): MaybePromise; } /** * 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`. + * abstract methods and static `supports` method, and register the decoder using {@link registerDecoder}. + * @group Custom coders * @public */ export abstract class CustomAudioDecoder { @@ -52,25 +55,26 @@ export abstract class CustomAudioDecoder { /** The callback to call when a decoded AudioSample is available. */ readonly onSample!: (sample: AudioSample) => unknown; - /** Returns true iff the decoder can decode the given codec configuration. */ + /** Returns true if and only if 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(): Promise | void; + abstract init(): MaybePromise; /** Decodes the provided encoded packet. */ - abstract decode(packet: EncodedPacket): Promise | void; + abstract decode(packet: EncodedPacket): MaybePromise; /** Decodes all remaining packets and then resolves. */ - abstract flush(): Promise | void; + abstract flush(): MaybePromise; /** Called when the decoder is no longer needed and its resources can be freed. */ - abstract close(): Promise | void; + abstract close(): MaybePromise; } /** * 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`. + * abstract methods and static `supports` method, and register the encoder using {@link registerEncoder}. + * @group Custom coders * @public */ export abstract class CustomVideoEncoder { @@ -81,25 +85,26 @@ export abstract class CustomVideoEncoder { /** The callback to call when an EncodedPacket is available. */ readonly onPacket!: (packet: EncodedPacket, meta?: EncodedVideoChunkMetadata) => unknown; - /** Returns true iff the encoder can encode the given codec configuration. */ + /** Returns true if and only if 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(): Promise | void; + abstract init(): MaybePromise; /** Encodes the provided video sample. */ - abstract encode(videoSample: VideoSample, options: VideoEncoderEncodeOptions): Promise | void; + abstract encode(videoSample: VideoSample, options: VideoEncoderEncodeOptions): MaybePromise; /** Encodes all remaining video samples and then resolves. */ - abstract flush(): Promise | void; + abstract flush(): MaybePromise; /** Called when the encoder is no longer needed and its resources can be freed. */ - abstract close(): Promise | void; + abstract close(): MaybePromise; } /** * 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`. + * abstract methods and static `supports` method, and register the encoder using {@link registerEncoder}. + * @group Custom coders * @public */ export abstract class CustomAudioEncoder { @@ -110,20 +115,20 @@ export abstract class CustomAudioEncoder { /** The callback to call when an EncodedPacket is available. */ readonly onPacket!: (packet: EncodedPacket, meta?: EncodedAudioChunkMetadata) => unknown; - /** Returns true iff the encoder can encode the given codec configuration. */ + /** Returns true if and only if 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(): Promise | void; + abstract init(): MaybePromise; /** Encodes the provided audio sample. */ - abstract encode(audioSample: AudioSample): Promise | void; + abstract encode(audioSample: AudioSample): MaybePromise; /** Encodes all remaining audio samples and then resolves. */ - abstract flush(): Promise | void; + abstract flush(): MaybePromise; /** Called when the encoder is no longer needed and its resources can be freed. */ - abstract close(): Promise | void; + abstract close(): MaybePromise; } export const customVideoDecoders: typeof CustomVideoDecoder[] = []; @@ -134,6 +139,7 @@ export const customAudioEncoders: typeof CustomAudioEncoder[] = []; /** * Registers a custom video or audio decoder. Registered decoders will automatically be used for decoding whenever * possible. + * @group Custom coders * @public */ export const registerDecoder = (decoder: typeof CustomVideoDecoder | typeof CustomAudioDecoder) => { @@ -163,6 +169,7 @@ export const registerDecoder = (decoder: typeof CustomVideoDecoder | typeof Cust /** * Registers a custom video or audio encoder. Registered encoders will automatically be used for encoding whenever * possible. + * @group Custom coders * @public */ export const registerEncoder = (encoder: typeof CustomVideoEncoder | typeof CustomAudioEncoder) => { diff --git a/src/encode.ts b/src/encode.ts index dd596c5..0eb26da 100644 --- a/src/encode.ts +++ b/src/encode.ts @@ -16,7 +16,6 @@ import { inferCodecFromCodecString, MediaCodec, PCM_AUDIO_CODECS, - Quality, SUBTITLE_CODECS, SubtitleCodec, VIDEO_CODECS, @@ -27,13 +26,14 @@ import { EncodedPacket } from './packet'; /** * Configuration object that controls video encoding. Can be used to set codec, quality, and more. + * @group Encoding * @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 + * The target bitrate for the encoded video, in bits per second. Alternatively, a subjective {@link Quality} can * be provided. */ bitrate: number | Quality; @@ -44,14 +44,14 @@ export type VideoEncodingConfig = { */ keyFrameInterval?: number; /** - * Video frames may change size overtime. This field controls the behavior in case this happens. + * Video frames may change size over time. This field controls the behavior in case this happens. * - * - 'deny' (default) will throw an error, requiring all frames to have the exact same dimensions. - * - 'passThrough' will allow the change and directly pass the frame to the encoder. - * - 'fill' will stretch the image to fill the entire original box, potentially altering aspect ratio. - * - 'contain' will contain the entire image within the originalbox while preserving aspect ratio. This may lead to - * letterboxing. - * - 'cover' will scale the image until the entire original box is filled, while preserving aspect ratio. + * - `'deny'` (default) will throw an error, requiring all frames to have the exact same dimensions. + * - `'passThrough'` will allow the change and directly pass the frame to the encoder. + * - `'fill'` will stretch the image to fill the entire original box, potentially altering aspect ratio. + * - `'contain'` will contain the entire image within the original box while preserving aspect ratio. This may lead + * to letterboxing. + * - `'cover'` will scale the image until the entire original box is filled, while preserving aspect ratio. * * The "original box" refers to the dimensions of the first encoded frame. */ @@ -59,7 +59,10 @@ export type VideoEncodingConfig = { /** Called for each successfully encoded packet. Both the packet and the encoding metadata are passed. */ onEncodedPacket?: (packet: EncodedPacket, meta: EncodedVideoChunkMetadata | undefined) => unknown; - /** Called when the internal encoder config, as used by the WebCodecs API, is created. */ + /** + * Called when the internal [encoder config](https://www.w3.org/TR/webcodecs/#video-encoder-config), as used by the + * WebCodecs API, is created. + */ onEncoderConfig?: (config: VideoEncoderConfig) => unknown; } & VideoEncodingAdditionalOptions; @@ -100,30 +103,33 @@ export const validateVideoEncodingConfig = (config: VideoEncodingConfig) => { /** * Additional options that control audio encoding. + * @group Encoding * @public */ export type VideoEncodingAdditionalOptions = { /** Configures the bitrate mode. */ bitrateMode?: 'constant' | 'variable'; /** The latency mode used by the encoder; controls the performance-quality tradeoff. */ - latencyMode?: VideoEncoderConfig['latencyMode']; + latencyMode?: 'quality' | 'realtime'; /** * The full codec string as specified in the WebCodecs Codec Registry. This string must match the codec * specified in `codec`. When not set, a fitting codec string will be constructed automatically by the library. */ fullCodecString?: string; - /** A hint that configures the hardware acceleration method of this codec. This is best left on 'no-preference'. */ - hardwareAcceleration?: VideoEncoderConfig['hardwareAcceleration']; + /** + * A hint that configures the hardware acceleration method of this codec. This is best left on `'no-preference'`. + */ + hardwareAcceleration?: 'no-preference' | 'prefer-hardware' | 'prefer-software'; /** * An encoding scalability mode identifier as defined by * [WebRTC-SVC](https://w3c.github.io/webrtc-svc/#scalabilitymodes*). */ - scalabilityMode?: VideoEncoderConfig['scalabilityMode']; + scalabilityMode?: string; /** * An encoding video content hint as defined by * [mst-content-hint](https://w3c.github.io/mst-content-hint/#video-content-hints). */ - contentHint?: VideoEncoderConfig['contentHint']; + contentHint?: string; }; export const validateVideoEncodingAdditionalOptions = (codec: VideoCodec, options: VideoEncodingAdditionalOptions) => { @@ -194,20 +200,24 @@ export const buildVideoEncoderConfig = (options: { /** * Configuration object that controls audio encoding. Can be used to set codec, quality, and more. + * @group Encoding * @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 + * The target bitrate for the encoded audio, in bits per second. Alternatively, a subjective {@link 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 the internal encoder config, as used by the WebCodecs API, is created. */ + /** + * Called when the internal [encoder config](https://www.w3.org/TR/webcodecs/#audio-encoder-config), as used by the + * WebCodecs API, is created. + */ onEncoderConfig?: (config: AudioEncoderConfig) => unknown; } & AudioEncodingAdditionalOptions; @@ -243,6 +253,7 @@ export const validateAudioEncodingConfig = (config: AudioEncodingConfig) => { /** * Additional options that control audio encoding. + * @group Encoding * @public */ export type AudioEncodingAdditionalOptions = { @@ -296,8 +307,119 @@ export const buildAudioEncoderConfig = (options: { }; }; +/** + * Represents a subjective media quality level. + * @group Encoding + * @public + */ +export class Quality { + /** @internal */ + _factor: number; + + /** @internal */ + constructor(factor: number) { + this._factor = factor; + } + + /** @internal */ + _toVideoBitrate(codec: VideoCodec, width: number, height: number) { + const pixels = width * height; + + const codecEfficiencyFactors = { + avc: 1.0, // H.264/AVC (baseline) + hevc: 0.6, // H.265/HEVC (~40% more efficient than AVC) + vp9: 0.6, // Similar to HEVC + av1: 0.4, // ~60% more efficient than AVC + vp8: 1.2, // Slightly less efficient than AVC + }; + + const referencePixels = 1920 * 1080; + const referenceBitrate = 3000000; + const scaleFactor = Math.pow(pixels / referencePixels, 0.95); // Slight non-linear scaling + const baseBitrate = referenceBitrate * scaleFactor; + + const codecAdjustedBitrate = baseBitrate * codecEfficiencyFactors[codec]; + const finalBitrate = codecAdjustedBitrate * this._factor; + + return Math.ceil(finalBitrate / 1000) * 1000; + } + + /** @internal */ + _toAudioBitrate(codec: AudioCodec) { + if ((PCM_AUDIO_CODECS as readonly string[]).includes(codec) || codec === 'flac') { + return undefined; + } + + const baseRates = { + aac: 128000, // 128kbps base for AAC + opus: 64000, // 64kbps base for Opus + mp3: 160000, // 160kbps base for MP3 + vorbis: 64000, // 64kbps base for Vorbis + }; + + const baseBitrate = baseRates[codec as keyof typeof baseRates]; + if (!baseBitrate) { + throw new Error(`Unhandled codec: ${codec}`); + } + + let finalBitrate = baseBitrate * this._factor; + + if (codec === 'aac') { + // AAC only works with specific bitrates, let's find the closest + const validRates = [96000, 128000, 160000, 192000]; + finalBitrate = validRates.reduce((prev, curr) => + Math.abs(curr - finalBitrate) < Math.abs(prev - finalBitrate) ? curr : prev, + ); + } else if (codec === 'opus' || codec === 'vorbis') { + finalBitrate = Math.max(6000, finalBitrate); + } else if (codec === 'mp3') { + const validRates = [ + 8000, 16000, 24000, 32000, 40000, 48000, 64000, 80000, + 96000, 112000, 128000, 160000, 192000, 224000, 256000, 320000, + ]; + finalBitrate = validRates.reduce((prev, curr) => + Math.abs(curr - finalBitrate) < Math.abs(prev - finalBitrate) ? curr : prev, + ); + } + + return Math.round(finalBitrate / 1000) * 1000; + } +} + +/** + * Represents a very low media quality. + * @group Encoding + * @public + */ +export const QUALITY_VERY_LOW = new Quality(0.3); +/** + * Represents a low media quality. + * @group Encoding + * @public + */ +export const QUALITY_LOW = new Quality(0.6); +/** + * Represents a medium media quality. + * @group Encoding + * @public + */ +export const QUALITY_MEDIUM = new Quality(1); +/** + * Represents a high media quality. + * @group Encoding + * @public + */ +export const QUALITY_HIGH = new Quality(2); +/** + * Represents a very high media quality. + * @group Encoding + * @public + */ +export const QUALITY_VERY_HIGH = new Quality(4); + /** * Checks if the browser is able to encode the given codec. + * @group Encoding * @public */ export const canEncode = (codec: MediaCodec) => { @@ -314,18 +436,24 @@ export const canEncode = (codec: MediaCodec) => { /** * Checks if the browser is able to encode the given video codec with the given parameters. + * @group Encoding * @public */ -export const canEncodeVideo = async (codec: VideoCodec, { - width = 1280, - height = 720, - bitrate = 1e6, - ...restOptions -}: { - width?: number; - height?: number; - bitrate?: number | Quality; -} & VideoEncodingAdditionalOptions = {}) => { +export const canEncodeVideo = async ( + codec: VideoCodec, + options: { + width?: number; + height?: number; + bitrate?: number | Quality; + } & VideoEncodingAdditionalOptions = {}, +) => { + const { + width = 1280, + height = 720, + bitrate = 1e6, + ...restOptions + } = options; + if (!VIDEO_CODECS.includes(codec)) { return false; } @@ -377,18 +505,24 @@ export const canEncodeVideo = async (codec: VideoCodec, { /** * Checks if the browser is able to encode the given audio codec with the given parameters. + * @group Encoding * @public */ -export const canEncodeAudio = async (codec: AudioCodec, { - numberOfChannels = 2, - sampleRate = 48000, - bitrate = 128e3, - ...restOptions -}: { - numberOfChannels?: number; - sampleRate?: number; - bitrate?: number | Quality; -} & AudioEncodingAdditionalOptions = {}) => { +export const canEncodeAudio = async ( + codec: AudioCodec, + options: { + numberOfChannels?: number; + sampleRate?: number; + bitrate?: number | Quality; + } & AudioEncodingAdditionalOptions = {}, +) => { + const { + numberOfChannels = 2, + sampleRate = 48000, + bitrate = 128e3, + ...restOptions + } = options; + if (!AUDIO_CODECS.includes(codec)) { return false; } @@ -442,6 +576,7 @@ export const canEncodeAudio = async (codec: AudioCodec, { /** * Checks if the browser is able to encode the given subtitle codec. + * @group Encoding * @public */ export const canEncodeSubtitles = async (codec: SubtitleCodec) => { @@ -454,6 +589,7 @@ export const canEncodeSubtitles = async (codec: SubtitleCodec) => { /** * Returns the list of all media codecs that can be encoded by the browser. + * @group Encoding * @public */ export const getEncodableCodecs = async (): Promise => { @@ -468,10 +604,11 @@ export const getEncodableCodecs = async (): Promise => { /** * Returns the list of all video codecs that can be encoded by the browser. + * @group Encoding * @public */ export const getEncodableVideoCodecs = async ( - checkedCodecs = VIDEO_CODECS as unknown as VideoCodec[], + checkedCodecs: VideoCodec[] = VIDEO_CODECS as unknown as VideoCodec[], options?: { width?: number; height?: number; @@ -484,10 +621,11 @@ export const getEncodableVideoCodecs = async ( /** * Returns the list of all audio codecs that can be encoded by the browser. + * @group Encoding * @public */ export const getEncodableAudioCodecs = async ( - checkedCodecs = AUDIO_CODECS as unknown as AudioCodec[], + checkedCodecs: AudioCodec[] = AUDIO_CODECS as unknown as AudioCodec[], options?: { numberOfChannels?: number; sampleRate?: number; @@ -500,10 +638,11 @@ export const getEncodableAudioCodecs = async ( /** * Returns the list of all subtitle codecs that can be encoded by the browser. + * @group Encoding * @public */ export const getEncodableSubtitleCodecs = async ( - checkedCodecs = SUBTITLE_CODECS as unknown as SubtitleCodec[], + checkedCodecs: SubtitleCodec[] = SUBTITLE_CODECS as unknown as SubtitleCodec[], ): Promise => { const bools = await Promise.all(checkedCodecs.map(canEncodeSubtitles)); return checkedCodecs.filter((_, i) => bools[i]); @@ -511,6 +650,7 @@ export const getEncodableSubtitleCodecs = async ( /** * Returns the first video codec from the given list that can be encoded by the browser. + * @group Encoding * @public */ export const getFirstEncodableVideoCodec = async ( @@ -532,6 +672,7 @@ export const getFirstEncodableVideoCodec = async ( /** * Returns the first audio codec from the given list that can be encoded by the browser. + * @group Encoding * @public */ export const getFirstEncodableAudioCodec = async ( @@ -553,6 +694,7 @@ export const getFirstEncodableAudioCodec = async ( /** * Returns the first subtitle codec from the given list that can be encoded by the browser. + * @group Encoding * @public */ export const getFirstEncodableSubtitleCodec = async ( diff --git a/src/index.ts b/src/index.ts index da1f4ce..9bd1991 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,61 +16,53 @@ export { VideoTrackMetadata, AudioTrackMetadata, SubtitleTrackMetadata, - TrackType, - ALL_TRACK_TYPES, } from './output'; export { OutputFormat, + AdtsOutputFormat, + AdtsOutputFormatOptions, IsobmffOutputFormat, - Mp4OutputFormat, - MovOutputFormat, IsobmffOutputFormatOptions, MkvOutputFormat, MkvOutputFormatOptions, - WebMOutputFormat, - WebMOutputFormatOptions, + MovOutputFormat, Mp3OutputFormat, Mp3OutputFormatOptions, - WavOutputFormat, - WavOutputFormatOptions, + Mp4OutputFormat, OggOutputFormat, OggOutputFormatOptions, - AdtsOutputFormat, - AdtsOutputFormatOptions, - TrackCountLimits, + WavOutputFormat, + WavOutputFormatOptions, + WebMOutputFormat, + WebMOutputFormatOptions, InclusiveIntegerRange, + TrackCountLimits, } from './output-format'; export { MediaSource, VideoSource, - EncodedVideoPacketSource, - VideoSampleSource, - CanvasSource, - MediaStreamVideoTrackSource, AudioSource, - EncodedAudioPacketSource, - AudioSampleSource, - AudioBufferSource, - MediaStreamAudioTrackSource, SubtitleSource, + AudioBufferSource, + AudioSampleSource, + CanvasSource, + EncodedAudioPacketSource, + EncodedVideoPacketSource, + MediaStreamAudioTrackSource, + MediaStreamVideoTrackSource, TextSubtitleSource, + VideoSampleSource, } from './media-source'; export { - VIDEO_CODECS, + MediaCodec, VideoCodec, + AudioCodec, + SubtitleCodec, + VIDEO_CODECS, + AUDIO_CODECS, PCM_AUDIO_CODECS, NON_PCM_AUDIO_CODECS, - AUDIO_CODECS, - AudioCodec, SUBTITLE_CODECS, - SubtitleCodec, - MediaCodec, - Quality, - QUALITY_VERY_LOW, - QUALITY_LOW, - QUALITY_MEDIUM, - QUALITY_HIGH, - QUALITY_VERY_HIGH, } from './codec'; export { VideoEncodingConfig, @@ -88,69 +80,110 @@ export { getFirstEncodableVideoCodec, getFirstEncodableAudioCodec, getFirstEncodableSubtitleCodec, + Quality, + QUALITY_VERY_LOW, + QUALITY_LOW, + QUALITY_MEDIUM, + QUALITY_HIGH, + QUALITY_VERY_HIGH, } from './encode'; -export { Target, BufferTarget, StreamTarget, StreamTargetChunk, StreamTargetOptions, NullTarget } from './target'; -export { Rotation, AnyIterable, SetRequired, MaybePromise } from './misc'; +export { + Target, + BufferTarget, + NullTarget, + StreamTarget, + StreamTargetOptions, + StreamTargetChunk, +} from './target'; +export { + AnyIterable, + MaybePromise, + Rotation, + SetRequired, +} from './misc'; +export { + TrackType, + ALL_TRACK_TYPES, +} from './output'; export { Source, - BufferSource, BlobSource, BlobSourceOptions, - UrlSource, - UrlSourceOptions, + BufferSource, FilePathSource, FilePathSourceOptions, StreamSource, StreamSourceOptions, ReadableStreamSource, ReadableStreamSourceOptions, + UrlSource, + UrlSourceOptions, } from './source'; export { InputFormat, + AdtsInputFormat, IsobmffInputFormat, - Mp4InputFormat, - QuickTimeInputFormat, MatroskaInputFormat, - WebMInputFormat, Mp3InputFormat, - WaveInputFormat, + Mp4InputFormat, OggInputFormat, + QuickTimeInputFormat, + WaveInputFormat, + WebMInputFormat, ALL_FORMATS, - MP4, - QTFF, + ADTS, MATROSKA, - WEBM, MP3, - WAVE, + MP4, OGG, + QTFF, + WAVE, + WEBM, } from './input-format'; -export { Input, InputOptions } from './input'; -export { InputTrack, InputVideoTrack, InputAudioTrack, PacketStats } from './input-track'; -export { EncodedPacket, PacketType } from './packet'; export { - VideoSample, - VideoSampleInit, + Input, + InputOptions, +} from './input'; +export { + InputTrack, + InputVideoTrack, + InputAudioTrack, + PacketStats, +} from './input-track'; +export { + EncodedPacket, + PacketType, +} from './packet'; +export { AudioSample, AudioSampleInit, AudioSampleCopyToOptions, + VideoSample, + VideoSampleInit, } from './sample'; export { - PacketRetrievalOptions, - EncodedPacketSink, - BaseMediaSampleSink, - VideoSampleSink, - CanvasSinkOptions, - CanvasSink, - WrappedCanvas, - AudioSampleSink, AudioBufferSink, + AudioSampleSink, + BaseMediaSampleSink, + CanvasSink, + CanvasSinkOptions, + EncodedPacketSink, + PacketRetrievalOptions, + VideoSampleSink, WrappedAudioBuffer, + WrappedCanvas, } from './media-sink'; -export { Conversion, ConversionOptions, ConversionVideoOptions, ConversionAudioOptions } from './conversion'; +export { + Conversion, + ConversionOptions, + ConversionVideoOptions, + ConversionAudioOptions, + DiscardedTrack, +} from './conversion'; export { CustomVideoDecoder, - CustomAudioDecoder, CustomVideoEncoder, + CustomAudioDecoder, CustomAudioEncoder, registerDecoder, registerEncoder, diff --git a/src/input-format.ts b/src/input-format.ts index 1d4f17b..72f2ffc 100644 --- a/src/input-format.ts +++ b/src/input-format.ts @@ -31,6 +31,7 @@ import { readAscii } from './reader'; /** * Base class representing an input media file format. + * @group Input formats * @public */ export abstract class InputFormat { @@ -48,6 +49,7 @@ export abstract class InputFormat { /** * Format representing files compatible with the ISO base media file format (ISOBMFF), like MP4 or MOV files. + * @group Input formats * @public */ export abstract class IsobmffInputFormat extends InputFormat { @@ -75,6 +77,10 @@ export abstract class IsobmffInputFormat extends InputFormat { /** * MPEG-4 Part 14 (MP4) file format. + * + * Do not instantiate this class; use the {@link MP4} singleton instead. + * + * @group Input formats * @public */ export class Mp4InputFormat extends IsobmffInputFormat { @@ -95,6 +101,10 @@ export class Mp4InputFormat extends IsobmffInputFormat { /** * QuickTime File Format (QTFF), often called MOV. + * + * Do not instantiate this class; use the {@link QTFF} singleton instead. + * + * @group Input formats * @public */ export class QuickTimeInputFormat extends IsobmffInputFormat { @@ -115,6 +125,10 @@ export class QuickTimeInputFormat extends IsobmffInputFormat { /** * Matroska file format. + * + * Do not instantiate this class; use the {@link MATROSKA} singleton instead. + * + * @group Input formats * @public */ export class MatroskaInputFormat extends InputFormat { @@ -211,6 +225,10 @@ export class MatroskaInputFormat extends InputFormat { /** * WebM file format, based on Matroska. + * + * Do not instantiate this class; use the {@link WEBM} singleton instead. + * + * @group Input formats * @public */ export class WebMInputFormat extends MatroskaInputFormat { @@ -230,6 +248,10 @@ export class WebMInputFormat extends MatroskaInputFormat { /** * MP3 file format. + * + * Do not instantiate this class; use the {@link MP3} singleton instead. + * + * @group Input formats * @public */ export class Mp3InputFormat extends InputFormat { @@ -294,6 +316,10 @@ export class Mp3InputFormat extends InputFormat { /** * WAVE file format, based on RIFF. + * + * Do not instantiate this class; use the {@link WAVE} singleton instead. + * + * @group Input formats * @public */ export class WaveInputFormat extends InputFormat { @@ -330,6 +356,10 @@ export class WaveInputFormat extends InputFormat { /** * Ogg file format. + * + * Do not instantiate this class; use the {@link OGG} singleton instead. + * + * @group Input formats * @public */ export class OggInputFormat extends InputFormat { @@ -358,6 +388,10 @@ export class OggInputFormat extends InputFormat { /** * ADTS file format. + * + * Do not instantiate this class; use the {@link ADTS} singleton instead. + * + * @group Input formats * @public */ export class AdtsInputFormat extends InputFormat { @@ -402,41 +436,49 @@ export class AdtsInputFormat extends InputFormat { /** * MP4 input format singleton. + * @group Input formats * @public */ export const MP4 = new Mp4InputFormat(); /** * QuickTime File Format input format singleton. + * @group Input formats * @public */ export const QTFF = new QuickTimeInputFormat(); /** * Matroska input format singleton. + * @group Input formats * @public */ export const MATROSKA = new MatroskaInputFormat(); /** * WebM input format singleton. + * @group Input formats * @public */ export const WEBM = new WebMInputFormat(); /** * MP3 input format singleton. + * @group Input formats * @public */ export const MP3 = new Mp3InputFormat(); /** * WAVE input format singleton. + * @group Input formats * @public */ export const WAVE = new WaveInputFormat(); /** * Ogg input format singleton. + * @group Input formats * @public */ export const OGG = new OggInputFormat(); /** * ADTS input format singleton. + * @group Input formats * @public */ export const ADTS = new AdtsInputFormat(); @@ -444,6 +486,7 @@ export const ADTS = new AdtsInputFormat(); /** * 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. + * @group Input formats * @public */ export const ALL_FORMATS: InputFormat[] = [MP4, QTFF, MATROSKA, WEBM, WAVE, OGG, MP3, ADTS]; diff --git a/src/input-track.ts b/src/input-track.ts index bb5ec42..b093b0d 100644 --- a/src/input-track.ts +++ b/src/input-track.ts @@ -16,6 +16,7 @@ import { EncodedPacket, PacketType } from './packet'; /** * Contains aggregate statistics about the encoded packets of a track. + * @group Input files & tracks * @public */ export type PacketStats = { @@ -46,6 +47,7 @@ export interface InputTrackBacking { /** * Represents a media track in an input file. + * @group Input files & tracks * @public */ export abstract class InputTrack { @@ -71,12 +73,12 @@ export abstract class InputTrack { */ abstract determinePacketType(packet: EncodedPacket): Promise; - /** Returns true iff this track is a video track. */ + /** Returns true if and only if this track is a video track. */ isVideoTrack(): this is InputVideoTrack { return this instanceof InputVideoTrack; } - /** Returns true iff this track is an audio track. */ + /** Returns true if and only if this track is an audio track. */ isAudioTrack(): this is InputAudioTrack { return this instanceof InputAudioTrack; } @@ -92,17 +94,19 @@ export abstract class InputTrack { * * This field can be used to determine the codec of a track in case Mediabunny doesn't know that codec. * - * - For ISOBMFF files, this field returns the name of the Sample Description Box (e.g. 'avc1'). - * - For Matroska files, this field returns the value of the CodecID element. - * - For WAVE files, this field returns the value of the format tag in the 'fmt ' chunk. - * - For ADTS files, this field contains the MPEG-4 Audio Object Type. + * - For ISOBMFF files, this field returns the name of the Sample Description Box (e.g. `'avc1'`). + * - For Matroska files, this field returns the value of the `CodecID` element. + * - For WAVE files, this field returns the value of the format tag in the `'fmt '` chunk. + * - For ADTS files, this field contains the `MPEG-4 Audio Object Type`. * - In all other cases, this field is `null`. */ get internalCodecId() { return this._backing.getInternalCodecId(); } - /** The ISO 639-2/T language code for this track. If the language is unknown, this field is 'und' (undetermined). */ + /** + * The ISO 639-2/T language code for this track. If the language is unknown, this field is `'und'` (undetermined). + */ get languageCode() { return this._backing.getLanguageCode(); } @@ -189,6 +193,7 @@ export interface InputVideoTrackBacking extends InputTrackBacking { /** * Represents a video track in an input file. + * @group Input files & tracks * @public */ export class InputVideoTrack extends InputTrack { @@ -206,7 +211,7 @@ export class InputVideoTrack extends InputTrack { return 'video'; } - get codec() { + get codec(): VideoCodec | null { return this._backing.getCodec(); } @@ -252,8 +257,8 @@ export class InputVideoTrack extends InputTrack { } /** - * Returns the decoder configuration for decoding the track's packets using a VideoDecoder. Returns null if the - * track's codec is unknown. + * Returns the [decoder configuration](https://www.w3.org/TR/webcodecs/#video-decoder-config) for decoding the + * track's packets using a VideoDecoder. Returns null if the track's codec is unknown. */ getDecoderConfig() { return this._backing.getDecoderConfig(); @@ -315,6 +320,7 @@ export interface InputAudioTrackBacking extends InputTrackBacking { /** * Represents an audio track in an input file. + * @group Input files & tracks * @public */ export class InputAudioTrack extends InputTrack { @@ -347,8 +353,8 @@ export class InputAudioTrack extends InputTrack { } /** - * Returns the decoder configuration for decoding the track's packets using an AudioDecoder. Returns null if the - * track's codec is unknown. + * Returns the [decoder configuration](https://www.w3.org/TR/webcodecs/#audio-decoder-config) for decoding the + * track's packets using an AudioDecoder. Returns null if the track's codec is unknown. */ getDecoderConfig() { return this._backing.getDecoderConfig(); diff --git a/src/input.ts b/src/input.ts index 1136214..4d815e5 100644 --- a/src/input.ts +++ b/src/input.ts @@ -14,6 +14,7 @@ import { Source } from './source'; /** * The options for creating an Input object. + * @group Input files & tracks * @public */ export type InputOptions = { @@ -25,6 +26,7 @@ export type InputOptions = { /** * Represents an input media file. This is the root object from which all media read operations start. + * @group Input files & tracks * @public */ export class Input { @@ -39,6 +41,10 @@ export class Input { /** @internal */ _reader: Reader; + /** + * Creates a new input file from the specified options. No reading operations will be performed until methods are + * called on this instance. + */ constructor(options: InputOptions) { if (!options || typeof options !== 'object') { throw new TypeError('options must be an object.'); @@ -81,9 +87,9 @@ 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). + * Returns the format of the input file. You can compare this result directly to the {@link 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(); @@ -112,18 +118,18 @@ export class Input { 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 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 primary audio track of this input file, or null if there are no audio tracks. */ async getPrimaryAudioTrack() { const tracks = await this.getTracks(); diff --git a/src/media-sink.ts b/src/media-sink.ts index b419e46..643f259 100644 --- a/src/media-sink.ts +++ b/src/media-sink.ts @@ -32,11 +32,12 @@ import { AudioSample, VideoSample } from './sample'; /** * Additional options for controlling packet retrieval. + * @group Media sinks * @public */ export type PacketRetrievalOptions = { /** - * When set to true, only packet metadata (like timestamp) will be retrieved - the actual packet data will not + * When set to `true`, only packet metadata (like timestamp) will be retrieved - the actual packet data will not * be loaded. */ metadataOnly?: boolean; @@ -97,12 +98,14 @@ const maybeFixPacketType = ( /** * Sink for retrieving encoded packets from an input track. + * @group Media sinks * @public */ export class EncodedPacketSink { /** @internal */ _track: InputTrack; + /** Creates a new {@link EncodedPacketSink} for the given {@link InputTrack}. */ constructor(track: InputTrack) { if (!(track instanceof InputTrack)) { throw new TypeError('track must be an InputTrack.'); @@ -342,6 +345,7 @@ abstract class DecoderWrapper< /** * Base class for decoded media sample sinks. + * @group Media sinks * @public */ export abstract class BaseMediaSampleSink< @@ -894,12 +898,14 @@ class VideoDecoderWrapper extends DecoderWrapper { /** * A sink that retrieves decoded video samples (video frames) from a video track. + * @group Media sinks * @public */ export class VideoSampleSink extends BaseMediaSampleSink { /** @internal */ _videoTrack: InputVideoTrack; + /** Creates a new {@link VideoSampleSink} for the given {@link InputVideoTrack}. */ constructor(videoTrack: InputVideoTrack) { if (!(videoTrack instanceof InputVideoTrack)) { throw new TypeError('videoTrack must be an InputVideoTrack.'); @@ -978,6 +984,7 @@ export class VideoSampleSink extends BaseMediaSampleSink { /** * A canvas with additional timing information (timestamp & duration). + * @group Media sinks * @public */ export type WrappedCanvas = { @@ -991,6 +998,7 @@ export type WrappedCanvas = { /** * Options for constructing a CanvasSink. + * @group Media sinks * @public */ export type CanvasSinkOptions = { @@ -1007,10 +1015,10 @@ export type CanvasSinkOptions = { /** * 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 + * - `'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. + * - `'cover'` will scale the image until the entire box is filled, while preserving aspect ratio. */ fit?: 'fill' | 'contain' | 'cover'; /** @@ -1032,7 +1040,9 @@ export type CanvasSinkOptions = { * directly retrieving frames, as it comes with common preprocessing steps such as resizing or applying rotation * metadata. * - * This sink will yield HTMLCanvasElements when in a DOM context, and OffscreenCanvases otherwise. + * This sink will yield `HTMLCanvasElement`s when in a DOM context, and `OffscreenCanvas`es otherwise. + * + * @group Media sinks * @public */ export class CanvasSink { @@ -1053,6 +1063,7 @@ export class CanvasSink { /** @internal */ _nextCanvasIndex = 0; + /** Creates a new {@link CanvasSink} for the given {@link InputVideoTrack}. */ constructor(videoTrack: InputVideoTrack, options: CanvasSinkOptions = {}) { if (!(videoTrack instanceof InputVideoTrack)) { throw new TypeError('videoTrack must be an InputVideoTrack.'); @@ -1509,12 +1520,14 @@ class PcmAudioDecoderWrapper extends DecoderWrapper { /** * Sink for retrieving decoded audio samples from an audio track. + * @group Media sinks * @public */ export class AudioSampleSink extends BaseMediaSampleSink { /** @internal */ _audioTrack: InputAudioTrack; + /** Creates a new {@link AudioSampleSink} for the given {@link InputAudioTrack}. */ constructor(audioTrack: InputAudioTrack) { if (!(audioTrack instanceof InputAudioTrack)) { throw new TypeError('audioTrack must be an InputAudioTrack.'); @@ -1595,6 +1608,7 @@ export class AudioSampleSink extends BaseMediaSampleSink { /** * An AudioBuffer with additional timing information (timestamp & duration). + * @group Media sinks * @public */ export type WrappedAudioBuffer = { @@ -1607,14 +1621,17 @@ export type WrappedAudioBuffer = { }; /** - * 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. + * A sink that retrieves decoded audio samples from an audio track and converts them to `AudioBuffer` instances. This is + * often more useful than directly retrieving audio samples, as audio buffers can be directly used with the + * Web Audio API. + * @group Media sinks * @public */ export class AudioBufferSink { /** @internal */ _audioSampleSink: AudioSampleSink; + /** Creates a new {@link AudioBufferSink} for the given {@link InputAudioTrack}. */ constructor(audioTrack: InputAudioTrack) { if (!(audioTrack instanceof InputAudioTrack)) { throw new TypeError('audioTrack must be an InputAudioTrack.'); diff --git a/src/media-source.ts b/src/media-source.ts index d8730e1..89b5485 100644 --- a/src/media-source.ts +++ b/src/media-source.ts @@ -41,6 +41,7 @@ import { /** * Base class for media sources. Media sources are used to add media samples to an output file. + * @group Media sources * @public */ export abstract class MediaSource { @@ -131,6 +132,7 @@ export abstract class MediaSource { /** * Base class for video sources - sources for video tracks. + * @group Media sources * @public */ export abstract class VideoSource extends MediaSource { @@ -139,6 +141,7 @@ export abstract class VideoSource extends MediaSource { /** @internal */ _codec: VideoCodec; + /** Internal constructor. */ constructor(codec: VideoCodec) { super(); @@ -152,9 +155,11 @@ export abstract class VideoSource extends MediaSource { /** * The most basic video source; can be used to directly pipe encoded packets into the output file. + * @group Media sources * @public */ export class EncodedVideoPacketSource extends VideoSource { + /** Creates a new {@link EncodedVideoPacketSource} whose packets are encoded using `codec`. */ constructor(codec: VideoCodec) { super(codec); } @@ -458,12 +463,17 @@ class VideoEncoderWrapper { /** * 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. + * @group Media sources * @public */ export class VideoSampleSource extends VideoSource { /** @internal */ private _encoder: VideoEncoderWrapper; + /** + * Creates a new {@link VideoSampleSource} whose samples are encoded according to the specified + * {@link VideoEncodingConfig}. + */ constructor(encodingConfig: VideoEncodingConfig) { validateVideoEncodingConfig(encodingConfig); @@ -493,7 +503,8 @@ export class VideoSampleSource extends VideoSource { /** * 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. + * used for rendering, this source provides a convenient wrapper around {@link VideoSampleSource}. + * @group Media sources * @public */ export class CanvasSource extends VideoSource { @@ -502,6 +513,10 @@ export class CanvasSource extends VideoSource { /** @internal */ private _canvas: HTMLCanvasElement | OffscreenCanvas; + /** + * Creates a new {@link CanvasSource} from a canvas element or `OffscreenCanvas` whose samples are encoded + * according to the specified {@link VideoEncodingConfig}. + */ constructor(canvas: HTMLCanvasElement | OffscreenCanvas, encodingConfig: VideoEncodingConfig) { if ( !(typeof HTMLCanvasElement !== 'undefined' && canvas instanceof HTMLCanvasElement) @@ -544,10 +559,12 @@ export class CanvasSource extends VideoSource { } /** - * 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. + * Video source that encodes the frames of a + * [`MediaStreamVideoTrack`](https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack) 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 {@link Output} is started, and will keep being captured until + * the {@link Output} is finalized or this source is closed. + * @group Media sources * @public */ export class MediaStreamVideoTrackSource extends VideoSource { @@ -572,6 +589,11 @@ export class MediaStreamVideoTrackSource extends VideoSource { return this._promiseWithResolvers.promise; } + /** + * Creates a new {@link MediaStreamVideoTrackSource} from a + * [`MediaStreamVideoTrack`](https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack), which will pull + * video samples from the stream in real time and encode them according to {@link VideoEncodingConfig}. + */ constructor(track: MediaStreamVideoTrack, encodingConfig: VideoEncodingConfig) { if (!(track instanceof MediaStreamTrack) || track.kind !== 'video') { throw new TypeError('track must be a video MediaStreamTrack.'); @@ -726,6 +748,7 @@ export class MediaStreamVideoTrackSource extends VideoSource { /** * Base class for audio sources - sources for audio tracks. + * @group Media sources * @public */ export abstract class AudioSource extends MediaSource { @@ -734,6 +757,7 @@ export abstract class AudioSource extends MediaSource { /** @internal */ _codec: AudioCodec; + /** Internal constructor. */ constructor(codec: AudioCodec) { super(); @@ -747,9 +771,11 @@ export abstract class AudioSource extends MediaSource { /** * The most basic audio source; can be used to directly pipe encoded packets into the output file. + * @group Media sources * @public */ export class EncodedAudioPacketSource extends AudioSource { + /** Creates a new {@link EncodedAudioPacketSource} whose packets are encoded using `codec`. */ constructor(codec: AudioCodec) { super(codec); } @@ -1171,12 +1197,17 @@ class AudioEncoderWrapper { /** * 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. + * @group Media sources * @public */ export class AudioSampleSource extends AudioSource { /** @internal */ private _encoder: AudioEncoderWrapper; + /** + * Creates a new {@link AudioSampleSource} whose samples are encoded according to the specified + * {@link AudioEncodingConfig}. + */ constructor(encodingConfig: AudioEncodingConfig) { validateAudioEncodingConfig(encodingConfig); @@ -1207,6 +1238,7 @@ export class AudioSampleSource extends AudioSource { /** * 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. + * @group Media sources * @public */ export class AudioBufferSource extends AudioSource { @@ -1215,6 +1247,10 @@ export class AudioBufferSource extends AudioSource { /** @internal */ private _accumulatedTime = 0; + /** + * Creates a new {@link AudioBufferSource} whose `AudioBuffer` instances are encoded according to the specified + * {@link AudioEncodingConfig}. + */ constructor(encodingConfig: AudioEncodingConfig) { validateAudioEncodingConfig(encodingConfig); @@ -1250,10 +1286,12 @@ export class AudioBufferSource extends AudioSource { } /** - * 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. + * Audio source that encodes the data of a + * [`MediaStreamAudioTrack`](https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack) 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 {@link Output} is started, and will keep being + * captured until the {@link Output} is finalized or this source is closed. + * @group Media sources * @public */ export class MediaStreamAudioTrackSource extends AudioSource { @@ -1278,6 +1316,10 @@ export class MediaStreamAudioTrackSource extends AudioSource { return this._promiseWithResolvers.promise; } + /** + * Creates a new {@link MediaStreamAudioTrackSource} from a `MediaStreamAudioTrack`, which will pull audio samples + * from the stream in real time and encode them according to {@link AudioEncodingConfig}. + */ constructor(track: MediaStreamAudioTrack, encodingConfig: AudioEncodingConfig) { if (!(track instanceof MediaStreamTrack) || track.kind !== 'audio') { throw new TypeError('track must be an audio MediaStreamTrack.'); @@ -1572,6 +1614,7 @@ const sendMessageToMediaStreamTrackProcessorWorker = ( /** * Base class for subtitle sources - sources for subtitle tracks. + * @group Media sources * @public */ export abstract class SubtitleSource extends MediaSource { @@ -1580,6 +1623,7 @@ export abstract class SubtitleSource extends MediaSource { /** @internal */ _codec: SubtitleCodec; + /** Internal constructor. */ constructor(codec: SubtitleCodec) { super(); @@ -1593,12 +1637,14 @@ export abstract class SubtitleSource extends MediaSource { /** * This source can be used to add subtitles from a subtitle text file. + * @group Media sources * @public */ export class TextSubtitleSource extends SubtitleSource { /** @internal */ private _parser: SubtitleParser; + /** Creates a new {@link TextSubtitleSource} where added text chunks are in the specified `codec`. */ constructor(codec: SubtitleCodec) { super(codec); diff --git a/src/misc.ts b/src/misc.ts index 7d2b1c8..99aac7a 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -14,6 +14,7 @@ export function assert(x: unknown): asserts x { /** * Represents a clockwise rotation in degrees. + * @group Miscellaneous * @public */ export type Rotation = 0 | 90 | 180 | 270; @@ -355,6 +356,7 @@ export const findLastIndex = (arr: T[], predicate: (x: T) => boolean) => { /** * Sync or async iterable. + * @group Miscellaneous * @public */ export type AnyIterable = @@ -501,6 +503,7 @@ export const SECOND_TO_MICROSECOND_FACTOR = 1e6 * (1 + Number.EPSILON); /** * Sets all keys K of T to be required. + * @group Miscellaneous * @public */ export type SetRequired = T & Required>; @@ -630,6 +633,7 @@ export const isSafari = () => { /** * T or a promise that resolves to T. + * @group Miscellaneous * @public */ export type MaybePromise = T | Promise; diff --git a/src/output-format.ts b/src/output-format.ts index 0f5e228..c1a1a17 100644 --- a/src/output-format.ts +++ b/src/output-format.ts @@ -29,6 +29,7 @@ import { WaveMuxer } from './wave/wave-muxer'; /** * Specifies an inclusive range of integers. + * @group Miscellaneous * @public */ export type InclusiveIntegerRange = { @@ -40,6 +41,7 @@ export type InclusiveIntegerRange = { /** * Specifies the number of tracks (for each track type and in total) that an output format supports. + * @group Output formats * @public */ export type TrackCountLimits = { @@ -51,6 +53,7 @@ export type TrackCountLimits = { /** * Base class representing an output media file format. + * @group Output formats * @public */ export abstract class OutputFormat { @@ -97,6 +100,7 @@ export abstract class OutputFormat { /** * ISOBMFF-specific output options. + * @group Output formats * @public */ export type IsobmffOutputFormatOptions = { @@ -168,12 +172,14 @@ export type IsobmffOutputFormatOptions = { /** * Format representing files compatible with the ISO base media file format (ISOBMFF), like MP4 or MOV files. + * @group Output formats * @public */ export abstract class IsobmffOutputFormat extends OutputFormat { /** @internal */ _options: IsobmffOutputFormatOptions; + /** Internal constructor. */ constructor(options: IsobmffOutputFormatOptions = {}) { if (!options || typeof options !== 'object') { throw new TypeError('options must be an object.'); @@ -225,10 +231,16 @@ export abstract class IsobmffOutputFormat extends OutputFormat { } /** - * MPEG-4 Part 14 (MP4) file format. Supports all codecs except PCM audio codecs. + * MPEG-4 Part 14 (MP4) file format. Supports most codecs. + * @group Output formats * @public */ export class Mp4OutputFormat extends IsobmffOutputFormat { + /** Creates a new {@link Mp4OutputFormat} configured with the specified `options`. */ + constructor(options?: IsobmffOutputFormatOptions) { + super(options); + } + /** @internal */ get _name() { return 'MP4'; @@ -273,9 +285,15 @@ export class Mp4OutputFormat extends IsobmffOutputFormat { /** * QuickTime File Format (QTFF), often called MOV. Supports all video and audio codecs, but not subtitle codecs. + * @group Output formats * @public */ export class MovOutputFormat extends IsobmffOutputFormat { + /** Creates a new {@link MovOutputFormat} configured with the specified `options`. */ + constructor(options?: IsobmffOutputFormatOptions) { + super(options); + } + /** @internal */ get _name() { return 'MOV'; @@ -308,6 +326,7 @@ export class MovOutputFormat extends IsobmffOutputFormat { /** * Matroska-specific output options. + * @group Output formats * @public */ export type MkvOutputFormatOptions = { @@ -353,12 +372,14 @@ export type MkvOutputFormatOptions = { /** * Matroska file format. + * @group Output formats * @public */ export class MkvOutputFormat extends OutputFormat { /** @internal */ _options: MkvOutputFormatOptions; + /** Creates a new {@link MkvOutputFormat} configured with the specified `options`. */ constructor(options: MkvOutputFormatOptions = {}) { if (!options || typeof options !== 'object') { throw new TypeError('options must be an object.'); @@ -431,15 +452,22 @@ export class MkvOutputFormat extends OutputFormat { /** * WebM-specific output options. + * @group Output formats * @public */ export type WebMOutputFormatOptions = MkvOutputFormatOptions; /** * WebM file format, based on Matroska. + * @group Output formats * @public */ export class WebMOutputFormat extends MkvOutputFormat { + /** Creates a new {@link WebMOutputFormat} configured with the specified `options`. */ + constructor(options?: MkvOutputFormatOptions) { + super(options); + } + override getSupportedCodecs(): MediaCodec[] { return [ ...VIDEO_CODECS.filter(codec => ['vp8', 'vp9', 'av1'].includes(codec)), @@ -473,12 +501,13 @@ export class WebMOutputFormat extends MkvOutputFormat { /** * MP3-specific output options. + * @group Output formats * @public */ export type Mp3OutputFormatOptions = { /** * Controls whether the Xing header, which contains additional metadata as well as an index, is written to the start - * of the MP3 file. When disabled, the writing process becomes append-only. Defaults to true. + * of the MP3 file. When disabled, the writing process becomes append-only. Defaults to `true`. */ xingHeader?: boolean; @@ -493,12 +522,14 @@ export type Mp3OutputFormatOptions = { /** * MP3 file format. + * @group Output formats * @public */ export class Mp3OutputFormat extends OutputFormat { /** @internal */ _options: Mp3OutputFormatOptions; + /** Creates a new {@link Mp3OutputFormat} configured with the specified `options`. */ constructor(options: Mp3OutputFormatOptions = {}) { if (!options || typeof options !== 'object') { throw new TypeError('options must be an object.'); @@ -553,12 +584,13 @@ export class Mp3OutputFormat extends OutputFormat { /** * WAVE-specific output options. + * @group Output formats * @public */ export type WavOutputFormatOptions = { /** - * When enabled, an RF64 file be written, allowing for file sizes to exceed 4 GiB, which is otherwise not possible - * for regular WAVE files. + * When enabled, an RF64 file will be written, allowing for file sizes to exceed 4 GiB, which is otherwise not + * possible for regular WAVE files. */ large?: boolean; @@ -571,12 +603,14 @@ export type WavOutputFormatOptions = { /** * WAVE file format, based on RIFF. + * @group Output formats * @public */ export class WavOutputFormat extends OutputFormat { /** @internal */ _options: WavOutputFormatOptions; + /** Creates a new {@link WavOutputFormat} configured with the specified `options`. */ constructor(options: WavOutputFormatOptions = {}) { if (!options || typeof options !== 'object') { throw new TypeError('options must be an object.'); @@ -635,6 +669,7 @@ export class WavOutputFormat extends OutputFormat { /** * Ogg-specific output options. + * @group Output formats * @public */ export type OggOutputFormatOptions = { @@ -643,19 +678,21 @@ export type OggOutputFormatOptions = { * * @param data - The raw bytes. * @param position - The byte offset of the data in the file. - * @param source - The media source backing the page's logical bitstream (track). + * @param source - The {@link MediaSource} backing the page's logical bitstream (track). */ onPage?: (data: Uint8Array, position: number, source: MediaSource) => unknown; }; /** * Ogg file format. + * @group Output formats * @public */ export class OggOutputFormat extends OutputFormat { /** @internal */ _options: OggOutputFormatOptions; + /** Creates a new {@link OggOutputFormat} configured with the specified `options`. */ constructor(options: OggOutputFormatOptions = {}) { if (!options || typeof options !== 'object') { throw new TypeError('options must be an object.'); @@ -709,6 +746,7 @@ export class OggOutputFormat extends OutputFormat { /** * ADTS-specific output options. + * @group Output formats * @public */ export type AdtsOutputFormatOptions = { @@ -723,12 +761,14 @@ export type AdtsOutputFormatOptions = { /** * ADTS file format. + * @group Output formats * @public */ export class AdtsOutputFormat extends OutputFormat { /** @internal */ _options: AdtsOutputFormatOptions; + /** Creates a new {@link AdtsOutputFormat} configured with the specified `options`. */ constructor(options: AdtsOutputFormatOptions = {}) { if (!options || typeof options !== 'object') { throw new TypeError('options must be an object.'); diff --git a/src/output.ts b/src/output.ts index 92c0d0f..24e71d7 100644 --- a/src/output.ts +++ b/src/output.ts @@ -15,6 +15,7 @@ import { Writer } from './writer'; /** * The options for creating an Output object. + * @group Output files * @public */ export type OutputOptions< @@ -29,11 +30,13 @@ export type OutputOptions< /** * List of all track types. + * @group Miscellaneous * @public */ export const ALL_TRACK_TYPES = ['video', 'audio', 'subtitle'] as const; /** * Union type of all track types. + * @group Miscellaneous * @public */ export type TrackType = typeof ALL_TRACK_TYPES[number]; @@ -62,6 +65,7 @@ export type OutputSubtitleTrack = OutputTrack & { type: 'subtitle' }; /** * Base track metadata, applicable to all tracks. + * @group Output files * @public */ export type BaseTrackMetadata = { @@ -73,6 +77,7 @@ export type BaseTrackMetadata = { /** * Additional metadata for video tracks. + * @group Output files * @public */ export type VideoTrackMetadata = BaseTrackMetadata & { @@ -87,11 +92,13 @@ export type VideoTrackMetadata = BaseTrackMetadata & { }; /** * Additional metadata for audio tracks. + * @group Output files * @public */ export type AudioTrackMetadata = BaseTrackMetadata & {}; /** * Additional metadata for subtitle tracks. + * @group Output files * @public */ export type SubtitleTrackMetadata = BaseTrackMetadata & {}; @@ -110,6 +117,7 @@ const validateBaseTrackMetadata = (metadata: BaseTrackMetadata) => { /** * Main class orchestrating the creation of a new media file. + * @group Output files * @public */ export class Output< @@ -138,6 +146,10 @@ export class Output< /** @internal */ _mutex = new AsyncMutex(); + /** + * Creates a new instance of {@link Output} which can then be used to create a new media file according to the + * specified {@link OutputOptions}. + */ constructor(options: OutputOptions) { if (!options || typeof options !== 'object') { throw new TypeError('options must be an object.'); diff --git a/src/packet.ts b/src/packet.ts index ddbdc56..5221267 100644 --- a/src/packet.ts +++ b/src/packet.ts @@ -13,13 +13,17 @@ export const PLACEHOLDER_DATA = new Uint8Array(0); /** * The type of a packet. Key packets can be decoded without previous packets, while delta packets depend on previous * packets. + * @group Packets * @public */ export type PacketType = 'key' | 'delta'; /** - * 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. + * Represents an encoded chunk of media. Mainly used as an expressive wrapper around WebCodecs API's + * [`EncodedVideoChunk`](https://developer.mozilla.org/en-US/docs/Web/API/EncodedVideoChunk) and + * [`EncodedAudioChunk`](https://developer.mozilla.org/en-US/docs/Web/API/EncodedAudioChunk), but can also be used + * standalone. + * @group Packets * @public */ export class EncodedPacket { @@ -29,6 +33,7 @@ export class EncodedPacket { */ readonly byteLength: number; + /** Creates a new {@link EncodedPacket} from raw bytes and timing information. */ constructor( /** The encoded data of this packet. */ public readonly data: Uint8Array, diff --git a/src/sample.ts b/src/sample.ts index b59b332..5f407cd 100644 --- a/src/sample.ts +++ b/src/sample.ts @@ -19,10 +19,14 @@ import { /** * Metadata used for VideoSample initialization. + * @group Samples * @public */ export type VideoSampleInit = { - /** The internal pixel format in which the frame is stored. */ + /** + * The internal pixel format in which the frame is stored. + * [See pixel formats](https://developer.mozilla.org/en-US/docs/Web/API/VideoFrame/format) + */ format?: VideoPixelFormat; /** The width of the frame in pixels. */ codedWidth?: number; @@ -40,7 +44,8 @@ export type VideoSampleInit = { /** * Represents a raw, unencoded video sample (frame). Mainly used as an expressive wrapper around WebCodecs API's - * VideoFrame, but can also be used standalone. + * [`VideoFrame`](https://developer.mozilla.org/en-US/docs/Web/API/VideoFrame), but can also be used standalone. + * @group Samples * @public */ export class VideoSample { @@ -49,7 +54,10 @@ export class VideoSample { /** @internal */ _closed: boolean = false; - /** The internal pixel format in which the frame is stored. */ + /** + * The internal pixel format in which the frame is stored. + * [See pixel formats](https://developer.mozilla.org/en-US/docs/Web/API/VideoFrame/format) + */ readonly format!: VideoPixelFormat | null; /** The width of the frame in pixels. */ readonly codedWidth!: number; @@ -87,8 +95,24 @@ export class VideoSample { return Math.trunc(SECOND_TO_MICROSECOND_FACTOR * this.duration); } + /** + * Creates a new {@link VideoSample} from a + * [`VideoFrame`](https://developer.mozilla.org/en-US/docs/Web/API/VideoFrame). This is essentially a near zero-cost + * wrapper around `VideoFrame`. The sample's metadata is optionally refined using the data specified in `init`. + */ constructor(data: VideoFrame, init?: VideoSampleInit); + /** + * Creates a new {@link VideoSample} from a + * [`CanvasImageSource`](https://udn.realityripple.com/docs/Web/API/CanvasImageSource), similar to the + * [`VideoFrame`](https://developer.mozilla.org/en-US/docs/Web/API/VideoFrame) constructor. When `VideoFrame` is + * available, this is simply a wrapper around its constructor. If not, it will copy the source's image data to an + * internal canvas for later use. + */ constructor(data: CanvasImageSource, init: SetRequired); + /** + * Creates a new {@link VideoSample} from raw pixel data specified in `data`. Additional metadata must be provided + * in `init`. + */ constructor( data: BufferSource, init: SetRequired @@ -544,10 +568,10 @@ export class VideoSample { /** * Controls the fitting algorithm. * - * - '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 + * - `'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. + * - `'cover'` will scale the image until the entire box is filled, while preserving aspect ratio. */ fit: 'fill' | 'contain' | 'cover'; /** A way to override rotation. Defaults to the rotation of the sample. */ @@ -596,7 +620,8 @@ export class VideoSample { } /** - * Converts this video sample to a CanvasImageSource for drawing to a canvas. + * Converts this video sample to a + * [`CanvasImageSource`](https://udn.realityripple.com/docs/Web/API/CanvasImageSource) for drawing to a canvas. * * You must use the value returned by this method immediately, as any VideoFrame created internally will * automatically be closed in the next microtask. @@ -660,12 +685,15 @@ const AUDIO_SAMPLE_FORMATS = new Set( /** * Metadata used for AudioSample initialization. + * @group Samples * @public */ export type AudioSampleInit = { /** The audio data for this sample. */ data: AllowSharedBufferSource; - /** The audio sample format. */ + /** + * The audio sample format. [See sample formats](https://developer.mozilla.org/en-US/docs/Web/API/AudioData/format) + */ format: AudioSampleFormat; /** The number of audio channels. */ numberOfChannels: number; @@ -677,6 +705,7 @@ export type AudioSampleInit = { /** * Options used for copying audio sample data. + * @group Samples * @public */ export type AudioSampleCopyToOptions = { @@ -684,7 +713,10 @@ 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. */ + /** + * The output format for the destination data. Defaults to the AudioSample's format. + * [See sample formats](https://developer.mozilla.org/en-US/docs/Web/API/AudioData/format) + */ format?: AudioSampleFormat; /** An offset into the source plane data indicating which frame to begin copying from. Defaults to 0. */ frameOffset?: number; @@ -696,8 +728,9 @@ export type AudioSampleCopyToOptions = { }; /** - * Represents a raw, unencoded audio sample. Mainly used as an expressive wrapper around WebCodecs API's AudioData, - * but can also be used standalone. + * Represents a raw, unencoded audio sample. Mainly used as an expressive wrapper around WebCodecs API's + * [`AudioData`](https://developer.mozilla.org/en-US/docs/Web/API/AudioData), but can also be used standalone. + * @group Samples * @public */ export class AudioSample { @@ -706,7 +739,10 @@ export class AudioSample { /** @internal */ _closed: boolean = false; - /** The audio sample format. */ + /** + * The audio sample format. + * [See sample formats](https://developer.mozilla.org/en-US/docs/Web/API/AudioData/format) + */ readonly format: AudioSampleFormat; /** The audio sample rate in hertz. */ readonly sampleRate: number; @@ -716,7 +752,7 @@ export class AudioSample { readonly numberOfFrames: number; /** The number of audio channels. */ readonly numberOfChannels: number; - /** The timestamp of the sample in seconds. */ + /** The duration of the sample in seconds. */ readonly duration: number; /** * The presentation timestamp of the sample in seconds. May be negative. Samples with negative end timestamps should @@ -734,6 +770,11 @@ export class AudioSample { return Math.trunc(SECOND_TO_MICROSECOND_FACTOR * this.duration); } + /** + * Creates a new {@link AudioSample}, either from an existing + * [`AudioData`](https://developer.mozilla.org/en-US/docs/Web/API/AudioData) or from raw bytes specified in + * {@link AudioSampleInit}. + */ constructor(init: AudioData | AudioSampleInit) { if (isAudioData(init)) { if (init.format === null) { diff --git a/src/source.ts b/src/source.ts index 1b3d41a..ae24aaf 100644 --- a/src/source.ts +++ b/src/source.ts @@ -16,6 +16,7 @@ import { promiseWithResolvers, retriedFetch, toDataView, + toUint8Array, } from './misc'; export type ReadResult = { @@ -27,6 +28,7 @@ export type ReadResult = { /** * The source base class, representing a resource from which bytes can be read. + * @group Input sources * @public */ export abstract class Source { @@ -69,6 +71,7 @@ export abstract class Source { /** * A source backed by an ArrayBuffer or ArrayBufferView, with the entire file held in memory. + * @group Input sources * @public */ export class BufferSource extends Source { @@ -79,15 +82,16 @@ export class BufferSource extends Source { /** @internal */ _onreadCalled = false; - constructor(buffer: ArrayBuffer | Uint8Array) { - if (!(buffer instanceof ArrayBuffer) && !(buffer instanceof Uint8Array)) { - throw new TypeError('buffer must be an ArrayBuffer or Uint8Array.'); + /** Creates a new {@link BufferSource} backed the specified `ArrayBuffer` or `ArrayBufferView`. */ + constructor(buffer: ArrayBuffer | ArrayBufferView) { + if (!(buffer instanceof ArrayBuffer) && !ArrayBuffer.isView(buffer)) { + throw new TypeError('buffer must be an ArrayBuffer or ArrayBufferView.'); } super(); - this._bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); - this._view = toDataView(this._bytes); + this._bytes = toUint8Array(buffer); + this._view = toDataView(buffer); } /** @internal */ @@ -112,7 +116,8 @@ export class BufferSource extends Source { } /** - * Options for BlobSource. + * Options for {@link BlobSource}. + * @group Input sources * @public */ export type BlobSourceOptions = { @@ -121,7 +126,10 @@ export type BlobSourceOptions = { }; /** - * A source backed by a Blob. Since Files are also Blobs, this is the source to use when reading files off the disk. + * A source backed by a [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob). Since a + * [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File) is also a `Blob`, this is the source to use when + * reading files off the disk. + * @group Input sources * @public */ export class BlobSource extends Source { @@ -130,6 +138,10 @@ export class BlobSource extends Source { /** @internal */ _orchestrator: ReadOrchestrator; + /** + * Creates a new {@link BlobSource} backed by the specified + * [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob). + */ constructor(blob: Blob, options: BlobSourceOptions = {}) { if (!(blob instanceof Blob)) { throw new TypeError('blob must be a Blob.'); @@ -203,13 +215,14 @@ export class BlobSource extends Source { const URL_SOURCE_MIN_LOAD_AMOUNT = 0.5 * 2 ** 20; // 0.5 MiB /** - * Options for UrlSource. + * Options for {@link UrlSource}. + * @group Input sources * @public */ export type UrlSourceOptions = { /** - * The RequestInit used by the Fetch API. Can be used to further control the requests, such as setting - * custom headers. + * The [`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit) used by the Fetch API. Can be + * used to further control the requests, such as setting custom headers. */ requestInit?: RequestInit; @@ -228,6 +241,7 @@ export type UrlSourceOptions = { /** * A source backed by a URL. This is useful for reading data from the network. Requests will be made using an optimized * reading and prefetching pattern to minimize request count and latency. + * @group Input sources * @public */ export class UrlSource extends Source { @@ -245,6 +259,7 @@ export class UrlSource extends Source { abortController: AbortController; }>(); + /** Creates a new {@link UrlSource} backed by the resource at the specified URL. */ constructor( url: string | URL, options: UrlSourceOptions = {}, @@ -476,7 +491,8 @@ export class UrlSource extends Source { } /** - * Options for FilePathSource. + * Options for {@link FilePathSource}. + * @group Input sources * @public */ export type FilePathSourceOptions = { @@ -486,12 +502,14 @@ export type FilePathSourceOptions = { /** * A source backed by a path to a file. Intended for server-side usage in Node, Bun, or Deno. + * @group Input sources * @public */ export class FilePathSource extends Source { /** @internal */ _streamSource: StreamSource; + /** Creates a new {@link FilePathSource} backed by the file at the specified file path. */ constructor(filePath: string, options: BlobSourceOptions = {}) { if (typeof filePath !== 'string') { throw new TypeError('filePath must be a string.'); @@ -544,7 +562,8 @@ export class FilePathSource extends Source { } /** - * Options for defining a StreamSource. + * Options for defining a {@link StreamSource}. + * @group Input sources * @public */ export type StreamSourceOptions = { @@ -564,7 +583,7 @@ export type StreamSourceOptions = { maxCacheSize?: number; /** - * Specifies the prefetch profile that the reader should use with this source. A prefetch propfile specifies the + * Specifies the prefetch profile that the reader should use with this source. A prefetch profile specifies the * pattern with which bytes outside of the requested range are preloaded to reduce latency for future reads. * * - `'none'` (default): No prefetching; only the data needed in the moment is requested. @@ -579,6 +598,7 @@ export type StreamSourceOptions = { /** * A general-purpose, callback-driven source that can get its data from anywhere. + * @group Input sources * @public */ export class StreamSource extends Source { @@ -587,6 +607,7 @@ export class StreamSource extends Source { /** @internal */ _orchestrator: ReadOrchestrator; + /** Creates a new {@link StreamSource} whose behavior is specified by `options`. */ constructor(options: StreamSourceOptions) { if (!options || typeof options !== 'object') { throw new TypeError('options must be an object.'); @@ -718,7 +739,8 @@ type ReadableStreamSourcePendingSlice = { }; /** - * Options for ReadableStreamSource. + * Options for {@link ReadableStreamSource}. + * @group Input sources * @public */ export type ReadableStreamSourceOptions = { @@ -727,16 +749,17 @@ export type ReadableStreamSourceOptions = { }; /** - * A source backed by a `ReadableStream` of `Uint8Array`, representing an append-only byte stream of unknown - * length. This is the source to use for incrementally streaming in input files that are still being constructed and - * whose size we don't yet know, like for example the output chunks of - * [MediaRecorder](https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder). + * A source backed by a [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) of + * `Uint8Array`, representing an append-only byte stream of unknown length. This is the source to use for incrementally + * streaming in input files that are still being constructed and whose size we don't yet know, like for example the + * output chunks of [MediaRecorder](https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder). * * This source is *unsized*, meaning calls to `.getSize()` will throw and readers are more limited due to the * lack of random file access. You should only use this source with sequential access patterns, such as reading all * packets from start to end. This source does not work well with random access patterns unless you increase its * max cache size. * + * @group Input sources * @public */ export class ReadableStreamSource extends Source { @@ -761,6 +784,7 @@ export class ReadableStreamSource extends Source { /** @internal */ _pulling = false; + /** Creates a new {@link ReadableStreamSource} backed by the specified `ReadableStream`. */ constructor(stream: ReadableStream, options: ReadableStreamSourceOptions = {}) { if (!(stream instanceof ReadableStream)) { throw new TypeError('stream must be a ReadableStream.'); diff --git a/src/target.ts b/src/target.ts index 03366bd..3893be7 100644 --- a/src/target.ts +++ b/src/target.ts @@ -11,6 +11,7 @@ import { Output } from './output'; /** * Base class for targets, specifying where output files are written. + * @group Output targets * @public */ export abstract class Target { @@ -32,10 +33,11 @@ export abstract class Target { /** * A target that writes data directly into an ArrayBuffer in memory. Great for performance, but not suitable for very * large files. The buffer will be available once the output has been finalized. + * @group Output targets * @public */ export class BufferTarget extends Target { - /** Stores the final output buffer. Until the output is finalized, this will be null. */ + /** Stores the final output buffer. Until the output is finalized, this will be `null`. */ buffer: ArrayBuffer | null = null; /** @internal */ @@ -45,7 +47,8 @@ export class BufferTarget extends Target { } /** - * A data chunk for StreamTarget. + * A data chunk for {@link StreamTarget}. + * @group Output targets * @public */ export type StreamTargetChunk = { @@ -58,7 +61,8 @@ export type StreamTargetChunk = { }; /** - * Options for StreamTarget. + * Options for {@link StreamTarget}. + * @group Output targets * @public */ export type StreamTargetOptions = { @@ -73,9 +77,12 @@ export type StreamTargetOptions = { }; /** - * 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. + * This target writes data to a [`WritableStream`](https://developer.mozilla.org/en-US/docs/Web/API/WritableStream), + * making it a general-purpose target for writing data anywhere. It is also compatible with + * [`FileSystemWritableFileStream`](https://developer.mozilla.org/en-US/docs/Web/API/FileSystemWritableFileStream) for + * use with the [File System Access API](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API). The + * `WritableStream` can also apply backpressure, which will propagate to the output and throttle the encoders. + * @group Output targets * @public */ export class StreamTarget extends Target { @@ -84,6 +91,7 @@ export class StreamTarget extends Target { /** @internal */ _options: StreamTargetOptions; + /** Creates a new {@link StreamTarget} which writes to the specified `writable`. */ constructor( writable: WritableStream, options: StreamTargetOptions = {}, @@ -114,8 +122,9 @@ export class StreamTarget extends Target { } /** - * This target just discards all incoming data. It is useful for when you need an `Output` but extract data from it - * differently, for example through format-specific callbacks (`onMoof`, `onMdat`, ...) or encoder events. + * This target just discards all incoming data. It is useful for when you need an {@link Output} but extract data from + * it differently, for example through format-specific callbacks (`onMoof`, `onMdat`, ...) or encoder events. + * @group Output targets * @public */ export class NullTarget extends Target { diff --git a/tsdoc.json b/tsdoc.json new file mode 100644 index 0000000..e0118bc --- /dev/null +++ b/tsdoc.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + "extends": ["@microsoft/api-extractor/extends/tsdoc-base.json"], + "tagDefinitions": [ + { + "tagName": "@group", + "syntaxKind": "block" + } + ], + "supportForTags": { + "@group": true + } +} \ No newline at end of file