Compare commits

..
54 Commits
Author SHA1 Message Date
Vanilagy 21cff32831 Bump patch 2025-11-06 10:02:23 +01:00
David P.andGitHub 73e936f325 Merge pull request #210 from JonnyBurger/increase-tolerance-by-one
Increase tolerance of pixels by 1 in tests
2025-11-06 09:01:40 +00:00
Jonny BurgerandGitHub 3c8d503158 Merge pull request #209 from JonnyBurger/flac-consecutive-headers
Ensure FLAC headers are consecutive
2025-11-06 10:00:37 +01:00
JonnyBurger fe4eebd487 Increase tolerance of pixels by 1 in tests 2025-11-05 10:54:03 +01:00
Vanilagy b5f5b5588c Update RVE logo 2025-11-03 15:24:17 +01:00
Vanilagy b1f17b25e3 Add Jellypod & fix RVE logo 2025-11-03 12:12:12 +01:00
Vanilagy efcf5d0069 Add vjeux 2025-11-02 22:03:35 +01:00
Vanilagy aad31009fe Make test more strict 2025-11-02 21:56:16 +01:00
Vanilagy 68c37b49dc Another test test 2025-11-02 21:50:49 +01:00
Vanilagy b12fee70a3 Log test 2025-11-02 21:47:49 +01:00
Vanilagy 90d5aed051 Test test 2025-11-02 21:46:24 +01:00
Vanilagy d2d893ff30 Bump patch 2025-11-02 21:36:09 +01:00
Vanilagy 3217490d81 Fix read worker abort bugs (fixes #197) 2025-11-02 14:03:54 +01:00
Vanilagy 5039c730bf Fix MediaStreamTracks not being stopped inside of the worker (fixes #204) 2025-11-02 13:00:38 +01:00
Vanilagy a14c0885d7 Bump patch 2025-10-21 16:28:03 +02:00
Vanilagy c7b45eabf0 Explanatory comment 2025-10-21 16:27:27 +02:00
Vanilagy 8629709cd7 < 8 2025-10-21 16:27:04 +02:00
David P.andGitHub 754af6f90f Merge pull request #186 from JonnyBurger/fix-185
Make Mediabunny more resilient against `data` boxes ending abruptly
2025-10-21 16:24:02 +02:00
David P.andGitHub fa1fc27577 Merge pull request #187 from JonnyBurger/explain-examples-server
Explain how to use examples dev server
2025-10-21 16:19:25 +02:00
Vanilagy e676de4129 Terminate reading a cluster early if another cluster is encountered (fixes #164) 2025-10-21 16:18:48 +02:00
Vanilagy 0c4c13cea2 Perform conversion API resampling even with process is set 2025-10-20 16:13:05 +02:00
JonnyBurger 3767aea7d9 Fix 2025-10-18 17:24:00 +02:00
JonnyBurger cdeeb6c6fa Explain how to use examples dev server 2025-10-18 11:25:51 +02:00
Jonny BurgerandGitHub c27042b24d Merge branch 'main' into fix-185 2025-10-18 11:24:44 +02:00
JonnyBurger f8cd66727c Make Mediabunny more resilient against data boxes ending abruptly 2025-10-18 11:23:44 +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
Vanilagy de4c44021a Bump minor 2025-10-02 11:29:38 +02:00
David P.andGitHub c91f46fd5e Merge pull request #158 from JonnyBurger/stop-retrying-on-cors-error
Stop retrying UrlSource if CORS error is suspected, add `src` to getRetryDelay
2025-10-02 11:01:17 +02:00
David P.andGitHub 08658e7728 Merge branch 'main' into stop-retrying-on-cors-error 2025-10-02 10:59:54 +02:00
Vanilagy bf0058987e Adjust CI logic *again* 2025-10-02 10:57:38 +02:00
Vanilagy 088d1f1701 Adjust Matroska cue point distribution logic, ensure no duplicate timestamps in cue points, fixing infinite recursion issue 2025-10-02 10:44:01 +02:00
David P.andGitHub 1e922e3d72 Merge pull request #162 from AJFunk/conversion_keyframes
add keyFrameInterval to ConversionVideoOptions
2025-10-01 22:24:13 +02:00
Vanilagy 7accface7b A few cleanups 2025-10-01 22:23:05 +02:00
AJ Funk dcd1f242b4 add keyFrameInterval to ConversionVideoOptions 2025-10-01 09:28:06 -07:00
Vanilagy c85a22493a Add AJ Funk 2025-10-01 17:32:24 +02:00
JonnyBurger 26c1debb7a Update docs for getRetryDelay() 2025-09-30 15:03:29 +02:00
JonnyBurger 548ca74c93 getRetryDelay() takes src as well 2025-09-30 14:59:48 +02:00
JonnyBurger 249d9444ce remove extra newline added 2025-09-30 14:11:48 +02:00
JonnyBurger d58b638277 Stop retrying UrlSource if CORS error is suspected 2025-09-30 14:11:17 +02:00
Vanilagy 8636297ee0 PQINA -> Pintura Labs 2025-09-30 13:37:02 +02:00
Vanilagy 045fe7652a Bump patch 2025-09-29 20:59:59 +02:00
Vanilagy b74854d0a0 Add .js extension even in declaration files 2025-09-29 20:43:40 +02:00
Vanilagy 7d747ae6d9 Dedent 2025-09-29 20:37:10 +02:00
Vanilagy 198928088b Make cluster/fragment lookup operations almost stateless, fix high Matroska memory, allow more maxCacheSizes 2025-09-29 20:36:37 +02:00
Vanilagy 0185383794 Add PQINA sponsor 2025-09-29 09:58:03 +02:00
30 changed files with 1344 additions and 955 deletions
+2 -1
View File
@@ -2,8 +2,9 @@ name: Lint
on:
push:
branches:
- main
pull_request:
types: [opened, reopened]
jobs:
lint:
+2 -1
View File
@@ -2,8 +2,9 @@ name: Test
on:
push:
branches:
- main
pull_request:
types: [opened, reopened]
jobs:
test:
+21 -9
View File
@@ -16,33 +16,45 @@ Mediabunny is a JavaScript library for reading, writing, and converting media fi
### Gold sponsors
<div align="center">
<a href="https://remotion.dev/" target="_blank">
<a href="https://remotion.dev/" target="_blank" rel="sponsored">
<img src="./docs/public/sponsors/remotion.png" width="60" height="60" alt="Remotion">
</a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://www.gling.ai/" target="_blank">
<a href="https://www.gling.ai/" target="_blank" rel="sponsored">
<img src="./docs/public/sponsors/gling.svg" width="60" height="60" alt="Gling AI">
</a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://diffusion.studio/" target="_blank">
<a href="https://diffusion.studio/" target="_blank" rel="sponsored">
<img src="./docs/public/sponsors/diffusionstudio.png" width="60" height="60" alt="Diffusion Studio">
</a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://kino.ai/" target="_blank">
<a href="https://kino.ai/" target="_blank" rel="sponsored">
<img src="./docs/public/sponsors/kino.jpg" width="60" height="60" alt="Kino">
</a>
</div>
### Silver sponsors
<div align="center">
<a href="https://pqina.nl/pintura/" target="_blank" rel="sponsored">
<img src="./docs/public/sponsors/pintura-labs.png" width="50" height="50" alt="Pintura Labs">
</a>
</div>
### Bronze sponsors
<div align="center">
<a href="https://www.reactvideoeditor.com/" target="_blank">
<img src="./docs/public/sponsors/rve.svg" width="40" height="40" alt="React Video Editor">
<a href="https://www.reactvideoeditor.com/" target="_blank" rel="sponsored">
<img src="./docs/public/sponsors/rve.png" width="40" height="40" alt="React Video Editor">
</a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://www.mux.com/" target="_blank">
<a href="https://www.mux.com/" target="_blank" rel="sponsored">
<img src="./docs/public/sponsors/mux.jpg" width="40" height="40" alt="Mux">
</a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://jellypod.ai/" target="_blank" rel="sponsored">
<img src="./docs/public/sponsors/jellypod.png" width="40" height="40" alt="Jellypod">
</a>
</div>
[Sponsor Mediabunny's development](https://github.com/sponsors/Vanilagy)
@@ -192,7 +204,7 @@ npm run lint # ESLint
npm run docs:generate # Generates API docs
npm run docs:dev # Start docs development server
npm run dev # Start examples development server
npm run dev # Start examples development server, will run at http://localhost:5173/examples/[name]/
npm run docs:build # Build docs and examples
```
+24 -1
View File
@@ -56,6 +56,11 @@
output,
audio: (_, n) => ({
discard: n > 1,
/*
process: (sample) => {
return sample;
},
*/
//codec: 'pcm-s16',
//sampleRate: 16000,
//numberOfChannels: 1,
@@ -92,7 +97,25 @@
},
*/
video: () => ({
width: 300,
/*
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,
//discard: true,
+54 -5
View File
@@ -14,13 +14,62 @@
source: new Mediabunny.BlobSource(file),
});
const audioTrack = await input.getPrimaryAudioTrack();
const sink = new Mediabunny.EncodedPacketSink(audioTrack);
for await (const packet of sink.packets()) {
console.log(packet)
const videoTrack = await input.getPrimaryVideoTrack();
const sink = new Mediabunny.VideoSampleSink(videoTrack);
console.log(await videoTrack.getDecoderConfig())
const sink2 = new Mediabunny.EncodedPacketSink(videoTrack);
for await (const packet of sink2.packets()) {
console.log(packet.data.join(', '))//, window.findNalUnitsInLengthPrefixed(packet.data, 4).map(x => window.extractNalUnitTypeForAvc(x)));
if (packet.timestamp > 1) break;
}
//return;
for await (const sample of sink.samples()) {
console.log(sample);
}
/*
let total = 0;
input.source.onread = (start, end) => {
total += end - start;
//console.log(total / file.size, end - start);
}
const videoTrack = await input.getPrimaryVideoTrack();
const sink = new Mediabunny.EncodedPacketSink(videoTrack);
*/
//console.log(await sink.getPacket(0));
/*
console.time()
console.log(await sink.getPacket(500));
console.timeEnd()
console.time()
console.log(await sink.getPacket(400));
console.timeEnd()
*/
//console.log(await sink.getPacket(50));
//console.log(await sink.getPacket(8000));
//const stats = await videoTrack.computePacketStats();
/*
const sink = new Mediabunny.EncodedPacketSink(videoTrack);
for await (const packet of sink.packets()) {
//console.log(packet)
}
*/
//console.log(await videoTrack.computeDuration());
//console.log("Done", stats, total, file.size)
//console.log(input);
/*
const videoTrack = await input.getPrimaryVideoTrack();
const sink = new Mediabunny.VideoSampleSink(videoTrack);
+59
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.
@@ -124,8 +125,16 @@ type ConversionVideoOptions = {
codec?: VideoCodec;
bitrate?: number | Quality;
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:
@@ -180,8 +189,43 @@ Use the `codec` property to control the codec of the output track. This should b
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.
Use the `keyFrameInterval` property to control the maximum interval in seconds between key frames in the output video. Setting this fields forces a transcode.
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:
@@ -193,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:
@@ -230,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
```
+4 -2
View File
@@ -487,7 +487,7 @@ If you're using this source in the browser and the URL is on a different origin,
```ts
type UrlSourceOptions = {
requestInit?: RequestInit;
getRetryDelay?: (previousAttempts: number) => number | null;
getRetryDelay?: (previousAttempts: number, error: unknown, url: string | URL | Request) => number | null;
// The maximum number of bytes the cache is allowed to hold
// in memory. Defaults to 8 MiB.
@@ -517,7 +517,9 @@ const source = new UrlSource('https://example.com/bigbuckbunny.mp4', {
});
```
Not setting `getRetryDelay` will default to an infinite, capped exponential backoff pattern.
Not setting `getRetryDelay` will lead to the default being used:
- Infinite exponential backoff pattern, capped at 16 seconds.
- If a CORS error is suspected (`fetch()` did reject even though `navigator.onLine` is true and origin is different), no further retries will be made.
---
+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.
+20 -5
View File
@@ -96,16 +96,22 @@ const sponsors = {
{ image: '/sponsors/diffusionstudio.png', name: 'Diffusion Studio', url: 'https://diffusion.studio/' },
{ image: '/sponsors/kino.jpg', name: 'Kino', url: 'https://kino.ai/' },
],
silver: [
{ image: '/sponsors/pintura-labs.png', name: 'Pintura Labs', url: 'https://pqina.nl/pintura/' },
],
bronze: [
{ image: '/sponsors/rve.svg', name: 'React Video Editor', url: 'https://www.reactvideoeditor.com/' },
{ image: '/sponsors/rve.png', name: 'React Video Editor', url: 'https://www.reactvideoeditor.com/' },
{ image: '/sponsors/mux.jpg', name: 'Mux', url: 'https://www.mux.com/' },
{ image: '/sponsors/jellypod.png', name: 'Jellypod', url: 'https://jellypod.ai/' },
],
individual: [
{ image: 'https://avatars.githubusercontent.com/u/197597', name: 'Christopher Chedeau', url: 'https://github.com/vjeux' },
{ image: 'https://avatars.githubusercontent.com/u/84167135', name: 'Memenome', url: 'https://github.com/memenome' },
{ image: 'https://avatars.githubusercontent.com/u/5913254', name: 'Brandon McConnell', url: 'https://github.com/brandonmcconnell' },
{ image: 'https://avatars.githubusercontent.com/u/9549394', name: 'studnitz', url: 'https://github.com/studnitz' },
{ image: 'https://avatars.githubusercontent.com/u/504909', name: 'Hirbod', url: 'https://github.com/hirbod' },
{ image: 'https://avatars.githubusercontent.com/u/2698271', name: 'Matthew Gardner', url: 'https://github.com/spheric' },
{ image: 'https://avatars.githubusercontent.com/u/5475819', name: 'AJ Funk', url: 'https://github.com/AJFunk' },
{ image: 'https://avatars.githubusercontent.com/u/30229596', name: 'Pablo Bonilla', url: 'https://github.com/devPablo' },
{ image: 'https://avatars.githubusercontent.com/u/38181164', name: 'wcw', url: 'https://github.com/asd55667' },
{ image: 'https://avatars.githubusercontent.com/u/1836701', name: 'Bean Deng', url: 'https://github.com/HADB' },
@@ -343,10 +349,19 @@ await conversion.execute();
</h1>
<p class="max-w-2xl">Mediabunny is an open-source project released under the <a href="https://choosealicense.com/licenses/mpl-2.0/" target="_blank">MPL-2.0</a> and is therefore free to use for any purpose, including closed-source commercial use. A permissive license is essential for a foundational library like this to truly thrive. That said, this project requires an immense amount of work and care. This is made possible by the generous financial backing of these awesome sponsors:</p>
<template v-if="sponsors.gold.length > 0">
<h3 class="!text-2xl">Gold sponsors</h3>
<h3 class="!text-3xl">Gold sponsors</h3>
<div class="flex flex-wrap mt-1 justify-center gap-1">
<a v-for="sponsor in sponsors.gold" :href="sponsor.url" target="_blank" class="flex items-center p-2 rounded-full hover:bg-(--vp-c-gray-3) !text-(--vp-c-text-1) !no-underline">
<a v-for="sponsor in sponsors.gold" :href="sponsor.url" target="_blank" rel="sponsored" class="flex items-center p-2 rounded-full hover:bg-(--vp-c-gray-3) !text-(--vp-c-text-1) !no-underline">
<img :src="sponsor.image" class="size-16 rounded-full">
<p class="!my-0 !font-medium px-3 text-lg">{{ sponsor.name }}</p>
</a>
</div>
</template>
<template v-if="sponsors.silver.length > 0">
<h3 class="!text-2xl">Silver sponsors</h3>
<div class="flex flex-wrap mt-1 justify-center gap-1">
<a v-for="sponsor in sponsors.silver" :href="sponsor.url" target="_blank" rel="sponsored" class="flex items-center p-2 rounded-full hover:bg-(--vp-c-gray-3) !text-(--vp-c-text-1) !no-underline">
<img :src="sponsor.image" class="size-13 rounded-full">
<p class="!my-0 !font-medium px-3">{{ sponsor.name }}</p>
</a>
</div>
@@ -354,7 +369,7 @@ await conversion.execute();
<template v-if="sponsors.bronze.length > 0">
<h3 class="!text-xl">Bronze sponsors</h3>
<div class="flex flex-wrap mt-1 justify-center gap-1">
<a v-for="sponsor in sponsors.bronze" :href="sponsor.url" target="_blank" class="flex items-center p-2 rounded-full hover:bg-(--vp-c-gray-3) !text-(--vp-c-text-1) !no-underline">
<a v-for="sponsor in sponsors.bronze" :href="sponsor.url" target="_blank" rel="sponsored" class="flex items-center p-2 rounded-full hover:bg-(--vp-c-gray-3) !text-(--vp-c-text-1) !no-underline">
<img :src="sponsor.image" class="size-10 rounded-full">
<p class="!my-0 !font-medium px-3 text-sm">{{ sponsor.name }}</p>
</a>
@@ -363,7 +378,7 @@ await conversion.execute();
<template v-if="sponsors.individual.length > 0">
<h4 class="!text-base">Individual sponsors</h4>
<div class="flex flex-wrap mt-1 justify-center">
<a v-for="sponsor in sponsors.individual" :href="sponsor.url" target="_blank" class="flex gap-1 w-24 flex-col items-center p-2 rounded-xl hover:bg-(--vp-c-gray-3) !text-(--vp-c-text-1) !no-underline">
<a v-for="sponsor in sponsors.individual" :href="sponsor.url" target="_blank" rel="sponsored" class="flex gap-1 w-24 flex-col items-center p-2 rounded-xl hover:bg-(--vp-c-gray-3) !text-(--vp-c-text-1) !no-underline">
<img :src="sponsor.image" class="size-8 rounded-full">
<p class="!my-0 !font-medium text-xs !leading-4 text-center">{{ sponsor.name }}</p>
</a>
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 590 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

-4
View File
@@ -1,4 +0,0 @@
<svg width="52" height="51" viewBox="0 0 52 51" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M25.1186 1.50109C23.3805 0.147773 21.1954 -0.253724 19.1203 0.397437L5.08373 4.80289C2.19204 5.71105 0.248047 8.44236 0.248047 11.6022V42.9894C0.248047 45.2558 1.24465 47.3313 2.98266 48.6825C4.72099 50.036 6.9084 50.4373 8.98111 49.7863L19.4595 46.4967C19.1318 45.366 18.9658 44.1862 18.9658 42.9894V28.735C18.9658 25.7417 20.0093 22.8829 21.9088 20.6899C23.303 19.0744 25.1116 17.8826 27.1329 17.2463L27.8534 17.0309V7.19654C27.8534 4.93006 26.8568 2.85458 25.1185 1.50109H25.1186Z" fill="#1C2AC5"/>
<path d="M51.2479 24.3293V38.5836C51.2479 41.7411 49.3061 44.4749 46.4122 45.3806L32.3756 49.7862C31.7253 49.9917 31.0632 50.0922 30.4081 50.0922C28.9739 50.0922 27.5703 49.6125 26.3771 48.6824C25.832 48.2565 25.3595 47.7645 24.9711 47.2139C24.4986 46.553 24.1453 45.8164 23.923 45.0257C23.9206 45.0257 23.9184 45.0257 23.916 45.0281L23.9206 45.0232C23.7406 44.3746 23.6423 43.6917 23.6423 42.9893V28.7349C23.6423 27.2712 24.0588 25.8983 24.7935 24.7576C25.528 23.6169 26.5807 22.7066 27.8534 22.1656C28.057 22.08 28.2652 22.0015 28.4782 21.9356L30.1953 21.3971L42.515 17.53C44.5899 16.8788 46.7751 17.2803 48.5132 18.6338C50.2516 19.9873 51.248 22.0628 51.248 24.3293H51.2479Z" fill="#3E8AF5"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

+6 -6
View File
@@ -1,12 +1,12 @@
{
"name": "mediabunny",
"version": "1.21.0",
"version": "1.24.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mediabunny",
"version": "1.21.0",
"version": "1.24.4",
"license": "MPL-2.0",
"workspaces": [
"packages/*"
@@ -7749,9 +7749,9 @@
}
},
"node_modules/mediabunny": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.20.1.tgz",
"integrity": "sha512-p5vwGxXFQ+c0fvLph/VlHpntnOw6jGeWXDDNhtSHuVOOt6A346BhX8qLOuN05uWSav5x7PpIM1pjhtSIGC/fyw==",
"version": "1.24.3",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.24.3.tgz",
"integrity": "sha512-uQu5lXg6c3z2YUKLimAsem1qYPdGfGziQ5CKaAMJAs0C0NNw5T/Zkn0foPML0gWqQJ5ZeC0wTgCxpiBQY8m1Tw==",
"license": "MPL-2.0",
"peer": true,
"workspaces": [
@@ -12242,7 +12242,7 @@
},
"packages/mp3-encoder": {
"name": "@mediabunny/mp3-encoder",
"version": "1.21.0",
"version": "1.24.4",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mediabunny",
"author": "Vanilagy",
"version": "1.21.0",
"version": "1.24.4",
"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.21.0",
"version": "1.24.4",
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
+8 -3
View File
@@ -13,7 +13,7 @@ const walkDir = (dir: string) => {
if (stat.isDirectory()) {
files.push(...walkDir(fullPath));
} else if (item.endsWith('.js')) {
} else if (item.endsWith('.js') || item.endsWith('.ts')) {
files.push(fullPath);
}
}
@@ -23,8 +23,13 @@ const walkDir = (dir: string) => {
const fixFile = (filePath: string) => {
const content = fs.readFileSync(filePath, 'utf8');
const fixed = content.replace(
/(\s+from\s+['"])(\.[^'"]*)(['"])/g, // This only matches relative imports
// We only match relative imports
let fixed = content.replace(
/(\s+from\s+['"])(\.[^'"]*)(['"])/g, // This matches static imports
'$1$2.js$3',
);
fixed = fixed.replace(
/(import\s*\(\s*['"])(\.[^'"]*)(['"]\s*\))/g, // This matches dynamic imports
'$1$2.js$3',
);
+252 -42
View File
@@ -166,8 +166,41 @@ export type ConversionVideoOptions = {
* VP9.
*/
alpha?: 'discard' | 'keep';
/**
* The interval, in seconds, of how often frames are encoded as a key frame. The default is 5 seconds. Frequent key
* frames improve seeking behavior but increase file size. When using multiple video tracks, you should give them
* all the same key frame interval.
*
* Setting this fields forces a transcode.
*/
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;
};
/**
@@ -188,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) => {
@@ -252,6 +309,27 @@ const validateVideoOptions = (videoOptions: ConversionVideoOptions | undefined)
if (videoOptions?.alpha !== undefined && !['discard', 'keep'].includes(videoOptions.alpha)) {
throw new TypeError('options.video.alpha, when provided, must be either \'discard\' or \'keep\'.');
}
if (
videoOptions?.keyFrameInterval !== undefined
&& (!Number.isFinite(videoOptions.keyFrameInterval) || videoOptions.keyFrameInterval < 0)
) {
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.');
}
};
const validateAudioOptions = (audioOptions: ConversionAudioOptions | undefined) => {
@@ -288,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;
@@ -775,7 +868,9 @@ export class Conversion {
const needsTranscode = !!trackOptions.forceTranscode
|| this._startTimestamp > 0
|| firstTimestamp < 0
|| !!trackOptions.frameRate;
|| !!trackOptions.frameRate
|| trackOptions.keyFrameInterval !== undefined
|| trackOptions.process !== undefined;
let needsRerender = width !== originalWidth
|| height !== originalHeight
|| (totalRotation !== 0 && !outputSupportsRotation)
@@ -807,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;
}
@@ -821,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();
@@ -846,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,
@@ -858,9 +961,9 @@ export class Conversion {
const encodingConfig: VideoEncodingConfig = {
codec: encodableCodec,
bitrate,
keyFrameInterval: trackOptions.keyFrameInterval,
sizeChangeBehavior: trackOptions.fit ?? 'passThrough',
alpha,
onEncodedPacket: sample => this._reportProgress(track.id, sample.timestamp + sample.duration),
};
const source = new VideoSampleSource(encodingConfig);
@@ -873,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
@@ -935,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;
}
@@ -975,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;
@@ -1018,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;
@@ -1060,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;
@@ -1097,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;
@@ -1129,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
@@ -1146,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();
@@ -1182,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,
});
@@ -1224,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;
@@ -1238,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();
}
@@ -1267,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,
@@ -1278,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 () => {
@@ -1289,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;
}
+25 -2
View File
@@ -339,16 +339,34 @@ export class FlacDemuxer extends Demuxer {
slice.skip(-2);
const lengthIfNextFlacFrameHeaderIsLegit = slice.filePos - startPos;
const nextIsLegit = this.readFlacFrameHeader({
const nextFrameHeader = this.readFlacFrameHeader({
slice,
isFirstPacket: false,
});
if (!nextIsLegit) {
if (!nextFrameHeader) {
slice.skip(-1);
continue;
}
// Ensure the frameOrSampleNum is consecutive.
// https://github.com/Vanilagy/mediabunny/issues/194
if (this.blockingBit === 0) {
// Case A: If the stream is fixed block size, this is the frame number, which increments by 1
if (nextFrameHeader.num - frameHeader.num !== 1) {
slice.skip(-1);
continue;
}
} else {
// Case B: If the stream is variable block size, this is the sample number, which increments by
// amount of samples in a frame.
if (nextFrameHeader.num - frameHeader.num !== frameHeader.blockSize) {
slice.skip(-1);
continue;
}
}
return {
num: frameHeader.num,
blockSize: frameHeader.blockSize,
@@ -442,6 +460,11 @@ export class FlacDemuxer extends Demuxer {
return null;
}
if (sampleRate !== this.audioInfo.sampleRate) {
// This cannot be a valid FLAC frame, the sample rate is not the same as in the stream info
return null;
}
const size = slice.filePos - startOffset;
const crc = readU8(slice);
+2
View File
@@ -92,6 +92,8 @@ export {
export {
Target,
BufferTarget,
FilePathTarget,
FilePathTargetOptions,
NullTarget,
StreamTarget,
StreamTargetOptions,
+275 -419
View File
@@ -41,13 +41,11 @@ import {
import { PacketRetrievalOptions } from '../media-sink';
import {
assert,
AsyncMutex,
binarySearchExact,
binarySearchLessOrEqual,
Bitstream,
COLOR_PRIMARIES_MAP_INVERSE,
findLastIndex,
insertSorted,
isIso639Dash2LanguageCode,
last,
MATRIX_COEFFICIENTS_MAP_INVERSE,
@@ -103,10 +101,17 @@ type InternalTrack = {
languageCode: string;
sampleTableByteOffset: number;
sampleTable: SampleTable | null;
fragmentLookupTable: FragmentLookupTableEntry[] | null;
fragmentLookupTable: FragmentLookupTableEntry[];
currentFragmentState: FragmentTrackState | null;
fragments: Fragment[];
fragmentsWithKeyFrame: Fragment[];
/**
* List of all encountered fragment offsets alongside their timestamps. This list never gets truncated, but memory
* consumption should be negligible.
*/
fragmentPositionCache: {
moofOffset: number;
startTimestamp: number;
endTimestamp: number;
}[];
/** The segment durations of all edit list entries leading up to the main one (from which the offset is taken.) */
editListPreviousSegmentDurations: number;
/** The media time offset of the main edit list entry (with media time !== -1) */
@@ -198,6 +203,7 @@ type FragmentTrackState = {
};
type FragmentTrackData = {
track: InternalTrack;
startTimestamp: number;
endTimestamp: number;
firstKeyFrameTimestamp: number | null;
@@ -222,10 +228,6 @@ type Fragment = {
moofSize: number;
implicitBaseDataOffset: number;
trackData: Map<InternalTrack['id'], FragmentTrackData>;
dataStart: number;
dataEnd: number;
nextFragment: Fragment | null;
isKnownToBeFirstFragment: boolean;
};
export class IsobmffDemuxer extends Demuxer {
@@ -243,9 +245,12 @@ export class IsobmffDemuxer extends Demuxer {
isFragmented = false;
fragmentTrackDefaults: FragmentTrackDefaults[] = [];
fragments: Fragment[] = [];
currentFragment: Fragment | null = null;
fragmentLookupMutex = new AsyncMutex();
/**
* Caches the last fragment that was read. Based on the assumption that there will be multiple reads to the
* same fragment in quick succession.
*/
lastReadFragment: Fragment | null = null;
constructor(input: Input) {
super(input);
@@ -502,6 +507,10 @@ export class IsobmffDemuxer extends Demuxer {
}
async readFragment(startPos: number): Promise<Fragment> {
if (this.lastReadFragment?.moofOffset === startPos) {
return this.lastReadFragment;
}
let headerSlice = this.reader.requestSliceRange(startPos, MIN_BOX_HEADER_SIZE, MAX_BOX_HEADER_SIZE);
if (headerSlice instanceof Promise) headerSlice = await headerSlice;
assert(headerSlice);
@@ -515,92 +524,60 @@ export class IsobmffDemuxer extends Demuxer {
this.traverseBox(entireSlice);
const index = binarySearchExact(this.fragments, startPos, x => x.moofOffset);
assert(index !== -1);
const fragment = this.lastReadFragment;
assert(fragment && fragment.moofOffset === startPos);
const fragment = this.fragments[index]!;
assert(fragment.moofOffset === startPos);
for (const [, trackData] of fragment.trackData) {
const track = trackData.track;
const { fragmentPositionCache } = track;
// It may be that some tracks don't define the base decode time, i.e. when the fragment begins. This means the
// only other option is to sum up the duration of all previous fragments.
for (const [trackId, trackData] of fragment.trackData) {
if (trackData.startTimestampIsFinal) {
continue;
}
if (!trackData.startTimestampIsFinal) {
// It may be that some tracks don't define the base decode time, i.e. when the fragment begins. This
// we'll need to figure out the start timestamp another way. We'll compute the timestamp by accessing
// the lookup entries and fragment cache, which works out nicely with the lookup algorithm: If these
// exist, then the lookup will automatically start at the furthest possible point. If they don't, the
// lookup starts sequentially from the start, incrementally summing up all fragment durations. It's sort
// of implicit, but it ends up working nicely.
const internalTrack = this.tracks.find(x => x.id === trackId)!;
let currentPos = 0;
let currentFragment: Fragment | null = null;
let lastFragment: Fragment | null = null;
const index = binarySearchLessOrEqual(
internalTrack.fragments,
startPos - 1,
x => x.moofOffset,
);
if (index !== -1) {
// Instead of starting at the start of the file, let's start at the previous fragment instead (which
// already has final timestamps).
currentFragment = internalTrack.fragments[index]!;
lastFragment = currentFragment;
currentPos = currentFragment.moofOffset + currentFragment.moofSize;
}
let nextFragmentIsFirstFragment = currentPos === 0;
while (currentPos <= startPos - MIN_BOX_HEADER_SIZE) {
if (currentFragment?.nextFragment) {
currentFragment = currentFragment.nextFragment;
currentPos = currentFragment.moofOffset + currentFragment.moofSize;
const lookupEntry = track.fragmentLookupTable.find(x => x.moofOffset === fragment.moofOffset);
if (lookupEntry) {
// There's a lookup entry, let's use its timestamp
offsetFragmentTrackDataByTimestamp(trackData, lookupEntry.timestamp);
} else {
let slice = this.reader.requestSliceRange(currentPos, MIN_BOX_HEADER_SIZE, MAX_BOX_HEADER_SIZE);
if (slice instanceof Promise) slice = await slice;
if (!slice) break;
const boxStartPos = currentPos;
const boxInfo = readBoxHeader(slice);
if (!boxInfo) {
break;
const lastCacheIndex = binarySearchLessOrEqual(
fragmentPositionCache,
fragment.moofOffset - 1,
x => x.moofOffset,
);
if (lastCacheIndex !== -1) {
// Let's use the timestamp of the previous fragment in the cache
const lastCache = fragmentPositionCache[lastCacheIndex]!;
offsetFragmentTrackDataByTimestamp(trackData, lastCache.endTimestamp);
} else {
// We're the first fragment I guess, "offset by 0"
}
if (boxInfo.name === 'moof') {
const index = binarySearchExact(this.fragments, boxStartPos, x => x.moofOffset);
let fragment: Fragment;
if (index === -1) {
fragment = await this.readFragment(boxStartPos); // Recursive call
} else {
// We already know this fragment
fragment = this.fragments[index]!;
}
// Even if we already know the fragment, we might not yet know its predecessor; always do this
if (currentFragment) currentFragment.nextFragment = fragment;
currentFragment = fragment;
if (nextFragmentIsFirstFragment) {
fragment.isKnownToBeFirstFragment = true;
nextFragmentIsFirstFragment = false;
}
}
currentPos = boxStartPos + boxInfo.totalSize;
}
if (currentFragment && currentFragment.trackData.has(trackId)) {
lastFragment = currentFragment;
}
trackData.startTimestampIsFinal = true;
}
if (lastFragment) {
const otherTrackData = lastFragment.trackData.get(trackId)!;
assert(otherTrackData.startTimestampIsFinal);
offsetFragmentTrackDataByTimestamp(trackData, otherTrackData.endTimestamp);
// Let's remember that a fragment with a given timestamp is here, speeding up future lookups if no
// lookup table exists
const insertionIndex = binarySearchLessOrEqual(
fragmentPositionCache,
trackData.startTimestamp,
x => x.startTimestamp,
);
if (
insertionIndex === -1
|| fragmentPositionCache[insertionIndex]!.moofOffset !== fragment.moofOffset
) {
fragmentPositionCache.splice(insertionIndex + 1, 0, {
moofOffset: fragment.moofOffset,
startTimestamp: trackData.startTimestamp,
endTimestamp: trackData.endTimestamp,
});
}
trackData.startTimestampIsFinal = true;
}
return fragment;
@@ -683,10 +660,9 @@ export class IsobmffDemuxer extends Demuxer {
languageCode: UNDETERMINED_LANGUAGE,
sampleTableByteOffset: -1,
sampleTable: null,
fragmentLookupTable: null,
fragmentLookupTable: [],
currentFragmentState: null,
fragments: [],
fragmentsWithKeyFrame: [],
fragmentPositionCache: [],
editListPreviousSegmentDurations: 0,
editListOffset: 0,
} satisfies InternalTrack as InternalTrack;
@@ -1735,8 +1711,6 @@ export class IsobmffDemuxer extends Demuxer {
break;
}
track.fragmentLookupTable = [];
const word = readU32Be(slice);
const lengthSizeOfTrafNum = (word & 0b110000) >> 4;
@@ -1763,6 +1737,20 @@ export class IsobmffDemuxer extends Demuxer {
moofOffset,
});
}
// Sort by timestamp in case it's not naturally sorted
track.fragmentLookupTable.sort((a, b) => a.timestamp - b.timestamp);
// Remove multiple entries for the same time
for (let i = 0; i < track.fragmentLookupTable.length - 1; i++) {
const entry1 = track.fragmentLookupTable[i]!;
const entry2 = track.fragmentLookupTable[i + 1]!;
if (entry1.timestamp === entry2.timestamp) {
track.fragmentLookupTable.splice(i + 1, 1);
i--;
}
}
}; break;
case 'moof': {
@@ -1771,31 +1759,11 @@ export class IsobmffDemuxer extends Demuxer {
moofSize: boxInfo.totalSize,
implicitBaseDataOffset: startPos,
trackData: new Map(),
dataStart: Infinity,
dataEnd: 0,
nextFragment: null,
isKnownToBeFirstFragment: false,
};
this.readContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));
insertSorted(this.fragments, this.currentFragment, x => x.moofOffset);
// Compute the byte range of the sample data in this fragment, so we can load the whole fragment at once
for (const [, trackData] of this.currentFragment.trackData) {
const firstSample = trackData.samples[0]!;
const lastSample = last(trackData.samples)!;
this.currentFragment.dataStart = Math.min(
this.currentFragment.dataStart,
firstSample.byteOffset,
);
this.currentFragment.dataEnd = Math.max(
this.currentFragment.dataEnd,
lastSample.byteOffset + lastSample.byteSize,
);
}
this.lastReadFragment = this.currentFragment;
this.currentFragment = null;
}; break;
@@ -1809,19 +1777,6 @@ export class IsobmffDemuxer extends Demuxer {
if (this.currentTrack) {
const trackData = this.currentFragment.trackData.get(this.currentTrack.id);
if (trackData) {
// We know there is sample data for this track in this fragment, so let's add it to the
// track's fragments:
insertSorted(this.currentTrack.fragments, this.currentFragment, x => x.moofOffset);
const hasKeyFrame = trackData.firstKeyFrameTimestamp !== null;
if (hasKeyFrame) {
insertSorted(
this.currentTrack.fragmentsWithKeyFrame,
this.currentFragment,
x => x.moofOffset,
);
}
const { currentFragmentState } = this.currentTrack;
assert(currentFragmentState);
@@ -1952,6 +1907,7 @@ export class IsobmffDemuxer extends Demuxer {
let currentTimestamp = 0;
const trackData: FragmentTrackData = {
track,
startTimestamp: 0,
endTimestamp: 0,
firstKeyFrameTimestamp: null,
@@ -2404,31 +2360,17 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
}
return this.performFragmentedLookup(
() => {
const startFragment = this.internalTrack.demuxer.fragments[0] ?? null;
if (startFragment?.isKnownToBeFirstFragment) {
// Walk from the very first fragment in the file until we find one with our track in it
let currentFragment: Fragment | null = startFragment;
while (currentFragment) {
const trackData = currentFragment.trackData.get(this.internalTrack.id);
if (trackData) {
return {
fragmentIndex: binarySearchExact(
this.internalTrack.fragments,
currentFragment.moofOffset,
x => x.moofOffset,
),
sampleIndex: 0,
correctSampleFound: true,
};
}
currentFragment = currentFragment.nextFragment;
}
null,
(fragment) => {
const trackData = fragment.trackData.get(this.internalTrack.id);
if (trackData) {
return {
sampleIndex: 0,
correctSampleFound: true,
};
}
return {
fragmentIndex: -1,
sampleIndex: -1,
correctSampleFound: false,
};
@@ -2459,7 +2401,24 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
}
return this.performFragmentedLookup(
() => this.findSampleInFragmentsForTimestamp(timestampInTimescale),
null,
(fragment) => {
const trackData = fragment.trackData.get(this.internalTrack.id);
if (!trackData) {
return { sampleIndex: -1, correctSampleFound: false };
}
const index = binarySearchLessOrEqual(
trackData.presentationTimestamps,
timestampInTimescale,
x => x.presentationTimestamp,
);
const sampleIndex = index !== -1 ? trackData.presentationTimestamps[index]!.sampleIndex : -1;
const correctSampleFound = index !== -1 && timestampInTimescale < trackData.endTimestamp;
return { sampleIndex, correctSampleFound };
},
timestampInTimescale,
timestampInTimescale,
options,
@@ -2479,53 +2438,32 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
throw new Error('Packet was not created from this track.');
}
const trackData = locationInFragment.fragment.trackData.get(this.internalTrack.id)!;
const fragmentIndex = binarySearchExact(
this.internalTrack.fragments,
locationInFragment.fragment.moofOffset,
x => x.moofOffset,
);
assert(fragmentIndex !== -1);
return this.performFragmentedLookup(
() => {
if (locationInFragment.sampleIndex + 1 < trackData.samples.length) {
// We can simply take the next sample in the fragment
return {
fragmentIndex,
sampleIndex: locationInFragment.sampleIndex + 1,
correctSampleFound: true,
};
locationInFragment.fragment,
(fragment) => {
if (fragment === locationInFragment.fragment) {
const trackData = fragment.trackData.get(this.internalTrack.id)!;
if (locationInFragment.sampleIndex + 1 < trackData.samples.length) {
// We can simply take the next sample in the fragment
return {
sampleIndex: locationInFragment.sampleIndex + 1,
correctSampleFound: true,
};
}
} else {
// Walk the list of fragments until we find the next fragment for this track
let currentFragment = locationInFragment.fragment;
while (currentFragment.nextFragment) {
currentFragment = currentFragment.nextFragment;
const trackData = currentFragment.trackData.get(this.internalTrack.id);
if (trackData) {
const fragmentIndex = binarySearchExact(
this.internalTrack.fragments,
currentFragment.moofOffset,
x => x.moofOffset,
);
assert(fragmentIndex !== -1);
return {
fragmentIndex,
sampleIndex: 0,
correctSampleFound: true,
};
}
const trackData = fragment.trackData.get(this.internalTrack.id);
if (trackData) {
return {
sampleIndex: 0,
correctSampleFound: true,
};
}
return {
fragmentIndex,
sampleIndex: -1,
correctSampleFound: false,
};
}
return {
sampleIndex: -1,
correctSampleFound: false,
};
},
-Infinity, // Use -Infinity as a search timestamp to avoid using the lookup entries
Infinity,
@@ -2549,7 +2487,23 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
}
return this.performFragmentedLookup(
() => this.findKeySampleInFragmentsForTimestamp(timestampInTimescale),
null,
(fragment) => {
const trackData = fragment.trackData.get(this.internalTrack.id);
if (!trackData) {
return { sampleIndex: -1, correctSampleFound: false };
}
const index = findLastIndex(trackData.presentationTimestamps, (x) => {
const sample = trackData.samples[x.sampleIndex]!;
return sample.isKeyFrame && x.presentationTimestamp <= timestampInTimescale;
});
const sampleIndex = index !== -1 ? trackData.presentationTimestamps[index]!.sampleIndex : -1;
const correctSampleFound = index !== -1 && timestampInTimescale < trackData.endTimestamp;
return { sampleIndex, correctSampleFound };
},
timestampInTimescale,
timestampInTimescale,
options,
@@ -2570,60 +2524,39 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
throw new Error('Packet was not created from this track.');
}
const trackData = locationInFragment.fragment.trackData.get(this.internalTrack.id)!;
const fragmentIndex = binarySearchExact(
this.internalTrack.fragments,
locationInFragment.fragment.moofOffset,
x => x.moofOffset,
);
assert(fragmentIndex !== -1);
return this.performFragmentedLookup(
() => {
const nextKeyFrameIndex = trackData.samples.findIndex(
(x, i) => x.isKeyFrame && i > locationInFragment.sampleIndex,
);
locationInFragment.fragment,
(fragment) => {
if (fragment === locationInFragment.fragment) {
const trackData = fragment.trackData.get(this.internalTrack.id)!;
const nextKeyFrameIndex = trackData.samples.findIndex(
(x, i) => x.isKeyFrame && i > locationInFragment.sampleIndex,
);
if (nextKeyFrameIndex !== -1) {
// We can simply take the next key frame in the fragment
return {
fragmentIndex,
sampleIndex: nextKeyFrameIndex,
correctSampleFound: true,
};
} else {
// Walk the list of fragments until we find the next fragment for this track with a key frame
let currentFragment = locationInFragment.fragment;
while (currentFragment.nextFragment) {
currentFragment = currentFragment.nextFragment;
const trackData = currentFragment.trackData.get(this.internalTrack.id);
if (trackData && trackData.firstKeyFrameTimestamp !== null) {
const fragmentIndex = binarySearchExact(
this.internalTrack.fragments,
currentFragment.moofOffset,
x => x.moofOffset,
);
assert(fragmentIndex !== -1);
const keyFrameIndex = trackData.samples.findIndex(x => x.isKeyFrame);
assert(keyFrameIndex !== -1); // There must be one
return {
fragmentIndex,
sampleIndex: keyFrameIndex,
correctSampleFound: true,
};
}
if (nextKeyFrameIndex !== -1) {
// We can simply take the next key frame in the fragment
return {
sampleIndex: nextKeyFrameIndex,
correctSampleFound: true,
};
}
} else {
const trackData = fragment.trackData.get(this.internalTrack.id);
if (trackData && trackData.firstKeyFrameTimestamp !== null) {
const keyFrameIndex = trackData.samples.findIndex(x => x.isKeyFrame);
assert(keyFrameIndex !== -1); // There must be one
return {
fragmentIndex,
sampleIndex: -1,
correctSampleFound: false,
};
return {
sampleIndex: keyFrameIndex,
correctSampleFound: true,
};
}
}
return {
sampleIndex: -1,
correctSampleFound: false,
};
},
-Infinity, // Use -Infinity as a search timestamp to avoid using the lookup entries
Infinity,
@@ -2713,77 +2646,12 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
return packet;
}
private findSampleInFragmentsForTimestamp(timestampInTimescale: number) {
const fragmentIndex = binarySearchLessOrEqual(
// This array is technically not sorted by start timestamp, but for any reasonable file, it basically is.
this.internalTrack.fragments,
timestampInTimescale,
x => x.trackData.get(this.internalTrack.id)!.startTimestamp,
);
let sampleIndex = -1;
let correctSampleFound = false;
if (fragmentIndex !== -1) {
const fragment = this.internalTrack.fragments[fragmentIndex]!;
const trackData = fragment.trackData.get(this.internalTrack.id)!;
const index = binarySearchLessOrEqual(
trackData.presentationTimestamps,
timestampInTimescale,
x => x.presentationTimestamp,
);
assert(index !== -1);
sampleIndex = trackData.presentationTimestamps[index]!.sampleIndex;
correctSampleFound = timestampInTimescale < trackData.endTimestamp;
}
return { fragmentIndex, sampleIndex, correctSampleFound };
}
private findKeySampleInFragmentsForTimestamp(timestampInTimescale: number) {
const indexInKeyFrameFragments = binarySearchLessOrEqual(
// This array is technically not sorted by start timestamp, but for any reasonable file, it basically is.
this.internalTrack.fragmentsWithKeyFrame,
timestampInTimescale,
x => x.trackData.get(this.internalTrack.id)!.startTimestamp,
);
let fragmentIndex = -1;
let sampleIndex = -1;
let correctSampleFound = false;
if (indexInKeyFrameFragments !== -1) {
const fragment = this.internalTrack.fragmentsWithKeyFrame[indexInKeyFrameFragments]!;
// Now, let's find the actual index of the fragment in the list of ALL fragments, not just key frame ones
fragmentIndex = binarySearchExact(
this.internalTrack.fragments,
fragment.moofOffset,
x => x.moofOffset,
);
assert(fragmentIndex !== -1);
const trackData = fragment.trackData.get(this.internalTrack.id)!;
const index = findLastIndex(trackData.presentationTimestamps, (x) => {
const sample = trackData.samples[x.sampleIndex]!;
return sample.isKeyFrame && x.presentationTimestamp <= timestampInTimescale;
});
assert(index !== -1); // It's a key frame fragment, so there must be a key frame
const entry = trackData.presentationTimestamps[index]!;
sampleIndex = entry.sampleIndex;
correctSampleFound = timestampInTimescale < trackData.endTimestamp;
}
return { fragmentIndex, sampleIndex, correctSampleFound };
}
/** Looks for a packet in the fragments while trying to load as few fragments as possible to retrieve it. */
private async performFragmentedLookup(
// This function returns the best-matching sample that is currently loaded. Based on this information, we know
// which fragments we need to load to find the actual match.
getBestMatch: () => { fragmentIndex: number; sampleIndex: number; correctSampleFound: boolean },
// The fragment where we start looking
startFragment: Fragment | null,
// This function returns the best-matching sample in a given fragment
getMatchInFragment: (fragment: Fragment) => { sampleIndex: number; correctSampleFound: boolean },
// The timestamp with which we can search the lookup table
searchTimestamp: number,
// The timestamp for which we know the correct sample will not come after it
@@ -2791,133 +2659,121 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
options: PacketRetrievalOptions,
): Promise<EncodedPacket | null> {
const demuxer = this.internalTrack.demuxer;
const release = await demuxer.fragmentLookupMutex.acquire(); // The algorithm requires exclusivity
try {
const { fragmentIndex, sampleIndex, correctSampleFound } = getBestMatch();
let currentFragment: Fragment | null = null;
let bestFragment: Fragment | null = null;
let bestSampleIndex = -1;
if (startFragment) {
const { sampleIndex, correctSampleFound } = getMatchInFragment(startFragment);
if (correctSampleFound) {
// The correct sample already exists, easy path.
const fragment = this.internalTrack.fragments[fragmentIndex]!;
return this.fetchPacketInFragment(fragment, sampleIndex, options);
return this.fetchPacketInFragment(startFragment, sampleIndex, options);
}
let prevFragment: Fragment | null = null;
let bestFragmentIndex = fragmentIndex;
let bestSampleIndex = sampleIndex;
if (sampleIndex !== -1) {
bestFragment = startFragment;
bestSampleIndex = sampleIndex;
}
}
// Search for a lookup entry; this way, we won't need to start searching from the start of the file
// but can jump right into the correct fragment (or at least nearby).
const lookupEntryIndex = this.internalTrack.fragmentLookupTable
? binarySearchLessOrEqual(
this.internalTrack.fragmentLookupTable,
searchTimestamp,
x => x.timestamp,
)
: -1;
const lookupEntry = lookupEntryIndex !== -1
? this.internalTrack.fragmentLookupTable![lookupEntryIndex]!
: null;
// Search for a lookup entry; this way, we won't need to start searching from the start of the file
// but can jump right into the correct fragment (or at least nearby).
const lookupEntryIndex = binarySearchLessOrEqual(
this.internalTrack.fragmentLookupTable,
searchTimestamp,
x => x.timestamp,
);
const lookupEntry = lookupEntryIndex !== -1
? this.internalTrack.fragmentLookupTable[lookupEntryIndex]!
: null;
let currentPos: number;
let nextFragmentIsFirstFragment = false;
const positionCacheIndex = binarySearchLessOrEqual(
this.internalTrack.fragmentPositionCache,
searchTimestamp,
x => x.startTimestamp,
);
const positionCacheEntry = positionCacheIndex !== -1
? this.internalTrack.fragmentPositionCache[positionCacheIndex]!
: null;
if (fragmentIndex === -1) {
currentPos = lookupEntry?.moofOffset ?? 0;
nextFragmentIsFirstFragment = currentPos === 0;
const lookupEntryPosition = Math.max(
lookupEntry?.moofOffset ?? 0,
positionCacheEntry?.moofOffset ?? 0,
) || null;
let currentPos: number;
if (!startFragment) {
currentPos = lookupEntryPosition ?? 0;
} else {
if (lookupEntryPosition === null || startFragment.moofOffset >= lookupEntryPosition) {
currentPos = startFragment.moofOffset + startFragment.moofSize;
currentFragment = startFragment;
} else {
const fragment = this.internalTrack.fragments[fragmentIndex]!;
if (!lookupEntry || fragment.moofOffset >= lookupEntry.moofOffset) {
currentPos = fragment.moofOffset + fragment.moofSize;
prevFragment = fragment;
} else {
// Use the lookup entry
currentPos = lookupEntry.moofOffset;
}
// Use the lookup entry
currentPos = lookupEntryPosition;
}
}
while (true) {
if (prevFragment) {
const trackData = prevFragment.trackData.get(this.internalTrack.id);
if (trackData && trackData.startTimestamp > latestTimestamp) {
// We're already past the upper bound, no need to keep searching
break;
}
if (prevFragment.nextFragment) {
// Skip ahead quickly without needing to read the file again
currentPos = prevFragment.nextFragment.moofOffset + prevFragment.nextFragment.moofSize;
prevFragment = prevFragment.nextFragment;
continue;
}
}
// Load the header
let slice = demuxer.reader.requestSliceRange(currentPos, MIN_BOX_HEADER_SIZE, MAX_BOX_HEADER_SIZE);
if (slice instanceof Promise) slice = await slice;
if (!slice) break;
const startPos = currentPos;
const boxInfo = readBoxHeader(slice);
if (!boxInfo) {
while (true) {
if (currentFragment) {
const trackData = currentFragment.trackData.get(this.internalTrack.id);
if (trackData && trackData.startTimestamp > latestTimestamp) {
// We're already past the upper bound, no need to keep searching
break;
}
}
if (boxInfo.name === 'moof') {
const index = binarySearchExact(demuxer.fragments, startPos, x => x.moofOffset);
// Load the header
let slice = demuxer.reader.requestSliceRange(currentPos, MIN_BOX_HEADER_SIZE, MAX_BOX_HEADER_SIZE);
if (slice instanceof Promise) slice = await slice;
if (!slice) break;
let fragment: Fragment;
if (index === -1) {
// This is the first time we've seen this fragment
fragment = await demuxer.readFragment(startPos);
} else {
// We already know this fragment
fragment = demuxer.fragments[index]!;
}
const boxStartPos = currentPos;
const boxInfo = readBoxHeader(slice);
if (!boxInfo) {
break;
}
// Even if we already know the fragment, we might not yet know its predecessor, so always do this
if (prevFragment) prevFragment.nextFragment = fragment;
prevFragment = fragment;
if (nextFragmentIsFirstFragment) {
fragment.isKnownToBeFirstFragment = true;
nextFragmentIsFirstFragment = false;
}
const { fragmentIndex, sampleIndex, correctSampleFound } = getBestMatch();
if (correctSampleFound) {
const fragment = this.internalTrack.fragments[fragmentIndex]!;
return this.fetchPacketInFragment(fragment, sampleIndex, options);
}
if (fragmentIndex !== -1) {
bestFragmentIndex = fragmentIndex;
bestSampleIndex = sampleIndex;
}
if (boxInfo.name === 'moof') {
currentFragment = await demuxer.readFragment(boxStartPos);
const { sampleIndex, correctSampleFound } = getMatchInFragment(currentFragment);
if (correctSampleFound) {
return this.fetchPacketInFragment(currentFragment, sampleIndex, options);
}
if (sampleIndex !== -1) {
bestFragment = currentFragment;
bestSampleIndex = sampleIndex;
}
currentPos = startPos + boxInfo.totalSize;
}
const bestFragment = bestFragmentIndex !== -1 ? this.internalTrack.fragments[bestFragmentIndex]! : null;
// Catch faulty lookup table entries
if (lookupEntry && (!bestFragment || bestFragment.moofOffset < lookupEntry.moofOffset)) {
// The lookup table entry lied to us! We found a lookup entry but no fragment there that satisfied
// the match. In this case, let's search again but using the lookup entry before that.
const previousLookupEntry = this.internalTrack.fragmentLookupTable![lookupEntryIndex - 1];
const newSearchTimestamp = previousLookupEntry?.timestamp ?? -Infinity;
return this.performFragmentedLookup(getBestMatch, newSearchTimestamp, latestTimestamp, options);
}
if (bestFragment) {
// If we finished looping but didn't find a perfect match, still return the best match we found
return this.fetchPacketInFragment(bestFragment, bestSampleIndex, options);
}
return null;
} finally {
release();
currentPos = boxStartPos + boxInfo.totalSize;
}
// Catch faulty lookup table entries
if (lookupEntry && (!bestFragment || bestFragment.moofOffset < lookupEntry.moofOffset)) {
// The lookup table entry lied to us! We found a lookup entry but no fragment there that satisfied
// the match. In this case, let's search again but using the lookup entry before that.
const previousLookupEntry = this.internalTrack.fragmentLookupTable[lookupEntryIndex - 1];
assert(!previousLookupEntry || previousLookupEntry.timestamp < lookupEntry.timestamp);
const newSearchTimestamp = previousLookupEntry?.timestamp ?? -Infinity;
return this.performFragmentedLookup(
null,
getMatchInFragment,
newSearchTimestamp,
latestTimestamp,
options,
);
}
if (bestFragment) {
// If we finished looping but didn't find a perfect match, still return the best match we found
return this.fetchPacketInFragment(bestFragment, bestSampleIndex, options);
}
return null;
}
}
+5
View File
@@ -70,6 +70,11 @@ export const readDataBox = (slice: FileSlice) => {
return null;
}
if (slice.remainingLength < 8) {
// Box is too small
return null;
}
const typeIndicator = readU32Be(slice);
slice.skip(4); // Locale indicator
const data = readBytes(slice, header.contentSize - 8);
+331 -418
View File
@@ -35,12 +35,9 @@ import { AttachedFile, MetadataTags } from '../tags';
import { PacketRetrievalOptions } from '../media-sink';
import {
assert,
AsyncMutex,
binarySearchExact,
binarySearchLessOrEqual,
COLOR_PRIMARIES_MAP_INVERSE,
findLastIndex,
insertSorted,
isIso639Dash2LanguageCode,
last,
MATRIX_COEFFICIENTS_MAP_INVERSE,
@@ -93,8 +90,11 @@ type Segment = {
elementEndPos: number | null;
clusterSeekStartPos: number;
clusters: Cluster[];
clusterLookupMutex: AsyncMutex;
/**
* Caches the last cluster that was read. Based on the assumption that there will be multiple reads to the
* same cluster in quick succession.
*/
lastReadCluster: Cluster | null;
metadataTags: MetadataTags;
metadataTagsCollected: boolean;
@@ -112,8 +112,6 @@ type Cluster = {
dataStartPos: number;
timestamp: number;
trackData: Map<number, ClusterTrackData>;
nextCluster: Cluster | null;
isKnownToBeFirstCluster: boolean;
};
type ClusterTrackData = {
@@ -182,8 +180,14 @@ type InternalTrack = {
id: number;
demuxer: MatroskaDemuxer;
segment: Segment;
clusters: Cluster[];
clustersWithKeyFrame: Cluster[];
/**
* List of all encountered cluster offsets alongside their timestamps. This list never gets truncated, but memory
* consumption should be negligible.
*/
clusterPositionCache: {
elementStartPos: number;
startTimestamp: number;
}[];
cuePoints: CuePoint[];
isDefault: boolean;
@@ -409,8 +413,7 @@ export class MatroskaDemuxer extends Demuxer {
: segmentDataStart + dataSize,
clusterSeekStartPos: segmentDataStart,
clusters: [],
clusterLookupMutex: new AsyncMutex(),
lastReadCluster: null,
metadataTags: {},
metadataTagsCollected: false,
@@ -538,59 +541,59 @@ export class MatroskaDemuxer extends Demuxer {
// Put default tracks first
this.currentSegment.tracks.sort((a, b) => Number(b.isDefault) - Number(a.isDefault));
// Sort cue points by cluster position (required for the next algorithm)
this.currentSegment.cuePoints.sort((a, b) => a.clusterPosition - b.clusterPosition);
// Now, let's distribute the cue points to each track. Ideally, each track has their own cue point, but some
// Matroska files may only specify cue points for a single track. In this case, we still wanna use those cue
// points for all tracks.
const allTrackIds = this.currentSegment.tracks.map(x => x.id);
const remainingTrackIds = new Set<number>();
let lastClusterPosition: number | null = null;
let lastCuePoint: CuePoint | null = null;
// Now, let's distribute the cue points to the tracks
const idToTrack = new Map(this.currentSegment.tracks.map(x => [x.id, x]));
// Assign cue points to their respective tracks
for (const cuePoint of this.currentSegment.cuePoints) {
if (cuePoint.clusterPosition !== lastClusterPosition) {
for (const id of remainingTrackIds) {
// These tracks didn't receive a cue point for the last cluster, so let's give them one
assert(lastCuePoint);
const track = this.currentSegment.tracks.find(x => x.id === id)!;
track.cuePoints.push(lastCuePoint);
}
for (const id of allTrackIds) {
remainingTrackIds.add(id);
}
const track = idToTrack.get(cuePoint.trackId);
if (track) {
track.cuePoints.push(cuePoint);
}
lastCuePoint = cuePoint;
if (!remainingTrackIds.has(cuePoint.trackId)) {
continue;
}
const track = this.currentSegment.tracks.find(x => x.id === cuePoint.trackId)!;
track.cuePoints.push(cuePoint);
remainingTrackIds.delete(cuePoint.trackId);
lastClusterPosition = cuePoint.clusterPosition;
}
for (const id of remainingTrackIds) {
assert(lastCuePoint);
const track = this.currentSegment.tracks.find(x => x.id === id)!;
track.cuePoints.push(lastCuePoint);
}
for (const track of this.currentSegment.tracks) {
// Sort cue points by time
track.cuePoints.sort((a, b) => a.time - b.time);
// Remove multiple cue points for the same time
for (let i = 0; i < track.cuePoints.length - 1; i++) {
const cuePoint1 = track.cuePoints[i]!;
const cuePoint2 = track.cuePoints[i + 1]!;
if (cuePoint1.time === cuePoint2.time) {
track.cuePoints.splice(i + 1, 1);
i--;
}
}
}
let trackWithMostCuePoints: InternalTrack | null = null;
let maxCuePointCount = -Infinity;
for (const track of this.currentSegment.tracks) {
if (track.cuePoints.length > maxCuePointCount) {
maxCuePointCount = track.cuePoints.length;
trackWithMostCuePoints = track;
}
}
// For every track that has received 0 cue points (can happen, often only the video track receives cue points),
// we still want to have better seeking. Therefore, let's give it the cue points of the track with the most cue
// points, which should provide us with the most fine-grained seeking.
for (const track of this.currentSegment.tracks) {
if (track.cuePoints.length === 0) {
track.cuePoints = trackWithMostCuePoints!.cuePoints;
}
}
this.currentSegment = null;
}
async readCluster(startPos: number, segment: Segment) {
if (segment.lastReadCluster?.elementStartPos === startPos) {
return segment.lastReadCluster;
}
let headerSlice = this.reader.requestSliceRange(startPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);
if (headerSlice instanceof Promise) headerSlice = await headerSlice;
assert(headerSlice);
@@ -600,6 +603,8 @@ export class MatroskaDemuxer extends Demuxer {
assert(elementHeader);
const id = elementHeader.id;
assert(id === EBMLId.Cluster);
let size = elementHeader.size;
const dataStartPos = headerSlice.filePos;
@@ -617,8 +622,6 @@ export class MatroskaDemuxer extends Demuxer {
size = nextElementPos.pos - dataStartPos;
}
assert(id === EBMLId.Cluster);
// Load the entire cluster
let dataSlice = this.reader.requestSlice(dataStartPos, size);
if (dataSlice instanceof Promise) dataSlice = await dataSlice;
@@ -630,13 +633,14 @@ export class MatroskaDemuxer extends Demuxer {
dataStartPos,
timestamp: -1,
trackData: new Map(),
nextCluster: null,
isKnownToBeFirstCluster: false,
};
this.currentCluster = cluster;
if (dataSlice) {
this.readContiguousElements(dataSlice);
// Read the children of the cluster, stopping early at level 0 or 1 EBML elements. We do this because some
// clusters have incorrect sizes that are too large
const endPos = this.readContiguousElements(dataSlice, LEVEL_0_AND_1_EBML_IDS);
cluster.elementEndPos = endPos;
}
for (const [, trackData] of cluster.trackData) {
@@ -705,17 +709,24 @@ export class MatroskaDemuxer extends Demuxer {
trackData.startTimestamp = firstBlock.timestamp;
trackData.endTimestamp = lastBlock.timestamp + lastBlock.duration;
insertSorted(track.clusters, cluster, x => x.elementStartPos);
const hasKeyFrame = trackData.firstKeyFrameTimestamp !== null;
if (hasKeyFrame) {
insertSorted(track.clustersWithKeyFrame, cluster, x => x.elementStartPos);
// Let's remember that a cluster with a given timestamp is here, speeding up future lookups if no cues exist
const insertionIndex = binarySearchLessOrEqual(
track.clusterPositionCache,
trackData.startTimestamp,
x => x.startTimestamp,
);
if (
insertionIndex === -1
|| track.clusterPositionCache[insertionIndex]!.elementStartPos !== elementStartPos
) {
track.clusterPositionCache.splice(insertionIndex + 1, 0, {
elementStartPos: cluster.elementStartPos,
startTimestamp: trackData.startTimestamp,
});
}
}
insertSorted(segment.clusters, cluster, x => x.elementStartPos);
this.currentCluster = null;
segment.lastReadCluster = cluster;
return cluster;
}
@@ -905,24 +916,31 @@ export class MatroskaDemuxer extends Demuxer {
}
}
readContiguousElements(slice: FileSlice) {
readContiguousElements(slice: FileSlice, stopIds?: number[]) {
const startIndex = slice.filePos;
while (slice.filePos - startIndex <= slice.length - MIN_HEADER_SIZE) {
const foundElement = this.traverseElement(slice);
const startPos = slice.filePos;
const foundElement = this.traverseElement(slice, stopIds);
if (!foundElement) {
break;
return startPos;
}
}
return slice.filePos;
}
traverseElement(slice: FileSlice): boolean {
traverseElement(slice: FileSlice, stopIds?: number[]): boolean {
const header = readElementHeader(slice);
if (!header) {
return false;
}
if (stopIds && stopIds.includes(header.id)) {
return false;
}
const { id, size } = header;
const dataStartPos = slice.filePos;
assertDefinedSize(size);
@@ -977,8 +995,7 @@ export class MatroskaDemuxer extends Demuxer {
id: -1,
segment: this.currentSegment,
demuxer: this,
clusters: [],
clustersWithKeyFrame: [],
clusterPositionCache: [],
cuePoints: [],
isDefault: false,
@@ -1844,31 +1861,17 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
async getFirstPacket(options: PacketRetrievalOptions) {
return this.performClusterLookup(
() => {
const startCluster = this.internalTrack.segment.clusters[0] ?? null;
if (startCluster?.isKnownToBeFirstCluster) {
// Walk from the very first cluster in the file until we find one with our track in it
let currentCluster: Cluster | null = startCluster;
while (currentCluster) {
const trackData = currentCluster.trackData.get(this.internalTrack.id);
if (trackData) {
return {
clusterIndex: binarySearchExact(
this.internalTrack.clusters,
currentCluster.elementStartPos,
x => x.elementStartPos,
),
blockIndex: 0,
correctBlockFound: true,
};
}
currentCluster = currentCluster.nextCluster;
}
null,
(cluster) => {
const trackData = cluster.trackData.get(this.internalTrack.id);
if (trackData) {
return {
blockIndex: 0,
correctBlockFound: true,
};
}
return {
clusterIndex: -1,
blockIndex: -1,
correctBlockFound: false,
};
@@ -1890,7 +1893,24 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
const timestampInTimescale = this.intoTimescale(timestamp);
return this.performClusterLookup(
() => this.findBlockInClustersForTimestamp(timestampInTimescale),
null,
(cluster) => {
const trackData = cluster.trackData.get(this.internalTrack.id);
if (!trackData) {
return { blockIndex: -1, correctBlockFound: false };
}
const index = binarySearchLessOrEqual(
trackData.presentationTimestamps,
timestampInTimescale,
x => x.timestamp,
);
const blockIndex = index !== -1 ? trackData.presentationTimestamps[index]!.blockIndex : -1;
const correctBlockFound = index !== -1 && timestampInTimescale < trackData.endTimestamp;
return { blockIndex, correctBlockFound };
},
timestampInTimescale,
timestampInTimescale,
options,
@@ -1903,53 +1923,32 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
throw new Error('Packet was not created from this track.');
}
const trackData = locationInCluster.cluster.trackData.get(this.internalTrack.id)!;
const clusterIndex = binarySearchExact(
this.internalTrack.clusters,
locationInCluster.cluster.elementStartPos,
x => x.elementStartPos,
);
assert(clusterIndex !== -1);
return this.performClusterLookup(
() => {
if (locationInCluster.blockIndex + 1 < trackData.blocks.length) {
// We can simply take the next block in the cluster
return {
clusterIndex,
blockIndex: locationInCluster.blockIndex + 1,
correctBlockFound: true,
};
locationInCluster.cluster,
(cluster) => {
if (cluster === locationInCluster.cluster) {
const trackData = cluster.trackData.get(this.internalTrack.id)!;
if (locationInCluster.blockIndex + 1 < trackData.blocks.length) {
// We can simply take the next block in the cluster
return {
blockIndex: locationInCluster.blockIndex + 1,
correctBlockFound: true,
};
}
} else {
// Walk the list of clusters until we find the next cluster for this track
let currentCluster = locationInCluster.cluster;
while (currentCluster.nextCluster) {
currentCluster = currentCluster.nextCluster;
const trackData = currentCluster.trackData.get(this.internalTrack.id);
if (trackData) {
const clusterIndex = binarySearchExact(
this.internalTrack.clusters,
currentCluster.elementStartPos,
x => x.elementStartPos,
);
assert(clusterIndex !== -1);
return {
clusterIndex,
blockIndex: 0,
correctBlockFound: true,
};
}
const trackData = cluster.trackData.get(this.internalTrack.id);
if (trackData) {
return {
blockIndex: 0,
correctBlockFound: true,
};
}
return {
clusterIndex,
blockIndex: -1,
correctBlockFound: false,
};
}
return {
blockIndex: -1,
correctBlockFound: false,
};
},
-Infinity, // Use -Infinity as a search timestamp to avoid using the cues
Infinity,
@@ -1961,7 +1960,23 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
const timestampInTimescale = this.intoTimescale(timestamp);
return this.performClusterLookup(
() => this.findKeyBlockInClustersForTimestamp(timestampInTimescale),
null,
(cluster) => {
const trackData = cluster.trackData.get(this.internalTrack.id);
if (!trackData) {
return { blockIndex: -1, correctBlockFound: false };
}
const index = findLastIndex(trackData.presentationTimestamps, (x) => {
const block = trackData.blocks[x.blockIndex]!;
return block.isKeyFrame && x.timestamp <= timestampInTimescale;
});
const blockIndex = index !== -1 ? trackData.presentationTimestamps[index]!.blockIndex : -1;
const correctBlockFound = index !== -1 && timestampInTimescale < trackData.endTimestamp;
return { blockIndex, correctBlockFound };
},
timestampInTimescale,
timestampInTimescale,
options,
@@ -1974,60 +1989,39 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
throw new Error('Packet was not created from this track.');
}
const trackData = locationInCluster.cluster.trackData.get(this.internalTrack.id)!;
const clusterIndex = binarySearchExact(
this.internalTrack.clusters,
locationInCluster.cluster.elementStartPos,
x => x.elementStartPos,
);
assert(clusterIndex !== -1);
return this.performClusterLookup(
() => {
const nextKeyFrameIndex = trackData.blocks.findIndex(
(x, i) => x.isKeyFrame && i > locationInCluster.blockIndex,
);
locationInCluster.cluster,
(cluster) => {
if (cluster === locationInCluster.cluster) {
const trackData = cluster.trackData.get(this.internalTrack.id)!;
const nextKeyFrameIndex = trackData.blocks.findIndex(
(x, i) => x.isKeyFrame && i > locationInCluster.blockIndex,
);
if (nextKeyFrameIndex !== -1) {
// We can simply take the next key frame in the cluster
return {
clusterIndex,
blockIndex: nextKeyFrameIndex,
correctBlockFound: true,
};
} else {
// Walk the list of clusters until we find the next cluster for this track with a key frame
let currentCluster = locationInCluster.cluster;
while (currentCluster.nextCluster) {
currentCluster = currentCluster.nextCluster;
const trackData = currentCluster.trackData.get(this.internalTrack.id);
if (trackData && trackData.firstKeyFrameTimestamp !== null) {
const clusterIndex = binarySearchExact(
this.internalTrack.clusters,
currentCluster.elementStartPos,
x => x.elementStartPos,
);
assert(clusterIndex !== -1);
const keyFrameIndex = trackData.blocks.findIndex(x => x.isKeyFrame);
assert(keyFrameIndex !== -1); // There must be one
return {
clusterIndex,
blockIndex: keyFrameIndex,
correctBlockFound: true,
};
}
if (nextKeyFrameIndex !== -1) {
// We can simply take the next key frame in the cluster
return {
blockIndex: nextKeyFrameIndex,
correctBlockFound: true,
};
}
} else {
const trackData = cluster.trackData.get(this.internalTrack.id);
if (trackData && trackData.firstKeyFrameTimestamp !== null) {
const keyFrameIndex = trackData.blocks.findIndex(x => x.isKeyFrame);
assert(keyFrameIndex !== -1); // There must be one
return {
clusterIndex,
blockIndex: -1,
correctBlockFound: false,
};
return {
blockIndex: keyFrameIndex,
correctBlockFound: true,
};
}
}
return {
blockIndex: -1,
correctBlockFound: false,
};
},
-Infinity, // Use -Infinity as a search timestamp to avoid using the cues
Infinity,
@@ -2075,77 +2069,12 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
return packet;
}
private findBlockInClustersForTimestamp(timestampInTimescale: number) {
const clusterIndex = binarySearchLessOrEqual(
// This array is technically not sorted by start timestamp, but for any reasonable file, it basically is.
this.internalTrack.clusters,
timestampInTimescale,
x => x.trackData.get(this.internalTrack.id)!.startTimestamp,
);
let blockIndex = -1;
let correctBlockFound = false;
if (clusterIndex !== -1) {
const cluster = this.internalTrack.clusters[clusterIndex]!;
const trackData = cluster.trackData.get(this.internalTrack.id)!;
const index = binarySearchLessOrEqual(
trackData.presentationTimestamps,
timestampInTimescale,
x => x.timestamp,
);
assert(index !== -1);
blockIndex = trackData.presentationTimestamps[index]!.blockIndex;
correctBlockFound = timestampInTimescale < trackData.endTimestamp;
}
return { clusterIndex, blockIndex, correctBlockFound };
}
private findKeyBlockInClustersForTimestamp(timestampInTimescale: number) {
const indexInKeyFrameClusters = binarySearchLessOrEqual(
// This array is technically not sorted by start timestamp, but for any reasonable file, it basically is.
this.internalTrack.clustersWithKeyFrame,
timestampInTimescale,
x => x.trackData.get(this.internalTrack.id)!.firstKeyFrameTimestamp!,
);
let clusterIndex = -1;
let blockIndex = -1;
let correctBlockFound = false;
if (indexInKeyFrameClusters !== -1) {
const cluster = this.internalTrack.clustersWithKeyFrame[indexInKeyFrameClusters]!;
// Now, let's find the actual index of the cluster in the list of ALL clusters, not just key frame ones
clusterIndex = binarySearchExact(
this.internalTrack.clusters,
cluster.elementStartPos,
x => x.elementStartPos,
);
assert(clusterIndex !== -1);
const trackData = cluster.trackData.get(this.internalTrack.id)!;
const index = findLastIndex(trackData.presentationTimestamps, (x) => {
const block = trackData.blocks[x.blockIndex]!;
return block.isKeyFrame && x.timestamp <= timestampInTimescale;
});
assert(index !== -1); // It's a key frame cluster, so there must be a key frame
const entry = trackData.presentationTimestamps[index]!;
blockIndex = entry.blockIndex;
correctBlockFound = timestampInTimescale < trackData.endTimestamp;
}
return { clusterIndex, blockIndex, correctBlockFound };
}
/** Looks for a packet in the clusters while trying to load as few clusters as possible to retrieve it. */
private async performClusterLookup(
// This function returns the best-matching block that is currently loaded. Based on this information, we know
// which clusters we need to load to find the actual match.
getBestMatch: () => { clusterIndex: number; blockIndex: number; correctBlockFound: boolean },
// The cluster where we start looking
startCluster: Cluster | null,
// This function returns the best-matching block in a given cluster
getMatchInCluster: (cluster: Cluster) => { blockIndex: number; correctBlockFound: boolean },
// The timestamp with which we can search the lookup table
searchTimestamp: number,
// The timestamp for which we know the correct block will not come after it
@@ -2153,192 +2082,176 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
options: PacketRetrievalOptions,
): Promise<EncodedPacket | null> {
const { demuxer, segment } = this.internalTrack;
const release = await segment.clusterLookupMutex.acquire(); // The algorithm requires exclusivity
try {
const { clusterIndex, blockIndex, correctBlockFound } = getBestMatch();
let currentCluster: Cluster | null = null;
let bestCluster: Cluster | null = null;
let bestBlockIndex = -1;
if (startCluster) {
const { blockIndex, correctBlockFound } = getMatchInCluster(startCluster);
if (correctBlockFound) {
// The correct block already exists, easy path.
const cluster = this.internalTrack.clusters[clusterIndex]!;
return this.fetchPacketInCluster(cluster, blockIndex, options);
return this.fetchPacketInCluster(startCluster, blockIndex, options);
}
let prevCluster: Cluster | null = null;
let bestClusterIndex = clusterIndex;
let bestBlockIndex = blockIndex;
if (blockIndex !== -1) {
bestCluster = startCluster;
bestBlockIndex = blockIndex;
}
}
// Search for a cue point; this way, we won't need to start searching from the start of the file
// but can jump right into the correct cluster (or at least nearby).
const cuePointIndex = binarySearchLessOrEqual(
this.internalTrack.cuePoints,
searchTimestamp,
x => x.time,
);
const cuePoint = cuePointIndex !== -1 ? this.internalTrack.cuePoints[cuePointIndex]! : null;
// Search for a cue point; this way, we won't need to start searching from the start of the file
// but can jump right into the correct cluster (or at least nearby).
const cuePointIndex = binarySearchLessOrEqual(
this.internalTrack.cuePoints,
searchTimestamp,
x => x.time,
);
const cuePoint = cuePointIndex !== -1
? this.internalTrack.cuePoints[cuePointIndex]!
: null;
let currentPos: number;
let nextClusterIsFirstCluster = false;
// Also check the position cache
const positionCacheIndex = binarySearchLessOrEqual(
this.internalTrack.clusterPositionCache,
searchTimestamp,
x => x.startTimestamp,
);
const positionCacheEntry = positionCacheIndex !== -1
? this.internalTrack.clusterPositionCache[positionCacheIndex]!
: null;
if (clusterIndex === -1) {
currentPos = cuePoint?.clusterPosition ?? segment.clusterSeekStartPos;
nextClusterIsFirstCluster = currentPos === segment.clusterSeekStartPos;
const lookupEntryPosition = Math.max(
cuePoint?.clusterPosition ?? 0,
positionCacheEntry?.elementStartPos ?? 0,
) || null;
let currentPos: number;
if (!startCluster) {
currentPos = lookupEntryPosition ?? segment.clusterSeekStartPos;
} else {
if (lookupEntryPosition === null || startCluster.elementStartPos >= lookupEntryPosition) {
currentPos = startCluster.elementEndPos;
currentCluster = startCluster;
} else {
const cluster = this.internalTrack.clusters[clusterIndex]!;
// Use the lookup entry
currentPos = lookupEntryPosition;
}
}
if (!cuePoint || cluster.elementStartPos >= cuePoint.clusterPosition) {
currentPos = cluster.elementEndPos;
prevCluster = cluster;
} else {
// Use the lookup entry
currentPos = cuePoint.clusterPosition;
while (segment.elementEndPos === null || currentPos <= segment.elementEndPos - MIN_HEADER_SIZE) {
if (currentCluster) {
const trackData = currentCluster.trackData.get(this.internalTrack.id);
if (trackData && trackData.startTimestamp > latestTimestamp) {
// We're already past the upper bound, no need to keep searching
break;
}
}
while (segment.elementEndPos === null || currentPos <= segment.elementEndPos - MIN_HEADER_SIZE) {
if (prevCluster) {
const trackData = prevCluster.trackData.get(this.internalTrack.id);
if (trackData && trackData.startTimestamp > latestTimestamp) {
// We're already past the upper bound, no need to keep searching
break;
}
// Load the header
let slice = demuxer.reader.requestSliceRange(currentPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);
if (slice instanceof Promise) slice = await slice;
if (!slice) break;
if (prevCluster.nextCluster) {
// Skip ahead quickly without needing to read the file again
currentPos = prevCluster.nextCluster.elementEndPos;
prevCluster = prevCluster.nextCluster;
continue;
}
const elementStartPos = currentPos;
const elementHeader = readElementHeader(slice);
if (
!elementHeader
|| (!LEVEL_1_EBML_IDS.includes(elementHeader.id) && elementHeader.id !== EBMLId.Void)
) {
// There's an element here that shouldn't be here. Might be garbage. In this case, let's
// try and resync to the next valid element.
const nextPos = await resync(
demuxer.reader,
elementStartPos,
LEVEL_1_EBML_IDS,
Math.min(segment.elementEndPos ?? Infinity, elementStartPos + MAX_RESYNC_LENGTH),
);
if (nextPos) {
currentPos = nextPos;
continue;
} else {
break; // Resync failed
}
}
const id = elementHeader.id;
let size = elementHeader.size;
const dataStartPos = slice.filePos;
if (id === EBMLId.Cluster) {
currentCluster = await demuxer.readCluster(elementStartPos, segment);
// readCluster computes the proper size even if it's undefined in the header, so let's use that instead
size = currentCluster.elementEndPos - dataStartPos;
const { blockIndex, correctBlockFound } = getMatchInCluster(currentCluster);
if (correctBlockFound) {
return this.fetchPacketInCluster(currentCluster, blockIndex, options);
}
// Load the header
let slice = demuxer.reader.requestSliceRange(currentPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);
if (blockIndex !== -1) {
bestCluster = currentCluster;
bestBlockIndex = blockIndex;
}
}
if (size === null) {
// Undefined element size (can happen in livestreamed files). In this case, we need to do some
// searching to determine the actual size of the element.
assert(id !== EBMLId.Cluster); // Undefined cluster sizes are fixed further up
// Search for the next element at level 0 or 1
const nextElementPos = await searchForNextElementId(
demuxer.reader,
dataStartPos,
LEVEL_0_AND_1_EBML_IDS,
segment.elementEndPos,
);
size = nextElementPos.pos - dataStartPos;
}
const endPos = dataStartPos + size;
if (segment.elementEndPos === null) {
// Check the next element. If it's a new segment, we know this segment ends here. The new
// segment is just ignored, since we're likely in a livestreamed file and thus only care about
// the first segment.
let slice = demuxer.reader.requestSliceRange(endPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);
if (slice instanceof Promise) slice = await slice;
if (!slice) break;
const elementStartPos = currentPos;
const elementHeader = readElementHeader(slice);
if (
!elementHeader
|| (!LEVEL_1_EBML_IDS.includes(elementHeader.id) && elementHeader.id !== EBMLId.Void)
) {
// There's an element here that shouldn't be here. Might be garbage. In this case, let's
// try and resync to the next valid element.
const nextPos = await resync(
demuxer.reader,
elementStartPos,
LEVEL_1_EBML_IDS,
Math.min(segment.elementEndPos ?? Infinity, elementStartPos + MAX_RESYNC_LENGTH),
);
if (nextPos) {
currentPos = nextPos;
continue;
} else {
break; // Resync failed
}
const elementId = readElementId(slice);
if (elementId === EBMLId.Segment) {
segment.elementEndPos = endPos; // We now know the segment's size
break;
}
const id = elementHeader.id;
let size = elementHeader.size;
const dataStartPos = slice.filePos;
if (id === EBMLId.Cluster) {
const index = binarySearchExact(segment.clusters, elementStartPos, x => x.elementStartPos);
let cluster: Cluster;
if (index === -1) {
// This is the first time we've seen this cluster
cluster = await demuxer.readCluster(elementStartPos, segment);
} else {
// We already know this cluster
cluster = segment.clusters[index]!;
}
// Even if we already know the cluster, we might not yet know its predecessor, so always do this
if (prevCluster) prevCluster.nextCluster = cluster;
prevCluster = cluster;
if (nextClusterIsFirstCluster) {
cluster.isKnownToBeFirstCluster = true;
nextClusterIsFirstCluster = false;
}
const { clusterIndex, blockIndex, correctBlockFound } = getBestMatch();
if (correctBlockFound) {
const cluster = this.internalTrack.clusters[clusterIndex]!;
return this.fetchPacketInCluster(cluster, blockIndex, options);
}
if (clusterIndex !== -1) {
bestClusterIndex = clusterIndex;
bestBlockIndex = blockIndex;
}
}
if (size === null) {
// Undefined element size (can happen in livestreamed files). In this case, we need to do some
// searching to determine the actual size of the element.
if (id === EBMLId.Cluster) {
// The cluster should have already computed its length, we can just copy that result
assert(prevCluster);
size = prevCluster.elementEndPos - dataStartPos;
} else {
// Search for the next element at level 0 or 1
const nextElementPos = await searchForNextElementId(
demuxer.reader,
dataStartPos,
LEVEL_0_AND_1_EBML_IDS,
segment.elementEndPos,
);
size = nextElementPos.pos - dataStartPos;
}
const endPos = dataStartPos + size;
if (segment.elementEndPos !== null && endPos > segment.elementEndPos - MIN_HEADER_SIZE) {
// No more elements fit in this segment
break;
} else {
// Check the next element. If it's a new segment, we know this segment ends here. The new
// segment is just ignored, since we're likely in a livestreamed file and thus only care about
// the first segment.
let slice = demuxer.reader.requestSliceRange(endPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);
if (slice instanceof Promise) slice = await slice;
if (!slice) break;
const elementId = readElementId(slice);
if (elementId === EBMLId.Segment) {
segment.elementEndPos = endPos;
break;
}
}
}
currentPos = dataStartPos + size;
}
const bestCluster = bestClusterIndex !== -1 ? this.internalTrack.clusters[bestClusterIndex]! : null;
// Catch faulty cue points
if (cuePoint && (!bestCluster || bestCluster.elementStartPos < cuePoint.clusterPosition)) {
// The cue point lied to us! We found a cue point but no cluster there that satisfied the match. In this
// case, let's search again but using the cue point before that.
const previousCuePoint = this.internalTrack.cuePoints[cuePointIndex - 1];
const newSearchTimestamp = previousCuePoint?.time ?? -Infinity;
return this.performClusterLookup(getBestMatch, newSearchTimestamp, latestTimestamp, options);
}
if (bestCluster) {
// If we finished looping but didn't find a perfect match, still return the best match we found
return this.fetchPacketInCluster(bestCluster, bestBlockIndex, options);
}
return null;
} finally {
release();
currentPos = endPos;
}
// Catch faulty cue points
if (cuePoint && (!bestCluster || bestCluster.elementStartPos < cuePoint.clusterPosition)) {
// The cue point lied to us! We found a cue point but no cluster there that satisfied the match. In this
// case, let's search again but using the cue point before that.
const previousCuePoint = this.internalTrack.cuePoints[cuePointIndex - 1];
assert(!previousCuePoint || previousCuePoint.time < cuePoint.time);
const newSearchTimestamp = previousCuePoint?.time ?? -Infinity;
return this.performClusterLookup(null, getMatchInCluster, newSearchTimestamp, latestTimestamp, options);
}
if (bestCluster) {
// If we finished looping but didn't find a perfect match, still return the best match we found
return this.fetchPacketInCluster(bestCluster, bestBlockIndex, options);
}
return null;
}
}
+2 -1
View File
@@ -25,6 +25,7 @@ import {
getUint24,
insertSorted,
isFirefox,
isNumber,
isSafari,
last,
mapAsyncGenerator,
@@ -75,7 +76,7 @@ const validatePacketRetrievalOptions = (options: PacketRetrievalOptions) => {
};
const validateTimestamp = (timestamp: number) => {
if (typeof timestamp !== 'number' || Number.isNaN(timestamp)) {
if (!isNumber(timestamp)) {
throw new TypeError('timestamp must be a number.'); // It can be non-finite, that's fine
}
};
+8 -4
View File
@@ -1159,7 +1159,7 @@ export class MediaStreamVideoTrackSource extends VideoSource {
type: 'videoTrack',
trackId: this._workerTrackId,
track: this._track,
}, [this._track]);
});
this._workerListener = (event: MessageEvent) => {
const message = event.data as MediaStreamTrackProcessorWorkerMessage;
@@ -1978,17 +1978,19 @@ const mediaStreamTrackProcessorWorkerCode = () => {
});
const abortControllers = new Map<number, AbortController>();
const stoppedTracks = new Set<number>();
const activeTracks = new Map<number, MediaStreamVideoTrack>();
self.addEventListener('message', (event) => {
const message = event.data as MediaStreamTrackProcessorControllerMessage;
switch (message.type) {
case 'videoTrack': {
activeTracks.set(message.trackId, message.track);
const processor = new MediaStreamTrackProcessor({ track: message.track });
const consumer = new WritableStream<VideoFrame>({
write: (videoFrame) => {
if (stoppedTracks.has(message.trackId)) {
if (!activeTracks.has(message.trackId)) {
videoFrame.close();
return;
}
@@ -2026,7 +2028,9 @@ const mediaStreamTrackProcessorWorkerCode = () => {
abortControllers.delete(message.trackId);
}
stoppedTracks.add(message.trackId);
const track = activeTracks.get(message.trackId);
track?.stop();
activeTracks.delete(message.trackId);
sendMessage({
type: 'trackStopped',
+7 -3
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);
@@ -578,7 +578,7 @@ export const retriedFetch = async (
fetchFn: typeof fetch,
url: string | URL | Request,
requestInit: RequestInit,
getRetryDelay: (previousAttempts: number, error: unknown) => number | null,
getRetryDelay: (previousAttempts: number, error: unknown, url: string | URL | Request) => number | null,
) => {
let attempts = 0;
@@ -587,7 +587,7 @@ export const retriedFetch = async (
return await fetchFn(url, requestInit);
} catch (error) {
attempts++;
const retryDelayInSeconds = getRetryDelay(attempts, error);
const retryDelayInSeconds = getRetryDelay(attempts, error, url);
if (retryDelayInSeconds === null) {
throw error;
@@ -786,3 +786,7 @@ export const polyfillSymbolDispose = () => {
// @ts-expect-error Readonly
Symbol.dispose ??= Symbol('Symbol.dispose');
};
export const isNumber = (x: unknown) => {
return typeof x === 'number' && !Number.isNaN(x);
};
+81 -17
View File
@@ -11,6 +11,7 @@ import {
assert,
binarySearchLessOrEqual,
closedIntervalsOverlap,
isNumber,
MaybePromise,
mergeRequestInit,
promiseWithResolvers,
@@ -172,9 +173,9 @@ export class BlobSource extends Source {
}
if (
options.maxCacheSize !== undefined
&& (!Number.isInteger(options.maxCacheSize) || options.maxCacheSize < 0)
&& (!isNumber(options.maxCacheSize) || options.maxCacheSize < 0)
) {
throw new TypeError('options.maxCacheSize, when provided, must be a non-negative integer.');
throw new TypeError('options.maxCacheSize, when provided, must be a non-negative number.');
}
super();
@@ -233,11 +234,19 @@ export class BlobSource extends Source {
break;
}
if (worker.aborted) {
break;
}
this.onread?.(worker.currentPos, worker.currentPos + value.length);
this._orchestrator.supplyWorkerData(worker, value);
} else {
const data = await this._blob.slice(worker.currentPos, worker.targetPos).arrayBuffer();
if (worker.aborted) {
break;
}
this.onread?.(worker.currentPos, worker.currentPos + data.byteLength);
this._orchestrator.supplyWorkerData(worker, new Uint8Array(data));
}
@@ -254,7 +263,38 @@ export class BlobSource extends Source {
const URL_SOURCE_MIN_LOAD_AMOUNT = 0.5 * 2 ** 20; // 0.5 MiB
const DEFAULT_RETRY_DELAY
= (previousAttempts => Math.min(2 ** (previousAttempts - 2), 16)) satisfies UrlSourceOptions['getRetryDelay'];
= ((previousAttempts, error, src) => {
// Check if this could be a CORS error. If so, we cannot recover from it and
// should not attempt to retry.
// CORS errors are intentionally not opaque, so we need to rely on heuristics.
const couldBeCorsError = error instanceof Error && (
error.message.includes('Failed to fetch') // Chrome
|| error.message.includes('Load failed') // Safari
|| error.message.includes('NetworkError when attempting to fetch resource') // Firefox
);
if (couldBeCorsError) {
let originOfSrc: string | null = null;
// Checking if the origin is different, because only then a CORS error could originate
try {
if (typeof window !== 'undefined' && typeof window.location !== 'undefined') {
originOfSrc = new URL(src instanceof Request ? src.url : src, window.location.href).origin;
}
} catch {
// URL parse failed
}
// If user is offline, it is probably not a CORS error.
const isOnline
= typeof navigator !== 'undefined' && typeof navigator.onLine === 'boolean' ? navigator.onLine : true;
if (isOnline && originOfSrc !== null && originOfSrc !== window.location.origin) {
return null;
}
}
return Math.min(2 ** (previousAttempts - 2), 16);
}) satisfies UrlSourceOptions['getRetryDelay'];
/**
* Options for {@link UrlSource}.
@@ -273,9 +313,10 @@ export type UrlSourceOptions = {
* with the number of previous, unsuccessful attempts, as well as with the error with which the previous request
* failed. If the function returns `null`, no more retries will be made.
*
* By default, it uses an exponential backoff algorithm that never fully gives up.
* By default, it uses an exponential backoff algorithm that never gives up unless
* a CORS error is suspected (`fetch()` did reject, `navigator.onLine` is true and origin is different)
*/
getRetryDelay?: (previousAttempts: number, error: unknown) => number | null;
getRetryDelay?: (previousAttempts: number, error: unknown, url: string | URL | Request) => number | null;
/** The maximum number of bytes the cache is allowed to hold in memory. Defaults to 64 MiB. */
maxCacheSize?: number;
@@ -297,7 +338,7 @@ export class UrlSource extends Source {
/** @internal */
_url: string | URL | Request;
/** @internal */
_getRetryDelay: (previousAttempts: number, error: unknown) => number | null;
_getRetryDelay: (previousAttempts: number, error: unknown, url: string | URL | Request) => number | null;
/** @internal */
_options: UrlSourceOptions;
/** @internal */
@@ -331,9 +372,9 @@ export class UrlSource extends Source {
}
if (
options.maxCacheSize !== undefined
&& (!Number.isInteger(options.maxCacheSize) || options.maxCacheSize < 0)
&& (!isNumber(options.maxCacheSize) || options.maxCacheSize < 0)
) {
throw new TypeError('options.maxCacheSize, when provided, must be a non-negative integer.');
throw new TypeError('options.maxCacheSize, when provided, must be a non-negative number.');
}
if (options.fetchFn !== undefined && typeof options.fetchFn !== 'function') {
throw new TypeError('options.fetchFn, when provided, must be a function.');
@@ -424,7 +465,7 @@ export class UrlSource extends Source {
/** @internal */
private async _runWorker(worker: ReadWorker) {
// The outer loop is for resuming a request if it dies mid-response
while (!worker.aborted) {
while (true) {
const existing = this._existingResponses.get(worker);
this._existingResponses.delete(worker);
@@ -490,7 +531,7 @@ export class UrlSource extends Source {
try {
readResult = await reader.read();
} catch (error) {
const retryDelayInSeconds = this._getRetryDelay(1, error);
const retryDelayInSeconds = this._getRetryDelay(1, error, this._url);
if (retryDelayInSeconds !== null) {
console.error('Error while reading response stream. Attempting to resume.', error);
await new Promise(resolve => setTimeout(resolve, 1000 * retryDelayInSeconds));
@@ -501,6 +542,10 @@ export class UrlSource extends Source {
}
}
if (worker.aborted) {
break;
}
const { done, value } = readResult;
if (done) {
@@ -519,6 +564,10 @@ export class UrlSource extends Source {
this.onread?.(worker.currentPos, worker.currentPos + value.length);
this._orchestrator.supplyWorkerData(worker, value);
}
if (worker.aborted) {
break;
}
}
worker.running = false;
@@ -591,9 +640,9 @@ export class FilePathSource extends Source {
}
if (
options.maxCacheSize !== undefined
&& (!Number.isInteger(options.maxCacheSize) || options.maxCacheSize < 0)
&& (!isNumber(options.maxCacheSize) || options.maxCacheSize < 0)
) {
throw new TypeError('options.maxCacheSize, when provided, must be a non-negative integer.');
throw new TypeError('options.maxCacheSize, when provided, must be a non-negative number.');
}
super();
@@ -704,9 +753,9 @@ export class StreamSource extends Source {
}
if (
options.maxCacheSize !== undefined
&& (!Number.isInteger(options.maxCacheSize) || options.maxCacheSize < 0)
&& (!isNumber(options.maxCacheSize) || options.maxCacheSize < 0)
) {
throw new TypeError('options.maxCacheSize, when provided, must be a non-negative integer.');
throw new TypeError('options.maxCacheSize, when provided, must be a non-negative number.');
}
if (options.prefetchProfile && !['none', 'fileSystem', 'network'].includes(options.prefetchProfile)) {
throw new TypeError(
@@ -763,6 +812,10 @@ export class StreamSource extends Source {
let data = this._options.read(worker.currentPos, originalTargetPos);
if (data instanceof Promise) data = await data;
if (worker.aborted) {
break;
}
if (data instanceof Uint8Array) {
data = toUint8Array(data); // Normalize things like Node.js Buffer to Uint8Array
@@ -800,6 +853,10 @@ export class StreamSource extends Source {
throw new TypeError('ReadableStream returned by options.read must yield Uint8Array chunks.');
}
if (worker.aborted) {
break;
}
const data = toUint8Array(value); // Normalize things like Node.js Buffer to Uint8Array
this.onread?.(worker.currentPos, worker.currentPos + data.length);
@@ -884,9 +941,9 @@ export class ReadableStreamSource extends Source {
}
if (
options.maxCacheSize !== undefined
&& (!Number.isInteger(options.maxCacheSize) || options.maxCacheSize < 0)
&& (!isNumber(options.maxCacheSize) || options.maxCacheSize < 0)
) {
throw new TypeError('options.maxCacheSize, when provided, must be a non-negative integer.');
throw new TypeError('options.maxCacheSize, when provided, must be a non-negative number.');
}
super();
@@ -1191,6 +1248,7 @@ class ReadOrchestrator {
workers: ReadWorker[] = [];
cache: CacheEntry[] = [];
currentCacheSize = 0;
disposed = false;
constructor(public options: {
maxCacheSize: number;
@@ -1386,7 +1444,10 @@ class ReadOrchestrator {
currentPos: startPos,
targetPos,
running: false,
aborted: false,
// Due to async shenanigans, it can happen that workers are started after disposal. In this case, instead of
// simply not creating the worker, we allow it to run but immediately label it as aborted, so it can then
// shut itself down.
aborted: this.disposed,
pendingSlices: [],
age: this.nextAge++,
};
@@ -1439,6 +1500,8 @@ class ReadOrchestrator {
/** Called by a worker when it has read some data. */
supplyWorkerData(worker: ReadWorker, bytes: Uint8Array) {
assert(!worker.aborted);
const start = worker.currentPos;
const end = start + bytes.length;
@@ -1612,5 +1675,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.
+11 -8
View File
@@ -139,17 +139,19 @@ test('Can encode transparent video', async () => {
probeContext.drawImage(video, 0, 0);
let imageData = probeContext.getImageData(0, 0, probeCanvas.width, probeCanvas.height);
expect(imageData.data[3]).lessThanOrEqual(2); // Transparent (within error)
const pos1 = { x: 100, y: 100 };
const index1 = (pos1.x + pos1.y * probeCanvas.width) * 4;
expect(imageData.data[index1 + 3]).toBe(0); // Transparent
const pos = { x: 300, y: 300 }; // Dead center in the red square
const index = (pos.x + pos.y * probeCanvas.width) * 4;
const pos2 = { x: 300, y: 300 }; // Dead center in the red square
const index2 = (pos2.x + pos2.y * probeCanvas.width) * 4;
// Red (within error)
expect(imageData.data[index + 0]).greaterThanOrEqual(253);
expect(imageData.data[index + 1]).lessThanOrEqual(2);
expect(imageData.data[index + 2]).lessThanOrEqual(2);
expect(imageData.data[index2 + 0]).greaterThanOrEqual(253);
expect(imageData.data[index2 + 1]).lessThanOrEqual(3);
expect(imageData.data[index2 + 2]).lessThanOrEqual(3);
expect(imageData.data[index + 3]).greaterThanOrEqual(253); // Opaque (within error)
expect(imageData.data[index2 + 3]).toBe(255); // Opaque
// Let's also check it's read correctly by Mediabunny
using input = new Input({
@@ -169,7 +171,8 @@ test('Can encode transparent video', async () => {
firstSample.draw(probeContext, 0, 0);
imageData = probeContext.getImageData(0, 0, probeCanvas.width, probeCanvas.height);
expect(imageData.data[3]).lessThanOrEqual(2); // Transparent (within error)
expect(imageData.data[3]).toBe(0); // Transparent
});
test('Can encode video with alternating transparency', async () => {