mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-27 02:43:48 +02:00
Implement frame rate adjustment logic for Conversion API, fix source not being closed
This commit is contained in:
+4
-2
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user