diff --git a/dev/convert.html b/dev/convert.html index c94dbb9..9b5117e 100644 --- a/dev/convert.html +++ b/dev/convert.html @@ -91,13 +91,16 @@ //const startTime = await primaryTrack.getFirstTimestamp(); //console.log(startTime) - let ctx = null; + const canvas = new OffscreenCanvas(1280, 720); + const ctx = canvas.getContext('2d'); + + //let ctx = null; let conversion = await Mediabunny.Conversion.init({ input, output, audio: { - codec: 'aac', - forceTranscode: true, + //codec: 'aac', + //forceTranscode: true, //forceTranscode: true, //sampleFormat: 's16', }, @@ -126,7 +129,10 @@ }, */ video: { - discard: true, + process: (sample) => { + sample.draw(ctx, 0, 0, 1280, 720); + return new Mediabunny.VideoSample(canvas, { timestamp: sample.timestamp, duration: sample.duration }); + }, }, tags: {} ?? { title: 'Bigggy', @@ -146,8 +152,10 @@ } }, trim: { - start: 300.14984567374756 - 100, - end: 310.1548298151939 - 100, + start: -2, + end: 10, + //start: 300.14984567374756 - 100, + //end: 310.1548298151939 - 100, //end: 10, //start: startTime, //end: startTime + 2, diff --git a/dev/demux.html b/dev/demux.html index ac9fba2..580bc38 100644 --- a/dev/demux.html +++ b/dev/demux.html @@ -17,9 +17,17 @@ source: new Mediabunny.BlobSource(file), }); - const track = await input.getPrimaryAudioTrack(); + const track = await input.getPrimaryVideoTrack(); const packetSink = new Mediabunny.EncodedPacketSink(track); + const first = await packetSink.getFirstPacket({ verifyKeyPackets: true }); + const second = await packetSink.getNextPacket(first, { verifyKeyPackets: true }); + const third = await packetSink.getNextPacket(second, { verifyKeyPackets: true }); + const fourth = await packetSink.getNextPacket(third, { verifyKeyPackets: true }); + console.log(first, second, third, fourth); + + /* + for await (const packet of packetSink.packets()) { //console.log(packet); break; @@ -39,6 +47,7 @@ break; } } + */ /* const track = await input.getPrimaryAudioTrack(); diff --git a/docs/api-config.json b/docs/api-config.json index 453cd8e..376e45e 100644 --- a/docs/api-config.json +++ b/docs/api-config.json @@ -19,6 +19,7 @@ "Encoding": "Encoder configuration and encodability checks.", "Decoding": "Decoder configuration and decodability checks.", "Custom coders": "API for adding custom encoders and decoders.", + "Logging": "Control over what Mediabunny logs to the console.", "Miscellaneous": "Whatever's left.", "@mediabunny/server": "Adds full video/audio decoder and encoder support to Mediabunny running in server-side environments such as Node, Bun, or Deno.", diff --git a/docs/guide/extensions/server.md b/docs/guide/extensions/server.md index 177b5ea..3ed3663 100644 --- a/docs/guide/extensions/server.md +++ b/docs/guide/extensions/server.md @@ -275,6 +275,40 @@ return new VideoSample(pixels, { }); ``` +## Controlling logging + +Especially in command-line applications you usually don't want Mediabunny interfering with your stdout and stderr output. Mediabunny provides ways to control its console output: +```ts +import { Logging, LogLevel } from 'mediabunny'; + +// The default: Mediabunny can log errors, warnings, and information messages. +Logging.level = LogLevel.Info; + +// Only log warnings and errors. +Logging.level = LogLevel.Warnings; + +// Only log errors. +Logging.level = LogLevel.Errors; + +// Don't log anything at all. +Logging.level = LogLevel.Silent; +``` + +You can also hook into log events: +```ts +Logging.on('error', (args: unknown[]) => { + // Handle error message +}); + +Logging.on('warn', (args: unknown[]) => { + // Handle warning message +}); + +Logging.on('info', (args: unknown[]) => { + // Handle info message +}); +``` + ## Implementation details `@mediabunny/server` uses [NodeAV](https://github.com/seydx/node-av) under the hood which provides N-API C bindings to FFmpeg's C API. Using NodeAV, this package implements [custom decoders and encoders](https://mediabunny.dev/guide/supported-formats-and-codecs#custom-coders) by directly using the APIs provided by `libavcodec`. diff --git a/docs/guide/media-sinks.md b/docs/guide/media-sinks.md index aed2ce2..dc39292 100644 --- a/docs/guide/media-sinks.md +++ b/docs/guide/media-sinks.md @@ -225,6 +225,11 @@ Create the sink like so: import { VideoSampleSink } from 'mediabunny'; const sink = new VideoSampleSink(videoTrack); + +// Optionally, configure the decoder: +const sink = new VideoSampleSink(videoTrack, { + hardwareAcceleration: 'prefer-software', +}); ``` #### Single retrieval @@ -363,6 +368,8 @@ type CanvasSinkOptions = { rotation?: 0 | 90 | 180 | 270; crop?: { left: number; top: number; width: number; height: number }; poolSize?: number; + alpha?: boolean; + decoderOptions?: VideoSinkDecoderOptions; }; ``` - `width`\ @@ -380,6 +387,10 @@ type CanvasSinkOptions = { Specifies the rectangular region of the input video to crop to. The crop region will automatically be clamped to the dimensions of the input video track. Cropping is performed after rotation but before resizing. The crop region is in the _display pixel space_ of the underlying video data. - `poolSize`\ See [Canvas pool](#canvas-pool). +- `alpha`\ + Whether the output canvases should have transparency instead of a black background. Defaults to `false`. Set this to `true` when using this sink to read transparent videos. +- `decoderOptions`\ + Additional preferences for the underlying video decoder. Some examples: ```ts diff --git a/docs/guide/reading-hls.md b/docs/guide/reading-hls.md index b0ed735..ca45467 100644 --- a/docs/guide/reading-hls.md +++ b/docs/guide/reading-hls.md @@ -233,6 +233,35 @@ This makes cross-track synchronization trivial. To know if a track's timestamps await track.isRelativeToUnixEpoch(); // => boolean ``` +### Disabling Unix offsets + +If you don't want Mediabunny to offset packet timestamps to be in Unix time, you can set `offsetTimestampsByDateTime` to `false` in the input format options: +```ts +const input = new Input({ + // ... + formatOptions: { + hls: { + offsetTimestampsByDateTime: false, + }, + }, +}); +``` + +This way, track and packet timestamps behave as if no `#EXT-X-PROGRAM-DATE-TIME` tags existed. This also means that any date time gaps are completely collapsed. + +You will still be able to query the Unix time metadata via a mapping function on the `InputTrack`: +```ts +const firstTimestamp = await inputTrack.getFirstTimestamp(); // => 0 +await inputTrack.getUnixTimeForTimestamp(firstTimestamp); // => 1704067200 (Unix timestamp for 2024-01-01T00:00:00Z) +``` + +This function performs a piecewise-continuous mapping of timestamp space into Unix time space. + +If no wall-clock time information is available, `getUnixTimeForTimestamp()` will return `null`. You can check the presence of Unix time metadata via: +```ts +await inputTrack.hasUnixTimeMapping(); // boolean +``` + ## Live HLS HLS playlists may be live. You can check that a track is live via: diff --git a/package-lock.json b/package-lock.json index d4080c7..4fa0b13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mediabunny", - "version": "1.46.0", + "version": "1.49.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mediabunny", - "version": "1.46.0", + "version": "1.49.0", "license": "MPL-2.0", "workspaces": [ ".", @@ -12864,7 +12864,7 @@ }, "packages/aac-encoder": { "name": "@mediabunny/aac-encoder", - "version": "1.46.0", + "version": "1.49.0", "license": "MPL-2.0", "devDependencies": { "@types/emscripten": "^1.40.1" @@ -12879,7 +12879,7 @@ }, "packages/ac3": { "name": "@mediabunny/ac3", - "version": "1.46.0", + "version": "1.49.0", "license": "MPL-2.0", "devDependencies": { "@types/emscripten": "^1.40.1" @@ -12894,7 +12894,7 @@ }, "packages/flac-encoder": { "name": "@mediabunny/flac-encoder", - "version": "1.46.0", + "version": "1.49.0", "license": "MPL-2.0", "devDependencies": { "@types/emscripten": "^1.40.1" @@ -12909,7 +12909,7 @@ }, "packages/mp3-encoder": { "name": "@mediabunny/mp3-encoder", - "version": "1.46.0", + "version": "1.49.0", "license": "MPL-2.0", "devDependencies": { "@types/emscripten": "^1.40.1" @@ -12924,7 +12924,7 @@ }, "packages/server": { "name": "@mediabunny/server", - "version": "1.46.0", + "version": "1.49.0", "license": "MPL-2.0", "dependencies": { "node-av": "^6.0.0" diff --git a/package.json b/package.json index b5dd1fe..8e96992 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mediabunny", "author": "Vanilagy", - "version": "1.46.0", + "version": "1.49.0", "description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.", "type": "module", "workspaces": [ diff --git a/packages/aac-encoder/package.json b/packages/aac-encoder/package.json index 78888e0..8e55443 100644 --- a/packages/aac-encoder/package.json +++ b/packages/aac-encoder/package.json @@ -1,7 +1,7 @@ { "name": "@mediabunny/aac-encoder", "author": "Vanilagy", - "version": "1.46.0", + "version": "1.49.0", "description": "AAC encoder extension for Mediabunny, based on FFmpeg.", "main": "./dist/bundles/mediabunny-aac-encoder.mjs", "module": "./dist/bundles/mediabunny-aac-encoder.mjs", diff --git a/packages/aac-encoder/src/index.ts b/packages/aac-encoder/src/index.ts index d0bed47..f1c0d5b 100644 --- a/packages/aac-encoder/src/index.ts +++ b/packages/aac-encoder/src/index.ts @@ -6,9 +6,11 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { Logging } from 'mediabunny'; + const AAC_ENCODER_LOADED_SYMBOL = Symbol.for('@mediabunny/aac-encoder loaded'); if ((globalThis as Record)[AAC_ENCODER_LOADED_SYMBOL]) { - console.error( + Logging._error( '[WARNING]\n@mediabunny/aac-encoder was loaded twice.' + ' This will likely cause the encoder not to work correctly.' + ' Check if multiple dependencies are importing different versions of @mediabunny/aac-encoder,' diff --git a/packages/ac3/package.json b/packages/ac3/package.json index 1a16c9a..12976ed 100644 --- a/packages/ac3/package.json +++ b/packages/ac3/package.json @@ -1,7 +1,7 @@ { "name": "@mediabunny/ac3", "author": "Vanilagy", - "version": "1.46.0", + "version": "1.49.0", "description": "AC-3 and E-AC-3 (Dolby Digital) decoder and encoder extension for Mediabunny, based on FFmpeg.", "main": "./dist/bundles/mediabunny-ac3.mjs", "module": "./dist/bundles/mediabunny-ac3.mjs", diff --git a/packages/ac3/src/index.ts b/packages/ac3/src/index.ts index 79ebf7f..cc136af 100644 --- a/packages/ac3/src/index.ts +++ b/packages/ac3/src/index.ts @@ -6,9 +6,11 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { Logging } from 'mediabunny'; + const AC3_LOADED_SYMBOL = Symbol.for('@mediabunny/ac3 loaded'); if ((globalThis as Record)[AC3_LOADED_SYMBOL]) { - console.error( + Logging._error( '[WARNING]\n@mediabunny/ac3 was loaded twice.' + ' This will likely cause the encoder/decoder not to work correctly.' + ' Check if multiple dependencies are importing different versions of @mediabunny/ac3,' diff --git a/packages/flac-encoder/package.json b/packages/flac-encoder/package.json index d19a759..9209a30 100644 --- a/packages/flac-encoder/package.json +++ b/packages/flac-encoder/package.json @@ -1,7 +1,7 @@ { "name": "@mediabunny/flac-encoder", "author": "Vanilagy", - "version": "1.46.0", + "version": "1.49.0", "description": "FLAC encoder extension for Mediabunny, based on libFLAC.", "main": "./dist/bundles/mediabunny-flac-encoder.mjs", "module": "./dist/bundles/mediabunny-flac-encoder.mjs", diff --git a/packages/flac-encoder/src/index.ts b/packages/flac-encoder/src/index.ts index 3a86463..0e3e97e 100644 --- a/packages/flac-encoder/src/index.ts +++ b/packages/flac-encoder/src/index.ts @@ -6,9 +6,11 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { Logging } from 'mediabunny'; + const FLAC_ENCODER_LOADED_SYMBOL = Symbol.for('@mediabunny/flac-encoder loaded'); if ((globalThis as Record)[FLAC_ENCODER_LOADED_SYMBOL]) { - console.error( + Logging._error( '[WARNING]\n@mediabunny/flac-encoder was loaded twice.' + ' This will likely cause the encoder not to work correctly.' + ' Check if multiple dependencies are importing different versions of @mediabunny/flac-encoder,' diff --git a/packages/mp3-encoder/package.json b/packages/mp3-encoder/package.json index ceb9e4c..1b44147 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.46.0", + "version": "1.49.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/packages/mp3-encoder/src/index.ts b/packages/mp3-encoder/src/index.ts index fdea91a..50023b2 100644 --- a/packages/mp3-encoder/src/index.ts +++ b/packages/mp3-encoder/src/index.ts @@ -6,7 +6,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { CustomAudioEncoder, AudioCodec, AudioSample, EncodedPacket, registerEncoder } from 'mediabunny'; +import { CustomAudioEncoder, AudioCodec, AudioSample, EncodedPacket, Logging, registerEncoder } from 'mediabunny'; import { MP3_FRAME_HEADER_SIZE, readMp3FrameHeader, SAMPLING_RATES } from '../../../shared/mp3-misc'; import type { WorkerCommand, WorkerResponse, WorkerResponseData } from './shared'; // @ts-expect-error An esbuild plugin handles this, TypeScript doesn't need to understand @@ -14,7 +14,7 @@ import createWorker from './encode.worker'; const MP3_ENCODER_LOADED_SYMBOL = Symbol.for('@mediabunny/mp3-encoder loaded'); if ((globalThis as Record)[MP3_ENCODER_LOADED_SYMBOL]) { - console.error( + Logging._error( '[WARNING]\n@mediabunny/mp3-encoder was loaded twice.' + ' This will likely cause the encoder not to work correctly.' + ' Check if multiple dependencies are importing different versions of @mediabunny/mp3-encoder,' diff --git a/packages/server/README.md b/packages/server/README.md index 99e7c20..9d9d3f3 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -279,6 +279,40 @@ return new VideoSample(pixels, { }); ``` +## Controlling logging + +Especially in command-line applications you usually don't want Mediabunny interfering with your stdout and stderr output. Mediabunny provides ways to control its console output: +```ts +import { Logging, LogLevel } from 'mediabunny'; + +// The default: Mediabunny can log errors, warnings, and information messages. +Logging.level = LogLevel.Info; + +// Only log warnings and errors. +Logging.level = LogLevel.Warnings; + +// Only log errors. +Logging.level = LogLevel.Errors; + +// Don't log anything at all. +Logging.level = LogLevel.None; +``` + +You can also hook into log events: +```ts +Logging.on('error', (args: unknown[]) => { + // Handle error message +}); + +Logging.on('warn', (args: unknown[]) => { + // Handle warning message +}); + +Logging.on('info', (args: unknown[]) => { + // Handle info message +}); +``` + ## Implementation details `@mediabunny/server` uses [NodeAV](https://github.com/seydx/node-av) under the hood which provides N-API C bindings to FFmpeg's C API. Using NodeAV, this package implements [custom decoders and encoders](https://mediabunny.dev/guide/supported-formats-and-codecs#custom-coders) by directly using the APIs provided by `libavcodec`. diff --git a/packages/server/package.json b/packages/server/package.json index 6f5ae85..5f8eb5f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "name": "@mediabunny/server", "author": "Vanilagy", - "version": "1.46.0", + "version": "1.49.0", "description": "Adds full video and audio decoder and encoder support to Mediabunny for use in server-side environments (Node, Bun, Deno). Based on NodeAV.", "main": "./dist/bundles/mediabunny-server.cjs", "module": "./dist/bundles/mediabunny-server.mjs", diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 890bbd9..818b1dd 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -8,6 +8,7 @@ import { AudioSample, + Logging, MaybePromise, registerDecoder, registerEncoder, @@ -24,7 +25,7 @@ import { copyAudioSampleToAvFrame, AvFrameAudioSampleResource } from './audio-sa const SERVER_LOADED_SYMBOL = Symbol.for('@mediabunny/server loaded'); if ((globalThis as Record)[SERVER_LOADED_SYMBOL]) { - console.error( + Logging._error( '[WARNING]\n@mediabunny/server was loaded twice.' + ' This will likely cause the package not to work correctly.' + ' Check if multiple dependencies are importing different versions of @mediabunny/server,' diff --git a/scripts/check-docblocks.ts b/scripts/check-docblocks.ts index 71daa3a..3216afd 100644 --- a/scripts/check-docblocks.ts +++ b/scripts/check-docblocks.ts @@ -23,6 +23,7 @@ const checkDocblocks = (filePath: string) => { || ts.isFunctionDeclaration(node) || ts.isTypeAliasDeclaration(node) || ts.isEnumDeclaration(node) + || ts.isEnumMember(node) || ts.isPropertySignature(node) || ts.isMethodSignature(node) || ts.isVariableStatement(node) diff --git a/scripts/generate-api-docs.ts b/scripts/generate-api-docs.ts index a0f4347..67cad16 100644 --- a/scripts/generate-api-docs.ts +++ b/scripts/generate-api-docs.ts @@ -683,7 +683,7 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) } // Check if it's a supported type - if (ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration) || ts.isTypeAliasDeclaration(declaration) || ts.isVariableDeclaration(declaration)) { + if (ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration) || ts.isTypeAliasDeclaration(declaration) || ts.isVariableDeclaration(declaration) || ts.isEnumDeclaration(declaration)) { // Supported types - continue processing } else { // Unsupported type - throw error with type info @@ -777,6 +777,43 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) return; } + // Handle enum declarations separately + if (ts.isEnumDeclaration(declaration)) { + const enumName = declaration.name.text; + + const description = extractJsDocDescription(declaration, { + tagHandling: 'filterAll', + transform: text => processLinkTags(text, enumName), + }); + + const order = symbolOrderMap.get(enumName); + if (order === undefined) { + throw new Error(`Symbol '${enumName}' not found in entry files export order`); + } + indexEntries.push({ name: enumName, type: 'Enum', group: groupName, order }); + + // Reconstruct the enum body so each member keeps its value and description + const memberLines = declaration.members.map((member) => { + const memberName = member.name.getText(); + const initializer = member.initializer ? ` = ${member.initializer.getText()}` : ''; + const memberDescription = extractJsDocDescription(member, { + tagHandling: 'filterAll', + transform: text => processLinkTags(text, enumName), + }); + const commentLine = memberDescription ? `\t/** ${memberDescription} */\n` : ''; + return `${commentLine}\t${memberName}${initializer},`; + }); + + const enumDefinition = `enum ${enumName} {\n${memberLines.join('\n')}\n}`; + const deprecationNotice = getDeprecationNotice(declaration, enumName); + let markdown = `${buildFrontmatter(description)}\n\n\n\n# ${enumName}\n\n${deprecationNotice}${description ? `${description}\n\n` : ''}`; + markdown += `\`\`\`ts\n${enumDefinition}\n\`\`\``; + + generatedDocs.set(enumName, markdown); + + return; + } + { const className = declaration.name.text; const isAbstract = ts.isClassDeclaration(declaration) && declaration.modifiers?.some(mod => mod.kind === ts.SyntaxKind.AbstractKeyword); diff --git a/src/adts/adts-demuxer.ts b/src/adts/adts-demuxer.ts index 5c78489..d39427f 100644 --- a/src/adts/adts-demuxer.ts +++ b/src/adts/adts-demuxer.ts @@ -215,6 +215,10 @@ class AdtsAudioTrackBacking implements InputAudioTrackBacking { return false; } + getUnixTimeForTimestamp() { + return null; + } + getPairingMask() { return 1n; } diff --git a/src/codec-data.ts b/src/codec-data.ts index a93d036..4a65df5 100644 --- a/src/codec-data.ts +++ b/src/codec-data.ts @@ -26,6 +26,7 @@ import { isChromium, setUint24, } from './misc'; +import { Logging } from './logging'; import { PacketType } from './packet'; import { MetadataTags } from './metadata'; import { AC3_SAMPLE_RATES, EAC3_REDUCED_SAMPLE_RATES } from '../shared/ac3-misc'; @@ -318,7 +319,7 @@ export const extractAvcDecoderConfigurationRecord = (packetData: Uint8Array): Av sequenceParameterSetExt: hasExtendedData ? spsExtUnits : null, }; } catch (error) { - console.error('Error building AVC Decoder Configuration Record:', error); + Logging._error('Error building AVC Decoder Configuration Record:', error); return null; } }; @@ -478,7 +479,7 @@ export const deserializeAvcDecoderConfigurationRecord = (data: Uint8Array): AvcD return record; } catch (error) { - console.error('Error deserializing AVC Decoder Configuration Record:', error); + Logging._error('Error deserializing AVC Decoder Configuration Record:', error); return null; } }; @@ -795,7 +796,7 @@ export const parseAvcSps = (sps: Uint8Array): AvcSpsInfo | null => { maxDecFrameBuffering, }; } catch (error) { - console.error('Error parsing AVC SPS:', error); + Logging._error('Error parsing AVC SPS:', error); return null; } }; @@ -1044,7 +1045,7 @@ export const parseHevcSps = (sps: Uint8Array): HevcSpsInfo | null => { minSpatialSegmentationIdc, }; } catch (error) { - console.error('Error parsing HEVC SPS:', error); + Logging._error('Error parsing HEVC SPS:', error); return null; } }; @@ -1176,7 +1177,7 @@ export const extractHevcDecoderConfigurationRecord = (packetData: Uint8Array) => return record; } catch (error) { - console.error('Error building HEVC Decoder Configuration Record:', error); + Logging._error('Error building HEVC Decoder Configuration Record:', error); return null; } }; @@ -1612,7 +1613,7 @@ export const deserializeHevcDecoderConfigurationRecord = (data: Uint8Array): Hev arrays, }; } catch (error) { - console.error('Error deserializing HEVC Decoder Configuration Record:', error); + Logging._error('Error deserializing HEVC Decoder Configuration Record:', error); return null; } }; diff --git a/src/conversion.ts b/src/conversion.ts index f7106fe..6f1dc01 100644 --- a/src/conversion.ts +++ b/src/conversion.ts @@ -14,6 +14,7 @@ import { VideoCodec, } from './codec'; import { + AudioEncodingConfig, getEncodableAudioCodecs, getFirstEncodableVideoCodec, Quality, @@ -22,6 +23,7 @@ import { } from './encode'; import { Input } from './input'; import { InputAudioTrack, InputTrack, InputVideoTrack } from './input-track'; +import { Logging } from './logging'; import { AudioSampleSink, EncodedPacketSink, @@ -50,17 +52,14 @@ import { Output, OutputTrackGroup, TrackType } from './output'; import { Mp4OutputFormat } from './output-format'; import { AudioSample, - audioSampleToInterleavedFormat, clampCropRectangle, CropRectangle, - toInterleavedAudioFormat, validateCropRectangle, VideoSample, VideoSampleResource, } from './sample'; import { MetadataTags, validateMetadataTags } from './metadata'; import { NullTarget } from './target'; -import { AudioResampler } from './resample'; /** * The options for media file conversion. @@ -960,7 +959,7 @@ export class Conversion { } if (warnElements.length > 0) { - console.warn(...warnElements); + Logging._warn(...warnElements); } } } @@ -1124,7 +1123,7 @@ export class Conversion { } if (this._canceled) { - console.warn('Conversion already canceled.'); + Logging._warn('Conversion already canceled.'); return; } @@ -1329,7 +1328,7 @@ export class Conversion { firstSample.close(); await tempOutput.finalize(); } catch (error) { - console.info('Error when probing encoder support. Falling back to rerender path.', error); + Logging._info('Error when probing encoder support. Falling back to rerender path.', error); needsRerender = true; void tempOutput.cancel(); } @@ -1342,6 +1341,10 @@ export class Conversion { encodingConfig.transform.frameRate = trackOptions.frameRate; } + if (trackOptions.process) { + encodingConfig.transform.process = trackOptions.process; + } + if (needsRerender) { outputTrackRotation = 0; // Since the rotation is baked into the output @@ -1353,6 +1356,12 @@ export class Conversion { encodingConfig.transform.alpha = alpha; } + // We need to do this because `process` can emit new timestamps + let lastSampleTimestamp: number | null = null; + encodingConfig.onEncodedSample = (sample) => { + lastSampleTimestamp = sample.timestamp; + }; + const source = new VideoSampleSource(encodingConfig); videoSource = source; @@ -1370,7 +1379,14 @@ export class Conversion { const adjustedSampleTimestamp = Math.max(sample.timestamp - this._startTimestamp, 0); sample.setTimestamp(adjustedSampleTimestamp); - await this._registerVideoSample(trackOptions, outputTrackId, source, sample); + this._reportProgress(outputTrackId, sample.timestamp + sample.duration); + await source.add(sample); + + if (lastSampleTimestamp !== null) { + if (this._synchronizer.shouldWait(outputTrackId, lastSampleTimestamp)) { + await this._synchronizer.wait(lastSampleTimestamp); + } + } sample.close(); } @@ -1403,69 +1419,6 @@ export class Conversion { this._outputOwnTrackGroups.push(ownGroup); } - /** @internal */ - async _registerVideoSample( - trackOptions: ConversionVideoOptions, - outputTrackId: number, - source: VideoSampleSource, - sample: VideoSample, - ) { - if (this._canceled) { - return; - } - - this._reportProgress(outputTrackId, sample.timestamp + sample.duration); - - let finalSamples: VideoSample[]; - if (!trackOptions.process) { - finalSamples = [sample]; - } else { - let processed = trackOptions.process(sample); - if (processed instanceof Promise) processed = await processed; - - if (!Array.isArray(processed)) { - processed = processed === null ? [] : [processed]; - } - - finalSamples = processed.map((x) => { - if (x instanceof VideoSample) { - return x; - } - - if (typeof VideoFrame !== 'undefined' && x instanceof VideoFrame) { - return new VideoSample(x); - } - - // Calling the VideoSample constructor here will automatically handle input validation for us - // (it throws for any non-legal argument). - return new VideoSample(x as CanvasImageSource, { - timestamp: sample.timestamp, - duration: sample.duration, - }); - }); - } - - try { - for (const finalSample of finalSamples) { - if (this._canceled) { - break; - } - - await source.add(finalSample); - - if (this._synchronizer.shouldWait(outputTrackId, finalSample.timestamp)) { - await this._synchronizer.wait(finalSample.timestamp); - } - } - } finally { - for (const finalSample of finalSamples) { - if (finalSample !== sample) { - finalSample.close(); - } - } - } - } - /** @internal */ async _processAudioTrack(track: InputAudioTrack, trackOptions: ConversionAudioOptions, outputTrackId: number) { const sourceCodec = await track.getCodec(); @@ -1487,16 +1440,18 @@ export class Conversion { let numberOfChannels = trackOptions.numberOfChannels ?? originalNumberOfChannels; let sampleRate = trackOptions.sampleRate ?? originalSampleRate; - let needsResample = numberOfChannels !== originalNumberOfChannels - || sampleRate !== originalSampleRate - || firstTimestamp < this._startTimestamp - || (firstTimestamp > this._startTimestamp && !this.output.format.supportsTimestampedMediaData); + + const needsTrimming = firstTimestamp < this._startTimestamp; + const needsPadding = firstTimestamp > this._startTimestamp && !this.output.format.supportsTimestampedMediaData; let audioCodecs = this.output.format.getSupportedAudioCodecs(); if ( !trackOptions.forceTranscode && !trackOptions.bitrate - && !needsResample + && numberOfChannels === originalNumberOfChannels + && sampleRate === originalSampleRate + && !needsTrimming + && !needsPadding && audioCodecs.includes(sourceCodec) && (!trackOptions.codec || trackOptions.codec === sourceCodec) && !trackOptions.process @@ -1589,7 +1544,6 @@ export class Conversion { .find(codec => (NON_PCM_AUDIO_CODECS as readonly string[]).includes(codec)); if (nonPcmCodec) { // We are able to encode using a non-PCM codec, but it'll require resampling - needsResample = true; codecOfChoice = nonPcmCodec; numberOfChannels = FALLBACK_NUMBER_OF_CHANNELS; sampleRate = FALLBACK_SAMPLE_RATE; @@ -1607,44 +1561,86 @@ export class Conversion { return; } - if (needsResample) { - audioSource = this._resampleAudio( - track, - trackOptions, - outputTrackId, - codecOfChoice, - numberOfChannels, - sampleRate, - bitrate, - ); - } else { - const source = new AudioSampleSource({ - codec: codecOfChoice, - bitrate, - }); - audioSource = source; + const encodingConfig: AudioEncodingConfig = { + codec: codecOfChoice, + bitrate, + transform: { + sampleFormat: trackOptions.sampleFormat, + process: trackOptions.process, + }, + }; + assert(encodingConfig.transform); - this._trackPromises.push((async () => { - await this._started; + if (numberOfChannels !== originalNumberOfChannels) { + encodingConfig.transform.numberOfChannels = numberOfChannels; + } + if (sampleRate !== originalSampleRate) { + encodingConfig.transform.sampleRate = sampleRate; + } - const sink = new AudioSampleSink(track); - for await (const sample of sink.samples(undefined, this._endTimestamp)) { - if (this._canceled) { - sample.close(); - return; - } + let lastSampleTimestamp: number | null = null; + encodingConfig.onEncodedSample = (sample) => { + lastSampleTimestamp = sample.timestamp; + }; - // Offset the timestamp as needed - sample.setTimestamp(sample.timestamp - this._startTimestamp); + const source = new AudioSampleSource(encodingConfig); + audioSource = source; - await this._registerAudioSample(trackOptions, outputTrackId, source, sample); + this._trackPromises.push((async () => { + await this._started; + + if (needsPadding) { + const paddingLength = firstTimestamp - this._startTimestamp; + const paddingLengthSamples = Math.round(paddingLength * originalSampleRate); + + const silentSample = new AudioSample({ + data: new Float32Array(paddingLengthSamples * originalNumberOfChannels), + format: 'f32-planar', + numberOfChannels: originalNumberOfChannels, + sampleRate: originalSampleRate, + timestamp: 0, + }); + await this._registerAudioSample(silentSample, source, outputTrackId, () => lastSampleTimestamp); + } + + const sink = new AudioSampleSink(track); + for await (let sample of sink.samples(this._startTimestamp, this._endTimestamp)) { + if (this._canceled) { sample.close(); + return; } - source.close(); - this._synchronizer.closeTrack(outputTrackId); - })()); - } + let startFrame = 0; + let endFrame = sample.numberOfFrames; + + if (sample.timestamp < this._startTimestamp) { + startFrame = Math.round((this._startTimestamp - sample.timestamp) * sample.sampleRate); + } + if (sample.timestamp + sample.duration > this._endTimestamp) { + endFrame = Math.round((this._endTimestamp - sample.timestamp) * sample.sampleRate); + } + + if (startFrame > 0 || endFrame < sample.numberOfFrames) { + // Trim the sample if it sticks out of the trim region on either end + const trimmedSample = sample.trim(startFrame, endFrame); + sample.close(); + sample = trimmedSample; + + if (sample.numberOfFrames === 0) { + sample.close(); + continue; + } + } + + // Offset the timestamp as needed + sample.setTimestamp(sample.timestamp - this._startTimestamp); + + await this._registerAudioSample(sample, source, outputTrackId, () => lastSampleTimestamp); + } + + source.close(); + this._synchronizer.closeTrack(outputTrackId); + })()); } let ownGroup: OutputTrackGroup | null = null; @@ -1670,126 +1666,24 @@ export class Conversion { /** @internal */ async _registerAudioSample( - trackOptions: ConversionAudioOptions, - outputTrackId: number, + sample: AudioSample, source: AudioSampleSource, - inputSample: AudioSample, + outputTrackId: number, + getLastSampleTimestamp: () => number | null, ) { - if (this._canceled) { - return; - } - - let sample = inputSample; - - if ( - trackOptions.sampleFormat !== undefined - && toInterleavedAudioFormat(sample.format) !== trackOptions.sampleFormat - ) { - // Do a sample format conversion - sample = audioSampleToInterleavedFormat(sample, trackOptions.sampleFormat); - } - this._reportProgress(outputTrackId, sample.timestamp + sample.duration); - let finalSamples: AudioSample[]; - if (!trackOptions.process) { - finalSamples = [sample]; - } else { - let processed = trackOptions.process(sample); - if (processed instanceof Promise) processed = await processed; + await source.add(sample); + sample.close(); - if (!Array.isArray(processed)) { - processed = processed === null ? [] : [processed]; - } - - if (!processed.every(x => x instanceof AudioSample)) { - throw new TypeError( - 'The audio process function must return an AudioSample, null, or an array of AudioSamples.', - ); - } - - finalSamples = processed; - } - - try { - for (const finalSample of finalSamples) { - if (this._canceled) { - break; - } - - await source.add(finalSample); - - if (this._synchronizer.shouldWait(outputTrackId, finalSample.timestamp)) { - await this._synchronizer.wait(finalSample.timestamp); - } - } - } finally { - if (sample !== inputSample) { - sample.close(); - } - - for (const finalSample of finalSamples) { - if (finalSample !== inputSample) { - finalSample.close(); - } + const lastSampleTimestamp = getLastSampleTimestamp(); + if (lastSampleTimestamp !== null) { + if (this._synchronizer.shouldWait(outputTrackId, lastSampleTimestamp)) { + await this._synchronizer.wait(lastSampleTimestamp); } } } - /** @internal */ - _resampleAudio( - track: InputAudioTrack, - trackOptions: ConversionAudioOptions, - outputTrackId: number, - codec: AudioCodec, - targetNumberOfChannels: number, - targetSampleRate: number, - bitrate: number | Quality, - ) { - const source = new AudioSampleSource({ - codec, - bitrate, - }); - - this._trackPromises.push((async () => { - await this._started; - - const resampler = new AudioResampler({ - targetNumberOfChannels, - targetSampleRate, - startTime: this._startTimestamp, - endTime: this._endTimestamp, - onSample: async (sample) => { - assert(sample.timestamp >= this._startTimestamp); - sample.setTimestamp(sample.timestamp - this._startTimestamp); - - await this._registerAudioSample(trackOptions, outputTrackId, source, sample); - sample.close(); - }, - }); - - const sink = new AudioSampleSink(track); - const iterator = sink.samples(this._startTimestamp, this._endTimestamp); - - for await (const sample of iterator) { - if (this._canceled) { - sample.close(); - return; - } - - await resampler.add(sample); - sample.close(); - } - - await resampler.finalize(); - - source.close(); - this._synchronizer.closeTrack(outputTrackId); - })()); - - return source; - } - /** @internal */ _reportProgress(trackId: number, endTimestamp: number) { if (!this._computeProgress) { diff --git a/src/custom-coder.ts b/src/custom-coder.ts index 3661d3a..0152876 100644 --- a/src/custom-coder.ts +++ b/src/custom-coder.ts @@ -9,6 +9,7 @@ import { AudioCodec, VideoCodec } from './codec'; import { canDecodeAudioMemo, canDecodeVideoMemo } from './decode'; import { canEncodeAudioMemo, canEncodeVideoMemo } from './encode'; +import { Logging } from './logging'; import { MaybePromise } from './misc'; import { EncodedPacket } from './packet'; import { AudioSample, VideoSample } from './sample'; @@ -149,7 +150,7 @@ export const registerDecoder = (decoder: typeof CustomVideoDecoder | typeof Cust const casted = decoder as typeof CustomVideoDecoder; if (customVideoDecoders.includes(casted)) { - console.warn('Video decoder already registered.'); + Logging._warn('Video decoder already registered.'); return; } @@ -159,7 +160,7 @@ export const registerDecoder = (decoder: typeof CustomVideoDecoder | typeof Cust const casted = decoder as typeof CustomAudioDecoder; if (customAudioDecoders.includes(casted)) { - console.warn('Audio decoder already registered.'); + Logging._warn('Audio decoder already registered.'); return; } @@ -181,7 +182,7 @@ export const registerEncoder = (encoder: typeof CustomVideoEncoder | typeof Cust const casted = encoder as typeof CustomVideoEncoder; if (customVideoEncoders.includes(casted)) { - console.warn('Video encoder already registered.'); + Logging._warn('Video encoder already registered.'); return; } @@ -191,7 +192,7 @@ export const registerEncoder = (encoder: typeof CustomVideoEncoder | typeof Cust const casted = encoder as typeof CustomAudioEncoder; if (customAudioEncoders.includes(casted)) { - console.warn('Audio encoder already registered.'); + Logging._warn('Audio encoder already registered.'); return; } diff --git a/src/encode.ts b/src/encode.ts index 3565da1..4f52495 100644 --- a/src/encode.ts +++ b/src/encode.ts @@ -74,6 +74,8 @@ export type VideoEncodingConfig = { * WebCodecs API, is created. */ onEncoderConfig?: (config: VideoEncoderConfig) => unknown; + /** Called right before a sample is passed to the encoder. */ + onEncodedSample?: (sample: VideoSample) => unknown; } & VideoEncodingAdditionalOptions; /** @@ -232,6 +234,9 @@ export const validateVideoEncodingConfig = (config: VideoEncodingConfig) => { if (config.onEncoderConfig !== undefined && typeof config.onEncoderConfig !== 'function') { throw new TypeError('config.onEncoderConfig, when provided, must be a function.'); } + if (config.onEncodedSample !== undefined && typeof config.onEncodedSample !== 'function') { + throw new TypeError('config.onEncodedSample, when provided, must be a function.'); + } validateVideoEncodingAdditionalOptions(config.codec, config); }; @@ -382,6 +387,8 @@ export type AudioEncodingConfig = { * WebCodecs API, is created. */ onEncoderConfig?: (config: AudioEncoderConfig) => unknown; + /** Called right before a sample is passed to the encoder. */ + onEncodedSample?: (sample: AudioSample) => unknown; } & AudioEncodingAdditionalOptions; /** @@ -462,6 +469,9 @@ export const validateAudioEncodingConfig = (config: AudioEncodingConfig) => { if (config.onEncoderConfig !== undefined && typeof config.onEncoderConfig !== 'function') { throw new TypeError('config.onEncoderConfig, when provided, must be a function.'); } + if (config.onEncodedSample !== undefined && typeof config.onEncodedSample !== 'function') { + throw new TypeError('config.onEncodedSample, when provided, must be a function.'); + } validateAudioEncodingAdditionalOptions(config.codec, config); }; diff --git a/src/flac/flac-demuxer.ts b/src/flac/flac-demuxer.ts index c0e3576..47887fa 100644 --- a/src/flac/flac-demuxer.ts +++ b/src/flac/flac-demuxer.ts @@ -37,6 +37,7 @@ import { getSampleRateOrUncommon, } from './flac-misc'; import { Bitstream } from '../../shared/bitstream'; +import { ID3_V2_HEADER_SIZE, parseId3V2Tag, readId3V2Header } from '../id3'; type FlacAudioInfo = { numberOfChannels: number; @@ -102,9 +103,34 @@ export class FlacDemuxer extends Demuxer { } async readMetadata() { - let currentPos = 4; // Skip 'fLaC' - return (this.metadataPromise ??= (async () => { + // Read all ID3v2 tags at the start of the file + let currentPos = 0; + while (true) { + let headerSlice = this.reader.requestSlice(currentPos, ID3_V2_HEADER_SIZE); + if (headerSlice instanceof Promise) headerSlice = await headerSlice; + + if (!headerSlice) { + this.lastSampleLoaded = true; + return; + } + + const id3V2Header = readId3V2Header(headerSlice); + if (!id3V2Header) { + break; + } + + let contentSlice = this.reader.requestSlice(headerSlice.filePos, id3V2Header.size); + if (contentSlice instanceof Promise) contentSlice = await contentSlice; + assert(contentSlice); + + parseId3V2Tag(contentSlice, id3V2Header, this.metadataTags); + + currentPos = headerSlice.filePos + id3V2Header.size; + } + + currentPos += 4; // Skip 'fLaC' + while ( this.reader.fileSize === null || currentPos < this.reader.fileSize @@ -595,6 +621,10 @@ class FlacAudioTrackBacking implements InputAudioTrackBacking { return false; } + getUnixTimeForTimestamp() { + return null; + } + getPairingMask() { return 1n; } diff --git a/src/hls/hls-demuxer.ts b/src/hls/hls-demuxer.ts index 057c7da..d0748bb 100644 --- a/src/hls/hls-demuxer.ts +++ b/src/hls/hls-demuxer.ts @@ -77,12 +77,15 @@ export class HlsDemuxer extends Demuxer { readMetadata() { return this.metadataPromise ??= (async () => { assert(this.input._rootSource instanceof PathedSource); - const { rootPath } = this.input._rootSource; const slice = await this.input._reader.requestEntireFile(); assert(slice); const lines = readAllLines(slice, slice.length, { ignore: canIgnoreLine }); + // Important: get the root path AFTER reading data to get the final root path, possibly affected by + // redirects. Any follow requests should be related to the redirected path, not the original one. + const { rootPath } = this.input._rootSource; + const variantStreams: { fullPath: string; attributes: AttributeList; @@ -756,6 +759,10 @@ abstract class HlsInputTrackBacking implements InputTrackBacking { return this.delegate(() => this.internalTrack.backingTrack!.isRelativeToUnixEpoch()); } + getUnixTimeForTimestamp(timestamp: number): MaybePromise { + return this.delegate(() => this.internalTrack.backingTrack!.getUnixTimeForTimestamp(timestamp)); + } + getBitrate(): number | null { return this.internalTrack.peakBitrate; } diff --git a/src/hls/hls-muxer.ts b/src/hls/hls-muxer.ts index fb2e39e..5f6baec 100644 --- a/src/hls/hls-muxer.ts +++ b/src/hls/hls-muxer.ts @@ -7,6 +7,7 @@ */ import { MediaCodec, validateAudioChunkMetadata, validateVideoChunkMetadata } from '../codec'; +import { Logging } from '../logging'; import { EncodedAudioPacketSource, EncodedVideoPacketSource } from '../media-source'; import { arrayArgmax, @@ -197,7 +198,7 @@ export class HlsMuxer extends Muxer { if (track.type === otherTrack.type) { if (!illegalPairingDetected) { - console.warn( + Logging._warn( `Illegal pairing of two ${track.type} tracks detected, which is not possible in HLS;` + ` treating them as unpaired.`, ); @@ -213,7 +214,7 @@ export class HlsMuxer extends Muxer { || (otherTrack.isVideoTrack() && otherTrack.metadata.hasOnlyKeyPackets) ) { if (!keyPacketsOnlyPairingWarned) { - console.warn( + Logging._warn( `A key-packets-only video track is pairable with another track, which is not` + ` possible in HLS; treating them as unpaired.`, ); @@ -1370,7 +1371,7 @@ export class HlsMuxer extends Muxer { masterPlaylistText += `#EXT-X-MEDIA:TYPE=${type.toUpperCase()},GROUP-ID="${decl.groupId}"`; if (name !== null && /[\n\r"]/.test(name)) { - console.warn( + Logging._warn( 'Dropping track name since it includes a line feed, carriage return, or double quote' + ' character, which are not allowed in HLS playlist attributes.', ); diff --git a/src/hls/hls-segmented-input.ts b/src/hls/hls-segmented-input.ts index 4815652..d530bdc 100644 --- a/src/hls/hls-segmented-input.ts +++ b/src/hls/hls-segmented-input.ts @@ -20,7 +20,7 @@ import { base64ToBytes, } from '../misc'; import { readAllLines, readBytes, Reader } from '../reader'; -import { CustomPathedSource, ReadableStreamSource, SourceRef, SourceRequest } from '../source'; +import { CustomPathedSource, PathedSource, ReadableStreamSource, SourceRef, SourceRequest } from '../source'; import { HlsDemuxer } from './hls-demuxer'; import { AttributeList, @@ -68,6 +68,7 @@ export type HlsSegmentLocation = { }; export class HlsSegmentedInput extends SegmentedInput { + rootPath: string; demuxer: HlsDemuxer; segments: HlsSegment[] = []; nextLines: string[] | null = null; @@ -84,6 +85,7 @@ export class HlsSegmentedInput extends SegmentedInput { ) { super(demuxer.input, path, trackDeclarations); + this.rootPath = path; this.demuxer = demuxer; this.nextLines = lines; } @@ -126,16 +128,24 @@ export class HlsSegmentedInput extends SegmentedInput { this.nextLines = null; if (!lines) { - using ref = await this.demuxer.input._getSourceUncached({ path: this.path, isRoot: false }); + using ref = await this.demuxer.input._getSourceUncached({ path: this.rootPath, isRoot: false }); const reader = new Reader(ref.source); const slice = await reader.requestEntireFile(); assert(slice); lines = readAllLines(slice, slice.length, { ignore: canIgnoreLine }); + + if (ref.source instanceof PathedSource) { + // Copy back the source's path to become aware of potential redirects + this.rootPath = ref.source.rootPath; + } } + const offsetTimestampsByDateTime = this.input._formatOptions.hls?.offsetTimestampsByDateTime !== false; + let headerRead = false; let accumulatedTime = 0; + let accumulatedUnixTime: number | null = null; let nextSegmentDuration: number | null = null; let currentKey: HlsEncryptionInfo | null = null; let nextSequenceNumber = 0; @@ -182,6 +192,9 @@ export class HlsSegmentedInput extends SegmentedInput { currentFirstSegment = prevLastSegment.firstSegment; currentInitSegment = prevLastSegment.initSegment; lastProgramDateTimeSeconds = prevLastSegment.lastProgramDateTimeSeconds; + accumulatedUnixTime = prevLastSegment.unixEpochTimestamp !== null + ? prevLastSegment.unixEpochTimestamp + prevLastSegment.duration + : null; prevLastSegment = null; } } @@ -219,7 +232,7 @@ export class HlsSegmentedInput extends SegmentedInput { key = { ...key, iv }; } - const fullPath = joinPaths(this.path, line); + const fullPath = joinPaths(this.rootPath, line); const location: HlsSegmentLocation = { path: fullPath, offset: nextByteRange?.offset ?? 0, @@ -228,7 +241,7 @@ export class HlsSegmentedInput extends SegmentedInput { const segment: HlsSegment = { timestamp: accumulatedTime, - relativeToUnixEpoch: lastProgramDateTimeSeconds !== null, + unixEpochTimestamp: accumulatedUnixTime, firstSegment: currentFirstSegment, sequenceNumber: nextSequenceNumber, location, @@ -240,6 +253,9 @@ export class HlsSegmentedInput extends SegmentedInput { currentFirstSegment ??= segment; accumulatedTime += nextSegmentDuration; + if (accumulatedUnixTime !== null) { + accumulatedUnixTime += nextSegmentDuration; + } this.segments.push(segment); } else { @@ -299,7 +315,7 @@ export class HlsSegmentedInput extends SegmentedInput { } if (!prevLastSegment) { - const fullPath = joinPaths(this.path, uri); + const fullPath = joinPaths(this.rootPath, uri); const location: HlsSegmentLocation = { path: fullPath, offset: parsedByteRange?.offset ?? 0, @@ -313,7 +329,7 @@ export class HlsSegmentedInput extends SegmentedInput { const segment: HlsSegment = { timestamp: accumulatedTime, - relativeToUnixEpoch: lastProgramDateTimeSeconds !== null, + unixEpochTimestamp: accumulatedUnixTime, firstSegment: null, sequenceNumber: null, location, @@ -375,7 +391,7 @@ export class HlsSegmentedInput extends SegmentedInput { currentKey = { method: 'AES-128', - keyUri: joinPaths(this.path, uri), + keyUri: joinPaths(this.rootPath, uri), iv, keyFormat, }; @@ -471,15 +487,19 @@ export class HlsSegmentedInput extends SegmentedInput { const offset = dateTimeSeconds - lastSegmentEnd; for (const segment of this.segments) { - segment.timestamp += offset; - segment.relativeToUnixEpoch = true; + segment.unixEpochTimestamp = segment.timestamp + offset; + if (offsetTimestampsByDateTime) { + segment.timestamp = segment.unixEpochTimestamp; + } } - - accumulatedTime += offset; } lastProgramDateTimeSeconds = dateTimeSeconds; - accumulatedTime = dateTimeSeconds; // Snap the accumulated time to the datetime + accumulatedUnixTime = dateTimeSeconds; + + if (offsetTimestampsByDateTime) { + accumulatedTime = dateTimeSeconds; // Snap the accumulated time into Unix space + } } else if (line === TAG_DISCONTINUITY) { currentFirstSegment = null; // Note: the init segment is not reset; the #EXT-X-MAP statement simply lasts until the next diff --git a/src/id3.ts b/src/id3.ts index 39094db..4354496 100644 --- a/src/id3.ts +++ b/src/id3.ts @@ -7,6 +7,7 @@ */ import { decodeSynchsafe, encodeSynchsafe } from '../shared/mp3-misc'; +import { Logging } from './logging'; import { MetadataTags } from './metadata'; import { coalesceIndex, @@ -152,7 +153,11 @@ export const readId3V2Header = (slice: FileSlice): Id3V2Header | null => { return null; } - const size = decodeSynchsafe(sizeRaw); + let size = decodeSynchsafe(sizeRaw); + + if (flags & Id3V2HeaderFlags.Footer) { + size += ID3_V2_HEADER_SIZE; + } return { majorVersion, revision, flags, size }; }; @@ -161,16 +166,16 @@ export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, tags: Metad // https://id3.org/id3v2.3.0 if (![2, 3, 4].includes(header.majorVersion)) { - console.warn(`Unsupported ID3v2 major version: ${header.majorVersion}`); + Logging._warn(`Unsupported ID3v2 major version: ${header.majorVersion}`); return; } - const bytes = readBytes(slice, header.size); - const reader = new Id3V2Reader(header, bytes); + const dataSize = (header.flags & Id3V2HeaderFlags.Footer) + ? header.size - ID3_V2_HEADER_SIZE + : header.size; - if (header.flags & Id3V2HeaderFlags.Footer) { - reader.removeFooter(); - } + const bytes = readBytes(slice, dataSize); + const reader = new Id3V2Reader(header, bytes); if ((header.flags & Id3V2HeaderFlags.Unsynchronisation) && header.majorVersion === 3) { reader.ununsynchronizeAll(); @@ -210,13 +215,13 @@ export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, tags: Metad } if (frameEncrypted) { - console.warn(`Skipping encrypted ID3v2 frame ${frame.id}`); + Logging._warn(`Skipping encrypted ID3v2 frame ${frame.id}`); reader.pos = frameEndPos; continue; } if (frameCompressed) { - console.warn(`Skipping compressed ID3v2 frame ${frame.id}`); // Maybe someday? Idk + Logging._warn(`Skipping compressed ID3v2 frame ${frame.id}`); // Maybe someday? Idk reader.pos = frameEndPos; continue; } @@ -463,11 +468,6 @@ export class Id3V2Reader { this.view = new DataView(this.bytes.buffer); } - removeFooter() { - this.bytes = this.bytes.subarray(0, this.bytes.length - ID3_V2_HEADER_SIZE); - this.view = new DataView(this.bytes.buffer); - } - readBytes(length: number) { const slice = this.bytes.subarray(this.pos, this.pos + length); this.pos += length; diff --git a/src/index.ts b/src/index.ts index 241229d..1e41cd4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,9 +9,11 @@ /// /// +import { Logging } from './logging'; + const MEDIABUNNY_LOADED_SYMBOL = Symbol.for('mediabunny loaded'); if ((globalThis as Record)[MEDIABUNNY_LOADED_SYMBOL]) { - console.error( + Logging._error( '[WARNING]\nMediabunny was loaded twice.' + ' This will likely cause Mediabunny not to work correctly.' + ' Check if multiple dependencies are importing different versions of Mediabunny,' @@ -151,6 +153,11 @@ export { type FilePath, type MaybePromise, } from './misc'; +export { + Logging, + LogLevel, + type LoggingEvents, +} from './logging'; export { type PsshBox, } from './isobmff/isobmff-misc'; @@ -197,6 +204,7 @@ export { IsobmffInputFormat, type IsobmffInputFormatOptions, HlsInputFormat, + type HlsInputFormatOptions, MatroskaInputFormat, Mp3InputFormat, Mp4InputFormat, @@ -270,6 +278,7 @@ export { EncodedPacketSink, type PacketRetrievalOptions, VideoSampleSink, + type VideoSinkDecoderOptions, type WrappedAudioBuffer, type WrappedCanvas, } from './media-sink'; diff --git a/src/input-format.ts b/src/input-format.ts index 8034621..b61adeb 100644 --- a/src/input-format.ts +++ b/src/input-format.ts @@ -446,7 +446,23 @@ export class OggInputFormat extends InputFormat { export class FlacInputFormat extends InputFormat { /** @internal */ async _canReadInput(input: Input) { - let slice = input._reader.requestSlice(0, 4); + let currentPos = 0; + + // There might be ID3v2 headers at the start, skip 'em + while (true) { + let slice = input._reader.requestSlice(currentPos, ID3_V2_HEADER_SIZE); + if (slice instanceof Promise) slice = await slice; + if (!slice) break; + + const id3V2Header = readId3V2Header(slice); + if (!id3V2Header) { + break; + } + + currentPos = slice.filePos + id3V2Header.size; + } + + let slice = input._reader.requestSlice(currentPos, 4); if (slice instanceof Promise) slice = await slice; if (!slice) return false; @@ -727,6 +743,8 @@ export const HLS_FORMATS: InputFormat[] = [HLS, MP4, QTFF, MP3, ADTS, MPEG_TS]; export type InputFormatOptions = { /** ISOBMFF-specific configuration. */ isobmff?: IsobmffInputFormatOptions; + /** HLS-specific configuration. */ + hls?: HlsInputFormatOptions; }; /** @@ -755,6 +773,26 @@ export type IsobmffInputFormatOptions = { _suppressPsshParsing?: boolean; }; +/** + * Additional HLS input configuration. + * @group Input formats + * @public + */ +export type HlsInputFormatOptions = { + /** + * Whether, in the presence of `#EXT-X-PROGRAM-DATE-TIME` tags, to offset track and packet timestamps to be relative + * to the Unix epoch. + * + * Defaults to `true`, meaning packet timestamps map directly to wall-clock time. This guarantees AV sync across + * multiple tracks, even with gaps present. + * + * When you don't want this mapping, you can set this value to `false`. In addition to timestamps not being Unix + * timestamps anymore, any gaps in the playlist are also naturally removed. When `false`, you can still access the + * wall-clock Unix timestamps via {@link InputTrack.getUnixTimeForTimestamp}. + */ + offsetTimestampsByDateTime?: boolean; +}; + export const validateInputFormatOptions = (options: InputFormatOptions, prefix: string) => { if (!options || typeof options !== 'object') { throw new TypeError(`${prefix}, when provided, must be an object.`); @@ -767,4 +805,15 @@ export const validateInputFormatOptions = (options: InputFormatOptions, prefix: throw new TypeError(`${prefix}.isobmff.resolveKeyId, when provided, must be a function.`); } } + if (options.hls !== undefined) { + if (!options.hls || typeof options.hls !== 'object') { + throw new TypeError(`${prefix}.hls, when provided, must be an object.`); + } + if ( + options.hls.offsetTimestampsByDateTime !== undefined + && typeof options.hls.offsetTimestampsByDateTime !== 'boolean' + ) { + throw new TypeError(`${prefix}.hls.offsetTimestampsByDateTime, when provided, must be a boolean.`); + } + } }; diff --git a/src/input-track.ts b/src/input-track.ts index fcaeb9e..49965bf 100644 --- a/src/input-track.ts +++ b/src/input-track.ts @@ -10,6 +10,7 @@ import { AudioCodec, MediaCodec, VideoCodec } from './codec'; import { determineVideoPacketType } from './codec-data'; import { customAudioDecoders, customVideoDecoders } from './custom-coder'; import { Input } from './input'; +import { Logging } from './logging'; import { EncodedPacketSink, PacketRetrievalOptions } from './media-sink'; import { assert, MaybePromise, Rational, Rotation, roundToDivisor, simplifyRational } from './misc'; import { TrackType } from './output'; @@ -42,6 +43,7 @@ export interface InputTrackBacking { getLanguageCode(): MaybePromise; getTimeResolution(): MaybePromise; isRelativeToUnixEpoch(): MaybePromise; + getUnixTimeForTimestamp(timestamp: number): MaybePromise; getDisposition(): MaybePromise; getPairingMask(): bigint; getBitrate(): MaybePromise; @@ -203,6 +205,28 @@ export abstract class InputTrack { return this._backing.isRelativeToUnixEpoch(); } + /** + * Returns the Unix time (in seconds since January 1, 1970 00:00:00 UTC) that the given track timestamp (in seconds) + * maps to, or `null` if there is no such mapping. This provides a piecewise-continuous mapping from this track's + * timestamp space into wall-clock time. Such mapping exists, for example, for HLS playlists with + * `#EXT-X-PROGRAM-DATE-TIME` tags present. + * + * This mapping can be available even when {@link InputTrack.isRelativeToUnixEpoch} is `false`, for example for HLS + * streams with program date time information but with {@link HlsInputFormatOptions.offsetTimestampsByDateTime} + * set to `false`. + */ + async getUnixTimeForTimestamp(timestamp: number): Promise { + return this._backing.getUnixTimeForTimestamp(timestamp); + } + + /** + * Whether the track's timestamps can be mapped to Unix wall clock time via + * {@link InputTrack.getUnixTimeForTimestamp}. + */ + async hasUnixTimeMapping(): Promise { + return (await this._backing.getUnixTimeForTimestamp(await this.getFirstTimestamp())) !== null; + } + /** Returns the track's disposition, i.e. information about its intended usage. */ async getDisposition() { return this._backing.getDisposition(); @@ -740,7 +764,7 @@ export class InputVideoTrack extends InputTrack { const support = await VideoDecoder.isConfigSupported(decoderConfig); return support.supported === true; } catch (error) { - console.error('Error during decodability check:', error); + Logging._error('Error during decodability check:', error); return false; } } @@ -880,7 +904,7 @@ export class InputAudioTrack extends InputTrack { return support.supported === true; } } catch (error) { - console.error('Error during decodability check:', error); + Logging._error('Error during decodability check:', error); return false; } } diff --git a/src/input.ts b/src/input.ts index 747d3f4..211478c 100644 --- a/src/input.ts +++ b/src/input.ts @@ -527,8 +527,12 @@ export class Input extends EventEmitter } this._sourceRefs.length = 0; - void this._demuxerPromise - ?.then(demuxer => demuxer.dispose()); + if (this._demuxerPromise) { + // The demuxer promise may already be rejected after failed format detection. + void this._demuxerPromise + .then(demuxer => demuxer.dispose()) + .catch(() => {}); + } } /** diff --git a/src/isobmff/isobmff-demuxer.ts b/src/isobmff/isobmff-demuxer.ts index a9e987e..a6f5a87 100644 --- a/src/isobmff/isobmff-demuxer.ts +++ b/src/isobmff/isobmff-demuxer.ts @@ -95,6 +95,7 @@ import { DEFAULT_TRACK_DISPOSITION, MetadataTags, RichImageData, TrackDispositio import { AC3_SAMPLE_RATES } from '../../shared/ac3-misc'; import { Bitstream } from '../../shared/bitstream'; import { Aes128CbcContext } from '../aes'; +import { Logging } from '../logging'; type InternalTrack = { id: number; @@ -927,7 +928,7 @@ export class IsobmffDemuxer extends Demuxer { } if (relevantEntryFound) { - console.warn( + Logging._warn( 'Unsupported edit list: multiple edits are not currently supported. Only using first edit.', ); break; @@ -939,7 +940,7 @@ export class IsobmffDemuxer extends Demuxer { } if (mediaRate !== 1) { - console.warn('Unsupported edit list entry: media rate must be 1.'); + Logging._warn('Unsupported edit list entry: media rate must be 1.'); break; } @@ -1099,9 +1100,9 @@ export class IsobmffDemuxer extends Demuxer { track.info.codec = 'prores'; track.info.proresFormat = lowercaseBoxName; } else if (codecName === null) { - console.warn(`Unknown encrypted video codec due to missing frma box.`); + Logging._warn(`Unknown encrypted video codec due to missing frma box.`); } else { - console.warn(`Unsupported video codec (sample entry type '${sampleBoxInfo.name}').`); + Logging._warn(`Unsupported video codec (sample entry type '${sampleBoxInfo.name}').`); } } else { slice.skip(6 * 1 + 2); @@ -1176,7 +1177,7 @@ export class IsobmffDemuxer extends Demuxer { } else if (sampleSize === 16) { track.info.codec = track.info.pcmLittleEndian ? 'pcm-s16' : 'pcm-s16be'; } else { - console.warn(`Unsupported sample size ${sampleSize} for codec 'twos'.`); + Logging._warn(`Unsupported sample size ${sampleSize} for codec 'twos'.`); track.info.codec = null; } } else if (codecName === 'sowt') { @@ -1185,7 +1186,7 @@ export class IsobmffDemuxer extends Demuxer { } else if (sampleSize === 16) { track.info.codec = 'pcm-s16'; } else { - console.warn(`Unsupported sample size ${sampleSize} for codec 'sowt'.`); + Logging._warn(`Unsupported sample size ${sampleSize} for codec 'sowt'.`); track.info.codec = null; } } else if (codecName === 'raw ') { @@ -1209,7 +1210,7 @@ export class IsobmffDemuxer extends Demuxer { } else if (pcmSampleSize === 32) { track.info.codec = 'pcm-s32'; } else { - console.warn(`Invalid ipcm sample size ${pcmSampleSize}.`); + Logging._warn(`Invalid ipcm sample size ${pcmSampleSize}.`); track.info.codec = null; } } else { @@ -1220,7 +1221,7 @@ export class IsobmffDemuxer extends Demuxer { } else if (pcmSampleSize === 32) { track.info.codec = 'pcm-s32be'; } else { - console.warn(`Invalid ipcm sample size ${pcmSampleSize}.`); + Logging._warn(`Invalid ipcm sample size ${pcmSampleSize}.`); track.info.codec = null; } } @@ -1233,7 +1234,7 @@ export class IsobmffDemuxer extends Demuxer { } else if (pcmSampleSize === 64) { track.info.codec = 'pcm-f64'; } else { - console.warn(`Invalid fpcm sample size ${pcmSampleSize}.`); + Logging._warn(`Invalid fpcm sample size ${pcmSampleSize}.`); track.info.codec = null; } } else { @@ -1242,7 +1243,7 @@ export class IsobmffDemuxer extends Demuxer { } else if (pcmSampleSize === 64) { track.info.codec = 'pcm-f64be'; } else { - console.warn(`Invalid fpcm sample size ${pcmSampleSize}.`); + Logging._warn(`Invalid fpcm sample size ${pcmSampleSize}.`); track.info.codec = null; } } @@ -1277,12 +1278,12 @@ export class IsobmffDemuxer extends Demuxer { } if (track.info.codec === null) { - console.warn('Unsupported PCM format.'); + Logging._warn('Unsupported PCM format.'); } } else if (codecName === null) { - console.warn(`Unknown encrypted audio codec due to missing frma box.`); + Logging._warn(`Unknown encrypted audio codec due to missing frma box.`); } else { - console.warn(`Unsupported audio codec (sample entry type '${sampleBoxInfo.name}').`); + Logging._warn(`Unsupported audio codec (sample entry type '${sampleBoxInfo.name}').`); } } @@ -1323,7 +1324,7 @@ export class IsobmffDemuxer extends Demuxer { defaultSkipByteBlock: null, }; } else { - console.warn(`Unsupported encryption scheme '${schemeType}'.`); + Logging._warn(`Unsupported encryption scheme '${schemeType}'.`); } }; break; @@ -1550,7 +1551,7 @@ export class IsobmffDemuxer extends Demuxer { } else if (objectTypeIndication === 0xdd) { track.info.codec = 'vorbis'; // "nonstandard, gpac uses it" - FFmpeg } else { - console.warn( + Logging._warn( `Unsupported audio codec (objectTypeIndication ${objectTypeIndication}) - discarding track.`, ); } @@ -1736,7 +1737,7 @@ export class IsobmffDemuxer extends Demuxer { const config = parseEac3Config(bytes); if (!config) { - console.warn('Invalid dec3 box contents, ignoring.'); + Logging._warn('Invalid dec3 box contents, ignoring.'); break; } @@ -2075,8 +2076,14 @@ export class IsobmffDemuxer extends Demuxer { // referenced in the track fragment header. if (this.currentTrack) { const trackData = this.currentFragment.trackData.get(this.currentTrack.id); + cond: if (trackData) { - this.currentFragment.implicitBaseDataOffset = trackData.currentOffset; + if (trackData.samples.length === 0) { + // Don't associate the fragment with the track if it has no samples, this simplifies + // other code + this.currentFragment.trackData.delete(this.currentTrack.id); + break cond; + } trackData.presentationTimestamps = trackData.samples .map((x, i) => ({ presentationTimestamp: x.presentationTimestamp, sampleIndex: i })) @@ -2266,12 +2273,6 @@ export class IsobmffDemuxer extends Demuxer { this.currentFragment.trackData.set(track.id, trackData); } - if (sampleCount === 0) { - // Don't associate the fragment with the track if it has no samples, this simplifies other code - this.currentFragment.implicitBaseDataOffset = trackData.currentOffset; - break; - } - for (let i = 0; i < sampleCount; i++) { let sampleDuration: number; if (sampleDurationPresent) { @@ -2323,6 +2324,8 @@ export class IsobmffDemuxer extends Demuxer { trackData.currentOffset += sampleSize; trackData.currentTimestamp += sampleDuration; } + + this.currentFragment.implicitBaseDataOffset = trackData.currentOffset; }; break; case 'saiz': { @@ -2381,7 +2384,7 @@ export class IsobmffDemuxer extends Demuxer { break; } if (entryCount > 1) { - console.warn('Multiple saio entries are not supported; using the first offset only.'); + Logging._warn('Multiple saio entries are not supported; using the first offset only.'); } let offset = version === 0 ? readU32Be(slice) : Number(readU64Be(slice)); @@ -2815,6 +2818,10 @@ abstract class IsobmffTrackBacking implements InputTrackBacking { return false; } + getUnixTimeForTimestamp() { + return null; + } + getDisposition() { return this.internalTrack.disposition; } diff --git a/src/logging.ts b/src/logging.ts new file mode 100644 index 0000000..a5c6e3c --- /dev/null +++ b/src/logging.ts @@ -0,0 +1,120 @@ +/*! + * Copyright (c) 2026-present, Vanilagy and contributors + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { EventEmitter, type EventListenerOptions } from './misc'; + +/** + * Controls how much information Mediabunny prints to the console. Higher levels include all lower levels. + * + * @group Logging + * @public + */ +export enum LogLevel { + /** Nothing is printed to the console. */ + Silent = 0, + /** Only errors are printed. */ + Errors = 1, + /** Errors and warnings are printed. */ + Warnings = 2, + /** Errors, warnings, and informational messages are printed. */ + Info = 3, +} + +/** + * The events emitted by {@link Logging}. Each event carries the same arguments that were passed to the corresponding + * log call. + * + * @group Logging + * @public + */ +export type LoggingEvents = { + /** Emitted before an error is logged. */ + error: unknown[]; + /** Emitted before a warning is logged. */ + warn: unknown[]; + /** Emitted before an informational message is logged. */ + info: unknown[]; +}; + +/** + * Mediabunny's central logging singleton. Use {@link Logging.level} to control how much is printed to the console, + * and subscribe to log events using {@link Logging.on}. + * + * Having manual control over logging is useful for command-line applications where you want full say over the output. + * + * @group Logging + * @public + */ +export class Logging { + private constructor() {} + + /** @internal */ + static _level: LogLevel = LogLevel.Info; + /** @internal */ + static _emitterInstance: EventEmitter | null = null; + + /** The current log level. Defaults to {@link LogLevel.Info}. */ + static get level() { + return Logging._level; + } + + static set level(value: LogLevel) { + if ( + value !== LogLevel.Silent + && value !== LogLevel.Errors + && value !== LogLevel.Warnings + && value !== LogLevel.Info + ) { + throw new TypeError('Invalid log level. Use one of the values of the LogLevel enum.'); + } + + Logging._level = value; + } + + /** @internal */ + static get _emitter() { + // Created lazily to avoid touching the EventEmitter binding at module-eval time + return Logging._emitterInstance ??= new EventEmitter(); + } + + /** Registers a listener for a log event. Returns a function that, when called, removes the listener again. */ + static on( + event: K, + listener: (data: LoggingEvents[K]) => unknown, + options?: EventListenerOptions, + ) { + return Logging._emitter.on(event, listener, options); + } + + /** @internal */ + static _error(...args: unknown[]) { + Logging._emitter._emit('error', args); + + if (Logging._level >= LogLevel.Errors) { + console.error(...args); + } + } + + /** @internal */ + static _warn(...args: unknown[]) { + Logging._emitter._emit('warn', args); + + if (Logging._level >= LogLevel.Warnings) { + console.warn(...args); + } + } + + /** @internal */ + static _info(...args: unknown[]) { + Logging._emitter._emit('info', args); + + if (Logging._level >= LogLevel.Info) { + console.info(...args); + } + } +} diff --git a/src/matroska/matroska-demuxer.ts b/src/matroska/matroska-demuxer.ts index 4717d85..9741da9 100644 --- a/src/matroska/matroska-demuxer.ts +++ b/src/matroska/matroska-demuxer.ts @@ -25,6 +25,7 @@ import { } from '../codec'; import { Demuxer } from '../demuxer'; import { Input } from '../input'; +import { Logging } from '../logging'; import { InputAudioTrackBacking, InputTrackBacking, @@ -1023,7 +1024,7 @@ export class MatroskaDemuxer extends Demuxer { || instruction.scope !== ContentEncodingScope.Block || instruction.data.algorithm !== ContentCompAlgo.HeaderStripping; })) { - console.warn(`Track #${this.currentTrack.id} has an unsupported content encoding; dropping.`); + Logging._warn(`Track #${this.currentTrack.id} has an unsupported content encoding; dropping.`); this.currentTrack = null; } @@ -1965,6 +1966,10 @@ abstract class MatroskaTrackBacking implements InputTrackBacking { return false; } + getUnixTimeForTimestamp() { + return null; + } + getDisposition() { return this.internalTrack.disposition; } diff --git a/src/media-sink.ts b/src/media-sink.ts index c245b16..72466ea 100644 --- a/src/media-sink.ts +++ b/src/media-sink.ts @@ -54,6 +54,7 @@ import { VideoSample, VideoSamplePixelFormat, } from './sample'; +import { Logging } from './logging'; /** * Additional options for controlling packet retrieval. @@ -1324,7 +1325,7 @@ export class ColorAlphaMerger { this.gl = null; this.canvas = null; mergerGpuUnavailable = true; - console.warn('Falling back to CPU for color/alpha merging.', error); + Logging._warn('Falling back to CPU for color/alpha merging.', error); } } } @@ -1749,6 +1750,42 @@ const colorAlphaMergerWorkerCode = () => { }; }; +/** + * Describes additional decoder preferences for video sinks. + * @group Media sinks + * @public + */ +export type VideoSinkDecoderOptions = { + /** + * A hint that configures the hardware acceleration method of the decoder. This is best left on `'no-preference'`, + * the default. + */ + hardwareAcceleration?: 'no-preference' | 'prefer-hardware' | 'prefer-software'; + /** + * Hint that the selected decoder should be configured to minimize the number of packets that have to be decoded + * before video frames are output. + */ + optimizeForLatency?: boolean; +}; + +const validateVideoSinkDecoderOptions = (decoderOptions: VideoSinkDecoderOptions) => { + if (!decoderOptions || typeof decoderOptions !== 'object') { + throw new TypeError('decoderOptions must be an object.'); + } + if ( + decoderOptions.hardwareAcceleration !== undefined + && !['no-preference', 'prefer-hardware', 'prefer-software'].includes(decoderOptions.hardwareAcceleration) + ) { + throw new TypeError( + 'decoderOptions.hardwareAcceleration, when provided, must be \'no-preference\', \'prefer-hardware\' or' + + ' \'prefer-software\'.', + ); + } + if (decoderOptions.optimizeForLatency !== undefined && typeof decoderOptions.optimizeForLatency !== 'boolean') { + throw new TypeError('decoderOptions.optimizeForLatency, when provided, must be a boolean.'); + } +}; + /** * A sink that retrieves decoded video samples (video frames) from a video track. * @group Media sinks @@ -1757,16 +1794,20 @@ const colorAlphaMergerWorkerCode = () => { export class VideoSampleSink extends BaseMediaSampleSink { /** @internal */ _track: InputVideoTrack; + /** @internal */ + _decoderOptions: VideoSinkDecoderOptions; /** Creates a new {@link VideoSampleSink} for the given {@link InputVideoTrack}. */ - constructor(videoTrack: InputVideoTrack) { + constructor(videoTrack: InputVideoTrack, decoderOptions: VideoSinkDecoderOptions = {}) { if (!(videoTrack instanceof InputVideoTrack)) { throw new TypeError('videoTrack must be an InputVideoTrack.'); } + validateVideoSinkDecoderOptions(decoderOptions); super(); this._track = videoTrack; + this._decoderOptions = decoderOptions; } /** @internal */ @@ -1783,10 +1824,16 @@ export class VideoSampleSink extends BaseMediaSampleSink { const codec = await this._track.getCodec(); const rotation = await this._track.getRotation(); - const decoderConfig = await this._track.getDecoderConfig(); + let decoderConfig = await this._track.getDecoderConfig(); const timeResolution = await this._track.getTimeResolution(); assert(codec && decoderConfig); + decoderConfig = { + ...decoderConfig, + hardwareAcceleration: this._decoderOptions.hardwareAcceleration, + optimizeForLatency: this._decoderOptions.optimizeForLatency, + }; + return new VideoDecoderWrapper(onSample, onError, codec, decoderConfig, rotation, timeResolution); } @@ -1903,6 +1950,8 @@ export type CanvasSinkOptions = { * canvas is created each time. */ poolSize?: number; + /** Additional preferences for the underlying video decoder. */ + decoderOptions?: VideoSinkDecoderOptions; }; /** @@ -1982,12 +2031,15 @@ export class CanvasSink { ) { throw new TypeError('poolSize must be a non-negative integer.'); } + if (options.decoderOptions !== undefined) { + validateVideoSinkDecoderOptions(options.decoderOptions); + } this._videoTrack = videoTrack; this._alpha = options.alpha ?? false; this._options = options; this._fit = options.fit ?? 'fill'; - this._videoSampleSink = new VideoSampleSink(videoTrack); + this._videoSampleSink = new VideoSampleSink(videoTrack, options.decoderOptions); this._canvasPool = Array.from({ length: options.poolSize ?? 0 }, () => null); } diff --git a/src/media-source.ts b/src/media-source.ts index ed6d29b..bb7ade9 100644 --- a/src/media-source.ts +++ b/src/media-source.ts @@ -64,6 +64,7 @@ import { } from './encode'; import { AudioResampler } from './resample'; import { determineVideoPacketType } from './codec-data'; +import { Logging } from './logging'; /** * Base class for media sources. Media sources are used to add media samples to an output file. @@ -265,6 +266,7 @@ class VideoEncoderWrapper { * So, we keep track of the encoder error and throw it as soon as we get the chance. */ private error: Error | null = null; + private closed = false; private lastMuxerPromise: Promise = Promise.resolve(); @@ -429,6 +431,8 @@ class VideoEncoderWrapper { return new VideoSample(x); } + // Calling the VideoSample constructor here will automatically handle input validation for us + // (it throws for any non-legal argument). return new VideoSample(x as CanvasImageSource, { timestamp: videoSample.timestamp, duration: videoSample.duration, @@ -457,6 +461,10 @@ class VideoEncoderWrapper { } assert(this.encoderInitialized); + if (this.closed) { + break; + } + const keyFrameInterval = this.encodingConfig.keyFrameInterval ?? 2; const multipleOfKeyFrameInterval = Math.floor(sampleToEncode.timestamp / keyFrameInterval); @@ -474,6 +482,8 @@ class VideoEncoderWrapper { }; this.lastMultipleOfKeyFrameInterval = multipleOfKeyFrameInterval; + this.encodingConfig.onEncodedSample?.(sampleToEncode); + if (this.customEncoder) { this.customEncoderQueueSize++; @@ -843,6 +853,8 @@ class VideoEncoderWrapper { await this.padFrameRate(alignedEnd); } + this.closed = true; + this.frameRateLastSample?.close(); this.frameRateLastSample = null; @@ -957,7 +969,7 @@ export class ColorAlphaSplitter { this.gl = null; this.canvas = null; splitterGpuUnavailable = true; - console.warn('Falling back to CPU for color/alpha splitting.', error); + Logging._warn('Falling back to CPU for color/alpha splitting.', error); } } } @@ -1105,7 +1117,7 @@ export class ColorAlphaSplitter { this.gl.shaderSource(shader, source); this.gl.compileShader(shader); if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) { - console.error('Shader compile error:', this.gl.getShaderInfoLog(shader)); + Logging._error('Shader compile error:', this.gl.getShaderInfoLog(shader)); } return shader; } @@ -1662,7 +1674,7 @@ export class MediaStreamVideoTrackSource extends VideoSource { /** @internal */ override async _start() { if (!this._errorPromiseAccessed) { - console.warn( + Logging._warn( 'Make sure not to ignore the `errorPromise` field on MediaStreamVideoTrackSource, so that any internal' + ' errors get bubbled up properly.', ); @@ -2033,6 +2045,7 @@ class AudioEncoderWrapper { */ private error: Error | null = null; private lastMuxerPromise: Promise = Promise.resolve(); + private closed = false; constructor(private source: AudioSource, private encodingConfig: AudioEncodingConfig) {} @@ -2070,8 +2083,6 @@ class AudioEncoderWrapper { ?? audioSample.numberOfChannels, targetSampleRate: config.transform!.sampleRate ?? audioSample.sampleRate, - startTime: audioSample.timestamp, - endTime: Infinity, onSample: async (sample) => { await this.processAndEncode(sample, true); }, @@ -2162,6 +2173,10 @@ class AudioEncoderWrapper { } assert(this.encoderInitialized); + if (this.closed) { + return; + } + // Handle padding of gaps with silence to avoid audio drift over time, like in // https://github.com/Vanilagy/mediabunny/issues/176 // TODO An open question is how encoders deal with the first AudioData having a non-zero timestamp, and with @@ -2197,6 +2212,8 @@ class AudioEncoderWrapper { } } + this.encodingConfig.onEncodedSample?.(audioSample); + if (this.customEncoder) { this.customEncoderQueueSize++; @@ -2536,6 +2553,8 @@ class AudioEncoderWrapper { } this.resampler = null; + this.closed = true; + if (this.customEncoder) { if (!forceClose) { void this.customEncoderCallSerializer.call(() => this.customEncoder!.flush()); @@ -2765,7 +2784,7 @@ export class MediaStreamAudioTrackSource extends AudioSource { /** @internal */ override async _start() { if (!this._errorPromiseAccessed) { - console.warn( + Logging._warn( 'Make sure not to ignore the `errorPromise` field on MediaStreamAudioTrackSource, so that any internal' + ' errors get bubbled up properly.', ); diff --git a/src/metadata.ts b/src/metadata.ts index d01246f..73e679d 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -20,7 +20,8 @@ import { isRecordStringString } from './misc'; * in Vorbis-style comment headers. * - For WAVE files, the metadata refers to the chunks within the RIFF INFO chunk. * - For ADTS files, the metadata refers to the ID3v2 tags. - * - For FLAC files, the metadata lives in Vorbis style in the Vorbis comment block. + * - For FLAC files, the metadata lives in Vorbis style in the Vorbis comment block, or sometimes in ID3v2 tags at the + * start of the file. * - For MPEG-TS files, metadata tags are currently not supported. * * @group Metadata tags @@ -78,7 +79,8 @@ export type MetadataTags = { * Additionally, the `'vendor'` key refers to the vendor string within this header. * - WAVE: The individual metadata chunks within the RIFF INFO chunk. Values are always ISO 8859-1 strings. * - FLAC: The key-value string pairs from the vorbis metadata block (see RFC 9639, Section D.2.3). - * Additionally, the `'vendor'` key refers to the vendor string within this header. + * Additionally, the `'vendor'` key refers to the vendor string within this header. If ID3v2 tags appear at the + * start of the file, their content is stored just like for MP3. * - MPEG-TS: Not supported. */ raw?: Record | null>; diff --git a/src/misc.ts b/src/misc.ts index ee938d3..6c923df 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -7,6 +7,7 @@ */ import { Bitstream } from '../shared/bitstream'; +import { Logging } from './logging'; export function assert(x: unknown): asserts x { if (!x) { @@ -577,7 +578,7 @@ export const retriedFetch = async ( throw error; } - console.error('Retrying failed fetch. Error:', error); + Logging._error('Retrying failed fetch. Error:', error); if (!Number.isFinite(retryDelayInSeconds) || retryDelayInSeconds < 0) { throw new TypeError('Retry delay must be a non-negative finite number.'); @@ -1215,7 +1216,7 @@ export class EventEmitter> { /** @internal */ _listeners = new Map unknown; once: boolean }>>(); - /** Registers a listener for the given event. */ + /** Registers a listener for the given event. Returns a function that, when called, removes the listener again. */ on( event: K, listener: (data: TEvents[K]) => unknown, @@ -1246,6 +1247,8 @@ export class EventEmitter> { try { (entry.fn as (data: unknown) => void)(data); } catch (error) { + // Deliberately not routed through Logging here: Logging emits via an EventEmitter, so a throwing + // log listener would recurse straight back into this handler. console.error(error); } diff --git a/src/mp3/mp3-demuxer.ts b/src/mp3/mp3-demuxer.ts index b32d76e..ebe5426 100644 --- a/src/mp3/mp3-demuxer.ts +++ b/src/mp3/mp3-demuxer.ts @@ -264,6 +264,10 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking { return false; } + getUnixTimeForTimestamp() { + return null; + } + getPairingMask() { return 1n; } diff --git a/src/mpeg-ts/mpeg-ts-demuxer.ts b/src/mpeg-ts/mpeg-ts-demuxer.ts index 41d4d79..f6c281f 100644 --- a/src/mpeg-ts/mpeg-ts-demuxer.ts +++ b/src/mpeg-ts/mpeg-ts-demuxer.ts @@ -26,8 +26,6 @@ import { determineVideoPacketType, extractAvcDecoderConfigurationRecord, extractHevcDecoderConfigurationRecord, - extractNalUnitTypeForAvc, - extractNalUnitTypeForHevc, EAC3_NUMBLKS_TABLE, getEac3ChannelCount, getEac3SampleRate, @@ -38,9 +36,12 @@ import { parseEac3SyncFrame, parseHevcSps, AC3_FRAME_SIZES, + extractNalUnitTypeForAvc, + extractNalUnitTypeForHevc, } from '../codec-data'; import { Demuxer } from '../demuxer'; import { Input } from '../input'; +import { Logging } from '../logging'; import { InputAudioTrackBacking, InputTrackBacking, @@ -57,6 +58,7 @@ import { floorToMultiple, last, MATRIX_COEFFICIENTS_MAP_INVERSE, + readExpGolomb, Rotation, roundIfAlmostInteger, toDataView, @@ -206,6 +208,12 @@ export class MpegTsDemuxer extends Demuxer { continue; } + if (hasProgramMap && !this.elementaryStreams.some(x => x.pid === packetHeader.pid)) { + // Don't care about this PID + currentPos += this.packetStride; + continue; + } + const section = await this.readSection( currentPos, true, @@ -396,7 +404,7 @@ export class MpegTsDemuxer extends Demuxer { // we can't determine its metadata and also have no idea how to packetize its data. if (!ignoredStreamTypes.has(streamType)) { - console.warn( + Logging._warn( `Note: MPEG-TS streams with stream_type 0x${streamType.toString(16)} are not` + ` currently supported.`, ); @@ -1103,6 +1111,10 @@ abstract class MpegTsTrackBacking implements InputTrackBacking { return false; } + getUnixTimeForTimestamp() { + return null; + } + getPairingMask() { return 1n; } @@ -1972,6 +1984,9 @@ class PacketReadingContext { const elementaryStream = this.elementaryStream; if (elementaryStream.info.type === 'video') { + // Our job here is to separate the video stream into access units. Sometimes this is easy (like when AUDs + // are present), sometimes it's a little harder. + const codec = elementaryStream.info.codec; const CHUNK_SIZE = 1024; @@ -1979,7 +1994,10 @@ class PacketReadingContext { throw new Error('Unhandled.'); } + const nalHeaderSize = codec === 'avc' ? 1 : 2; let packetStartPos: number | null = null; + let frameStartFound = false; + let lastFirstMacroblockInSlice = 0; while (true) { let remaining = this.ensureBuffered(CHUNK_SIZE); @@ -2001,11 +2019,10 @@ class PacketReadingContext { } i = zeroIndex; - // Check if we have enough bytes to identify a start code const posBeforeZero = chunkStartPos + i; - // Need at least 4 more bytes after the 0x00 to check for start code + NAL type - if (i + 4 >= length) { + // Need 3 more bytes after the 0x00 to recognize a start code prefix + if (i + 3 >= length) { // Not enough data in current chunk, seek back and let the next iteration handle it this.seekTo(posBeforeZero); break; @@ -2016,16 +2033,13 @@ class PacketReadingContext { const b3 = chunk[i + 3]!; let startCodeLength = 0; - let nalUnitTypeByte: number | null = null; // Check for 4-byte start code (0x00000001) if (b1 === 0x00 && b2 === 0x00 && b3 === 0x01) { startCodeLength = 4; - nalUnitTypeByte = chunk[i + 4]!; } else if (b1 === 0x00 && b2 === 0x01) { // 3-byte start code (0x000001) startCodeLength = 3; - nalUnitTypeByte = b3; } if (startCodeLength === 0) { @@ -2036,31 +2050,90 @@ class PacketReadingContext { const startCodePos = posBeforeZero; - if (packetStartPos === null) { - // This is our first start code, mark packet start - packetStartPos = startCodePos; - i += startCodeLength; - continue; + // The packet only really begins at the first NAL unit; anything before it isn't usable + packetStartPos ??= startCodePos; + + const nalHeaderStart = i + startCodeLength; + const payloadStart = nalHeaderStart + nalHeaderSize; + + // Bytes peeked from the start of a slice header to decode first_mb_in_slice. Six bytes (48 bits) + // comfortably covers the exp-Golomb code for any realistic macroblock count + const AVC_SLICE_HEADER_PEEK_SIZE = 6; + + // We read the NAL header plus, for slices, the start of the slice header to decode the + // first_mb_in_slice / first_slice_segment_in_pic_flag. Make sure all of it is buffered. + const bytesNeeded = payloadStart + (codec === 'avc' ? AVC_SLICE_HEADER_PEEK_SIZE : 1); + if (bytesNeeded > length) { + this.seekTo(posBeforeZero); + break; } - // We have a second start code. Check if it's an AUD. - if (nalUnitTypeByte !== null) { - const nalUnitType = codec === 'avc' - ? extractNalUnitTypeForAvc(nalUnitTypeByte) - : extractNalUnitTypeForHevc(nalUnitTypeByte); - const isAud = codec === 'avc' - ? nalUnitType === AvcNalUnitType.AUD - : nalUnitType === HevcNalUnitType.AUD_NUT; + const headerByte0 = chunk[nalHeaderStart]!; - if (isAud) { - // End the packet at this start code (before the AUD) - const packetLength = startCodePos - packetStartPos; - this.seekTo(packetStartPos); - return this.supplyPacket(packetLength, 0); + let nalUnitType: number; + let isSlice: boolean; + let isAccessUnitStart: boolean; + + if (codec === 'avc') { + nalUnitType = extractNalUnitTypeForAvc(headerByte0); + isSlice = nalUnitType === AvcNalUnitType.NON_IDR_SLICE + || nalUnitType === AvcNalUnitType.SLICE_DPA + || nalUnitType === AvcNalUnitType.IDR; + isAccessUnitStart = nalUnitType === AvcNalUnitType.SEI + || nalUnitType === AvcNalUnitType.SPS + || nalUnitType === AvcNalUnitType.PPS + || nalUnitType === AvcNalUnitType.AUD; + } else { + nalUnitType = extractNalUnitTypeForHevc(headerByte0); + const layerId = ((headerByte0 & 1) << 5) | (chunk[nalHeaderStart + 1]! >> 3); + if (layerId > 0) { + // Higher layers don't delimit the base-layer frames we care about + i += startCodeLength; + continue; } + + // VCL slices: 0..RASL_R, plus the IRAP range BLA_W_LP..CRA_NUT + isSlice = nalUnitType <= HevcNalUnitType.RASL_R + || (nalUnitType >= HevcNalUnitType.BLA_W_LP && nalUnitType <= 21); + // VPS..FD, prefix SEI, and the reserved/unspecified non-VCL ranges + isAccessUnitStart = (nalUnitType >= HevcNalUnitType.VPS_NUT && nalUnitType <= 37) + || nalUnitType === HevcNalUnitType.PREFIX_SEI_NUT + || (nalUnitType >= 41 && nalUnitType <= 44) + || (nalUnitType >= 48 && nalUnitType <= 55); + } + + let isFrameBoundary = false; + + if (isSlice) { + let startsNewPicture: boolean; + + if (codec === 'avc') { + const headerBytes = chunk.subarray(payloadStart, payloadStart + AVC_SLICE_HEADER_PEEK_SIZE); + const firstMacroblockInSlice = readExpGolomb(new Bitstream(headerBytes)); + startsNewPicture = !frameStartFound || firstMacroblockInSlice <= lastFirstMacroblockInSlice; + lastFirstMacroblockInSlice = firstMacroblockInSlice; + } else { + startsNewPicture = (chunk[payloadStart]! >> 7) === 1; + } + + if (startsNewPicture) { + if (frameStartFound) { + isFrameBoundary = true; + } else { + frameStartFound = true; + } + } + } else if (isAccessUnitStart && frameStartFound) { + isFrameBoundary = true; + } + + if (isFrameBoundary) { + // End the packet at this start code (the next frame begins here) + const packetLength = startCodePos - packetStartPos; + this.seekTo(packetStartPos); + return this.supplyPacket(packetLength, 0); } - // Not an AUD, continue searching i += startCodeLength; } @@ -2070,8 +2143,8 @@ class PacketReadingContext { } } - // End of stream - return remaining data if we have a packet start - if (packetStartPos !== null) { + // End of stream - emit whatever's left as the final packet + if (packetStartPos !== null && this.endPos > packetStartPos) { const packetLength = this.endPos - packetStartPos; this.seekTo(packetStartPos); return this.supplyPacket(packetLength, 0); diff --git a/src/ogg/ogg-demuxer.ts b/src/ogg/ogg-demuxer.ts index 6191e7f..c550beb 100644 --- a/src/ogg/ogg-demuxer.ts +++ b/src/ogg/ogg-demuxer.ts @@ -454,6 +454,10 @@ class OggAudioTrackBacking implements InputAudioTrackBacking { return false; } + getUnixTimeForTimestamp() { + return null; + } + getPairingMask() { return 1n; } diff --git a/src/output.ts b/src/output.ts index ecd3862..f6541f4 100644 --- a/src/output.ts +++ b/src/output.ts @@ -13,6 +13,7 @@ import { OutputFormat } from './output-format'; import { AudioSource, MediaSource, SubtitleSource, VideoSource } from './media-source'; import { PathedTarget, Target, TargetRequest } from './target'; import { Writer } from './writer'; +import { Logging } from './logging'; /** * List of all track types. @@ -794,7 +795,7 @@ export class Output< } if (this._startPromise) { - console.warn('Output has already been started.'); + Logging._warn('Output has already been started.'); return this._startPromise; } @@ -831,13 +832,13 @@ export class Output< */ async cancel() { if (this._cancelPromise) { - console.warn('Output has already been canceled.'); + Logging._warn('Output has already been canceled.'); return this._cancelPromise; } else if (this.state === 'finalizing' || this.state === 'finalized') { // Don't wanna warn when finalizing since that shows a warning when finalization fails and then cancel // is called if (this.state === 'finalized') { - console.warn('Output has already been finalized.'); + Logging._warn('Output has already been finalized.'); } return; @@ -872,7 +873,7 @@ export class Output< throw new Error('Cannot finalize after canceling.'); } if (this._finalizePromise) { - console.warn('Output has already been finalized.'); + Logging._warn('Output has already been finalized.'); return this._finalizePromise; } diff --git a/src/resample.ts b/src/resample.ts index 4686a9d..bb78a1d 100644 --- a/src/resample.ts +++ b/src/resample.ts @@ -20,41 +20,32 @@ export class AudioResampler { targetSampleRate: number; sourceNumberOfChannels: number | null = null; targetNumberOfChannels: number; - endTime: number; + startTime: number | null = null; onSample: (sample: AudioSample) => Promise; bufferSizeInFrames: number; bufferSizeInSamples: number; outputBuffer: Float32Array; /** Start frame of current buffer */ - bufferStartFrame: number; + bufferStartFrame = 0; /** The highest index written to in the current buffer */ maxWrittenFrame: number | null = null; channelMixer!: (sourceData: Float32Array, sourceFrameIndex: number, targetChannelIndex: number) => number; tempSourceBuffer!: Float32Array; - timestampOffset: number; constructor(options: { targetSampleRate: number; targetNumberOfChannels: number; - startTime: number; - endTime: number; onSample: (sample: AudioSample) => Promise; }) { this.targetSampleRate = options.targetSampleRate; this.targetNumberOfChannels = options.targetNumberOfChannels; - this.endTime = options.endTime; this.onSample = options.onSample; this.bufferSizeInFrames = Math.floor(this.targetSampleRate * 5.0); // 5 seconds this.bufferSizeInSamples = this.bufferSizeInFrames * this.targetNumberOfChannels; this.outputBuffer = new Float32Array(this.bufferSizeInSamples); - - this.bufferStartFrame = Math.floor(options.startTime * this.targetSampleRate); - // Set to ensure that if the buffer start frame lands on a fractional sample, that the first timestamp still - // comes out as exactly startTime - this.timestampOffset = options.startTime - this.bufferStartFrame / this.targetSampleRate; } /** @@ -186,6 +177,7 @@ export class AudioResampler { // they see fit. this.sourceSampleRate = audioSample.sampleRate; this.sourceNumberOfChannels = audioSample.numberOfChannels; + this.startTime = audioSample.timestamp; // Pre-allocate temporary buffer for source data this.tempSourceBuffer = new Float32Array(this.sourceSampleRate * this.sourceNumberOfChannels); @@ -193,6 +185,8 @@ export class AudioResampler { this.doChannelMixerSetup(); } + assert(this.startTime !== null); + const requiredSamples = audioSample.numberOfFrames * audioSample.numberOfChannels; this.ensureTempBufferSize(requiredSamples); @@ -201,8 +195,8 @@ export class AudioResampler { const sourceView = new Float32Array(this.tempSourceBuffer.buffer, 0, sourceDataSize / 4); audioSample.copyTo(sourceView, { planeIndex: 0, format: 'f32' }); - const inputStartTime = audioSample.timestamp; - const inputEndTime = Math.min(audioSample.timestamp + audioSample.duration, this.endTime); + const inputStartTime = audioSample.timestamp - this.startTime; + const inputEndTime = inputStartTime + audioSample.duration; // Compute which output frames are affected by this sample const outputStartFrame = Math.floor(inputStartTime * this.targetSampleRate); @@ -266,17 +260,18 @@ export class AudioResampler { return; // Nothing to finalize } + assert(this.startTime !== null); + const samplesWritten = (this.maxWrittenFrame + 1) * this.targetNumberOfChannels; const outputData = new Float32Array(samplesWritten); outputData.set(this.outputBuffer.subarray(0, samplesWritten)); - const timestampSeconds = this.bufferStartFrame / this.targetSampleRate; const audioSample = new AudioSample({ format: 'f32', sampleRate: this.targetSampleRate, numberOfChannels: this.targetNumberOfChannels, - timestamp: timestampSeconds + this.timestampOffset, + timestamp: this.startTime + this.bufferStartFrame / this.targetSampleRate, data: outputData, }); diff --git a/src/sample.ts b/src/sample.ts index bd97ba8..19cd62b 100644 --- a/src/sample.ts +++ b/src/sample.ts @@ -32,6 +32,7 @@ import { MaybePromise, DeepReadonly, } from './misc'; +import { Logging } from './logging'; polyfillSymbolDispose(); @@ -55,7 +56,7 @@ if (typeof FinalizationRegistry !== 'undefined') { if (value.type === 'video') { if (now - lastVideoGcErrorLog >= 1000) { // This error is annoying but oh so important - console.error( + Logging._error( `A VideoSample was garbage collected without first being closed. For proper resource management,` + ` make sure to call close() on all your VideoSamples as soon as you're done using them.`, ); @@ -68,7 +69,7 @@ if (typeof FinalizationRegistry !== 'undefined') { } } else { if (now - lastAudioGcErrorLog >= 1000) { - console.error( + Logging._error( `An AudioSample was garbage collected without first being closed. For proper resource management,` + ` make sure to call close() on all your AudioSamples as soon as you're done using them.`, ); @@ -2712,6 +2713,74 @@ export class AudioSample implements Disposable { } } + /** + * Returns a new {@link AudioSample} containing only the frames in the range [startSample, endSample). Both bounds + * must lie within this sample's range of frames. The returned sample's timestamp is shifted to match the start of + * the trimmed section. + */ + trim(startSample: number, endSample = this.numberOfFrames) { + if (!Number.isInteger(startSample) || startSample < 0) { + throw new TypeError('startSample must be a non-negative integer.'); + } + if (!Number.isInteger(endSample) || endSample < 0) { + throw new TypeError('endSample must be a non-negative integer.'); + } + if (startSample > this.numberOfFrames) { + throw new RangeError('startSample out of range.'); + } + if (endSample > this.numberOfFrames) { + throw new RangeError('endSample out of range.'); + } + if (endSample < startSample) { + throw new RangeError('endSample must not be less than startSample.'); + } + + if (this._closed) { + throw new Error('AudioSample is closed.'); + } + + const frameCount = endSample - startSample; + const bytesPerSample = getBytesPerSample(this.format); + + let data: Uint8Array; + if (formatIsPlanar(this.format)) { + const planeSize = frameCount * bytesPerSample; + data = new Uint8Array(planeSize * this.numberOfChannels); + + if (frameCount > 0) { + // Copy plane-by-plane + for (let i = 0; i < this.numberOfChannels; i++) { + this.copyTo(data.subarray(i * planeSize, (i + 1) * planeSize), { + planeIndex: i, + format: this.format, + frameOffset: startSample, + frameCount, + }); + } + } + } else { + // Trivial + data = new Uint8Array(frameCount * this.numberOfChannels * bytesPerSample); + + if (frameCount > 0) { + this.copyTo(data, { + planeIndex: 0, + format: this.format, + frameOffset: startSample, + frameCount, + }); + } + } + + return new AudioSample({ + data, + format: this.format, + sampleRate: this.sampleRate, + numberOfChannels: this.numberOfChannels, + timestamp: this.timestamp + startSample / this.sampleRate, + }); + } + /** * Closes this audio sample, releasing held resources. Audio samples should be closed as soon as they are not * needed anymore. diff --git a/src/segmented-input.ts b/src/segmented-input.ts index c38f32a..5f104df 100644 --- a/src/segmented-input.ts +++ b/src/segmented-input.ts @@ -41,7 +41,12 @@ export type AssociatedGroup = { export type Segment = { timestamp: number; duration: number; - relativeToUnixEpoch: boolean; + /** + * The Unix time (in seconds) corresponding to this segment's start timestamp, or null if unknown. This is computed + * whenever the source provides wall-clock information (e.g. HLS program date time), even if the segment timestamps + * themselves are not shifted into Unix time space. + */ + unixEpochTimestamp: number | null; firstSegment: Segment | null; }; @@ -97,6 +102,18 @@ export abstract class SegmentedInput { return lastSegment.timestamp + lastSegment.duration; } + async getUnixTimeForTimestamp(timestamp: number): Promise { + let segment = await this.getSegmentAt(timestamp, {}); + segment ??= await this.getFirstSegment({}); + + if (!segment || segment.unixEpochTimestamp === null) { + return null; + } + + const elapsed = timestamp - segment.timestamp; + return segment.unixEpochTimestamp + elapsed; + } + async getTrackBackings(): Promise { return this.trackBackingsPromise ??= (async () => { const backings: InputTrackBacking[] = []; @@ -310,7 +327,11 @@ class SegmentedInputInputTrackBacking implements InputTrackBacking { await this.hydrate(); assert(this.segmentedInput.firstSegment); - return this.segmentedInput.firstSegment.relativeToUnixEpoch; + return this.segmentedInput.firstSegment.unixEpochTimestamp === this.segmentedInput.firstSegment.timestamp; + } + + getUnixTimeForTimestamp(timestamp: number) { + return this.segmentedInput.getUnixTimeForTimestamp(timestamp); } getBitrate() { diff --git a/src/source.ts b/src/source.ts index 1fb266a..ea56e37 100644 --- a/src/source.ts +++ b/src/source.ts @@ -28,6 +28,7 @@ import { } from './misc'; import * as nodeAlias from './node'; import { InputDisposedError } from './input'; +import { Logging } from './logging'; polyfillSymbolDispose(); @@ -289,10 +290,15 @@ export class SourceRef implements Disposable { */ export abstract class PathedSource extends Source { constructor( - /** The path that points to the root file; the entry file of the media. */ + /** + * The path that points to the root file; the entry file of the media. + * + * This path may be modified by the source to indicate a redirect: an updated path to perform new requests + * relative to. + */ public rootPath: FilePath, /** The callback that is called for each requested file; must return a {@link Source} or {@link SourceRef}. */ - public requestHandler: (request: SourceRequest) => MaybePromise, + public readonly requestHandler: (request: SourceRequest) => MaybePromise, ) { if (typeof rootPath !== 'string') { throw new TypeError('rootPath must be a string.'); @@ -653,7 +659,7 @@ const DEFAULT_RETRY_DELAY = typeof navigator !== 'undefined' && typeof navigator.onLine === 'boolean' ? navigator.onLine : true; if (isOnline && originOfSrc !== null && originOfSrc !== window.location.origin) { - console.warn( + Logging._warn( `Request will not be retried because a CORS error was suspected due to different origins. You can` + ` modify this behavior by providing your own function for the 'getRetryDelay' option.`, ); @@ -778,7 +784,10 @@ export class UrlSource extends PathedSource { ? url.href : url; - super(urlString, request => new UrlSource(request.path, this._options)); + super( + urlString, + request => new UrlSource(request.path, this._options), + ); this._url = url; this._options = options; @@ -905,6 +914,11 @@ export class UrlSource extends PathedSource { throw new Error(`Error fetching ${String(this._url)}: ${response.status} ${response.statusText}`); } + if (response.redirected) { + // Modify our own root path so that future subrequests get made relative to the redirected URL + this.rootPath = response.url; + } + outer: if (this._orchestrator.fileSize === null) { // See if we can deduce the file size from the response @@ -942,8 +956,7 @@ export class UrlSource extends PathedSource { && !(url.pathname.endsWith('.m3u8') || url.pathname.endsWith('.m3u')) ) { if (!warnedOrigins.has(url.origin)) { - console.log(this._usedForHls, this._url, url.pathname); - console.warn( + Logging._warn( `HTTP server (origin ${url.origin}) did not respond to a range request with 206 Partial` + ' Content, meaning the entire resource will now be downloaded. To enable efficient' + ' media file streaming across a network, please make sure your server supports' @@ -997,7 +1010,7 @@ export class UrlSource extends PathedSource { const retryDelayInSeconds = this._getRetryDelay(1, error, this._url); if (retryDelayInSeconds !== null) { - console.error('Error while reading response stream. Attempting to resume.', error); + Logging._error('Error while reading response stream. Attempting to resume.', error); await wait(1000 * retryDelayInSeconds); break; @@ -1989,6 +2002,14 @@ class ReadOrchestrator { } satisfies ReadResult)); } else { // The requested region was satisfied by the cache, but the entire prefetch region was not + promise.catch((error) => { + if (this.disposed) { + return; // Swallow the error + } + + // Nobody's awaiting this result but an errored read is still notable + throw error; + }); } return result; @@ -2103,12 +2124,12 @@ class ReadOrchestrator { if (worker.pendingSlices.length > 0) { worker.pendingSlices.forEach(x => x.reject(error)); // Make sure to propagate any errors worker.pendingSlices.length = 0; - } else { + } else if (!worker.aborted && !this.disposed) { throw error; // So it doesn't get swallowed } }) .finally(() => { - if (worker.running) { + if (worker.running || this.workers.length >= this.options.maxWorkerCount) { // Rare, but can happen with multiple concurrent reads. In this case, don't do anything. return; } diff --git a/src/wave/wave-demuxer.ts b/src/wave/wave-demuxer.ts index f52231c..1324c6f 100644 --- a/src/wave/wave-demuxer.ts +++ b/src/wave/wave-demuxer.ts @@ -402,6 +402,10 @@ class WaveAudioTrackBacking implements InputAudioTrackBacking { return false; } + getUnixTimeForTimestamp() { + return null; + } + getPairingMask() { return 1n; } diff --git a/src/wave/wave-muxer.ts b/src/wave/wave-muxer.ts index 68b1072..f094646 100644 --- a/src/wave/wave-muxer.ts +++ b/src/wave/wave-muxer.ts @@ -17,6 +17,7 @@ import { WavOutputFormat } from '../output-format'; import { assert, assertNever, isIso88591Compatible, keyValueIterator } from '../misc'; import { MetadataTags, metadataTagsAreEmpty } from '../metadata'; import { Id3V2Writer } from '../id3'; +import { Logging } from '../logging'; export class WaveMuxer extends Muxer { private format: WavOutputFormat; @@ -206,7 +207,7 @@ export class WaveMuxer extends Muxer { const writeInfoTag = (tag: string, value: string) => { if (!isIso88591Compatible(value)) { // No Unicode supported here - console.warn(`Didn't write tag '${tag}' because '${value}' is not ISO 8859-1-compatible.`); + Logging._warn(`Didn't write tag '${tag}' because '${value}' is not ISO 8859-1-compatible.`); return; } diff --git a/test/browser/audio-samples.test.ts b/test/browser/audio-samples.test.ts new file mode 100644 index 0000000..dffa535 --- /dev/null +++ b/test/browser/audio-samples.test.ts @@ -0,0 +1,139 @@ +import { expect, test } from 'vitest'; +import { AudioSample } from '../../src/sample.js'; + +const SAMPLE_RATE = 48000; +const NUM_CHANNELS = 2; +const NUM_FRAMES = 10; +const TIMESTAMP = 1; + +const cellValue = (frame: number, channel: number) => frame * 10 + channel; + +const makeInterleavedSample = () => { + const data = new Int16Array(NUM_FRAMES * NUM_CHANNELS); + for (let f = 0; f < NUM_FRAMES; f++) { + for (let c = 0; c < NUM_CHANNELS; c++) { + data[f * NUM_CHANNELS + c] = cellValue(f, c); + } + } + + return new AudioSample({ + data, + format: 's16', + sampleRate: SAMPLE_RATE, + numberOfChannels: NUM_CHANNELS, + timestamp: TIMESTAMP, + }); +}; + +const makePlanarSample = () => { + const data = new Float32Array(NUM_FRAMES * NUM_CHANNELS); + for (let c = 0; c < NUM_CHANNELS; c++) { + for (let f = 0; f < NUM_FRAMES; f++) { + data[c * NUM_FRAMES + f] = cellValue(f, c); + } + } + + return new AudioSample({ + data, + format: 'f32-planar', + sampleRate: SAMPLE_RATE, + numberOfChannels: NUM_CHANNELS, + timestamp: TIMESTAMP, + }); +}; + +const readCell = (sample: AudioSample, frame: number, channel: number) => { + if (sample.format === 's16') { + const out = new Int16Array(sample.numberOfFrames * sample.numberOfChannels); + sample.copyTo(out, { planeIndex: 0, format: 's16' }); + return out[frame * sample.numberOfChannels + channel]; + } else { + const out = new Float32Array(sample.numberOfFrames); + sample.copyTo(out, { planeIndex: channel, format: 'f32-planar' }); + return out[frame]; + } +}; + +for (const [label, makeSample] of [ + ['interleaved', makeInterleavedSample], + ['planar', makePlanarSample], +] as const) { + test(`trim, normal case (${label})`, () => { + using sample = makeSample(); + using trimmed = sample.trim(3, 8); + + expect(trimmed).not.toBe(sample); + expect(trimmed.format).toBe(sample.format); + expect(trimmed.sampleRate).toBe(SAMPLE_RATE); + expect(trimmed.numberOfChannels).toBe(NUM_CHANNELS); + expect(trimmed.numberOfFrames).toBe(5); + expect(trimmed.duration).toBeCloseTo(5 / SAMPLE_RATE); + expect(trimmed.timestamp).toBeCloseTo(TIMESTAMP + 3 / SAMPLE_RATE); + + // The trimmed frame i corresponds to the original frame i + 3 + for (let f = 0; f < trimmed.numberOfFrames; f++) { + for (let c = 0; c < NUM_CHANNELS; c++) { + expect(readCell(trimmed, f, c)).toBe(cellValue(f + 3, c)); + } + } + + // The original sample must be untouched + expect(sample.numberOfFrames).toBe(NUM_FRAMES); + expect(readCell(sample, 3, 0)).toBe(cellValue(3, 0)); + }); + + test(`trim, full range is an independent copy (${label})`, () => { + using sample = makeSample(); + using trimmed = sample.trim(0, NUM_FRAMES); + + expect(trimmed.numberOfFrames).toBe(NUM_FRAMES); + expect(trimmed.timestamp).toBeCloseTo(TIMESTAMP); + for (let f = 0; f < NUM_FRAMES; f++) { + for (let c = 0; c < NUM_CHANNELS; c++) { + expect(readCell(trimmed, f, c)).toBe(cellValue(f, c)); + } + } + }); + + test(`trim, pathological zero-sample case (${label})`, () => { + using sample = makeSample(); + + // Empty range in the middle + using empty = sample.trim(4, 4); + expect(empty.numberOfFrames).toBe(0); + expect(empty.duration).toBe(0); + expect(empty.format).toBe(sample.format); + expect(empty.numberOfChannels).toBe(NUM_CHANNELS); + expect(empty.timestamp).toBeCloseTo(TIMESTAMP + 4 / SAMPLE_RATE); + + // Empty range right at the end (startSample === numberOfFrames) is still valid + using emptyAtEnd = sample.trim(NUM_FRAMES, NUM_FRAMES); + expect(emptyAtEnd.numberOfFrames).toBe(0); + expect(emptyAtEnd.timestamp).toBeCloseTo(TIMESTAMP + NUM_FRAMES / SAMPLE_RATE); + }); + + test(`trim, illegal params (${label})`, () => { + using sample = makeSample(); + + // Non-integer + expect(() => sample.trim(1.5, 5)).toThrow(TypeError); + expect(() => sample.trim(1, 5.5)).toThrow(TypeError); + + // Negative + expect(() => sample.trim(-1, 5)).toThrow(TypeError); + expect(() => sample.trim(0, -1)).toThrow(TypeError); + + // Out of range + expect(() => sample.trim(0, NUM_FRAMES + 1)).toThrow(RangeError); + expect(() => sample.trim(NUM_FRAMES + 1, NUM_FRAMES + 1)).toThrow(RangeError); + + // End before start + expect(() => sample.trim(6, 3)).toThrow(RangeError); + }); +} + +test('trim, throws on a closed sample', () => { + const sample = makeInterleavedSample(); + sample.close(); + expect(() => sample.trim(0, 5)).toThrow('AudioSample is closed.'); +}); diff --git a/test/node/flac.test.ts b/test/node/flac.test.ts index df3742c..861ded3 100644 --- a/test/node/flac.test.ts +++ b/test/node/flac.test.ts @@ -1,5 +1,6 @@ import { expect, test } from 'vitest'; import path from 'node:path'; +import fs from 'node:fs/promises'; import { assert, toUint8Array } from '../../src/misc.js'; import { Input } from '../../src/input.js'; import { BufferSource, FilePathSource } from '../../src/source.js'; @@ -284,3 +285,59 @@ test('appendOnly writes correct STREAMINFO header', async () => { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, ])); }); + +test('can read a FLAC file with leading ID3v2 tags', async () => { + const createId3V23TitleTag = (title: string) => { + const titleBytes = new TextEncoder().encode(title); + const frame = new Uint8Array(11 + titleBytes.length); + frame.set([0x54, 0x49, 0x54, 0x32]); // TIT2 + frame[7] = 1 + titleBytes.length; + frame[10] = 0; // ISO-8859-1 + frame.set(titleBytes, 11); + + const tag = new Uint8Array(10 + frame.length); + tag.set([0x49, 0x44, 0x33, 0x03, 0x00, 0x00]); // ID3v2.3 + tag[9] = frame.length; + tag.set(frame, 10); + + return tag; + }; + + const concatenateBytes = (...chunks: Uint8Array[]) => { + const result = new Uint8Array(chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0)); + let offset = 0; + + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.byteLength; + } + + return result; + }; + + using input = new Input({ + source: new BufferSource(concatenateBytes( + createId3V23TitleTag('First tag'), + createId3V23TitleTag('Second tag'), + await fs.readFile(new URL('../public/sample.flac', import.meta.url)), + )), + formats: ALL_FORMATS, + }); + + expect(await input.canRead()).toBe(true); + expect(await input.getFormat()).toBe(FLAC); + + const track = await input.getPrimaryAudioTrack(); + assert(track); + expect(await track.getDurationFromMetadata()).toEqual(19.714285714285715); + + const firstPacket = await new EncodedPacketSink(track).getPacket(0); + assert(firstPacket); + expect(firstPacket.sequenceNumber).toBe(0); + expect(firstPacket.timestamp).toBe(0); + + const metadataTags = await input.getMetadataTags(); + expect(metadataTags.title).toBe('First tag'); + expect(metadataTags.raw!['TIT2']).toBe('First tag'); + expect(metadataTags.raw!['TITLE']).toBe('The Happy Meeting'); +}); diff --git a/test/node/hls-input.test.ts b/test/node/hls-input.test.ts index cd29be4..3aadfc6 100644 --- a/test/node/hls-input.test.ts +++ b/test/node/hls-input.test.ts @@ -526,11 +526,14 @@ test.concurrent('Single-value PDT', { timeout: 15_000 }, async () => { const tracks = await input.getTracks(); expect((await Promise.all(tracks.map(x => x.isRelativeToUnixEpoch()))).every(x => x)).toBe(true); + expect((await Promise.all(tracks.map(x => x.hasUnixTimeMapping()))).every(x => x)).toBe(true); const track = tracks[0]!; const firstTimestamp = await track.getFirstTimestamp(); expect(firstTimestamp).toBe(Date.parse('2013-05-08T17:40:50Z') / 1000); + expect(await track.getUnixTimeForTimestamp(firstTimestamp)).toBe(firstTimestamp); + const endTimestamp = await track.computeDuration(); expect(endTimestamp).toBe(firstTimestamp + 50); @@ -600,6 +603,49 @@ test.concurrent('PDT with bad values', { timeout: 15_000 }, async () => { expect(await audioTrack.isRelativeToUnixEpoch()).toBe(false); }); +test.concurrent('Single-value PDT with unix offsets disabled', { timeout: 15_000 }, async () => { + using input = new Input({ + source: new UrlSource('https://playertest.longtailvideo.com/adaptive/aviion/manifest.m3u8'), + formats: ALL_FORMATS, + formatOptions: { + hls: { + offsetTimestampsByDateTime: false, + }, + }, + }); + + const tracks = await input.getTracks(); + expect((await Promise.all(tracks.map(x => x.isRelativeToUnixEpoch()))).every(x => x)).toBe(false); + expect((await Promise.all(tracks.map(x => x.hasUnixTimeMapping()))).every(x => x)).toBe(true); + + const track = tracks[0]!; + const firstTimestamp = await track.getFirstTimestamp(); + expect(firstTimestamp).toBe(0); + + const endTimestamp = await track.computeDuration(); + expect(endTimestamp).toBe(firstTimestamp + 50); + + const firstPacket = await new EncodedPacketSink(track).getFirstPacket(); + assert(firstPacket); + expect(firstPacket.timestamp).toBe(firstTimestamp); // Kinda obvious check tbh + + const unixStartTime = await track.getUnixTimeForTimestamp(firstTimestamp); + expect(unixStartTime).toBe(Date.parse('2013-05-08T17:40:50Z') / 1000); + + const unixEndTime = await track.getUnixTimeForTimestamp(endTimestamp); + expect(unixEndTime).toBe(Date.parse('2013-05-08T17:41:40Z') / 1000); + + const unixTimeBeforeStart = await track.getUnixTimeForTimestamp(firstTimestamp - 10); + expect(unixTimeBeforeStart).toBe(Date.parse('2013-05-08T17:40:40Z') / 1000); + + const unixTimeAfterEnd = await track.getUnixTimeForTimestamp(endTimestamp + 10); + expect(unixTimeAfterEnd).toBe(Date.parse('2013-05-08T17:41:50Z') / 1000); + + const timestampDt = 0.001; + const unixTimeDt = (await track.getUnixTimeForTimestamp(firstTimestamp + 0.001))! - unixStartTime!; + expect(unixTimeDt).toBeCloseTo(timestampDt); +}); + test.concurrent('Alternative audio only', { timeout: 15_000 }, async () => { using input = new Input({ source: new UrlSource('https://playertest.longtailvideo.com/adaptive/alt-audio-no-video/sintel/playlist.m3u8'), diff --git a/test/node/input.test.ts b/test/node/input.test.ts new file mode 100644 index 0000000..5e923af --- /dev/null +++ b/test/node/input.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from 'vitest'; +import { Input, UnsupportedInputFormatError } from '../../src/input.js'; +import { ALL_FORMATS } from '../../src/input-format.js'; +import { BufferSource } from '../../src/source.js'; + +test('Disposing after failed format detection does not emit an unhandled rejection', async () => { + const input = new Input({ + source: new BufferSource(new Uint8Array([1, 2, 3, 4])), + formats: ALL_FORMATS, + }); + + await expect(input.getFormat()).rejects.toThrow(UnsupportedInputFormatError); + + const unhandledRejections: unknown[] = []; + const onUnhandledRejection = (reason: unknown) => { + unhandledRejections.push(reason); + }; + + process.on('unhandledRejection', onUnhandledRejection); + try { + input.dispose(); + await new Promise(resolve => setTimeout(resolve, 0)); + } finally { + process.off('unhandledRejection', onUnhandledRejection); + } + + expect(unhandledRejections).toEqual([]); +}); diff --git a/test/node/mpeg-ts-demuxing.test.ts b/test/node/mpeg-ts-demuxing.test.ts index 4936dc3..01e1a9b 100644 --- a/test/node/mpeg-ts-demuxing.test.ts +++ b/test/node/mpeg-ts-demuxing.test.ts @@ -354,7 +354,7 @@ test('MPEG-TS seeking race condition test', async () => { } }); -test('MPEG-TS video key packets', async () => { +test('MPEG-TS video key packets', { timeout: 10_000 }, async () => { for (let i = 0; i < 2; i++) { using input = new Input({ source: new FilePathSource(path.join(__dirname, '../public/trim-buck-bunny-ffmpeg.ts')), @@ -875,3 +875,26 @@ test('MPEG-TS with "extension" PES packets without PTS', async () => { 4693, 223, 144, 174, 118, 9155, 1188, 379, 169, 213, ]); }); + +test('MPEG-TS with AUD-less video packets', async () => { + using input = new Input({ + source: new FilePathSource(path.join(__dirname, '../public/no-aud.ts')), + formats: ALL_FORMATS, + }); + + const videoTrack = await input.getPrimaryVideoTrack(); + assert(videoTrack); + + const sink = new EncodedPacketSink(videoTrack); + + const firstPacket = await sink.getFirstPacket(); + assert(firstPacket); + const secondPacket = await sink.getNextPacket(firstPacket); + assert(secondPacket); + const thirdPacket = await sink.getNextPacket(secondPacket); + assert(thirdPacket); + + expect(firstPacket.data.byteLength).toBe(331774); + expect(secondPacket.data.byteLength).toBe(1749); + expect(thirdPacket.data.byteLength).toBe(4273); +}); diff --git a/test/public/no-aud.ts b/test/public/no-aud.ts new file mode 100644 index 0000000..4bbd956 Binary files /dev/null and b/test/public/no-aud.ts differ