diff --git a/dev/convert.html b/dev/convert.html index 0482907..1d3f215 100644 --- a/dev/convert.html +++ b/dev/convert.html @@ -41,7 +41,7 @@ //numberOfChannels: 1, //sampleRate: 4000 //discard: true - //forceReencode: true, + //forceTranscode: true, }, /* video: { @@ -72,7 +72,7 @@ //width: 1280, //discard: true, //width: 640 - //forceReencode: true, + //forceTranscode: true, //rotate: 90 //width: 720 ?? 2160, //height: 1280 ?? 3840, diff --git a/docs/guide/converting-media-files.md b/docs/guide/converting-media-files.md index ce27117..5304019 100644 --- a/docs/guide/converting-media-files.md +++ b/docs/guide/converting-media-files.md @@ -1,3 +1,280 @@ # Converting media files -ayo \ No newline at end of file +The [reading](./reading-media-files) and [writing](./writing-media-files) primitives in Mediakit provide everything you need to convert media files. However, since this is such a common operation and the details can be tricky, Mediakit ships with a built-in file conversion abstraction. + +It has the following features: + +- Transmuxing (changing the container format) +- Transcoding (changing a track's codec) +- Track removal +- Compression +- Trimming +- Video resizing & fitting +- Video rotation +- Audio resampling +- Audio up/downmixing + +The conversion API was built to be simple, versatile and extremely performant. + +## Basic usage + +### Running a conversion + +Each conversion process is represented by an instance of `Conversion`. Create a new instance using `Conversion.init(...)`, then run the conversion using `.execute()`. + +Here, we're converting to WebM: +```ts +import { + Input, + Output, + WebMOutputFormat, + BufferTarget, + Conversion, +} from 'mediakit'; + +const input = new Input({ ... }); +const output = new Output({ + format: new WebMOutputFormat(), + target: new BufferTarget(), +}); + +const conversion = await Conversion.init({ input, output }); +await conversion.execute(); + +// output.target.buffer contains the final file +``` + +That's it! A `Conversion` simply takes an instance of `Input` and `Output`, then reads the data from the input and writes it to the output. If you're unfamiliar with [`Input`](./reading-media-files) and [`Output`](./writing-media-files), check out their respective guides. + +::: info +The `Output` passed to the `Conversion` must be *fresh*; that is, it must have no added tracks and be in the `'pending'` state (not started yet). +::: + +Unconfigured, the conversion process handles all the details automatically, such as: + +- Copying media data whenever possible, otherwise transcoding it +- Dropping tracks that aren't supported in the output format + +You should consider inspecting the [discarded tracks](#discarded-tracks) before executing a `Conversion`. + +### Monitoring progress + +To monitor the progress of a `Conversion`, set its `onProgress` property *before* calling `execute`: +```ts +const conversion = await Conversion.init({ input, output }); + +conversion.onProgress = (progress: number) => { + // `progress` is a number between 0 and 1 (inclusive) +}; + +await conversion.execute(); +``` + +This callback is called each time the progress of the conversion advances. + +::: warning +A progress of `1` doesn't indicate the conversion has finished; the conversion is only finished once the promise returned by `.execute()` resolves. +::: + +::: info +Tracking conversion progress may slightly affect performance, as it requires knowledge of the input file's total duration - but this is usually negligible. +::: + +### Canceling a conversion + +Sometimes, you may want to cancel an ongoing conversion process. For this, use the `cancel` method: +```ts +await conversion.cancel(); // Resolves once the conversion is canceled +``` + +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: +```ts +type ConversionOptions = { + // ... + video?: { + discard?: boolean; + width?: number; + height?: number; + fit?: 'fill' | 'contain' | 'cover'; + rotate?: 0 | 90 | 180 | 270; + codec?: VideoCodec; + bitrate?: number | Quality; + forceTranscode?: boolean; + }; + // ... +}; +``` + +For example, here we resize the video track to 720p: +```ts +const conversion = await Conversion.init({ + input, + output, + video: { + width: 1280, + height: 720, + fit: 'contain', + }, +}); +``` + +::: info +The provided configuration will apply equally to all video tracks of the input. +::: + +### Discarding video + +If you want to get rid of the video track, use `discard: true`. + +### Resizing/rotating video + +The `width`, `height` and `fit` properties control how the video is resized. If only `width` or `height` is provided, the other value is deduced automatically to preserve the video's original aspect ratio. If both are used, `fit` must be set to control the fitting algorithm: +- `'fill'` will stretch the image to fill the entire box, potentially altering aspect ratio. +- `'contain'` will contain the entire image within the box while preserving aspect ratio. This may lead to letterboxing. +- `'cover'` will scale the image until the entire box is filled, while preserving aspect ratio. + +`rotation` rotates the video by the specified number of degrees clockwise. This rotation is applied on top of any rotation metadata in the original input file. + +If `width` or `height` is used in conjunction with `rotation`, they control the post-rotation dimensions. + +### 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 `bitrate` property to control the bitrate of the output video. For example, you can use this field to compress the video track. Accepted values are the number of bits per second or a [subjective quality](./media-sources#subjective-qualities). If this property is set, transcoding will always happen. If this property is not set but transcoding is still required, `QUALITY_HIGH` will be used as the value. + +If you want to prevent direct copying of media data and force a transcoding step, use `forceTranscode: true`. + +## Audio options + +You can set the `audio` property in the conversion options to configure the converter's behavior for audio tracks: +```ts +type ConversionOptions = { + // ... + audio?: { + discard?: boolean; + codec?: AudioCodec; + bitrate?: number | Quality; + numberOfChannels?: number; + sampleRate?: number; + forceTranscode?: boolean; + }; + // ... +}; +``` + +For example, here we convert the audio track to mono and set a specific sample rate: +```ts +const conversion = await Conversion.init({ + input, + output, + audio: { + numberOfChannels: 1, + sampleRate: 48000, + }, +}); +``` + +::: info +The provided configuration will apply equally to all audio tracks of the input. +::: + +### Discarding audio + +If you want to get rid of the audio track, use `discard: true`. + +### Resampling audio + +The `numberOfChannels` property controls the channel count of the output audio (e.g., 1 for mono, 2 for stereo). If this value differs from the number of channels in the input track, Mediakit will perform up/downmixing of the channel data using [the same algorithm as the Web Audio API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Basic_concepts_behind_Web_Audio_API#audio_channels). + +The `sampleRate` property controls the sample rate in Hz (e.g., 44100, 48000). If this value differs from the input track's sample rate, Mediakit will resample the audio. + +### Transcoding audio + +Use the `codec` property to control the codec of the output track. This should be set to a [codec](./supported-formats-and-codecs#audio-codecs) supported by the output file, or else the track will be [discarded](#discarded-tracks). + +Use the `bitrate` property to control the bitrate of the output audio. For example, you can use this field to compress the audio track. Accepted values are the number of bits per second or a [subjective quality](./media-sources#subjective-qualities). If this property is set, transcoding will always happen. If this property is not set but transcoding is still required, `QUALITY_HIGH` will be used as the value. + +If you want to prevent direct copying of media data and force a transcoding step, use `forceTranscode: true`. + +## Trimming + +Use the `trim` property in the conversion options to extract only a section of the input file into the output file: + +```ts +type ConversionOptions = { + // ... + trim?: { + start: number; // in seconds + end: number; // in seconds + }; + // ... +}; +``` + +For example, here we extract a clip from 10s to 25s: +```ts +const conversion = await Conversion.init({ + input, + output, + trim: { + start: 10, + end: 25, + }, +}); +``` + +In this case, the output will be 15 seconds long. + +If only `start` is set, the clip will run until the end of the input file. If only `end` is set, the clip will start at the beginning of the input file. + +## Discarded tracks + +If an input track is excluded from the output file, it is considered *discarded*. The list of discarded tracks can be accessed after initializing a `Conversion`: +```ts +const conversion = await Conversion.init({ input, output }); +conversion.discardedTracks; // => DiscardedTrack[] + +type DiscardedTrack = { + // The track that was discarded + track: InputTrack; + // The reason for discarding the track + reason: + | 'discarded_by_user' + | 'max_track_count_reached' + | 'max_track_count_of_type_reached' + | 'unknown_source_codec' + | 'undecodable_source_codec' + | 'no_encodable_target_codec'; +}; +``` + +Since you can inspect this list before executing a `Conversion`, this gives you the option to decide if you still want to move forward with the conversion process. + +--- + +The following reasons exist: +- `discarded_by_user`\ + You discarded this track by setting `discard: true`. +- `max_track_count_reached`\ + The output had no more room for another track. +- `max_track_count_of_type_reached`\ + The output had no more room for another track of this type, or the output doesn't support this track type at all. +- `unknown_source_codec`\ + We don't know the codec of the input track and therefore don't know what to do with it. +- `undecodable_source_codec`\ + The input track's codec is known, but we are unable to decode it. +- `no_encodable_target_codec`\ + We can't find a codec that we are able to encode and that can be contained within the output format. This reason can be hit if the environment doesn't support the necessary encoders, or if you requested a codec that cannot be contained within the output format. + +--- + +On the flip side, you can always query which input tracks made it into the output: +```ts +const conversion = await Conversion.init({ input, output }); +conversion.utilizedTracks; // => InputTrack[] +``` \ No newline at end of file diff --git a/docs/guide/media-sources.md b/docs/guide/media-sources.md index 5b08524..bf42b34 100644 --- a/docs/guide/media-sources.md +++ b/docs/guide/media-sources.md @@ -103,10 +103,10 @@ Mediakit provides five subjective quality options as an alternative to manually ```ts import { QUALITY_VERY_LOW, - QUALITY_LOW, - QUALITY_MEDIUM, - QUALITY_HIGH, - QUALITY_VERY_HIGH, + QUALITY_LOW, + QUALITY_MEDIUM, + QUALITY_HIGH, + QUALITY_VERY_HIGH, } from 'mediakit'; ``` diff --git a/docs/index.md b/docs/index.md index 1ccf756..3522e28 100644 --- a/docs/index.md +++ b/docs/index.md @@ -35,7 +35,7 @@ things the guide needs to cover: - utility functions ✅ - supported containers / codecs ✅ - samples & packets ✅ -- conversion +- conversion ✅ Examples: ✅ Extracting media file metadata diff --git a/src/conversion.ts b/src/conversion.ts index 42606fd..1356fc5 100644 --- a/src/conversion.ts +++ b/src/conversion.ts @@ -45,10 +45,6 @@ export type ConversionOptions = { video?: { /** If true, all video tracks will be discarded and will not be present in the output. */ discard?: boolean; - /** The desired output video codec. */ - codec?: VideoCodec; - /** The desired bitrate of the output video. */ - bitrate?: VideoEncodingConfig['bitrate']; /** * The desired width of the output video in pixels, defaulting to the video's natural display width. If height * is not set, it will be deduced automatically based on aspect ratio. @@ -73,31 +69,35 @@ export type ConversionOptions = { * rotation is _in addition to_ the natural rotation of the input video as specified in input file's metadata. */ rotate?: Rotation; + /** The desired output video codec. */ + codec?: VideoCodec; + /** The desired bitrate of the output video. */ + bitrate?: VideoEncodingConfig['bitrate']; /** When true, video will always be re-encoded instead of directly copying over the encoded samples. */ - forceReencode?: boolean; + forceTranscode?: boolean; }; /** Audio-specific options. */ audio?: { /** 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. */ + numberOfChannels?: number; + /** The desired sample rate of the output audio, in hertz. */ + sampleRate?: number; /** The desired output audio codec. */ codec?: AudioCodec; /** The desired bitrate of the output audio. */ bitrate?: AudioEncodingConfig['bitrate']; - /** The desired channel count of the output audio. */ - numberOfChannels?: number; - /** The desired sample rate of the output audio. */ - sampleRate?: number; /** When true, audio will always be re-encoded instead of directly copying over the encoded samples. */ - forceReencode?: boolean; + forceTranscode?: boolean; }; /** Options to trim the input file. */ trim?: { - /** The time in the input file at which the output file should start. Must be less than `end`. */ + /** The time in the input file in seconds at which the output file should start. Must be less than `end`. */ start: number; - /** The time in the input file at which the output file should end. Must be greater than `start`. */ + /** The time in the input file in seconds at which the output file should end. Must be greater than `start`. */ end: number; }; }; @@ -201,14 +201,17 @@ export class Conversion { if (!(options.output instanceof Output)) { throw new TypeError('options.output must be an Output.'); } + 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 (options.video?.discard !== undefined && typeof options.video.discard !== 'boolean') { throw new TypeError('options.video.discard, when provided, must be a boolean.'); } - if (options.video?.forceReencode !== undefined && typeof options.video.forceReencode !== 'boolean') { - throw new TypeError('options.video.forceReencode, 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( @@ -256,8 +259,8 @@ export class Conversion { 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?.forceReencode !== undefined && typeof options.audio.forceReencode !== 'boolean') { - throw new TypeError('options.audio.forceReencode, 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( @@ -459,14 +462,14 @@ export class Conversion { } const firstTimestamp = await track.getFirstTimestamp(); - const needsReencode = !!this._options.video?.forceReencode || this._startTimestamp > 0 || firstTimestamp < 0; + const needsTranscode = !!this._options.video?.forceTranscode || this._startTimestamp > 0 || firstTimestamp < 0; const needsRerender = width !== originalWidth || height !== originalHeight || (totalRotation !== 0 && !outputSupportsRotation); let videoCodecs = this.output.format.getSupportedVideoCodecs(); if ( - !needsReencode + !needsTranscode && !this._options.video?.bitrate && !needsRerender && videoCodecs.includes(sourceCodec) @@ -637,7 +640,7 @@ export class Conversion { let audioCodecs = this.output.format.getSupportedAudioCodecs(); if ( - !this._options.audio?.forceReencode + !this._options.audio?.forceTranscode && !this._options.audio?.bitrate && !needsResample && audioCodecs.includes(sourceCodec)