From 11c15c2392e49b85b78300094ce4d90a0383e93e Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Mon, 11 Aug 2025 00:00:04 +0200 Subject: [PATCH] Add track-specific conversion options, fix esbuild race condition --- dev/convert.html | 7 +- docs/guide/converting-media-files.md | 92 ++++-- scripts/esbuild/inlined-workers.ts | 5 + src/conversion.ts | 423 +++++++++++++++------------ src/index.ts | 4 +- src/misc.ts | 6 + 6 files changed, 323 insertions(+), 214 deletions(-) diff --git a/dev/convert.html b/dev/convert.html index 790d8e5..24afb95 100644 --- a/dev/convert.html +++ b/dev/convert.html @@ -24,7 +24,7 @@ chunked: true, chunkSize: 2**20 }); - const outputFormat = new Mediabunny.Mp3OutputFormat({}); + const outputFormat = new Mediabunny.Mp4OutputFormat({}); const button = document.createElement('button'); button.textContent = 'Cancel'; @@ -41,6 +41,7 @@ target }), audio: { + discard: true, //codec: 'opus', //bitrate: 128000, //numberOfChannels: 1, @@ -72,7 +73,7 @@ bitrate: 320000 }, */ - video: { + video: () => ({ //frameRate: 27.123, //width: 320, //forceTranscode: true, @@ -91,7 +92,7 @@ //height: 512, //width: 200, //height: 100, - }, + }), trim: { start: 0, end: 20 diff --git a/docs/guide/converting-media-files.md b/docs/guide/converting-media-files.md index 69c9e51..14505df 100644 --- a/docs/guide/converting-media-files.md +++ b/docs/guide/converting-media-files.md @@ -92,22 +92,18 @@ This automatically frees up all resources used by the conversion process. ## Video options -You can set the `video` property in the conversion options to configure the converter's behavior for video tracks: +You can set the `video` property in the conversion options to configure the converter's behavior for video tracks. The options are: ```ts -type ConversionOptions = { - // ... - video?: { - discard?: boolean; - width?: number; - height?: number; - fit?: 'fill' | 'contain' | 'cover'; - rotate?: 0 | 90 | 180 | 270; - frameRate?: number; - codec?: VideoCodec; - bitrate?: number | Quality; - forceTranscode?: boolean; - }; - // ... +type ConversionVideoOptions = { + discard?: boolean; + width?: number; + height?: number; + fit?: 'fill' | 'contain' | 'cover'; + rotate?: 0 | 90 | 180 | 270; + frameRate?: number; + codec?: VideoCodec; + bitrate?: number | Quality; + forceTranscode?: boolean; }; ``` @@ -125,7 +121,7 @@ const conversion = await Conversion.init({ ``` ::: info -The provided configuration will apply equally to all video tracks of the input. +The provided configuration will apply equally to all video tracks of the input. If you want to apply a separate configuration to each video track, check [track-specific options](#track-specific-options). ::: ### Discarding video @@ -143,6 +139,8 @@ The `width`, `height` and `fit` properties control how the video is resized. If If `width` or `height` is used in conjunction with `rotation`, they control the post-rotation dimensions. +If you want to apply max/min constraints to a video's dimensions, check out [track-specific options](#track-specific-options). + ### Adjusting frame rate The `frameRate` property can be used to set the frame rate of the output video in Hz. If not specified, the original input frame rate will be used (which may be variable). @@ -157,19 +155,15 @@ If you want to prevent direct copying of media data and force a transcoding step ## Audio options -You can set the `audio` property in the conversion options to configure the converter's behavior for audio tracks: +You can set the `audio` property in the conversion options to configure the converter's behavior for audio tracks. The options are: ```ts -type ConversionOptions = { - // ... - audio?: { - discard?: boolean; - codec?: AudioCodec; - bitrate?: number | Quality; - numberOfChannels?: number; - sampleRate?: number; - forceTranscode?: boolean; - }; - // ... +type ConversionAudioOptions = { + discard?: boolean; + codec?: AudioCodec; + bitrate?: number | Quality; + numberOfChannels?: number; + sampleRate?: number; + forceTranscode?: boolean; }; ``` @@ -186,7 +180,7 @@ const conversion = await Conversion.init({ ``` ::: info -The provided configuration will apply equally to all audio tracks of the input. +The provided configuration will apply equally to all audio tracks of the input. If you want to apply a separate configuration to each audio track, check [track-specific options](#track-specific-options). ::: ### Discarding audio @@ -207,6 +201,46 @@ Use the `bitrate` property to control the bitrate of the output audio. For examp If you want to prevent direct copying of media data and force a transcoding step, use `forceTranscode: true`. +## Track-specific options + +You may want to configure your video and audio options differently depending on the specifics of the input track. Or, in case a media file has multiple video or audio tracks, you may want to discard only specific tracks or configure each track separately. + +For this, instead of passing an object for `video` and `audio`, you can instead pass a function: + +```ts +const conversion = await Conversion.init({ + input, + output, + + // Function gets invoked for each video track: + video: (videoTrack, n) => { + if (n > 1) { + // Keep only the first video track + return { discard: true }; + } + + return { + // Shrink width to 640 only if the track is wider + width: Math.min(videoTrack.displayWidth, 640), + }; + }, + + // Async functions work too: + audio: async (audioTrack, n) => { + if (audioTrack.languageCode !== 'rus') { + // Keep only Russian audio tracks + return { discard: true }; + } + + return { + codec: 'aac', + }; + }, +}); +``` + +For documentation about the properties of video and audio tracks, refer to [Reading track metadata](./reading-media-files#reading-track-metadata). + ## Trimming Use the `trim` property in the conversion options to extract only a section of the input file into the output file: diff --git a/scripts/esbuild/inlined-workers.ts b/scripts/esbuild/inlined-workers.ts index a0a32e7..eee44ac 100644 --- a/scripts/esbuild/inlined-workers.ts +++ b/scripts/esbuild/inlined-workers.ts @@ -71,13 +71,18 @@ if (cacheDir === undefined) { throw new Error('Cache directory not found.'); } +let i = 0; + async function buildWorker(workerPath: string, extraConfig: esbuild.BuildOptions) { const scriptNameParts = path.basename(workerPath).split('.'); scriptNameParts.pop(); + scriptNameParts.push(String(i)); // To make sure it doesn't clash with other builds scriptNameParts.push('js'); const scriptName = scriptNameParts.join('.'); const bundlePath = path.resolve(cacheDir!, scriptName); + i = (i + 1) % 32; + if (extraConfig) { delete extraConfig.entryPoints; delete extraConfig.outfile; diff --git a/src/conversion.ts b/src/conversion.ts index c9a9b04..ef099cf 100644 --- a/src/conversion.ts +++ b/src/conversion.ts @@ -35,10 +35,131 @@ import { VideoSampleSource, AudioSampleSource, } from './media-source'; -import { assert, clamp, normalizeRotation, promiseWithResolvers, Rotation } from './misc'; +import { assert, clamp, MaybePromise, normalizeRotation, promiseWithResolvers, Rotation } from './misc'; import { Output, TrackType } from './output'; import { AudioSample, VideoSample } from './sample'; +/** + * Video-specific options. + * @public + */ +export type ConversionVideoOptions = { + /** If true, all video tracks will be discarded and will not be present in the output. */ + discard?: boolean; + /** + * The desired width of the output video in pixels, defaulting to the video's natural display width. If height + * is not set, it will be deduced automatically based on aspect ratio. + */ + width?: number; + /** + * The desired height of the output video in pixels, defaulting to the video's natural display height. If width + * is not set, it will be deduced automatically based on aspect ratio. + */ + height?: number; + /** + * The fitting algorithm in case both width and height are set. + * + * - 'fill' will stretch the image to fill the entire box, potentially altering aspect ratio. + * - 'contain' will contain the entire image within the box while preserving aspect ratio. This may lead to + * letterboxing. + * - 'cover' will scale the image until the entire box is filled, while preserving aspect ratio. + */ + fit?: 'fill' | 'contain' | 'cover'; + /** + * The angle in degrees to rotate the input video by, clockwise. Rotation is applied before resizing. This + * rotation is _in addition to_ the natural rotation of the input video as specified in input file's metadata. + */ + rotate?: Rotation; + /** + * The desired frame rate of the output video, in hertz. If not specified, the original input frame rate will + * be used (which may be variable). + */ + frameRate?: number; + /** The desired output video codec. */ + codec?: VideoCodec; + /** The desired bitrate of the output video. */ + bitrate?: VideoEncodingConfig['bitrate']; + /** When true, video will always be re-encoded instead of directly copying over the encoded samples. */ + forceTranscode?: boolean; +}; + +/** + * Audio-specific options. + * @public + */ +export type ConversionAudioOptions = { + /** If true, all audio tracks will be discarded and will not be present in the output. */ + discard?: boolean; + /** The desired channel count of the output audio. */ + numberOfChannels?: number; + /** The desired sample rate of the output audio, in hertz. */ + sampleRate?: number; + /** The desired output audio codec. */ + codec?: AudioCodec; + /** The desired bitrate of the output audio. */ + bitrate?: AudioEncodingConfig['bitrate']; + /** When true, audio will always be re-encoded instead of directly copying over the encoded samples. */ + forceTranscode?: boolean; +}; + +const validateVideoOptions = (videoOptions: ConversionVideoOptions | undefined) => { + if (videoOptions !== undefined && (!videoOptions || typeof videoOptions !== 'object')) { + throw new TypeError('options.video, when provided, must be an object.'); + } + if (videoOptions?.discard !== undefined && typeof videoOptions.discard !== 'boolean') { + throw new TypeError('options.video.discard, when provided, must be a boolean.'); + } + if (videoOptions?.forceTranscode !== undefined && typeof videoOptions.forceTranscode !== 'boolean') { + throw new TypeError('options.video.forceTranscode, when provided, must be a boolean.'); + } + if (videoOptions?.codec !== undefined && !VIDEO_CODECS.includes(videoOptions.codec)) { + throw new TypeError( + `options.video.codec, when provided, must be one of: ${VIDEO_CODECS.join(', ')}.`, + ); + } + if ( + videoOptions?.bitrate !== undefined + && !(videoOptions.bitrate instanceof Quality) + && (!Number.isInteger(videoOptions.bitrate) || videoOptions.bitrate <= 0) + ) { + throw new TypeError('options.video.bitrate, when provided, must be a positive integer or a quality.'); + } + if ( + videoOptions?.width !== undefined + && (!Number.isInteger(videoOptions.width) || videoOptions.width <= 0) + ) { + throw new TypeError('options.video.width, when provided, must be a positive integer.'); + } + if ( + videoOptions?.height !== undefined + && (!Number.isInteger(videoOptions.height) || videoOptions.height <= 0) + ) { + throw new TypeError('options.video.height, when provided, must be a positive integer.'); + } + if (videoOptions?.fit !== undefined && !['fill', 'contain', 'cover'].includes(videoOptions.fit)) { + throw new TypeError('options.video.fit, when provided, must be one of "fill", "contain", or "cover".'); + } + if ( + videoOptions?.width !== undefined + && videoOptions.height !== undefined + && videoOptions.fit === undefined + ) { + throw new TypeError( + 'When both options.video.width and options.video.height are provided, options.video.fit must also be' + + ' provided.', + ); + } + if (videoOptions?.rotate !== undefined && ![0, 90, 180, 270].includes(videoOptions.rotate)) { + throw new TypeError('options.video.rotate, when provided, must be 0, 90, 180 or 270.'); + } + if ( + videoOptions?.frameRate !== undefined + && (!Number.isFinite(videoOptions.frameRate) || videoOptions.frameRate <= 0) + ) { + throw new TypeError('options.video.frameRate, when provided, must be a finite positive number.'); + } +}; + /** * The options for media file conversion. * @public @@ -49,62 +170,23 @@ export type ConversionOptions = { /** The output file. */ output: Output; - /** Video-specific options. */ - video?: { - /** If true, all video tracks will be discarded and will not be present in the output. */ - discard?: boolean; - /** - * The desired width of the output video in pixels, defaulting to the video's natural display width. If height - * is not set, it will be deduced automatically based on aspect ratio. - */ - width?: number; - /** - * The desired height of the output video in pixels, defaulting to the video's natural display height. If width - * is not set, it will be deduced automatically based on aspect ratio. - */ - height?: number; - /** - * The fitting algorithm in case both width and height are set. - * - * - 'fill' will stretch the image to fill the entire box, potentially altering aspect ratio. - * - 'contain' will contain the entire image within the box while preserving aspect ratio. This may lead to - * letterboxing. - * - 'cover' will scale the image until the entire box is filled, while preserving aspect ratio. - */ - fit?: 'fill' | 'contain' | 'cover'; - /** - * The angle in degrees to rotate the input video by, clockwise. Rotation is applied before resizing. This - * rotation is _in addition to_ the natural rotation of the input video as specified in input file's metadata. - */ - rotate?: Rotation; - /** - * The desired frame rate of the output video, in hertz. If not specified, the original input frame rate will - * be used (which may be variable). - */ - frameRate?: number; - /** The desired output video codec. */ - codec?: VideoCodec; - /** The desired bitrate of the output video. */ - bitrate?: VideoEncodingConfig['bitrate']; - /** When true, video will always be re-encoded instead of directly copying over the encoded samples. */ - forceTranscode?: boolean; - }; + /** + * Video-specific options. When passing an object, the same options are applied to all video tracks. When passing a + * function, it will be invoked for each video track and is expected to return or resolve to the options + * for that specific track. The function is passed an instance of `InputVideoTrack` as well as a number `n`, which + * is the 1-based index of the track in the list of all video tracks. + */ + video?: ConversionVideoOptions + | ((track: InputVideoTrack, n: number) => MaybePromise); - /** Audio-specific options. */ - audio?: { - /** If true, all audio tracks will be discarded and will not be present in the output. */ - discard?: boolean; - /** The desired channel count of the output audio. */ - numberOfChannels?: number; - /** The desired sample rate of the output audio, in hertz. */ - sampleRate?: number; - /** The desired output audio codec. */ - codec?: AudioCodec; - /** The desired bitrate of the output audio. */ - bitrate?: AudioEncodingConfig['bitrate']; - /** When true, audio will always be re-encoded instead of directly copying over the encoded samples. */ - forceTranscode?: boolean; - }; + /** + * Audio-specific options. When passing an object, the same options are applied to all audio tracks. When passing a + * function, it will be invoked for each audio track and is expected to return or resolve to the options + * for that specific track. The function is passed an instance of `InputAudioTrack` as well as a number `n`, which + * is the 1-based index of the track in the list of all audio tracks. + */ + audio?: ConversionAudioOptions + | ((track: InputAudioTrack, n: number) => MaybePromise); /** Options to trim the input file. */ trim?: { @@ -115,6 +197,42 @@ export type ConversionOptions = { }; }; +const validateAudioOptions = (audioOptions: ConversionAudioOptions | undefined) => { + if (audioOptions !== undefined && (!audioOptions || typeof audioOptions !== 'object')) { + throw new TypeError('options.audio, when provided, must be an object.'); + } + if (audioOptions?.discard !== undefined && typeof audioOptions.discard !== 'boolean') { + throw new TypeError('options.audio.discard, when provided, must be a boolean.'); + } + if (audioOptions?.forceTranscode !== undefined && typeof audioOptions.forceTranscode !== 'boolean') { + throw new TypeError('options.audio.forceTranscode, when provided, must be a boolean.'); + } + if (audioOptions?.codec !== undefined && !AUDIO_CODECS.includes(audioOptions.codec)) { + throw new TypeError( + `options.audio.codec, when provided, must be one of: ${AUDIO_CODECS.join(', ')}.`, + ); + } + if ( + audioOptions?.bitrate !== undefined + && !(audioOptions.bitrate instanceof Quality) + && (!Number.isInteger(audioOptions.bitrate) || audioOptions.bitrate <= 0) + ) { + throw new TypeError('options.audio.bitrate, when provided, must be a positive integer or a quality.'); + } + if ( + audioOptions?.numberOfChannels !== undefined + && (!Number.isInteger(audioOptions.numberOfChannels) || audioOptions.numberOfChannels <= 0) + ) { + throw new TypeError('options.audio.numberOfChannels, when provided, must be a positive integer.'); + } + if ( + audioOptions?.sampleRate !== undefined + && (!Number.isInteger(audioOptions.sampleRate) || audioOptions.sampleRate <= 0) + ) { + throw new TypeError('options.audio.sampleRate, when provided, must be a positive integer.'); + } +}; + const FALLBACK_NUMBER_OF_CHANNELS = 2; const FALLBACK_SAMPLE_RATE = 48000; @@ -217,94 +335,19 @@ export class Conversion { if (options.output._tracks.length > 0 || options.output.state !== 'pending') { throw new TypeError('options.output must be fresh: no tracks added and not started.'); } - if (options.video !== undefined && (!options.video || typeof options.video !== 'object')) { - throw new TypeError('options.video, when provided, must be an object.'); + + if (typeof options.video !== 'function') { + validateVideoOptions(options.video); + } else { + // We'll validate the return value later } - if (options.video?.discard !== undefined && typeof options.video.discard !== 'boolean') { - throw new TypeError('options.video.discard, when provided, must be a boolean.'); - } - if (options.video?.forceTranscode !== undefined && typeof options.video.forceTranscode !== 'boolean') { - throw new TypeError('options.video.forceTranscode, when provided, must be a boolean.'); - } - if (options.video?.codec !== undefined && !VIDEO_CODECS.includes(options.video.codec)) { - throw new TypeError( - `options.video.codec, when provided, must be one of: ${VIDEO_CODECS.join(', ')}.`, - ); - } - if ( - options.video?.bitrate !== undefined - && !(options.video.bitrate instanceof Quality) - && (!Number.isInteger(options.video.bitrate) || options.video.bitrate <= 0) - ) { - throw new TypeError('options.video.bitrate, when provided, must be a positive integer or a quality.'); - } - if ( - options.video?.width !== undefined - && (!Number.isInteger(options.video.width) || options.video.width <= 0) - ) { - throw new TypeError('options.video.width, when provided, must be a positive integer.'); - } - if ( - options.video?.height !== undefined - && (!Number.isInteger(options.video.height) || options.video.height <= 0) - ) { - throw new TypeError('options.video.height, when provided, must be a positive integer.'); - } - if (options.video?.fit !== undefined && !['fill', 'contain', 'cover'].includes(options.video.fit)) { - throw new TypeError('options.video.fit, when provided, must be one of "fill", "contain", or "cover".'); - } - if ( - options.video?.width !== undefined - && options.video.height !== undefined - && options.video.fit === undefined - ) { - throw new TypeError( - 'When both options.video.width and options.video.height are provided, options.video.fit must also be' - + ' provided.', - ); - } - if (options.video?.rotate !== undefined && ![0, 90, 180, 270].includes(options.video.rotate)) { - throw new TypeError('options.video.rotate, when provided, must be 0, 90, 180 or 270.'); - } - if ( - options.video?.frameRate !== undefined - && (!Number.isFinite(options.video.frameRate) || options.video.frameRate <= 0) - ) { - throw new TypeError('options.video.frameRate, when provided, must be a finite positive number.'); - } - if (options.audio !== undefined && (!options.audio || typeof options.audio !== 'object')) { - throw new TypeError('options.audio, when provided, must be an object.'); - } - if (options.audio?.discard !== undefined && typeof options.audio.discard !== 'boolean') { - throw new TypeError('options.audio.discard, when provided, must be a boolean.'); - } - if (options.audio?.forceTranscode !== undefined && typeof options.audio.forceTranscode !== 'boolean') { - throw new TypeError('options.audio.forceTranscode, when provided, must be a boolean.'); - } - if (options.audio?.codec !== undefined && !AUDIO_CODECS.includes(options.audio.codec)) { - throw new TypeError( - `options.audio.codec, when provided, must be one of: ${AUDIO_CODECS.join(', ')}.`, - ); - } - if ( - options.audio?.bitrate !== undefined - && !(options.audio.bitrate instanceof Quality) - && (!Number.isInteger(options.audio.bitrate) || options.audio.bitrate <= 0) - ) { - throw new TypeError('options.audio.bitrate, when provided, must be a positive integer or a quality.'); - } - if ( - options.audio?.numberOfChannels !== undefined - && (!Number.isInteger(options.audio.numberOfChannels) || options.audio.numberOfChannels <= 0) - ) { - throw new TypeError('options.audio.numberOfChannels, when provided, must be a positive integer.'); - } - if ( - options.audio?.sampleRate !== undefined - && (!Number.isInteger(options.audio.sampleRate) || options.audio.sampleRate <= 0) - ) { - throw new TypeError('options.audio.sampleRate, when provided, must be a positive integer.'); + + if (typeof options.audio !== 'function') { + validateAudioOptions(options.audio); + } else { + // We'll validate the return value later } + if (options.trim !== undefined && (!options.trim || typeof options.trim !== 'object')) { throw new TypeError('options.trim, when provided, must be an object.'); } @@ -338,16 +381,36 @@ export class Conversion { const inputTracks = await this.input.getTracks(); const outputTrackCounts = this.output.format.getSupportedTrackCounts(); + let nVideo = 1; + let nAudio = 1; + for (const track of inputTracks) { - if (track.isVideoTrack() && this._options.video?.discard) { - this.discardedTracks.push({ - track, - reason: 'discarded_by_user', - }); - continue; + let trackOptions: ConversionVideoOptions | ConversionAudioOptions | undefined = undefined; + if (track.isVideoTrack()) { + if (this._options.video) { + if (typeof this._options.video === 'function') { + trackOptions = await this._options.video(track, nVideo); + validateVideoOptions(trackOptions); + nVideo++; + } else { + trackOptions = this._options.video; + } + } + } else if (track.isAudioTrack()) { + if (this._options.audio) { + if (typeof this._options.audio === 'function') { + trackOptions = await this._options.audio(track, nAudio); + validateAudioOptions(trackOptions); + nAudio++; + } else { + trackOptions = this._options.audio; + } + } + } else { + assert(false); } - if (track.isAudioTrack() && this._options.audio?.discard) { + if (trackOptions?.discard) { this.discardedTracks.push({ track, reason: 'discarded_by_user', @@ -372,9 +435,9 @@ export class Conversion { } if (track.isVideoTrack()) { - await this._processVideoTrack(track); + await this._processVideoTrack(track, (trackOptions ?? {}) as ConversionVideoOptions); } else if (track.isAudioTrack()) { - await this._processAudioTrack(track); + await this._processAudioTrack(track, (trackOptions ?? {}) as ConversionAudioOptions); } } @@ -443,7 +506,7 @@ export class Conversion { } /** @internal */ - async _processVideoTrack(track: InputVideoTrack) { + async _processVideoTrack(track: InputVideoTrack, trackOptions: ConversionVideoOptions) { const sourceCodec = track.codec; if (!sourceCodec) { this.discardedTracks.push({ @@ -455,7 +518,7 @@ export class Conversion { let videoSource: VideoSource; - const totalRotation = normalizeRotation(track.rotation + (this._options.video?.rotate ?? 0)); + const totalRotation = normalizeRotation(track.rotation + (trackOptions.rotate ?? 0)); const outputSupportsRotation = this.output.format.supportsVideoRotationMetadata; const [originalWidth, originalHeight] = totalRotation % 180 === 0 @@ -469,22 +532,22 @@ export class Conversion { // A lot of video encoders require that the dimensions be multiples of 2 const ceilToMultipleOfTwo = (value: number) => Math.ceil(value / 2) * 2; - if (this._options.video?.width !== undefined && this._options.video.height === undefined) { - width = ceilToMultipleOfTwo(this._options.video.width); + if (trackOptions.width !== undefined && trackOptions.height === undefined) { + width = ceilToMultipleOfTwo(trackOptions.width); height = ceilToMultipleOfTwo(Math.round(width / aspectRatio)); - } else if (this._options.video?.width === undefined && this._options.video?.height !== undefined) { - height = ceilToMultipleOfTwo(this._options.video.height); + } else if (trackOptions.width === undefined && trackOptions.height !== undefined) { + height = ceilToMultipleOfTwo(trackOptions.height); width = ceilToMultipleOfTwo(Math.round(height * aspectRatio)); - } else if (this._options.video?.width !== undefined && this._options.video.height !== undefined) { - width = ceilToMultipleOfTwo(this._options.video.width); - height = ceilToMultipleOfTwo(this._options.video.height); + } else if (trackOptions.width !== undefined && trackOptions.height !== undefined) { + width = ceilToMultipleOfTwo(trackOptions.width); + height = ceilToMultipleOfTwo(trackOptions.height); } const firstTimestamp = await track.getFirstTimestamp(); - const needsTranscode = !!this._options.video?.forceTranscode + const needsTranscode = !!trackOptions.forceTranscode || this._startTimestamp > 0 || firstTimestamp < 0 - || !!this._options.video?.frameRate; + || !!trackOptions.frameRate; const needsRerender = width !== originalWidth || height !== originalHeight || (totalRotation !== 0 && !outputSupportsRotation); @@ -492,10 +555,10 @@ export class Conversion { let videoCodecs = this.output.format.getSupportedVideoCodecs(); if ( !needsTranscode - && !this._options.video?.bitrate + && !trackOptions.bitrate && !needsRerender && videoCodecs.includes(sourceCodec) - && (!this._options.video?.codec || this._options.video?.codec === sourceCodec) + && (!trackOptions.codec || trackOptions.codec === sourceCodec) ) { // Fast path, we can simply copy over the encoded packets @@ -540,11 +603,11 @@ export class Conversion { return; } - if (this._options.video?.codec) { - videoCodecs = videoCodecs.filter(codec => codec === this._options.video?.codec); + if (trackOptions.codec) { + videoCodecs = videoCodecs.filter(codec => codec === trackOptions.codec); } - const bitrate = this._options.video?.bitrate ?? QUALITY_HIGH; + const bitrate = trackOptions.bitrate ?? QUALITY_HIGH; const encodableCodec = await getFirstEncodableVideoCodec(videoCodecs, { width, height, bitrate }); if (!encodableCodec) { @@ -571,12 +634,12 @@ export class Conversion { const sink = new CanvasSink(track, { width, height, - fit: this._options.video?.fit ?? 'fill', + fit: trackOptions.fit ?? 'fill', rotation: totalRotation, // Bake the rotation into the output poolSize: 1, }); const iterator = sink.canvases(this._startTimestamp, this._endTimestamp); - const frameRate = this._options.video?.frameRate; + const frameRate = trackOptions.frameRate; let lastCanvas: HTMLCanvasElement | OffscreenCanvas | null = null; let lastCanvasTimestamp: number | null = null; @@ -661,7 +724,7 @@ export class Conversion { await this._started; const sink = new VideoSampleSink(track); - const frameRate = this._options.video?.frameRate; + const frameRate = trackOptions.frameRate; let lastSample: VideoSample | null = null; let lastSampleTimestamp: number | null = null; @@ -744,7 +807,7 @@ export class Conversion { } this.output.addVideoTrack(videoSource, { - frameRate: this._options.video?.frameRate, + frameRate: trackOptions.frameRate, languageCode: track.languageCode, rotation: needsRerender ? 0 : totalRotation, // Rerendering will bake the rotation into the output }); @@ -755,7 +818,7 @@ export class Conversion { } /** @internal */ - async _processAudioTrack(track: InputAudioTrack) { + async _processAudioTrack(track: InputAudioTrack, trackOptions: ConversionAudioOptions) { const sourceCodec = track.codec; if (!sourceCodec) { this.discardedTracks.push({ @@ -772,8 +835,8 @@ export class Conversion { const firstTimestamp = await track.getFirstTimestamp(); - let numberOfChannels = this._options.audio?.numberOfChannels ?? originalNumberOfChannels; - let sampleRate = this._options.audio?.sampleRate ?? originalSampleRate; + let numberOfChannels = trackOptions.numberOfChannels ?? originalNumberOfChannels; + let sampleRate = trackOptions.sampleRate ?? originalSampleRate; let needsResample = numberOfChannels !== originalNumberOfChannels || sampleRate !== originalSampleRate || this._startTimestamp > 0 @@ -781,11 +844,11 @@ export class Conversion { let audioCodecs = this.output.format.getSupportedAudioCodecs(); if ( - !this._options.audio?.forceTranscode - && !this._options.audio?.bitrate + !trackOptions.forceTranscode + && !trackOptions.bitrate && !needsResample && audioCodecs.includes(sourceCodec) - && (!this._options.audio?.codec || this._options.audio.codec === sourceCodec) + && (!trackOptions.codec || trackOptions.codec === sourceCodec) ) { // Fast path, we can simply copy over the encoded packets @@ -832,11 +895,11 @@ export class Conversion { let codecOfChoice: AudioCodec | null = null; - if (this._options.audio?.codec) { - audioCodecs = audioCodecs.filter(codec => codec === this._options.audio!.codec); + if (trackOptions.codec) { + audioCodecs = audioCodecs.filter(codec => codec === trackOptions.codec); } - const bitrate = this._options.audio?.bitrate ?? QUALITY_HIGH; + const bitrate = trackOptions.bitrate ?? QUALITY_HIGH; const encodableCodecs = await getEncodableAudioCodecs(audioCodecs, { numberOfChannels, diff --git a/src/index.ts b/src/index.ts index ba24c07..5c28b03 100644 --- a/src/index.ts +++ b/src/index.ts @@ -84,7 +84,7 @@ export { getFirstEncodableSubtitleCodec, } from './codec'; export { Target, BufferTarget, StreamTarget, StreamTargetChunk, StreamTargetOptions } from './target'; -export { Rotation, AnyIterable, SetRequired } from './misc'; +export { Rotation, AnyIterable, SetRequired, MaybePromise } from './misc'; export { Source, BufferSource, @@ -135,7 +135,7 @@ export { AudioBufferSink, WrappedAudioBuffer, } from './media-sink'; -export { ConversionOptions, Conversion } from './conversion'; +export { Conversion, ConversionOptions, ConversionVideoOptions, ConversionAudioOptions } from './conversion'; export { CustomVideoDecoder, CustomAudioDecoder, diff --git a/src/misc.ts b/src/misc.ts index 079ab5e..25494f5 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -610,3 +610,9 @@ export const isSafari = () => { isSafariCache = result; return result; }; + +/** + * T or a promise that resolves to T. + * @public + */ +export type MaybePromise = T | Promise;