mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-27 10:53:50 +02:00
Add track-specific conversion options, fix esbuild race condition
This commit is contained in:
+4
-3
@@ -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
|
||||
|
||||
@@ -92,11 +92,9 @@ 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?: {
|
||||
type ConversionVideoOptions = {
|
||||
discard?: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
@@ -107,8 +105,6 @@ type ConversionOptions = {
|
||||
bitrate?: number | Quality;
|
||||
forceTranscode?: boolean;
|
||||
};
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
For example, here we resize the video track to 720p:
|
||||
@@ -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,11 +155,9 @@ 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?: {
|
||||
type ConversionAudioOptions = {
|
||||
discard?: boolean;
|
||||
codec?: AudioCodec;
|
||||
bitrate?: number | Quality;
|
||||
@@ -169,8 +165,6 @@ type ConversionOptions = {
|
||||
sampleRate?: number;
|
||||
forceTranscode?: boolean;
|
||||
};
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
For example, here we convert the audio track to mono and set a specific sample rate:
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
+199
-136
@@ -35,22 +35,15 @@ 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';
|
||||
|
||||
/**
|
||||
* The options for media file conversion.
|
||||
* Video-specific options.
|
||||
* @public
|
||||
*/
|
||||
export type ConversionOptions = {
|
||||
/** The input file. */
|
||||
input: Input;
|
||||
/** The output file. */
|
||||
output: Output;
|
||||
|
||||
/** Video-specific options. */
|
||||
video?: {
|
||||
export type ConversionVideoOptions = {
|
||||
/** If true, all video tracks will be discarded and will not be present in the output. */
|
||||
discard?: boolean;
|
||||
/**
|
||||
@@ -90,8 +83,11 @@ export type ConversionOptions = {
|
||||
forceTranscode?: boolean;
|
||||
};
|
||||
|
||||
/** Audio-specific options. */
|
||||
audio?: {
|
||||
/**
|
||||
* 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. */
|
||||
@@ -106,6 +102,92 @@ export type ConversionOptions = {
|
||||
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
|
||||
*/
|
||||
export type ConversionOptions = {
|
||||
/** The input file. */
|
||||
input: Input;
|
||||
/** The output file. */
|
||||
output: Output;
|
||||
|
||||
/**
|
||||
* 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<ConversionVideoOptions | undefined>);
|
||||
|
||||
/**
|
||||
* 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<ConversionAudioOptions | undefined>);
|
||||
|
||||
/** Options to trim the input file. */
|
||||
trim?: {
|
||||
/** The time in the input file in seconds at which the output file should start. Must be less than `end`. */
|
||||
@@ -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,
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
@@ -610,3 +610,9 @@ export const isSafari = () => {
|
||||
isSafariCache = result;
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* T or a promise that resolves to T.
|
||||
* @public
|
||||
*/
|
||||
export type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
Reference in New Issue
Block a user