mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-05 06:43:48 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c4c13cea2 | ||
|
|
6d3af1b043 | ||
|
|
c29adb5997 | ||
|
|
e880f54553 | ||
|
|
21456e3bf6 | ||
|
|
1143984021 | ||
|
|
c4159c1391 | ||
|
|
f2f9b2f0c3 | ||
|
|
203c0f307e | ||
|
|
045ce43eb2 | ||
|
|
bb32f2ba26 |
@@ -56,6 +56,11 @@
|
||||
output,
|
||||
audio: (_, n) => ({
|
||||
discard: n > 1,
|
||||
/*
|
||||
process: (sample) => {
|
||||
return sample;
|
||||
},
|
||||
*/
|
||||
//codec: 'pcm-s16',
|
||||
//sampleRate: 16000,
|
||||
//numberOfChannels: 1,
|
||||
@@ -92,6 +97,24 @@
|
||||
},
|
||||
*/
|
||||
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,
|
||||
//alpha: 'keep',
|
||||
//width: 320,
|
||||
|
||||
@@ -16,6 +16,7 @@ It has the following features:
|
||||
- Video transparency removal/preservation
|
||||
- Audio resampling
|
||||
- Audio up/downmixing
|
||||
- User-defined video & audio processing
|
||||
|
||||
The conversion API was built to be simple, versatile and extremely performant.
|
||||
|
||||
@@ -126,7 +127,14 @@ type ConversionVideoOptions = {
|
||||
alpha?: 'discard' | 'keep'; // Defaults to 'discard'
|
||||
keyFrameInterval?: number;
|
||||
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:
|
||||
@@ -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`.
|
||||
|
||||
### 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
|
||||
|
||||
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;
|
||||
sampleRate?: number;
|
||||
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:
|
||||
@@ -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`.
|
||||
|
||||
### 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
|
||||
|
||||
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.
|
||||
|
||||
@@ -578,4 +578,49 @@ await conversion.execute();
|
||||
|
||||
::: info
|
||||
- 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
|
||||
```
|
||||
@@ -290,7 +290,7 @@ By default, data will be emitted by the `StreamTarget` as soon as it is availabl
|
||||
new StreamTarget(writable, {
|
||||
chunked: true,
|
||||
chunkSize: 2 ** 20, // Optional; defaults to 16 MiB
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
#### Applying backpressure
|
||||
@@ -329,6 +329,33 @@ const output = new Output({
|
||||
await output.finalize(); // Will automatically close the writable stream
|
||||
```
|
||||
|
||||
### `FilePathTarget`
|
||||
|
||||
This target writes to a file at the specified path. It is intended for server-side usage in Node, Bun, or Deno, and offers a simpler API than `StreamTarget` when you just want to write directly to a file path.
|
||||
|
||||
```ts
|
||||
import { Output, FilePathTarget } from 'mediabunny';
|
||||
|
||||
const output = new Output({
|
||||
target: new FilePathTarget('/path/to/output.mp4'),
|
||||
// ...
|
||||
});
|
||||
|
||||
// ...
|
||||
|
||||
await output.finalize(); // Will automatically close the file handle
|
||||
```
|
||||
|
||||
The internally held file handle will be closed when `finalize` or `cancel` are called on the `Output`.
|
||||
|
||||
Writing is chunked by default, for performance. Like `StreamTarget`, you can configure chunked mode options:
|
||||
```ts
|
||||
new FilePathTarget('/path/to/output.mp4', {
|
||||
chunked: false, // Disable chunking (slower)
|
||||
chunkSize: 2 ** 20, // Optional; defaults to 16 MiB
|
||||
});
|
||||
```
|
||||
|
||||
### `NullTarget`
|
||||
|
||||
This target simply discards all data that is passed into it. It is useful for when you need an `Output` but extract data from it differently, for example through output format-specific callbacks or encoder events.
|
||||
|
||||
Generated
+6
-6
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mediabunny",
|
||||
"version": "1.22.0",
|
||||
"version": "1.24.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mediabunny",
|
||||
"version": "1.22.0",
|
||||
"version": "1.24.1",
|
||||
"license": "MPL-2.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
@@ -7749,9 +7749,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mediabunny": {
|
||||
"version": "1.21.1",
|
||||
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.21.1.tgz",
|
||||
"integrity": "sha512-heBCNei4nBRJ2jkA08LL2zDZVyV9ADsSwFWOaRn37PEjUr6A46vlD+qlUYVTSSv5iIjIt1YTqTP7Nqo+q1hEvw==",
|
||||
"version": "1.24.0",
|
||||
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.24.0.tgz",
|
||||
"integrity": "sha512-9fQfAYlnuVSHMfbJtKK0pAgDlfwGDoyLQZ7gbOCug9jHPfjdi0XYQwvi/kaOFFMD70ouPkXzvKRc3AMPwkSK6Q==",
|
||||
"license": "MPL-2.0",
|
||||
"peer": true,
|
||||
"workspaces": [
|
||||
@@ -12242,7 +12242,7 @@
|
||||
},
|
||||
"packages/mp3-encoder": {
|
||||
"name": "@mediabunny/mp3-encoder",
|
||||
"version": "1.22.0",
|
||||
"version": "1.24.1",
|
||||
"license": "MPL-2.0",
|
||||
"devDependencies": {
|
||||
"@types/emscripten": "^1.40.1"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mediabunny",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.22.0",
|
||||
"version": "1.24.1",
|
||||
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@mediabunny/mp3-encoder",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.22.0",
|
||||
"version": "1.24.1",
|
||||
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
|
||||
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
|
||||
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
|
||||
|
||||
+237
-43
@@ -176,6 +176,31 @@ export type ConversionVideoOptions = {
|
||||
keyFrameInterval?: number;
|
||||
/** When `true`, video will always be re-encoded instead of directly copying over the encoded samples. */
|
||||
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. If these fields aren't set, Mediabunny will assume you won't perform any resizing.
|
||||
*/
|
||||
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,30 @@ export type ConversionAudioOptions = {
|
||||
bitrate?: number | Quality;
|
||||
/** When `true`, audio will always be re-encoded instead of directly copying over the encoded samples. */
|
||||
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. If these fields aren't set, Mediabunny will assume you won't perform
|
||||
* remixing or resampling.
|
||||
*/
|
||||
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) => {
|
||||
@@ -264,7 +313,22 @@ const validateVideoOptions = (videoOptions: ConversionVideoOptions | undefined)
|
||||
videoOptions?.keyFrameInterval !== undefined
|
||||
&& (!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 +366,21 @@ const validateAudioOptions = (audioOptions: ConversionAudioOptions | undefined)
|
||||
) {
|
||||
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;
|
||||
@@ -790,7 +869,8 @@ export class Conversion {
|
||||
|| this._startTimestamp > 0
|
||||
|| firstTimestamp < 0
|
||||
|| !!trackOptions.frameRate
|
||||
|| trackOptions.keyFrameInterval !== undefined;
|
||||
|| trackOptions.keyFrameInterval !== undefined
|
||||
|| trackOptions.process !== undefined;
|
||||
let needsRerender = width !== originalWidth
|
||||
|| height !== originalHeight
|
||||
|| (totalRotation !== 0 && !outputSupportsRotation)
|
||||
@@ -822,10 +902,6 @@ export class Conversion {
|
||||
: undefined;
|
||||
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
@@ -836,8 +912,12 @@ export class Conversion {
|
||||
delete packet.sideData.alphaByteLength;
|
||||
}
|
||||
|
||||
this._reportProgress(track.id, packet.timestamp);
|
||||
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();
|
||||
@@ -861,7 +941,15 @@ export class Conversion {
|
||||
|
||||
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) {
|
||||
this.discardedTracks.push({
|
||||
track,
|
||||
@@ -876,7 +964,6 @@ export class Conversion {
|
||||
keyFrameInterval: trackOptions.keyFrameInterval,
|
||||
sizeChangeBehavior: trackOptions.fit ?? 'passThrough',
|
||||
alpha,
|
||||
onEncodedPacket: sample => this._reportProgress(track.id, sample.timestamp + sample.duration),
|
||||
};
|
||||
|
||||
const source = new VideoSampleSource(encodingConfig);
|
||||
@@ -889,7 +976,7 @@ export class Conversion {
|
||||
// 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
|
||||
// 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({
|
||||
format: new Mp4OutputFormat(), // Supports all video codecs
|
||||
@@ -951,15 +1038,11 @@ export class Conversion {
|
||||
timestamp: lastCanvasTimestamp! + i / frameRate,
|
||||
duration: 1 / frameRate,
|
||||
});
|
||||
await source.add(sample);
|
||||
await this._registerVideoSample(track, trackOptions, source, sample);
|
||||
}
|
||||
};
|
||||
|
||||
for await (const { canvas, timestamp, duration } of iterator) {
|
||||
if (this._synchronizer.shouldWait(track.id, timestamp)) {
|
||||
await this._synchronizer.wait(timestamp);
|
||||
}
|
||||
|
||||
if (this._canceled) {
|
||||
return;
|
||||
}
|
||||
@@ -991,8 +1074,7 @@ export class Conversion {
|
||||
timestamp: adjustedSampleTimestamp,
|
||||
duration: frameRate !== undefined ? 1 / frameRate : duration,
|
||||
});
|
||||
|
||||
await source.add(sample);
|
||||
await this._registerVideoSample(track, trackOptions, source, sample);
|
||||
|
||||
if (frameRate !== undefined) {
|
||||
lastCanvas = canvas;
|
||||
@@ -1034,17 +1116,13 @@ export class Conversion {
|
||||
for (let i = 1; i < frameDifference; i++) {
|
||||
lastSample.setTimestamp(lastSampleTimestamp! + i / frameRate);
|
||||
lastSample.setDuration(1 / frameRate);
|
||||
await source.add(lastSample);
|
||||
await this._registerVideoSample(track, trackOptions, source, lastSample);
|
||||
}
|
||||
|
||||
lastSample.close();
|
||||
};
|
||||
|
||||
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) {
|
||||
lastSample?.close();
|
||||
return;
|
||||
@@ -1076,7 +1154,7 @@ export class Conversion {
|
||||
}
|
||||
|
||||
sample.setTimestamp(adjustedSampleTimestamp);
|
||||
await source.add(sample);
|
||||
await this._registerVideoSample(track, trackOptions, source, sample);
|
||||
|
||||
if (frameRate !== undefined) {
|
||||
lastSample = sample;
|
||||
@@ -1113,6 +1191,67 @@ export class Conversion {
|
||||
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 */
|
||||
async _processAudioTrack(track: InputAudioTrack, trackOptions: ConversionAudioOptions) {
|
||||
const sourceCodec = track.codec;
|
||||
@@ -1145,6 +1284,7 @@ export class Conversion {
|
||||
&& !needsResample
|
||||
&& audioCodecs.includes(sourceCodec)
|
||||
&& (!trackOptions.codec || trackOptions.codec === sourceCodec)
|
||||
&& !trackOptions.process
|
||||
) {
|
||||
// Fast path, we can simply copy over the encoded packets
|
||||
|
||||
@@ -1162,16 +1302,16 @@ export class Conversion {
|
||||
: undefined;
|
||||
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._reportProgress(track.id, packet.timestamp);
|
||||
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();
|
||||
@@ -1198,8 +1338,12 @@ export class Conversion {
|
||||
const bitrate = trackOptions.bitrate ?? QUALITY_HIGH;
|
||||
|
||||
const encodableCodecs = await getEncodableAudioCodecs(audioCodecs, {
|
||||
numberOfChannels,
|
||||
sampleRate,
|
||||
numberOfChannels: trackOptions.process && trackOptions.processedNumberOfChannels
|
||||
? trackOptions.processedNumberOfChannels
|
||||
: numberOfChannels,
|
||||
sampleRate: trackOptions.process && trackOptions.processedSampleRate
|
||||
? trackOptions.processedSampleRate
|
||||
: sampleRate,
|
||||
bitrate,
|
||||
});
|
||||
|
||||
@@ -1240,12 +1384,18 @@ export class Conversion {
|
||||
}
|
||||
|
||||
if (needsResample) {
|
||||
audioSource = this._resampleAudio(track, codecOfChoice, numberOfChannels, sampleRate, bitrate);
|
||||
audioSource = this._resampleAudio(
|
||||
track,
|
||||
trackOptions,
|
||||
codecOfChoice,
|
||||
numberOfChannels,
|
||||
sampleRate,
|
||||
bitrate,
|
||||
);
|
||||
} else {
|
||||
const source = new AudioSampleSource({
|
||||
codec: codecOfChoice,
|
||||
bitrate,
|
||||
onEncodedPacket: packet => this._reportProgress(track.id, packet.timestamp + packet.duration),
|
||||
});
|
||||
audioSource = source;
|
||||
|
||||
@@ -1254,15 +1404,11 @@ export class Conversion {
|
||||
|
||||
const sink = new AudioSampleSink(track);
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
await source.add(sample);
|
||||
await this._registerAudioSample(track, trackOptions, source, sample);
|
||||
sample.close();
|
||||
}
|
||||
|
||||
@@ -1283,9 +1429,62 @@ export class Conversion {
|
||||
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 */
|
||||
_resampleAudio(
|
||||
track: InputAudioTrack,
|
||||
trackOptions: ConversionAudioOptions,
|
||||
codec: AudioCodec,
|
||||
targetNumberOfChannels: number,
|
||||
targetSampleRate: number,
|
||||
@@ -1294,7 +1493,6 @@ export class Conversion {
|
||||
const source = new AudioSampleSource({
|
||||
codec,
|
||||
bitrate,
|
||||
onEncodedPacket: packet => this._reportProgress(track.id, packet.timestamp + packet.duration),
|
||||
});
|
||||
|
||||
this._trackPromises.push((async () => {
|
||||
@@ -1305,17 +1503,13 @@ export class Conversion {
|
||||
targetSampleRate,
|
||||
startTime: this._startTimestamp,
|
||||
endTime: this._endTimestamp,
|
||||
onSample: sample => source.add(sample),
|
||||
onSample: sample => this._registerAudioSample(track, trackOptions, source, sample),
|
||||
});
|
||||
|
||||
const sink = new AudioSampleSink(track);
|
||||
const iterator = sink.samples(this._startTimestamp, this._endTimestamp);
|
||||
|
||||
for await (const sample of iterator) {
|
||||
if (this._synchronizer.shouldWait(track.id, sample.timestamp)) {
|
||||
await this._synchronizer.wait(sample.timestamp);
|
||||
}
|
||||
|
||||
if (this._canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -92,6 +92,8 @@ export {
|
||||
export {
|
||||
Target,
|
||||
BufferTarget,
|
||||
FilePathTarget,
|
||||
FilePathTargetOptions,
|
||||
NullTarget,
|
||||
StreamTarget,
|
||||
StreamTargetOptions,
|
||||
|
||||
+1
-1
@@ -208,7 +208,7 @@ export const TRANSFER_CHARACTERISTICS_MAP = {
|
||||
'smpte170m': 6, // SMPTE 170M
|
||||
'linear': 8, // Linear transfer characteristics
|
||||
'iec61966-2-1': 13, // IEC 61966-2-1
|
||||
'pg': 16, // Rec. ITU-R BT.2100-2 perceptual quantization (PQ) system
|
||||
'pq': 16, // Rec. ITU-R BT.2100-2 perceptual quantization (PQ) system
|
||||
'hlg': 18, // Rec. ITU-R BT.2100-2 hybrid loggamma (HLG) system
|
||||
};
|
||||
export const TRANSFER_CHARACTERISTICS_MAP_INVERSE = invertObject(TRANSFER_CHARACTERISTICS_MAP);
|
||||
|
||||
@@ -1224,6 +1224,7 @@ class ReadOrchestrator {
|
||||
workers: ReadWorker[] = [];
|
||||
cache: CacheEntry[] = [];
|
||||
currentCacheSize = 0;
|
||||
disposed = false;
|
||||
|
||||
constructor(public options: {
|
||||
maxCacheSize: number;
|
||||
@@ -1472,6 +1473,11 @@ class ReadOrchestrator {
|
||||
|
||||
/** Called by a worker when it has read some data. */
|
||||
supplyWorkerData(worker: ReadWorker, bytes: Uint8Array) {
|
||||
if (this.disposed) {
|
||||
// Writes may still come in after disposal, but we just ignore those
|
||||
return;
|
||||
}
|
||||
|
||||
const start = worker.currentPos;
|
||||
const end = start + bytes.length;
|
||||
|
||||
@@ -1645,5 +1651,6 @@ class ReadOrchestrator {
|
||||
|
||||
this.workers.length = 0;
|
||||
this.cache.length = 0;
|
||||
this.disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,15 @@
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import type { FileHandle } from 'node:fs/promises';
|
||||
import { BufferTargetWriter, NullTargetWriter, StreamTargetWriter, Writer } from './writer';
|
||||
import { Output } from './output';
|
||||
import * as nodeAlias from './node';
|
||||
import { assert } from './misc';
|
||||
|
||||
const node = typeof nodeAlias !== 'undefined'
|
||||
? nodeAlias // Aliasing it prevents some bundler warnings
|
||||
: undefined!;
|
||||
|
||||
/**
|
||||
* Base class for targets, specifying where output files are written.
|
||||
@@ -121,6 +128,68 @@ export class StreamTarget extends Target {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for {@link FilePathTarget}.
|
||||
* @group Output targets
|
||||
* @public
|
||||
*/
|
||||
export type FilePathTargetOptions = StreamTargetOptions;
|
||||
|
||||
/**
|
||||
* A target that writes to a file at the specified path. Intended for server-side usage in Node, Bun, or Deno.
|
||||
*
|
||||
* Writing is chunked by default. The internally held file handle will be closed when `.finalize()` or `.cancel()` are
|
||||
* called on the corresponding {@link Output}.
|
||||
* @group Output targets
|
||||
* @public
|
||||
*/
|
||||
export class FilePathTarget extends Target {
|
||||
/** @internal */
|
||||
_streamTarget: StreamTarget;
|
||||
/** @internal */
|
||||
_fileHandle: FileHandle | null = null;
|
||||
|
||||
/** Creates a new {@link FilePathTarget} that writes to the file at the specified file path. */
|
||||
constructor(filePath: string, options: FilePathTargetOptions = {}) {
|
||||
if (typeof filePath !== 'string') {
|
||||
throw new TypeError('filePath must be a string.');
|
||||
}
|
||||
if (!options || typeof options !== 'object') {
|
||||
throw new TypeError('options must be an object.');
|
||||
}
|
||||
|
||||
super();
|
||||
|
||||
// Let's back this target with a StreamTarget, makes the implementation very simple
|
||||
const writable = new WritableStream<StreamTargetChunk>({
|
||||
start: async () => {
|
||||
this._fileHandle = await node.fs.open(filePath, 'w');
|
||||
},
|
||||
write: async (chunk) => {
|
||||
assert(this._fileHandle);
|
||||
await this._fileHandle.write(chunk.data, 0, chunk.data.byteLength, chunk.position);
|
||||
},
|
||||
close: async () => {
|
||||
if (this._fileHandle) {
|
||||
await this._fileHandle.close();
|
||||
this._fileHandle = null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
this._streamTarget = new StreamTarget(writable, {
|
||||
chunked: true,
|
||||
...options,
|
||||
});
|
||||
this._streamTarget._output = this._output;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_createWriter(): Writer {
|
||||
return this._streamTarget._createWriter();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This target just discards all incoming data. It is useful for when you need an {@link Output} but extract data from
|
||||
* it differently, for example through format-specific callbacks (`onMoof`, `onMdat`, ...) or encoder events.
|
||||
|
||||
Reference in New Issue
Block a user