diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8456745..31a0ce8 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -10,10 +10,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22 cache: 'npm' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 630d342..3267201 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: permissions: write-all steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 0 @@ -27,7 +27,7 @@ jobs: git push origin release - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22 cache: 'npm' @@ -39,7 +39,7 @@ jobs: - name: Check package.json version against tag name if: steps.package-json-version.outputs.version != github.event.release.tag_name - uses: actions/github-script@v3 + uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3.2.0 with: script: core.setFailed('Release tag does not match package.json version!') diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6d5ad9a..28d920c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,10 +10,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22 cache: 'npm' diff --git a/dev/demux.html b/dev/demux.html index 7df202b..eff6137 100644 --- a/dev/demux.html +++ b/dev/demux.html @@ -14,7 +14,29 @@ source: new Mediabunny.BlobSource(file), }); - console.log(await input.getMetadataTags()) + const videoTrack = await input.getPrimaryVideoTrack(); + const sink = new Mediabunny.VideoSampleSink(videoTrack); + const sink2 = new Mediabunny.EncodedPacketSink(videoTrack); + + for await (const packet of sink2.packets()) { + console.log(packet)//, await videoTrack.determinePacketType(packet)); + + if (packet.timestamp >= 1) { + break; + } + } + + console.log(await sink.getSample(0)) + + return; + + for await (const sample of sink.samples()) { + console.log(sample); + + if (sample.timestamp >= 1) { + break; + } + } /* const sink = new Mediabunny.EncodedPacketSink(videoTrack); diff --git a/dev/mux.html b/dev/mux.html index 3df9586..21509c3 100644 --- a/dev/mux.html +++ b/dev/mux.html @@ -46,6 +46,7 @@ format = new Mediabunny.WavOutputFormat(); format = new Mediabunny.MkvOutputFormat(); format = new Mediabunny.MovOutputFormat(); + format = new Mediabunny.Mp4OutputFormat({ fastStart: 'reserve' }); let target = new Mediabunny.BufferTarget(); /* @@ -127,8 +128,8 @@ }); let subtitleSource = new Mediabunny.TextSubtitleSource('webvtt'); - output.addVideoTrack(videoSource, { languageCode: 'eng', name: 'Mononoké' }); - output.addAudioTrack(audioSource, { name: 'Yooo' }); + output.addVideoTrack(videoSource, { languageCode: 'eng', name: 'Mononoké', maximumPacketCount: 100 }); + output.addAudioTrack(audioSource, { name: 'Yooo', maximumPacketCount: 1000 }); //output.addSubtitleTrack(subtitleSource); output.start(); diff --git a/docs/guide/output-formats.md b/docs/guide/output-formats.md index 479287c..2f2e325 100644 --- a/docs/guide/output-formats.md +++ b/docs/guide/output-formats.md @@ -62,7 +62,7 @@ const output = new Output({ The following options are available: ```ts type IsobmffOutputFormatOptions = { - fastStart?: false | 'in-memory' | 'fragmented'; + fastStart?: false | 'in-memory' | 'reserve' | 'fragmented'; minimumFragmentDuration?: number; onFtyp?: (data: Uint8Array, position: number) => unknown; @@ -80,6 +80,8 @@ type IsobmffOutputFormatOptions = { ::: info This option ensures [append-only writing](#append-only-writing), although all the writing happens in bulk, at the end. ::: + - `'reserve'`\ + Produces a file with Fast Start by reserving space at the start of the file into which the metadata will be written later. This requires knowledge about the expected length of the file beforehand. When using this option, you must set the [`maximumPacketCount`](../api/BaseTrackMetadata#maximumpacketcount) field in the track metadata for all tracks. - `'fragmented'`\ Produces a _fragmented MP4 (fMP4)_ file, evenly placing sample metadata throughout the file by grouping it into "fragments" (short sections of media), while placing general metadata at the beginning of the file. Fragmented files are ideal in streaming contexts, as each fragment can be played individually without requiring knowledge of the other fragments. Furthermore, they remain lightweight to create no matter how large the file becomes, as they don't require media to be kept in memory for very long. However, fragmented files are not as widely and wholly supported as regular MP4 files, and some players don't provide seeking functionality for them. ::: info diff --git a/docs/index.md b/docs/index.md index 820970f..8d3799d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -107,6 +107,7 @@ const sponsors = { { image: 'https://avatars.githubusercontent.com/u/504909', name: 'Hirbod', url: 'https://github.com/hirbod' }, { image: 'https://avatars.githubusercontent.com/u/30229596', name: 'Pablo Bonilla', url: 'https://github.com/devPablo' }, { image: 'https://avatars.githubusercontent.com/u/38181164', name: 'wcw', url: 'https://github.com/asd55667' }, + { image: 'https://avatars.githubusercontent.com/u/1836701', name: 'Bean Deng', url: 'https://github.com/HADB' }, { image: 'https://avatars.githubusercontent.com/u/63088713', name: 'taf2000', url: 'https://github.com/taf2000' }, { image: 'https://avatars.githubusercontent.com/u/58149663', name: 'H7GhosT', url: 'https://github.com/H7GhosT' }, { image: 'https://avatars.githubusercontent.com/u/91711202', name: 'ihasq', url: 'https://github.com/ihasq' }, diff --git a/package-lock.json b/package-lock.json index 385bbac..79780ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mediabunny", - "version": "1.15.2", + "version": "1.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mediabunny", - "version": "1.15.2", + "version": "1.16.0", "license": "MPL-2.0", "workspaces": [ "packages/*" @@ -7749,9 +7749,9 @@ } }, "node_modules/mediabunny": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.15.1.tgz", - "integrity": "sha512-+eRTVzd3E4LuGYZzPSQcPzuGdAIljohSlzYTX358XsfLM2qH1lQIBYa+erx7wzVcGQLRNjdV7x7ZS0EpK04DfA==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.15.2.tgz", + "integrity": "sha512-OvIuSK10wNdz+7Dd4bmeq/IWkYG0bkm33FQb29vmyDztpr47ru8XSxYansa3EHLy6lLEm4rUADUENOcmrnN0jg==", "license": "MPL-2.0", "peer": true, "workspaces": [ @@ -12242,7 +12242,7 @@ }, "packages/mp3-encoder": { "name": "@mediabunny/mp3-encoder", - "version": "1.15.2", + "version": "1.16.0", "license": "MPL-2.0", "devDependencies": { "@types/emscripten": "^1.40.1" diff --git a/package.json b/package.json index 86e7528..c5f1bfb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mediabunny", "author": "Vanilagy", - "version": "1.15.2", + "version": "1.16.0", "description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.", "type": "module", "workspaces": [ diff --git a/packages/mp3-encoder/package.json b/packages/mp3-encoder/package.json index 182dff3..7151341 100644 --- a/packages/mp3-encoder/package.json +++ b/packages/mp3-encoder/package.json @@ -1,7 +1,7 @@ { "name": "@mediabunny/mp3-encoder", "author": "Vanilagy", - "version": "1.15.2", + "version": "1.16.0", "description": "MP3 encoder extension for Mediabunny, based on LAME.", "main": "./dist/bundles/mediabunny-mp3-encoder.mjs", "module": "./dist/bundles/mediabunny-mp3-encoder.mjs", diff --git a/scripts/generate-api-docs.ts b/scripts/generate-api-docs.ts index 47f776f..7205b16 100644 --- a/scripts/generate-api-docs.ts +++ b/scripts/generate-api-docs.ts @@ -169,29 +169,72 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) // 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}\``; + // Updated regex to handle member links and optional link text, e.g., {@link Type.member | text} + return text.replace(/\{@link\s+([^}|]+)(?:\s*\|\s*([^}]+))?\}/g, (_, target, linkText) => { + const cleanTarget = target.trim(); + + // Split into type and member parts + const parts = cleanTarget.split('.'); + const typeName = parts[0]; + const memberName = parts.length > 1 ? parts[1] : undefined; + + let displayText: string; + if (linkText) { + // If custom link text is provided, always use it. + displayText = linkText.trim(); + } else if (memberName) { + // If it's a member link, default the text to just the member name. + displayText = `\`${memberName}\``; + } else { + // Otherwise, it's a type link, so use the full type name. + displayText = `\`${cleanTarget}\``; } - if (exportedTypes.has(cleanTypeName)) { - return `[\`${cleanTypeName}\`](./${cleanTypeName}.md)`; + + // Check if the base type is a known exported type + if (exportedTypes.has(typeName)) { + let linkUrl = ''; + + if (memberName) { + // It's a link to a member (property or method) + const anchor = memberName.toLowerCase(); + + if (typeName === currentTypeName) { + // Link to an anchor on the same page + linkUrl = `#${anchor}`; + } else { + // Link to another page's anchor + linkUrl = `./${typeName}.md#${anchor}`; + } + } else { + // It's a link to a type + if (typeName === currentTypeName) { + // Don't link to the current page, just format it + return `\`${cleanTarget}\``; + } + linkUrl = `./${typeName}.md`; + } + return `[${displayText}](${linkUrl})`; } - return `\`${cleanTypeName}\``; + + // Fallback for unknown types: just format as code + return `\`${cleanTarget}\``; }); }; // 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; - }); + const linkedTypes: string[] = []; + // Use a regex to find all link targets + const regex = /\{@link\s+([^}|]+)/g; + let match; + while ((match = regex.exec(text)) !== null) { + const target = match[1]!.trim(); + // Return only the base type name (the part before the first dot) + const typeName = target.split('.')[0]; + linkedTypes.push(typeName!); + } + return linkedTypes; }; // Helper to format references with proper "and" and period @@ -1206,9 +1249,9 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) 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); + const rawDesc = getFullJSDocDescription(propDeclaration); + if (rawDesc) { + desc = processLinkTags(rawDesc, className); } } @@ -1620,6 +1663,45 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) } }; +// Helper to get the full description text from a JSDoc comment, handling inline tags. +const getFullJSDocDescription = (node: ts.Node): string => { + const jsDoc = ts.getJSDocCommentsAndTags(node)[0]; + if (!jsDoc || !ts.isJSDoc(jsDoc)) return ''; + + // If it's a simple string, just return it. + if (typeof jsDoc.comment === 'string') { + return jsDoc.comment.trim(); + } + + // If it's a structured comment (with inline tags), get the raw text. + const sourceFile = node.getSourceFile(); + const sourceText = sourceFile.getFullText(); + const start = jsDoc.getStart(); + const end = jsDoc.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 (like @param, @returns) to keep only the main description + const lines = content.split('\n'); + const descLines = []; + for (const line of lines) { + if (line.trim().startsWith('@')) break; // Stop at the first @-tag + descLines.push(line); + } + return descLines.join('\n').trim(); + } + + return ''; +}; + const main = () => { const args = process.argv.slice(2); diff --git a/src/isobmff/isobmff-boxes.ts b/src/isobmff/isobmff-boxes.ts index c15b05f..8b58f9d 100644 --- a/src/isobmff/isobmff-boxes.ts +++ b/src/isobmff/isobmff-boxes.ts @@ -330,17 +330,17 @@ export const ftyp = (details: { /** Movie Sample Data Box. Contains the actual frames/samples of the media. */ export const mdat = (reserveLargeSize: boolean): Box => ({ type: 'mdat', largeSize: reserveLargeSize }); +/** Free Space Box: A box that designates unused space in the movie data file. */ +export const free = (size: number): Box => ({ type: 'free', size }); + /** * Movie Box: Used to specify the information that defines a movie - that is, the information that allows * an application to interpret the sample data that is stored elsewhere. */ -export const moov = ( - muxer: IsobmffMuxer, - fragmented = false, -) => box('moov', undefined, [ +export const moov = (muxer: IsobmffMuxer) => box('moov', undefined, [ mvhd(muxer.creationTime, muxer.trackDatas), ...muxer.trackDatas.map(x => trak(x, muxer.creationTime)), - fragmented ? mvex(muxer.trackDatas) : null, + muxer.isFragmented ? mvex(muxer.trackDatas) : null, udta(muxer), ]); diff --git a/src/isobmff/isobmff-muxer.ts b/src/isobmff/isobmff-muxer.ts index cd14688..a0a794c 100644 --- a/src/isobmff/isobmff-muxer.ts +++ b/src/isobmff/isobmff-muxer.ts @@ -6,7 +6,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { Box, ftyp, IsobmffBoxWriter, mdat, mfra, moof, moov, vtta, vttc, vtte } from './isobmff-boxes'; +import { Box, free, ftyp, IsobmffBoxWriter, mdat, mfra, moof, moov, vtta, vttc, vtte } from './isobmff-boxes'; import { Muxer } from '../muxer'; import { Output, OutputAudioTrack, OutputSubtitleTrack, OutputTrack, OutputVideoTrack } from '../output'; import { BufferTargetWriter, Writer } from '../writer'; @@ -142,7 +142,7 @@ export class IsobmffMuxer extends Muxer { private writer: Writer; private boxWriter: IsobmffBoxWriter; private fastStart: NonNullable; - private isFragmented: boolean; + isFragmented: boolean; isQuickTime: boolean; @@ -151,6 +151,7 @@ export class IsobmffMuxer extends Muxer { private auxBoxWriter = new IsobmffBoxWriter(this.auxWriter); private mdat: Box | null = null; + private ftypSize: number | null = null; trackDatas: IsobmffTrackData[] = []; private allTracksKnown = promiseWithResolvers(); @@ -208,8 +209,22 @@ export class IsobmffMuxer extends Muxer { } } + this.ftypSize = this.writer.getPos(); + if (this.fastStart === 'in-memory') { - this.mdat = mdat(false); + // We're write at finalization + } else if (this.fastStart === 'reserve') { + // Validate that all tracks have set maximumPacketCount + for (const track of this.output._tracks) { + if (track.metadata.maximumPacketCount === undefined) { + throw new Error( + 'All tracks must specify maximumPacketCount in their metadata when using' + + ' fastStart: \'reserve\'.', + ); + } + } + + // We'll start writing once we know all tracks } else if (this.isFragmented) { // We write the moov box once we write out the first fragment to make sure we get the decoder configs } else { @@ -830,6 +845,8 @@ export class IsobmffMuxer extends Muxer { if (this.isFragmented) { trackData.sampleQueue.push(sample); await this.interleaveSamples(); + } else if (this.fastStart === 'reserve') { + await this.registerSampleFastStartReserve(trackData, sample); } else { await this.addSampleToTrack(trackData, sample); } @@ -838,6 +855,18 @@ export class IsobmffMuxer extends Muxer { private async addSampleToTrack(trackData: IsobmffTrackData, sample: Sample) { if (!this.isFragmented) { trackData.samples.push(sample); + + if (this.fastStart === 'reserve') { + const maximumPacketCount = trackData.track.metadata.maximumPacketCount; + assert(maximumPacketCount !== undefined); + + if (trackData.samples.length > maximumPacketCount) { + throw new Error( + `Track #${trackData.track.id} has already reached the maximum packet count` + + ` (${maximumPacketCount}). Either add less packets or increase the maximum packet count.`, + ); + } + } } let beginNewChunk = false; @@ -946,10 +975,8 @@ export class IsobmffMuxer extends Muxer { private async interleaveSamples(isFinalCall = false) { assert(this.isFragmented); - if (!isFinalCall) { - if (!this.allTracksAreKnown()) { - return; // We can't interleave yet as we don't yet know how many tracks we'll truly have - } + if (!isFinalCall && !this.allTracksAreKnown()) { + return; // We can't interleave yet as we don't yet know how many tracks we'll truly have } outer: @@ -988,7 +1015,7 @@ export class IsobmffMuxer extends Muxer { } // Write the moov box now that we have all decoder configs - const movieBox = moov(this, true); + const movieBox = moov(this); this.boxWriter.writeBox(movieBox); if (this.format._options.onMoov) { @@ -1077,6 +1104,72 @@ export class IsobmffMuxer extends Muxer { } } + private async registerSampleFastStartReserve(trackData: IsobmffTrackData, sample: Sample) { + if (this.allTracksAreKnown()) { + if (!this.mdat) { + // We finally know all tracks, let's reserve space for the moov box + const moovBox = moov(this); + const moovSize = this.boxWriter.measureBox(moovBox); + + const reservedSize = moovSize + + this.computeSampleTableSizeUpperBound() + + 4096; // Just a little extra headroom + + assert(this.ftypSize !== null); + this.writer.seek(this.ftypSize + reservedSize); + + if (this.format._options.onMdat) { + this.writer.startTrackingWrites(); + } + + this.mdat = mdat(true); + this.boxWriter.writeBox(this.mdat); + + // Now write everything that was queued + for (const trackData of this.trackDatas) { + for (const sample of trackData.sampleQueue) { + await this.addSampleToTrack(trackData, sample); + } + trackData.sampleQueue.length = 0; + } + } + + await this.addSampleToTrack(trackData, sample); + } else { + // Queue it for when we know all tracks + trackData.sampleQueue.push(sample); + } + } + + private computeSampleTableSizeUpperBound() { + assert(this.fastStart === 'reserve'); + + let upperBound = 0; + + for (const trackData of this.trackDatas) { + const n = trackData.track.metadata.maximumPacketCount; + assert(n !== undefined); // We validated this earlier + + // Given the max allowed packet count, compute the space they'll take up in the Sample Table Box, assuming + // the worst case for each individual box: + + // stts box - since it is compactly coded, the maximum length of this table will be 2/3n + upperBound += (4 + 4) * Math.ceil(2 / 3 * n); + // stss box - 1 entry per sample + upperBound += 4 * n; + // ctts box - since it is compactly coded, the maximum length of this table will be 2/3n + upperBound += (4 + 4) * Math.ceil(2 / 3 * n); + // stsc box - since it is compactly coded, the maximum length of this table will be 2/3n + upperBound += (4 + 4 + 4) * Math.ceil(2 / 3 * n); + // stsz box - 1 entry per sample + upperBound += 4 * n; + // co64 box - we assume 1 sample per chunk and 64-bit chunk offsets (co64 instead of stco) + upperBound += 8 * n; + } + + return upperBound; + } + // eslint-disable-next-line @typescript-eslint/no-misused-promises override async onTrackClose(track: OutputTrack) { const release = await this.mutex.acquire(); @@ -1128,7 +1221,7 @@ export class IsobmffMuxer extends Muxer { } if (this.fastStart === 'in-memory') { - assert(this.mdat); + this.mdat = mdat(false); let mdatSize: number; // We know how many chunks there are, but computing the chunk positions requires an iterative approach: @@ -1214,12 +1307,28 @@ export class IsobmffMuxer extends Muxer { this.format._options.onMdat(data, start); } - if (this.format._options.onMoov) { - this.writer.startTrackingWrites(); - } - const movieBox = moov(this); - this.boxWriter.writeBox(movieBox); + + if (this.fastStart === 'reserve') { + assert(this.ftypSize !== null); + this.writer.seek(this.ftypSize); + + if (this.format._options.onMoov) { + this.writer.startTrackingWrites(); + } + + this.boxWriter.writeBox(movieBox); + + // Fill the remaining space with a free box. If there are less than 8 bytes left, sucks I guess + const remainingSpace = this.boxWriter.offsets.get(this.mdat)! - this.writer.getPos(); + this.boxWriter.writeBox(free(remainingSpace)); + } else { + if (this.format._options.onMoov) { + this.writer.startTrackingWrites(); + } + + this.boxWriter.writeBox(movieBox); + } if (this.format._options.onMoov) { const { data, start } = this.writer.stopTrackingWrites(); diff --git a/src/matroska/ebml.ts b/src/matroska/ebml.ts index 1d89de4..2704ff7 100644 --- a/src/matroska/ebml.ts +++ b/src/matroska/ebml.ts @@ -150,6 +150,14 @@ export enum EBMLId { TagLanguage = 0x447a, TagString = 0x4487, TagBinary = 0x4485, + ContentEncodings = 0x6d80, + ContentEncoding = 0x6240, + ContentEncodingOrder = 0x5031, + ContentEncodingScope = 0x5032, + ContentCompression = 0x5034, + ContentCompAlgo = 0x4254, + ContentCompSettings = 0x4255, + ContentEncryption = 0x5035, } export const LEVEL_0_EBML_IDS: EBMLId[] = [ diff --git a/src/matroska/matroska-demuxer.ts b/src/matroska/matroska-demuxer.ts index bda341e..89617ce 100644 --- a/src/matroska/matroska-demuxer.ts +++ b/src/matroska/matroska-demuxer.ts @@ -105,6 +105,7 @@ type SeekEntry = { }; type Cluster = { + segment: Segment; elementStartPos: number; elementEndPos: number; dataStartPos: number; @@ -115,6 +116,7 @@ type Cluster = { }; type ClusterTrackData = { + track: InternalTrack | null; startTimestamp: number; endTimestamp: number; firstKeyFrameTimestamp: number | null; @@ -147,6 +149,32 @@ type CuePoint = { clusterPosition: number; }; +enum ContentEncodingScope { + Block = 1, + Private = 2, + Next = 4, +} + +enum ContentCompAlgo { + Zlib, + Bzlib, + lzo1x, + HeaderStripping, +} + +type DecodingInstruction = { + order: number; + scope: ContentEncodingScope; + data: { + type: 'decompress'; + algorithm: ContentCompAlgo | null; + settings: Uint8Array | null; + } | { + type: 'decrypt'; + // Don't store more yet since this operation is unsupported + } | null; +}; + type InternalTrack = { id: number; demuxer: MatroskaDemuxer; @@ -162,6 +190,8 @@ type InternalTrack = { defaultDuration: number | null; name: string | null; languageCode: string; + decodingInstructions: DecodingInstruction[]; + info: | null | { @@ -205,6 +235,7 @@ export class MatroskaDemuxer extends Demuxer { currentCluster: Cluster | null = null; currentBlock: ClusterBlock | null = null; currentCueTime: number | null = null; + currentDecodingInstruction: DecodingInstruction | null = null; currentTagTargetIsMovie: boolean = true; currentSimpleTagName: string | null = null; currentAttachedFile: { @@ -583,6 +614,7 @@ export class MatroskaDemuxer extends Demuxer { if (dataSlice instanceof Promise) dataSlice = await dataSlice; const cluster: Cluster = { + segment, elementStartPos, elementEndPos: dataStartPos + size, dataStartPos, @@ -597,8 +629,8 @@ export class MatroskaDemuxer extends Demuxer { this.readContiguousElements(dataSlice); } - for (const [trackId, trackData] of cluster.trackData) { - const track = segment.tracks.find(x => x.id === trackId) ?? null; + for (const [, trackData] of cluster.trackData) { + const track = trackData.track; // This must hold, as track datas only get created if a block for that track is encountered assert(trackData.blocks.length > 0); @@ -683,6 +715,7 @@ export class MatroskaDemuxer extends Demuxer { let trackData = cluster.trackData.get(trackNumber); if (!trackData) { trackData = { + track: cluster.segment.tracks.find(x => x.id === trackNumber) ?? null, startTimestamp: 0, endTimestamp: 0, firstKeyFrameTimestamp: null, @@ -934,11 +967,21 @@ export class MatroskaDemuxer extends Demuxer { defaultDuration: null, name: null, languageCode: UNDETERMINED_LANGUAGE, + decodingInstructions: [], + info: null, }; this.readContiguousElements(slice.slice(dataStartPos, size)); + if (this.currentTrack.decodingInstructions.some((instruction) => { + return instruction.data?.type !== 'decompress' + || instruction.data.algorithm !== ContentCompAlgo.HeaderStripping; + })) { + console.warn(`Track #${this.currentTrack.id} has an unsupported content encoding; dropping.`); + this.currentTrack = null; + } + if ( this.currentTrack && this.currentTrack.id !== -1 @@ -1295,12 +1338,18 @@ export class MatroskaDemuxer extends Demuxer { const lacing = (flags >> 1) & 0x3 as BlockLacing; // If the block is laced, we'll expand it later const trackData = this.getTrackDataInCluster(this.currentCluster, trackNumber); + let blockData = readBytes(slice, size - (slice.filePos - dataStartPos)); + + if (trackData.track) { + blockData = this.decodeBlockData(trackData.track, blockData); + } + trackData.blocks.push({ timestamp: relativeTimestamp, // We'll add the cluster's timestamp to this later duration: 0, // Will set later isKeyFrame, referencedTimestamps: [], - data: readBytes(slice, size - (slice.filePos - dataStartPos)), + data: blockData, lacing, }); }; break; @@ -1331,12 +1380,18 @@ export class MatroskaDemuxer extends Demuxer { const lacing = (flags >> 1) & 0x3 as BlockLacing; // If the block is laced, we'll expand it later const trackData = this.getTrackDataInCluster(this.currentCluster, trackNumber); + let blockData = readBytes(slice, size - (slice.filePos - dataStartPos)); + + if (trackData.track) { + blockData = this.decodeBlockData(trackData.track, blockData); + } + this.currentBlock = { timestamp: relativeTimestamp, // We'll add the cluster's timestamp to this later duration: 0, // Will set later isKeyFrame: true, referencedTimestamps: [], - data: readBytes(slice, size - (slice.filePos - dataStartPos)), + data: blockData, lacing, }; trackData.blocks.push(this.currentBlock); @@ -1469,12 +1524,117 @@ export class MatroskaDemuxer extends Demuxer { this.currentAttachedFile.fileDescription = readUnicodeString(slice, size); }; break; + + case EBMLId.ContentEncodings: { + if (!this.currentTrack) break; + + this.readContiguousElements(slice.slice(dataStartPos, size)); + + // "**MUST** start with the `ContentEncoding` with the highest `ContentEncodingOrder`" + this.currentTrack.decodingInstructions.sort((a, b) => b.order - a.order); + }; break; + + case EBMLId.ContentEncoding: { + this.currentDecodingInstruction = { + order: 0, + scope: ContentEncodingScope.Block, + data: null, + }; + + this.readContiguousElements(slice.slice(dataStartPos, size)); + + if (this.currentDecodingInstruction.data) { + this.currentTrack!.decodingInstructions.push(this.currentDecodingInstruction); + } + + this.currentDecodingInstruction = null; + }; break; + + case EBMLId.ContentEncodingOrder: { + if (!this.currentDecodingInstruction) break; + + this.currentDecodingInstruction.order = readUnsignedInt(slice, size); + }; break; + + case EBMLId.ContentEncodingScope: { + if (!this.currentDecodingInstruction) break; + + this.currentDecodingInstruction.scope = readUnsignedInt(slice, size); + }; break; + + case EBMLId.ContentCompression: { + if (!this.currentDecodingInstruction) break; + + this.currentDecodingInstruction.data = { + type: 'decompress', + algorithm: ContentCompAlgo.Zlib, + settings: null, + }; + + this.readContiguousElements(slice.slice(dataStartPos, size)); + }; break; + + case EBMLId.ContentCompAlgo: { + if (this.currentDecodingInstruction?.data?.type !== 'decompress') break; + + this.currentDecodingInstruction.data.algorithm = readUnsignedInt(slice, size); + }; break; + + case EBMLId.ContentCompSettings: { + if (this.currentDecodingInstruction?.data?.type !== 'decompress') break; + + this.currentDecodingInstruction.data.settings = readBytes(slice, size); + }; break; + + case EBMLId.ContentEncryption: { + if (!this.currentDecodingInstruction) break; + + this.currentDecodingInstruction.data = { + type: 'decrypt', + }; + }; break; } slice.filePos = dataStartPos + size; return true; } + decodeBlockData(track: InternalTrack, rawData: Uint8Array) { + let currentData = rawData; + + // In the vast number of cases there are exactly zero decoding instructions + for (let i = 0; i < track.decodingInstructions.length; i++) { + const instruction = track.decodingInstructions[i]!; + assert(instruction.data); + + switch (instruction.data.type) { + case 'decompress': { + switch (instruction.data.algorithm) { + case ContentCompAlgo.HeaderStripping: { + const prefix = instruction.data.settings ?? new Uint8Array(0); + const newData = new Uint8Array(prefix.length + currentData.length); + + newData.set(prefix, 0); + newData.set(currentData, prefix.length); + + currentData = newData; + }; break; + + default: { + // Unhandled + }; + } + }; break; + + default: { + // Unhandled + }; + } + } + + return currentData; + } + processTagValue(name: string, value: string | Uint8Array) { if (!this.currentSegment?.metadataTags) return; diff --git a/src/matroska/matroska-muxer.ts b/src/matroska/matroska-muxer.ts index 93f052c..04c9a42 100644 --- a/src/matroska/matroska-muxer.ts +++ b/src/matroska/matroska-muxer.ts @@ -854,10 +854,8 @@ export class MatroskaMuxer extends Muxer { } private async interleaveChunks(isFinalCall = false) { - if (!isFinalCall) { - if (!this.allTracksAreKnown()) { - return; // We can't interleave yet as we don't yet know how many tracks we'll truly have - } + if (!isFinalCall && !this.allTracksAreKnown()) { + return; // We can't interleave yet as we don't yet know how many tracks we'll truly have } outer: diff --git a/src/muxer.ts b/src/muxer.ts index 45ee2cd..b23356f 100644 --- a/src/muxer.ts +++ b/src/muxer.ts @@ -75,9 +75,9 @@ export abstract class Muxer { if (timestampInSeconds < timestampInfo.maxTimestampBeforeLastKeyFrame) { throw new Error( - `Timestamps cannot be smaller than the highest timestamp of the previous run (a run begins with a` - + ` key frame and ends right before the next key frame). Got ${timestampInSeconds}s, but highest` - + ` timestamp is ${timestampInfo.maxTimestampBeforeLastKeyFrame}s.`, + `Timestamps cannot be smaller than the highest timestamp of the previous GOP (a GOP begins with a key` + + ` frame and ends right before the next key frame). Got ${timestampInSeconds}s, but highest timestamp` + + ` is ${timestampInfo.maxTimestampBeforeLastKeyFrame}s.`, ); } diff --git a/src/ogg/ogg-demuxer.ts b/src/ogg/ogg-demuxer.ts index 264eabf..2edadc0 100644 --- a/src/ogg/ogg-demuxer.ts +++ b/src/ogg/ogg-demuxer.ts @@ -192,7 +192,7 @@ export class OggDemuxer extends Demuxer { 1 + lacingValues.length + firstPacket.data.length + secondPacket.data.length + thirdPacket.data.length, ); - description[0] = lacingValues.length; + description[0] = 2; // Num entries in the segment table description.set( lacingValues, 1, ); diff --git a/src/output-format.ts b/src/output-format.ts index c1a1a17..da562ee 100644 --- a/src/output-format.ts +++ b/src/output-format.ts @@ -115,6 +115,11 @@ export type IsobmffOutputFormatOptions = { * finalized. This produces a high-quality and compact output at the cost of a more expensive finalization step and * higher memory requirements. Data will be written monotonically (in order) when this option is set. * + * Use `'reserve'` to reserve space at the start of the file into which the metadata will be written later. This + * produces a file with Fast Start but requires knowledge about the expected length of the file beforehand. When + * using this option, you must set the {@link BaseTrackMetadata.maximumPacketCount} field in the track metadata + * for all tracks. + * * Use `'fragmented'` to place metadata at the start of the file by creating a fragmented file (fMP4). In a * fragmented file, chunks of media and their metadata are written to the file in "fragments", eliminating the need * to put all metadata in one place. Fragmented files are useful for streaming contexts, as each fragment can be @@ -126,7 +131,7 @@ export type IsobmffOutputFormatOptions = { * When this field is not defined, either `false` or `'in-memory'` will be used, automatically determined based on * the type of output target used. */ - fastStart?: false | 'in-memory' | 'fragmented'; + fastStart?: false | 'in-memory' | 'reserve' | 'fragmented'; /** * When using `fastStart: 'fragmented'`, this field controls the minimum duration of each fragment, in seconds. @@ -184,8 +189,13 @@ export abstract class IsobmffOutputFormat extends OutputFormat { if (!options || typeof options !== 'object') { throw new TypeError('options must be an object.'); } - if (options.fastStart !== undefined && ![false, 'in-memory', 'fragmented'].includes(options.fastStart)) { - throw new TypeError('options.fastStart, when provided, must be false, "in-memory", or "fragmented".'); + if ( + options.fastStart !== undefined + && ![false, 'in-memory', 'reserve', 'fragmented'].includes(options.fastStart) + ) { + throw new TypeError( + 'options.fastStart, when provided, must be false, \'in-memory\', \'reserve\', or \'fragmented\'.', + ); } if ( options.minimumFragmentDuration !== undefined diff --git a/src/output.ts b/src/output.ts index 5ce5e48..28a079e 100644 --- a/src/output.ts +++ b/src/output.ts @@ -74,6 +74,21 @@ export type BaseTrackMetadata = { languageCode?: string; /** A user-defined name for this track, like "English" or "Director Commentary". */ name?: string; + /** + * The maximum amount of encoded packets that will be added to this track. Setting this field provides the muxer + * with an additional signal that it can use to preallocate space in the file. + * + * When this field is set, it is an error to provide more packets than whatever this field specifies. + * + * Predicting the maximum packet count requires considering both the maximum duration as well as the codec. + * - For video codecs, you can assume one packet per frame. + * - For audio codecs, there is one packet for each "audio chunk", the duration of which depends on the codec. For + * simplicity, you can assume each packet is roughly 10 ms or 512 samples long, whichever is shorter. + * - For subtitles, assume each cue and each gap in the subtitles adds a packet. + * + * If you're not fully sure, make sure to add a buffer of around 33% to make sure you stay below the maximum. + */ + maximumPacketCount?: number; }; /** @@ -114,6 +129,12 @@ const validateBaseTrackMetadata = (metadata: BaseTrackMetadata) => { if (metadata.name !== undefined && typeof metadata.name !== 'string') { throw new TypeError('metadata.name, when provided, must be a string.'); } + if ( + metadata.maximumPacketCount !== undefined + && (!Number.isInteger(metadata.maximumPacketCount) || metadata.maximumPacketCount < 0) + ) { + throw new TypeError('metadata.maximumPacketCount, when provided, must be a non-negative integer.'); + } }; /**