Compare commits

..
11 Commits
Author SHA1 Message Date
Vanilagy 0c4c13cea2 Perform conversion API resampling even with process is set 2025-10-20 16:13:05 +02:00
Vanilagy 6d3af1b043 Bump minor 2025-10-17 15:17:25 +02:00
Vanilagy c29adb5997 Was fun while it lasted 2025-10-17 14:52:39 +02:00
Vanilagy e880f54553 Add custom processing to Conversion API 2025-10-17 14:52:13 +02:00
David P.andGitHub 21456e3bf6 Merge pull request #181 from thurinus/fix-pq
Fix the transfer characteristics map key for BT.2100 PQ
2025-10-15 21:08:24 -07:00
jlim 1143984021 BT.2100 PQ should be "pq", not "pg" 2025-10-15 18:08:29 -07:00
Vanilagy c4159c1391 Bump minor 2025-10-02 16:00:08 +02:00
Vanilagy f2f9b2f0c3 Add FilePathTarget 2025-10-02 15:59:46 +02:00
Vanilagy 203c0f307e Ignore read data after source disposal 2025-10-02 15:42:44 +02:00
Vanilagy 045ce43eb2 Vaycay 2025-10-02 11:39:18 +02:00
Vanilagy bb32f2ba26 Like this? 2025-10-02 11:38:29 +02:00
12 changed files with 477 additions and 54 deletions
+23
View File
@@ -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,
+56
View File
@@ -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.
+46 -1
View File
@@ -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
```
+28 -1
View File
@@ -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.
+6 -6
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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;
}
+2
View File
@@ -92,6 +92,8 @@ export {
export {
Target,
BufferTarget,
FilePathTarget,
FilePathTargetOptions,
NullTarget,
StreamTarget,
StreamTargetOptions,
+1 -1
View File
@@ -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);
+7
View File
@@ -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;
}
}
+69
View File
@@ -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.