mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-02 13:23:51 +02:00
Add custom processing to Conversion API
This commit is contained in:
@@ -56,6 +56,11 @@
|
|||||||
output,
|
output,
|
||||||
audio: (_, n) => ({
|
audio: (_, n) => ({
|
||||||
discard: n > 1,
|
discard: n > 1,
|
||||||
|
/*
|
||||||
|
process: (sample) => {
|
||||||
|
return sample;
|
||||||
|
},
|
||||||
|
*/
|
||||||
//codec: 'pcm-s16',
|
//codec: 'pcm-s16',
|
||||||
//sampleRate: 16000,
|
//sampleRate: 16000,
|
||||||
//numberOfChannels: 1,
|
//numberOfChannels: 1,
|
||||||
@@ -92,6 +97,24 @@
|
|||||||
},
|
},
|
||||||
*/
|
*/
|
||||||
video: () => ({
|
video: () => ({
|
||||||
|
/*
|
||||||
|
process: (sample) => {
|
||||||
|
if (!ctx) {
|
||||||
|
// Create a canvas for image compositing
|
||||||
|
const canvas = new OffscreenCanvas(
|
||||||
|
sample.displayWidth,
|
||||||
|
sample.displayHeight,
|
||||||
|
);
|
||||||
|
ctx = canvas.getContext('2d');
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||||
|
sample.draw(ctx, 0, 0);
|
||||||
|
ctx.drawImage(watermark, 32, 32);
|
||||||
|
|
||||||
|
return ctx.canvas;
|
||||||
|
},
|
||||||
|
*/
|
||||||
width: 300,
|
width: 300,
|
||||||
//alpha: 'keep',
|
//alpha: 'keep',
|
||||||
//width: 320,
|
//width: 320,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ It has the following features:
|
|||||||
- Video transparency removal/preservation
|
- Video transparency removal/preservation
|
||||||
- Audio resampling
|
- Audio resampling
|
||||||
- Audio up/downmixing
|
- Audio up/downmixing
|
||||||
|
- User-defined video & audio processing
|
||||||
|
|
||||||
The conversion API was built to be simple, versatile and extremely performant.
|
The conversion API was built to be simple, versatile and extremely performant.
|
||||||
|
|
||||||
@@ -126,7 +127,14 @@ type ConversionVideoOptions = {
|
|||||||
alpha?: 'discard' | 'keep'; // Defaults to 'discard'
|
alpha?: 'discard' | 'keep'; // Defaults to 'discard'
|
||||||
keyFrameInterval?: number;
|
keyFrameInterval?: number;
|
||||||
forceTranscode?: boolean;
|
forceTranscode?: boolean;
|
||||||
|
process?: (sample: VideoSample) => MaybePromise<
|
||||||
|
CanvasImageSource | VideoSample | (CanvasImageSource | VideoSample)[] | null
|
||||||
|
>;
|
||||||
|
processedWidth?: number;
|
||||||
|
processedHeight?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MaybePromise<T> = T | Promise<T>;
|
||||||
```
|
```
|
||||||
|
|
||||||
For example, here we resize the video track to 720p:
|
For example, here we resize the video track to 720p:
|
||||||
@@ -185,6 +193,39 @@ Use the `keyFrameInterval` property to control the maximum interval in seconds b
|
|||||||
|
|
||||||
If you want to prevent direct copying of media data and force a transcoding step, use `forceTranscode: true`.
|
If you want to prevent direct copying of media data and force a transcoding step, use `forceTranscode: true`.
|
||||||
|
|
||||||
|
### Processing video
|
||||||
|
|
||||||
|
The `process` property can be used to define a custom video sample processing function, e.g. for [applying overlays](./quick-start#add-a-video-overlay), color transformations, or timestamp modifications. You are expected to perform this processing yourself, for example using the Canvas API.
|
||||||
|
|
||||||
|
An example:
|
||||||
|
```ts
|
||||||
|
let ctx: CanvasRenderingContext2D | null = null;
|
||||||
|
const conversion = await Conversion.init({
|
||||||
|
video: {
|
||||||
|
process: (sample) => {
|
||||||
|
if (!ctx) {
|
||||||
|
const canvas = new OffscreenCanvas(
|
||||||
|
sample.displayWidth,
|
||||||
|
sample.displayHeight,
|
||||||
|
);
|
||||||
|
ctx = canvas.getContext('2d')!;
|
||||||
|
|
||||||
|
// Convert the video to grayscale
|
||||||
|
ctx.filter = 'saturate(0)';
|
||||||
|
}
|
||||||
|
|
||||||
|
sample.draw(ctx, 0, 0);
|
||||||
|
|
||||||
|
return ctx.canvas;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The function is called for each input video sample after transformations and frame rate corrections. It must return a [`VideoSample`](./packets-and-samples#videosample), something that can convert to a `VideoSample`, an array of them, or `null` for dropping the frame.
|
||||||
|
|
||||||
|
This function can also be used to manually resize frames. When doing so, you should signal the post-process dimensions using the `processedWidth` and `processedHeight` fields, which enables the encoder to better know what to expect.
|
||||||
|
|
||||||
## Audio options
|
## Audio options
|
||||||
|
|
||||||
You can set the `audio` property in the conversion options to configure the converter's behavior for audio tracks. The options are:
|
You can set the `audio` property in the conversion options to configure the converter's behavior for audio tracks. The options are:
|
||||||
@@ -196,7 +237,14 @@ type ConversionAudioOptions = {
|
|||||||
numberOfChannels?: number;
|
numberOfChannels?: number;
|
||||||
sampleRate?: number;
|
sampleRate?: number;
|
||||||
forceTranscode?: boolean;
|
forceTranscode?: boolean;
|
||||||
|
process?: (sample: AudioSample) => MaybePromise<
|
||||||
|
AudioSample | AudioSample[] | null
|
||||||
|
>;
|
||||||
|
processedNumberOfChannels?: number;
|
||||||
|
processedSampleRate?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MaybePromise<T> = T | Promise<T>;
|
||||||
```
|
```
|
||||||
|
|
||||||
For example, here we convert the audio track to mono and set a specific sample rate:
|
For example, here we convert the audio track to mono and set a specific sample rate:
|
||||||
@@ -233,6 +281,14 @@ 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`.
|
If you want to prevent direct copying of media data and force a transcoding step, use `forceTranscode: true`.
|
||||||
|
|
||||||
|
### Processing audio
|
||||||
|
|
||||||
|
The `process` property can be used to define a custom audio sample processing function, e.g. for applying audio effects, transformations, or timestamp modifications. You are expected to perform this processing yourself.
|
||||||
|
|
||||||
|
The function is called for each input audio sample after remixing and resampling. It must return an [`AudioSample`](./packets-and-samples#audiosample), an array of them, or `null` for dropping the sample.
|
||||||
|
|
||||||
|
This function can also be used to manually perform remixing or resampling. When doing so, you should signal the post-process parameters using the `processedNumberOfChannels` and `processedSampleRate` fields, which enables the encoder to better know what to expect.
|
||||||
|
|
||||||
## Track-specific options
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -579,3 +579,48 @@ await conversion.execute();
|
|||||||
::: info
|
::: info
|
||||||
- Check out the <a href="/examples/file-compression">File compression example</a> for this code in action.
|
- Check out the <a href="/examples/file-compression">File compression example</a> for this code in action.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
## Add a video overlay
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
Input,
|
||||||
|
Output,
|
||||||
|
Conversion,
|
||||||
|
} from 'mediabunny';
|
||||||
|
|
||||||
|
// For example, let's load a watermark image
|
||||||
|
const watermark = new Image();
|
||||||
|
watermark.src = '/watermark.jpg';
|
||||||
|
await new Promise(resolve => watermark.onload = resolve);
|
||||||
|
|
||||||
|
const input = new Input(...);
|
||||||
|
const output = new Output(...);
|
||||||
|
|
||||||
|
let ctx: CanvasRenderingContext2D | null = null;
|
||||||
|
const conversion = await Conversion.init({
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
video: {
|
||||||
|
process: (sample) => {
|
||||||
|
if (!ctx) {
|
||||||
|
// Create a canvas for image compositing
|
||||||
|
const canvas = new OffscreenCanvas(
|
||||||
|
sample.displayWidth,
|
||||||
|
sample.displayHeight,
|
||||||
|
);
|
||||||
|
ctx = canvas.getContext('2d')!;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||||
|
sample.draw(ctx, 0, 0);
|
||||||
|
ctx.drawImage(watermark, 32, 32);
|
||||||
|
|
||||||
|
return ctx.canvas;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await conversion.execute();
|
||||||
|
// Conversion is complete
|
||||||
|
```
|
||||||
+237
-43
@@ -176,6 +176,31 @@ export type ConversionVideoOptions = {
|
|||||||
keyFrameInterval?: number;
|
keyFrameInterval?: number;
|
||||||
/** 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;
|
||||||
|
/**
|
||||||
|
* Allows for custom user-defined processing of video frames, e.g. for applying overlays, color transformations, or
|
||||||
|
* timestamp modifications. Will be called for each input video sample after transformations and frame rate
|
||||||
|
* corrections.
|
||||||
|
*
|
||||||
|
* Must return a {@link VideoSample} or a `CanvasImageSource`, an array of them, or `null` for dropping the frame.
|
||||||
|
* When non-timestamped data is returned, the timestamp and duration from the source sample will be used.
|
||||||
|
*
|
||||||
|
* This function can also be used to manually resize frames. When doing so, you should signal the post-process
|
||||||
|
* dimensions using the `processedWidth` and `processedHeight` fields, which enables the encoder to better know what
|
||||||
|
* to expect.
|
||||||
|
*/
|
||||||
|
process?: (sample: VideoSample) => MaybePromise<
|
||||||
|
CanvasImageSource | VideoSample | (CanvasImageSource | VideoSample)[] | null
|
||||||
|
>;
|
||||||
|
/**
|
||||||
|
* An optional hint specifying the width of video samples returned by the `process` function, for better
|
||||||
|
* encoder configuration.
|
||||||
|
*/
|
||||||
|
processedWidth?: number;
|
||||||
|
/**
|
||||||
|
* An optional hint specifying the height of video samples returned by the `process` function, for better
|
||||||
|
* encoder configuration.
|
||||||
|
*/
|
||||||
|
processedHeight?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -196,6 +221,29 @@ export type ConversionAudioOptions = {
|
|||||||
bitrate?: number | Quality;
|
bitrate?: number | Quality;
|
||||||
/** When `true`, audio will always be re-encoded instead of directly copying over the encoded samples. */
|
/** When `true`, audio will always be re-encoded instead of directly copying over the encoded samples. */
|
||||||
forceTranscode?: boolean;
|
forceTranscode?: boolean;
|
||||||
|
/**
|
||||||
|
* Allows for custom user-defined processing of audio samples, e.g. for applying audio effects, transformations, or
|
||||||
|
* timestamp modifications. Will be called for each input audio sample after remixing and resampling.
|
||||||
|
*
|
||||||
|
* Must return an {@link AudioSample}, an array of them, or `null` for dropping the sample.
|
||||||
|
*
|
||||||
|
* This function can also be used to manually perform remixing or resampling. When doing so, you should signal the
|
||||||
|
* post-process parameters using the `processedNumberOfChannels` and `processedSampleRate` fields, which enables the
|
||||||
|
* encoder to better know what to expect.
|
||||||
|
*/
|
||||||
|
process?: (sample: AudioSample) => MaybePromise<
|
||||||
|
AudioSample | AudioSample[] | null
|
||||||
|
>;
|
||||||
|
/**
|
||||||
|
* An optional hint specifying the channel count of audio samples returned by the `process` function, for better
|
||||||
|
* encoder configuration.
|
||||||
|
*/
|
||||||
|
processedNumberOfChannels?: number;
|
||||||
|
/**
|
||||||
|
* An optional hint specifying the sample rate of audio samples returned by the `process` function, for better
|
||||||
|
* encoder configuration.
|
||||||
|
*/
|
||||||
|
processedSampleRate?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateVideoOptions = (videoOptions: ConversionVideoOptions | undefined) => {
|
const validateVideoOptions = (videoOptions: ConversionVideoOptions | undefined) => {
|
||||||
@@ -264,7 +312,22 @@ const validateVideoOptions = (videoOptions: ConversionVideoOptions | undefined)
|
|||||||
videoOptions?.keyFrameInterval !== undefined
|
videoOptions?.keyFrameInterval !== undefined
|
||||||
&& (!Number.isFinite(videoOptions.keyFrameInterval) || videoOptions.keyFrameInterval < 0)
|
&& (!Number.isFinite(videoOptions.keyFrameInterval) || videoOptions.keyFrameInterval < 0)
|
||||||
) {
|
) {
|
||||||
throw new TypeError('config.keyFrameInterval, when provided, must be a non-negative number.');
|
throw new TypeError('options.video.keyFrameInterval, when provided, must be a non-negative number.');
|
||||||
|
}
|
||||||
|
if (videoOptions?.process !== undefined && typeof videoOptions.process !== 'function') {
|
||||||
|
throw new TypeError('options.video.process, when provided, must be a function.');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
videoOptions?.processedWidth !== undefined
|
||||||
|
&& (!Number.isInteger(videoOptions.processedWidth) || videoOptions.processedWidth <= 0)
|
||||||
|
) {
|
||||||
|
throw new TypeError('options.video.processedWidth, when provided, must be a positive integer.');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
videoOptions?.processedHeight !== undefined
|
||||||
|
&& (!Number.isInteger(videoOptions.processedHeight) || videoOptions.processedHeight <= 0)
|
||||||
|
) {
|
||||||
|
throw new TypeError('options.video.processedHeight, when provided, must be a positive integer.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -302,6 +365,21 @@ const validateAudioOptions = (audioOptions: ConversionAudioOptions | undefined)
|
|||||||
) {
|
) {
|
||||||
throw new TypeError('options.audio.sampleRate, when provided, must be a positive integer.');
|
throw new TypeError('options.audio.sampleRate, when provided, must be a positive integer.');
|
||||||
}
|
}
|
||||||
|
if (audioOptions?.process !== undefined && typeof audioOptions.process !== 'function') {
|
||||||
|
throw new TypeError('options.audio.process, when provided, must be a function.');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
audioOptions?.processedNumberOfChannels !== undefined
|
||||||
|
&& (!Number.isInteger(audioOptions.processedNumberOfChannels) || audioOptions.processedNumberOfChannels <= 0)
|
||||||
|
) {
|
||||||
|
throw new TypeError('options.audio.processedNumberOfChannels, when provided, must be a positive integer.');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
audioOptions?.processedSampleRate !== undefined
|
||||||
|
&& (!Number.isInteger(audioOptions.processedSampleRate) || audioOptions.processedSampleRate <= 0)
|
||||||
|
) {
|
||||||
|
throw new TypeError('options.audio.processedSampleRate, when provided, must be a positive integer.');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const FALLBACK_NUMBER_OF_CHANNELS = 2;
|
const FALLBACK_NUMBER_OF_CHANNELS = 2;
|
||||||
@@ -790,7 +868,8 @@ export class Conversion {
|
|||||||
|| this._startTimestamp > 0
|
|| this._startTimestamp > 0
|
||||||
|| firstTimestamp < 0
|
|| firstTimestamp < 0
|
||||||
|| !!trackOptions.frameRate
|
|| !!trackOptions.frameRate
|
||||||
|| trackOptions.keyFrameInterval !== undefined;
|
|| trackOptions.keyFrameInterval !== undefined
|
||||||
|
|| trackOptions.process !== undefined;
|
||||||
let needsRerender = width !== originalWidth
|
let needsRerender = width !== originalWidth
|
||||||
|| height !== originalHeight
|
|| height !== originalHeight
|
||||||
|| (totalRotation !== 0 && !outputSupportsRotation)
|
|| (totalRotation !== 0 && !outputSupportsRotation)
|
||||||
@@ -822,10 +901,6 @@ export class Conversion {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
for await (const packet of sink.packets(undefined, endPacket, { verifyKeyPackets: true })) {
|
for await (const packet of sink.packets(undefined, endPacket, { verifyKeyPackets: true })) {
|
||||||
if (this._synchronizer.shouldWait(track.id, packet.timestamp)) {
|
|
||||||
await this._synchronizer.wait(packet.timestamp);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._canceled) {
|
if (this._canceled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -836,8 +911,12 @@ export class Conversion {
|
|||||||
delete packet.sideData.alphaByteLength;
|
delete packet.sideData.alphaByteLength;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._reportProgress(track.id, packet.timestamp);
|
||||||
await source.add(packet, meta);
|
await source.add(packet, meta);
|
||||||
this._reportProgress(track.id, packet.timestamp + packet.duration);
|
|
||||||
|
if (this._synchronizer.shouldWait(track.id, packet.timestamp)) {
|
||||||
|
await this._synchronizer.wait(packet.timestamp);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
source.close();
|
source.close();
|
||||||
@@ -861,7 +940,15 @@ export class Conversion {
|
|||||||
|
|
||||||
const bitrate = trackOptions.bitrate ?? QUALITY_HIGH;
|
const bitrate = trackOptions.bitrate ?? QUALITY_HIGH;
|
||||||
|
|
||||||
const encodableCodec = await getFirstEncodableVideoCodec(videoCodecs, { width, height, bitrate });
|
const encodableCodec = await getFirstEncodableVideoCodec(videoCodecs, {
|
||||||
|
width: trackOptions.process && trackOptions.processedWidth
|
||||||
|
? trackOptions.processedWidth
|
||||||
|
: width,
|
||||||
|
height: trackOptions.process && trackOptions.processedHeight
|
||||||
|
? trackOptions.processedHeight
|
||||||
|
: height,
|
||||||
|
bitrate,
|
||||||
|
});
|
||||||
if (!encodableCodec) {
|
if (!encodableCodec) {
|
||||||
this.discardedTracks.push({
|
this.discardedTracks.push({
|
||||||
track,
|
track,
|
||||||
@@ -876,7 +963,6 @@ export class Conversion {
|
|||||||
keyFrameInterval: trackOptions.keyFrameInterval,
|
keyFrameInterval: trackOptions.keyFrameInterval,
|
||||||
sizeChangeBehavior: trackOptions.fit ?? 'passThrough',
|
sizeChangeBehavior: trackOptions.fit ?? 'passThrough',
|
||||||
alpha,
|
alpha,
|
||||||
onEncodedPacket: sample => this._reportProgress(track.id, sample.timestamp + sample.duration),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const source = new VideoSampleSource(encodingConfig);
|
const source = new VideoSampleSource(encodingConfig);
|
||||||
@@ -889,7 +975,7 @@ export class Conversion {
|
|||||||
// back to the rerender path.
|
// back to the rerender path.
|
||||||
//
|
//
|
||||||
// Creating a new temporary Output is sort of hacky, but due to a lack of an isolated encoder API right
|
// Creating a new temporary Output is sort of hacky, but due to a lack of an isolated encoder API right
|
||||||
// now, this is the simplest way. Will refactor in the future!
|
// now, this is the simplest way. Will refactor in the future! TODO
|
||||||
|
|
||||||
const tempOutput = new Output({
|
const tempOutput = new Output({
|
||||||
format: new Mp4OutputFormat(), // Supports all video codecs
|
format: new Mp4OutputFormat(), // Supports all video codecs
|
||||||
@@ -951,15 +1037,11 @@ export class Conversion {
|
|||||||
timestamp: lastCanvasTimestamp! + i / frameRate,
|
timestamp: lastCanvasTimestamp! + i / frameRate,
|
||||||
duration: 1 / frameRate,
|
duration: 1 / frameRate,
|
||||||
});
|
});
|
||||||
await source.add(sample);
|
await this._registerVideoSample(track, trackOptions, source, sample);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
for await (const { canvas, timestamp, duration } of iterator) {
|
for await (const { canvas, timestamp, duration } of iterator) {
|
||||||
if (this._synchronizer.shouldWait(track.id, timestamp)) {
|
|
||||||
await this._synchronizer.wait(timestamp);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._canceled) {
|
if (this._canceled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -991,8 +1073,7 @@ export class Conversion {
|
|||||||
timestamp: adjustedSampleTimestamp,
|
timestamp: adjustedSampleTimestamp,
|
||||||
duration: frameRate !== undefined ? 1 / frameRate : duration,
|
duration: frameRate !== undefined ? 1 / frameRate : duration,
|
||||||
});
|
});
|
||||||
|
await this._registerVideoSample(track, trackOptions, source, sample);
|
||||||
await source.add(sample);
|
|
||||||
|
|
||||||
if (frameRate !== undefined) {
|
if (frameRate !== undefined) {
|
||||||
lastCanvas = canvas;
|
lastCanvas = canvas;
|
||||||
@@ -1034,17 +1115,13 @@ export class Conversion {
|
|||||||
for (let i = 1; i < frameDifference; i++) {
|
for (let i = 1; i < frameDifference; i++) {
|
||||||
lastSample.setTimestamp(lastSampleTimestamp! + i / frameRate);
|
lastSample.setTimestamp(lastSampleTimestamp! + i / frameRate);
|
||||||
lastSample.setDuration(1 / frameRate);
|
lastSample.setDuration(1 / frameRate);
|
||||||
await source.add(lastSample);
|
await this._registerVideoSample(track, trackOptions, source, lastSample);
|
||||||
}
|
}
|
||||||
|
|
||||||
lastSample.close();
|
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)) {
|
|
||||||
await this._synchronizer.wait(sample.timestamp);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._canceled) {
|
if (this._canceled) {
|
||||||
lastSample?.close();
|
lastSample?.close();
|
||||||
return;
|
return;
|
||||||
@@ -1076,7 +1153,7 @@ export class Conversion {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sample.setTimestamp(adjustedSampleTimestamp);
|
sample.setTimestamp(adjustedSampleTimestamp);
|
||||||
await source.add(sample);
|
await this._registerVideoSample(track, trackOptions, source, sample);
|
||||||
|
|
||||||
if (frameRate !== undefined) {
|
if (frameRate !== undefined) {
|
||||||
lastSample = sample;
|
lastSample = sample;
|
||||||
@@ -1113,6 +1190,67 @@ export class Conversion {
|
|||||||
this.utilizedTracks.push(track);
|
this.utilizedTracks.push(track);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
async _registerVideoSample(
|
||||||
|
track: InputVideoTrack,
|
||||||
|
trackOptions: ConversionVideoOptions,
|
||||||
|
source: VideoSampleSource,
|
||||||
|
sample: VideoSample,
|
||||||
|
) {
|
||||||
|
if (this._canceled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._reportProgress(track.id, sample.timestamp);
|
||||||
|
|
||||||
|
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, {
|
||||||
|
timestamp: sample.timestamp,
|
||||||
|
duration: sample.duration,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const finalSample of finalSamples) {
|
||||||
|
if (this._canceled) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await source.add(finalSample);
|
||||||
|
|
||||||
|
if (this._synchronizer.shouldWait(track.id, finalSample.timestamp)) {
|
||||||
|
await this._synchronizer.wait(finalSample.timestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const finalSample of finalSamples) {
|
||||||
|
if (finalSample !== sample) {
|
||||||
|
finalSample.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
async _processAudioTrack(track: InputAudioTrack, trackOptions: ConversionAudioOptions) {
|
async _processAudioTrack(track: InputAudioTrack, trackOptions: ConversionAudioOptions) {
|
||||||
const sourceCodec = track.codec;
|
const sourceCodec = track.codec;
|
||||||
@@ -1145,6 +1283,7 @@ export class Conversion {
|
|||||||
&& !needsResample
|
&& !needsResample
|
||||||
&& audioCodecs.includes(sourceCodec)
|
&& audioCodecs.includes(sourceCodec)
|
||||||
&& (!trackOptions.codec || trackOptions.codec === sourceCodec)
|
&& (!trackOptions.codec || trackOptions.codec === sourceCodec)
|
||||||
|
&& !trackOptions.process
|
||||||
) {
|
) {
|
||||||
// Fast path, we can simply copy over the encoded packets
|
// Fast path, we can simply copy over the encoded packets
|
||||||
|
|
||||||
@@ -1162,16 +1301,16 @@ export class Conversion {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
for await (const packet of sink.packets(undefined, endPacket)) {
|
for await (const packet of sink.packets(undefined, endPacket)) {
|
||||||
if (this._synchronizer.shouldWait(track.id, packet.timestamp)) {
|
|
||||||
await this._synchronizer.wait(packet.timestamp);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._canceled) {
|
if (this._canceled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._reportProgress(track.id, packet.timestamp);
|
||||||
await source.add(packet, meta);
|
await source.add(packet, meta);
|
||||||
this._reportProgress(track.id, packet.timestamp + packet.duration);
|
|
||||||
|
if (this._synchronizer.shouldWait(track.id, packet.timestamp)) {
|
||||||
|
await this._synchronizer.wait(packet.timestamp);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
source.close();
|
source.close();
|
||||||
@@ -1198,8 +1337,12 @@ export class Conversion {
|
|||||||
const bitrate = trackOptions.bitrate ?? QUALITY_HIGH;
|
const bitrate = trackOptions.bitrate ?? QUALITY_HIGH;
|
||||||
|
|
||||||
const encodableCodecs = await getEncodableAudioCodecs(audioCodecs, {
|
const encodableCodecs = await getEncodableAudioCodecs(audioCodecs, {
|
||||||
numberOfChannels,
|
numberOfChannels: trackOptions.process && trackOptions.processedNumberOfChannels
|
||||||
sampleRate,
|
? trackOptions.processedNumberOfChannels
|
||||||
|
: numberOfChannels,
|
||||||
|
sampleRate: trackOptions.process && trackOptions.processedSampleRate
|
||||||
|
? trackOptions.processedSampleRate
|
||||||
|
: sampleRate,
|
||||||
bitrate,
|
bitrate,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1207,6 +1350,7 @@ export class Conversion {
|
|||||||
!encodableCodecs.some(codec => (NON_PCM_AUDIO_CODECS as readonly string[]).includes(codec))
|
!encodableCodecs.some(codec => (NON_PCM_AUDIO_CODECS as readonly string[]).includes(codec))
|
||||||
&& audioCodecs.some(codec => (NON_PCM_AUDIO_CODECS as readonly string[]).includes(codec))
|
&& audioCodecs.some(codec => (NON_PCM_AUDIO_CODECS as readonly string[]).includes(codec))
|
||||||
&& (numberOfChannels !== FALLBACK_NUMBER_OF_CHANNELS || sampleRate !== FALLBACK_SAMPLE_RATE)
|
&& (numberOfChannels !== FALLBACK_NUMBER_OF_CHANNELS || sampleRate !== FALLBACK_SAMPLE_RATE)
|
||||||
|
&& !trackOptions.process
|
||||||
) {
|
) {
|
||||||
// We could not find a compatible non-PCM codec despite the container supporting them. This can be
|
// We could not find a compatible non-PCM codec despite the container supporting them. This can be
|
||||||
// caused by strange channel count or sample rate configurations. Therefore, let's try again but with
|
// caused by strange channel count or sample rate configurations. Therefore, let's try again but with
|
||||||
@@ -1240,12 +1384,18 @@ export class Conversion {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (needsResample) {
|
if (needsResample) {
|
||||||
audioSource = this._resampleAudio(track, codecOfChoice, numberOfChannels, sampleRate, bitrate);
|
audioSource = this._resampleAudio(
|
||||||
|
track,
|
||||||
|
trackOptions,
|
||||||
|
codecOfChoice,
|
||||||
|
numberOfChannels,
|
||||||
|
sampleRate,
|
||||||
|
bitrate,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
const source = new AudioSampleSource({
|
const source = new AudioSampleSource({
|
||||||
codec: codecOfChoice,
|
codec: codecOfChoice,
|
||||||
bitrate,
|
bitrate,
|
||||||
onEncodedPacket: packet => this._reportProgress(track.id, packet.timestamp + packet.duration),
|
|
||||||
});
|
});
|
||||||
audioSource = source;
|
audioSource = source;
|
||||||
|
|
||||||
@@ -1254,15 +1404,11 @@ export class Conversion {
|
|||||||
|
|
||||||
const sink = new AudioSampleSink(track);
|
const sink = new AudioSampleSink(track);
|
||||||
for await (const sample of sink.samples(undefined, this._endTimestamp)) {
|
for await (const sample of sink.samples(undefined, this._endTimestamp)) {
|
||||||
if (this._synchronizer.shouldWait(track.id, sample.timestamp)) {
|
|
||||||
await this._synchronizer.wait(sample.timestamp);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._canceled) {
|
if (this._canceled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await source.add(sample);
|
await this._registerAudioSample(track, trackOptions, source, sample);
|
||||||
sample.close();
|
sample.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1283,9 +1429,62 @@ export class Conversion {
|
|||||||
this.utilizedTracks.push(track);
|
this.utilizedTracks.push(track);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
async _registerAudioSample(
|
||||||
|
track: InputAudioTrack,
|
||||||
|
trackOptions: ConversionAudioOptions,
|
||||||
|
source: AudioSampleSource,
|
||||||
|
sample: AudioSample,
|
||||||
|
) {
|
||||||
|
if (this._canceled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._reportProgress(track.id, sample.timestamp);
|
||||||
|
|
||||||
|
let finalSamples: AudioSample[];
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const finalSample of finalSamples) {
|
||||||
|
if (this._canceled) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await source.add(finalSample);
|
||||||
|
|
||||||
|
if (this._synchronizer.shouldWait(track.id, finalSample.timestamp)) {
|
||||||
|
await this._synchronizer.wait(finalSample.timestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const finalSample of finalSamples) {
|
||||||
|
if (finalSample !== sample) {
|
||||||
|
finalSample.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
_resampleAudio(
|
_resampleAudio(
|
||||||
track: InputAudioTrack,
|
track: InputAudioTrack,
|
||||||
|
trackOptions: ConversionAudioOptions,
|
||||||
codec: AudioCodec,
|
codec: AudioCodec,
|
||||||
targetNumberOfChannels: number,
|
targetNumberOfChannels: number,
|
||||||
targetSampleRate: number,
|
targetSampleRate: number,
|
||||||
@@ -1294,7 +1493,6 @@ export class Conversion {
|
|||||||
const source = new AudioSampleSource({
|
const source = new AudioSampleSource({
|
||||||
codec,
|
codec,
|
||||||
bitrate,
|
bitrate,
|
||||||
onEncodedPacket: packet => this._reportProgress(track.id, packet.timestamp + packet.duration),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this._trackPromises.push((async () => {
|
this._trackPromises.push((async () => {
|
||||||
@@ -1305,17 +1503,13 @@ export class Conversion {
|
|||||||
targetSampleRate,
|
targetSampleRate,
|
||||||
startTime: this._startTimestamp,
|
startTime: this._startTimestamp,
|
||||||
endTime: this._endTimestamp,
|
endTime: this._endTimestamp,
|
||||||
onSample: sample => source.add(sample),
|
onSample: sample => this._registerAudioSample(track, trackOptions, source, sample),
|
||||||
});
|
});
|
||||||
|
|
||||||
const sink = new AudioSampleSink(track);
|
const sink = new AudioSampleSink(track);
|
||||||
const iterator = sink.samples(this._startTimestamp, this._endTimestamp);
|
const iterator = sink.samples(this._startTimestamp, this._endTimestamp);
|
||||||
|
|
||||||
for await (const sample of iterator) {
|
for await (const sample of iterator) {
|
||||||
if (this._synchronizer.shouldWait(track.id, sample.timestamp)) {
|
|
||||||
await this._synchronizer.wait(sample.timestamp);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._canceled) {
|
if (this._canceled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user