diff --git a/dev/convert.html b/dev/convert.html index 0bcba3a..38b9d23 100644 --- a/dev/convert.html +++ b/dev/convert.html @@ -70,8 +70,10 @@ }, */ video: { - forceTranscode: true, - codec: 'av1', + frameRate: 27.123, + //width: 320, + //forceTranscode: true, + //codec: 'av1', //discard: true, //width: 1280, //discard: true, diff --git a/docs/guide/converting-media-files.md b/docs/guide/converting-media-files.md index b3547cd..69c9e51 100644 --- a/docs/guide/converting-media-files.md +++ b/docs/guide/converting-media-files.md @@ -11,6 +11,7 @@ It has the following features: - Trimming - Video resizing & fitting - Video rotation +- Video frame rate adjustment - Audio resampling - Audio up/downmixing @@ -101,6 +102,7 @@ type ConversionOptions = { height?: number; fit?: 'fill' | 'contain' | 'cover'; rotate?: 0 | 90 | 180 | 270; + frameRate?: number; codec?: VideoCodec; bitrate?: number | Quality; forceTranscode?: boolean; @@ -141,6 +143,10 @@ 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. +### 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). + ### Transcoding video Use the `codec` property to control the codec of the output track. This should be set to a [codec](./supported-formats-and-codecs#video-codecs) supported by the output file, or else the track will be [discarded](#discarded-tracks). diff --git a/src/conversion.ts b/src/conversion.ts index afc6f90..7f4e156 100644 --- a/src/conversion.ts +++ b/src/conversion.ts @@ -77,14 +77,17 @@ export type ConversionOptions = { * 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; - /** The desired fps of the output video, if not specified will use the fps of the input video. */ - fps?: number; }; /** Audio-specific options. */ @@ -264,13 +267,13 @@ export class Conversion { throw new TypeError('options.video.rotate, when provided, must be 0, 90, 180 or 270.'); } if ( - options.video?.fps !== undefined - && (typeof options.video.fps !== 'number' || isNaN(options.video.fps) || options.video.fps <= 0) + options.video?.frameRate !== undefined + && (!Number.isFinite(options.video.frameRate) || options.video.frameRate <= 0) ) { - throw new TypeError('options.video.fps, when provided, must be a positive number.'); + 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.video, when provided, must be an 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.'); @@ -477,19 +480,14 @@ export class Conversion { height = ceilToMultipleOfTwo(this._options.video.height); } - const packetStats = await track.computePacketStats(); - const originalFps = packetStats.averagePacketRate; - let fps = originalFps; - if (this._options.video?.fps !== undefined) { - fps = this._options.video.fps; - } - const firstTimestamp = await track.getFirstTimestamp(); - const needsTranscode = !!this._options.video?.forceTranscode || this._startTimestamp > 0 || firstTimestamp < 0; + const needsTranscode = !!this._options.video?.forceTranscode + || this._startTimestamp > 0 + || firstTimestamp < 0 + || !!this._options.video?.frameRate; const needsRerender = width !== originalWidth || height !== originalHeight - || (totalRotation !== 0 && !outputSupportsRotation) - || fps !== originalFps; + || (totalRotation !== 0 && !outputSupportsRotation); let videoCodecs = this.output.format.getSupportedVideoCodecs(); if ( @@ -563,10 +561,10 @@ export class Conversion { onEncodedPacket: sample => this._reportProgress(track.id, sample.timestamp + sample.duration), }; - if (needsRerender) { - const source = new VideoSampleSource(encodingConfig); - videoSource = source; + const source = new VideoSampleSource(encodingConfig); + videoSource = source; + if (needsRerender) { this._trackPromises.push((async () => { await this._started; @@ -578,10 +576,27 @@ export class Conversion { poolSize: 1, }); const iterator = sink.canvases(this._startTimestamp, this._endTimestamp); + const frameRate = this._options.video?.frameRate; - const frameDuration = 1 / fps; + let lastCanvas: HTMLCanvasElement | OffscreenCanvas | null = null; + let lastCanvasTimestamp: number | null = null; + let lastCanvasEndTimestamp: number | null = null; - let outputTimestamp = 0; + /** Repeats the last sample to pad out the time until the specified timestamp. */ + const padFrames = async (until: number) => { + assert(lastCanvas); + assert(frameRate !== undefined); + + const frameDifference = Math.round((until - lastCanvasTimestamp!) * frameRate); + + for (let i = 1; i < frameDifference; i++) { + const sample = new VideoSample(lastCanvas, { + timestamp: lastCanvasTimestamp! + i / frameRate, + duration: 1 / frameRate, + }); + await source.add(sample); + } + }; for await (const { canvas, timestamp, duration } of iterator) { if (this._synchronizer.shouldWait(track.id, timestamp)) { @@ -592,49 +607,134 @@ export class Conversion { return; } - const relativeToStartTimestamp = Math.max(timestamp - this._startTimestamp, 0); - if (originalFps === fps) { - const sample = new VideoSample(canvas, { - timestamp: relativeToStartTimestamp, - duration, - }); - await source.add(sample); - sample.close(); - } else { - while (relativeToStartTimestamp >= outputTimestamp) { - const sample = new VideoSample(canvas, { - timestamp: outputTimestamp, - duration: frameDuration, - }); - await source.add(sample); - sample.close(); - outputTimestamp += frameDuration; + let adjustedSampleTimestamp = Math.max(timestamp - this._startTimestamp, 0); + lastCanvasEndTimestamp = timestamp + duration; + + if (frameRate !== undefined) { + // Logic for skipping/repeating frames when a frame rate is set + const alignedTimestamp = Math.floor(adjustedSampleTimestamp * frameRate) / frameRate; + + if (lastCanvas !== null) { + if (alignedTimestamp <= lastCanvasTimestamp!) { + lastCanvas = canvas; + lastCanvasTimestamp = alignedTimestamp; + + // Skip this sample, since we already added one for this frame + continue; + } else { + // Check if we may need to repeat the previous frame + await padFrames(alignedTimestamp); + } } + + adjustedSampleTimestamp = alignedTimestamp; + } + + const sample = new VideoSample(canvas, { + timestamp: adjustedSampleTimestamp, + duration: frameRate !== undefined ? 1 / frameRate : duration, + }); + + await source.add(sample); + + if (frameRate !== undefined) { + lastCanvas = canvas; + lastCanvasTimestamp = adjustedSampleTimestamp; + } else { + sample.close(); } } + + if (lastCanvas) { + assert(lastCanvasEndTimestamp !== null); + assert(frameRate !== undefined); + + // If necessary, pad until the end timestamp of the last sample + await padFrames(Math.floor(lastCanvasEndTimestamp * frameRate) / frameRate); + } + + source.close(); + this._synchronizer.closeTrack(track.id); })()); } else { - const source = new VideoSampleSource(encodingConfig); - videoSource = source; - this._trackPromises.push((async () => { await this._started; const sink = new VideoSampleSink(track); + const frameRate = this._options.video?.frameRate; + + let lastSample: VideoSample | null = null; + let lastSampleTimestamp: number | null = null; + let lastSampleEndTimestamp: number | null = null; + + /** Repeats the last sample to pad out the time until the specified timestamp. */ + const padFrames = async (until: number) => { + assert(lastSample); + assert(frameRate !== undefined); + + const frameDifference = Math.round((until - lastSampleTimestamp!) * frameRate); + + for (let i = 1; i < frameDifference; i++) { + lastSample.setTimestamp(lastSampleTimestamp! + i / frameRate); + lastSample.setDuration(1 / frameRate); + await source.add(lastSample); + } + + lastSample.close(); + }; for await (const sample of sink.samples(this._startTimestamp, this._endTimestamp)) { if (this._synchronizer.shouldWait(track.id, sample.timestamp)) { await this._synchronizer.wait(sample.timestamp); } - sample.setTimestamp(Math.max(sample.timestamp - this._startTimestamp, 0)); - if (this._canceled) { + lastSample?.close(); return; } + let adjustedSampleTimestamp = Math.max(sample.timestamp - this._startTimestamp, 0); + lastSampleEndTimestamp = sample.timestamp + sample.duration; + + if (frameRate !== undefined) { + // Logic for skipping/repeating frames when a frame rate is set + const alignedTimestamp = Math.floor(adjustedSampleTimestamp * frameRate) / frameRate; + + if (lastSample !== null) { + if (alignedTimestamp <= lastSampleTimestamp!) { + lastSample.close(); + lastSample = sample; + lastSampleTimestamp = alignedTimestamp; + + // Skip this sample, since we already added one for this frame + continue; + } else { + // Check if we may need to repeat the previous frame + await padFrames(alignedTimestamp); + } + } + + adjustedSampleTimestamp = alignedTimestamp; + sample.setDuration(1 / frameRate); + } + + sample.setTimestamp(adjustedSampleTimestamp); await source.add(sample); - sample.close(); + + if (frameRate !== undefined) { + lastSample = sample; + lastSampleTimestamp = adjustedSampleTimestamp; + } else { + sample.close(); + } + } + + if (lastSample) { + assert(lastSampleEndTimestamp !== null); + assert(frameRate !== undefined); + + // If necessary, pad until the end timestamp of the last sample + await padFrames(Math.floor(lastSampleEndTimestamp * frameRate) / frameRate); } source.close(); @@ -644,6 +744,7 @@ export class Conversion { } this.output.addVideoTrack(videoSource, { + frameRate: this._options.video?.frameRate, languageCode: track.languageCode, rotation: needsRerender ? 0 : totalRotation, // Rerendering will bake the rotation into the output });