Implement frame rate adjustment logic for Conversion API, fix source not being closed

This commit is contained in:
Vanilagy
2025-08-08 18:39:08 +02:00
parent 7e42e298c5
commit 1fe115ac83
3 changed files with 155 additions and 46 deletions
+4 -2
View File
@@ -70,8 +70,10 @@
}, },
*/ */
video: { video: {
forceTranscode: true, frameRate: 27.123,
codec: 'av1', //width: 320,
//forceTranscode: true,
//codec: 'av1',
//discard: true, //discard: true,
//width: 1280, //width: 1280,
//discard: true, //discard: true,
+6
View File
@@ -11,6 +11,7 @@ It has the following features:
- Trimming - Trimming
- Video resizing & fitting - Video resizing & fitting
- Video rotation - Video rotation
- Video frame rate adjustment
- Audio resampling - Audio resampling
- Audio up/downmixing - Audio up/downmixing
@@ -101,6 +102,7 @@ type ConversionOptions = {
height?: number; height?: number;
fit?: 'fill' | 'contain' | 'cover'; fit?: 'fill' | 'contain' | 'cover';
rotate?: 0 | 90 | 180 | 270; rotate?: 0 | 90 | 180 | 270;
frameRate?: number;
codec?: VideoCodec; codec?: VideoCodec;
bitrate?: number | Quality; bitrate?: number | Quality;
forceTranscode?: boolean; 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. 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 ### 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). 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).
+145 -44
View File
@@ -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. * rotation is _in addition to_ the natural rotation of the input video as specified in input file's metadata.
*/ */
rotate?: Rotation; 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. */ /** The desired output video codec. */
codec?: VideoCodec; codec?: VideoCodec;
/** The desired bitrate of the output video. */ /** The desired bitrate of the output video. */
bitrate?: VideoEncodingConfig['bitrate']; bitrate?: VideoEncodingConfig['bitrate'];
/** When true, video will always be re-encoded instead of directly copying over the encoded samples. */ /** When true, video will always be re-encoded instead of directly copying over the encoded samples. */
forceTranscode?: boolean; 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. */ /** 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.'); throw new TypeError('options.video.rotate, when provided, must be 0, 90, 180 or 270.');
} }
if ( if (
options.video?.fps !== undefined options.video?.frameRate !== undefined
&& (typeof options.video.fps !== 'number' || isNaN(options.video.fps) || options.video.fps <= 0) && (!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')) { 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') { if (options.audio?.discard !== undefined && typeof options.audio.discard !== 'boolean') {
throw new TypeError('options.audio.discard, when provided, must be a 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); 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 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 const needsRerender = width !== originalWidth
|| height !== originalHeight || height !== originalHeight
|| (totalRotation !== 0 && !outputSupportsRotation) || (totalRotation !== 0 && !outputSupportsRotation);
|| fps !== originalFps;
let videoCodecs = this.output.format.getSupportedVideoCodecs(); let videoCodecs = this.output.format.getSupportedVideoCodecs();
if ( if (
@@ -563,10 +561,10 @@ export class Conversion {
onEncodedPacket: sample => this._reportProgress(track.id, sample.timestamp + sample.duration), onEncodedPacket: sample => this._reportProgress(track.id, sample.timestamp + sample.duration),
}; };
if (needsRerender) { const source = new VideoSampleSource(encodingConfig);
const source = new VideoSampleSource(encodingConfig); videoSource = source;
videoSource = source;
if (needsRerender) {
this._trackPromises.push((async () => { this._trackPromises.push((async () => {
await this._started; await this._started;
@@ -578,10 +576,27 @@ export class Conversion {
poolSize: 1, poolSize: 1,
}); });
const iterator = sink.canvases(this._startTimestamp, this._endTimestamp); 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) { for await (const { canvas, timestamp, duration } of iterator) {
if (this._synchronizer.shouldWait(track.id, timestamp)) { if (this._synchronizer.shouldWait(track.id, timestamp)) {
@@ -592,49 +607,134 @@ export class Conversion {
return; return;
} }
const relativeToStartTimestamp = Math.max(timestamp - this._startTimestamp, 0); let adjustedSampleTimestamp = Math.max(timestamp - this._startTimestamp, 0);
if (originalFps === fps) { lastCanvasEndTimestamp = timestamp + duration;
const sample = new VideoSample(canvas, {
timestamp: relativeToStartTimestamp, if (frameRate !== undefined) {
duration, // Logic for skipping/repeating frames when a frame rate is set
}); const alignedTimestamp = Math.floor(adjustedSampleTimestamp * frameRate) / frameRate;
await source.add(sample);
sample.close(); if (lastCanvas !== null) {
} else { if (alignedTimestamp <= lastCanvasTimestamp!) {
while (relativeToStartTimestamp >= outputTimestamp) { lastCanvas = canvas;
const sample = new VideoSample(canvas, { lastCanvasTimestamp = alignedTimestamp;
timestamp: outputTimestamp,
duration: frameDuration, // Skip this sample, since we already added one for this frame
}); continue;
await source.add(sample); } else {
sample.close(); // Check if we may need to repeat the previous frame
outputTimestamp += frameDuration; 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 { } else {
const source = new VideoSampleSource(encodingConfig);
videoSource = source;
this._trackPromises.push((async () => { this._trackPromises.push((async () => {
await this._started; await this._started;
const sink = new VideoSampleSink(track); 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)) { for await (const sample of sink.samples(this._startTimestamp, this._endTimestamp)) {
if (this._synchronizer.shouldWait(track.id, sample.timestamp)) { if (this._synchronizer.shouldWait(track.id, sample.timestamp)) {
await this._synchronizer.wait(sample.timestamp); await this._synchronizer.wait(sample.timestamp);
} }
sample.setTimestamp(Math.max(sample.timestamp - this._startTimestamp, 0));
if (this._canceled) { if (this._canceled) {
lastSample?.close();
return; 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); 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(); source.close();
@@ -644,6 +744,7 @@ export class Conversion {
} }
this.output.addVideoTrack(videoSource, { this.output.addVideoTrack(videoSource, {
frameRate: this._options.video?.frameRate,
languageCode: track.languageCode, languageCode: track.languageCode,
rotation: needsRerender ? 0 : totalRotation, // Rerendering will bake the rotation into the output rotation: needsRerender ? 0 : totalRotation, // Rerendering will bake the rotation into the output
}); });