mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-09 08:43:49 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
634186fd5e | ||
|
|
ae5a5838d6 | ||
|
|
f41eef0937 | ||
|
|
794b84884f | ||
|
|
45c34f9dcd | ||
|
|
3ec4061e03 | ||
|
|
bf592a87ca | ||
|
|
454476ab26 | ||
|
|
d2aea552d9 | ||
|
|
94d42667f1 | ||
|
|
0554696505 | ||
|
|
bfbd90e1d8 | ||
|
|
1183b542ee | ||
|
|
db75747533 | ||
|
|
3fcb93712d | ||
|
|
5b1dbf5558 | ||
|
|
9180af799d | ||
|
|
b8d5cc5b36 | ||
|
|
d5b07373b3 | ||
|
|
b99b86db0b | ||
|
|
b0e5f655aa | ||
|
|
0c2853d0aa | ||
|
|
cc970e1f53 | ||
|
|
5e68d9655c | ||
|
|
7171cf4338 | ||
|
|
baff6a67a3 | ||
|
|
d0401b42a5 | ||
|
|
72427b8b39 | ||
|
|
6812cf1643 | ||
|
|
c9a828c272 | ||
|
|
e851120bfe | ||
|
|
1a4fec9f6e | ||
|
|
8c8ce8a4a5 | ||
|
|
7ea8f6d751 | ||
|
|
8fb22633fe | ||
|
|
0480ecbd3a | ||
|
|
9547d36353 | ||
|
|
d8d22aa4eb | ||
|
|
50b281a9f8 | ||
|
|
409961c02b | ||
|
|
d8a6e09a85 | ||
|
|
2c7e8d85ca | ||
|
|
a9441aa9a9 | ||
|
|
403093c514 | ||
|
|
54e74bfa4b | ||
|
|
df76bae503 | ||
|
|
b617c5f720 | ||
|
|
a5df6c7b89 | ||
|
|
210d382113 | ||
|
|
a294d943e6 | ||
|
|
9a8ccf6f6c | ||
|
|
f34e4cd369 | ||
|
|
93e6de980d | ||
|
|
e6fe540cbc | ||
|
|
23e6901046 | ||
|
|
143ecea6c0 | ||
|
|
067081d5eb | ||
|
|
f55a2b4764 | ||
|
|
1b1c9a0299 | ||
|
|
2b2e80dc24 | ||
|
|
13d1e280e8 | ||
|
|
62287156d6 | ||
|
|
100852f266 | ||
|
|
f8f08a48e1 | ||
|
|
71dd34fc3c | ||
|
|
487ae3c69a | ||
|
|
0d5bf18864 | ||
|
|
fc67e436e3 | ||
|
|
735a6622a2 | ||
|
|
d51903edfe | ||
|
|
6cdaa76fbb | ||
|
|
8efe7e75db | ||
|
|
a892676453 | ||
|
|
00f020921b | ||
|
|
8071933f15 | ||
|
|
d97611453b | ||
|
|
5e59ae4a1c | ||
|
|
3b148f961a | ||
|
|
95d60805ed | ||
|
|
857956fb07 | ||
|
|
e9fc97e070 | ||
|
|
15db8a27d4 | ||
|
|
637d77b2cf | ||
|
|
b6bb382783 | ||
|
|
7bb9961156 | ||
|
|
776ae43f50 | ||
|
|
e533bab18a | ||
|
|
26f3a576f3 | ||
|
|
1f6457a50c | ||
|
|
f6aee7b414 | ||
|
|
0cb4665d8e | ||
|
|
525a03d220 | ||
|
|
808422f4f5 | ||
|
|
e6110a5b9b | ||
|
|
cd2ded8767 | ||
|
|
db91d07780 | ||
|
|
af57c431d3 | ||
|
|
049f24f2cb | ||
|
|
aa509cd62e | ||
|
|
60789841a8 | ||
|
|
b6d3e3269d | ||
|
|
cea3a40902 | ||
|
|
201a1b4066 | ||
|
|
882051daf2 | ||
|
|
91f96458fa | ||
|
|
9d87a3e580 | ||
|
|
040b0747be | ||
|
|
53923e72dc | ||
|
|
46b3ce39fb | ||
|
|
14ae14b565 | ||
|
|
70e3e98b53 | ||
|
|
6b0527b691 | ||
|
|
3e333636ec | ||
|
|
a583b9d168 | ||
|
|
29aaaf64bc | ||
|
|
5c45fd1f63 | ||
|
|
7232296217 | ||
|
|
30fc407c4c | ||
|
|
52fd8d0e19 | ||
|
|
a3e43987ed | ||
|
|
823ede1668 | ||
|
|
1703e70a16 | ||
|
|
df0932d49e | ||
|
|
64468b0e5b | ||
|
|
903e36430f | ||
|
|
dbb4150718 | ||
|
|
9d1f4a5f80 | ||
|
|
76ac4e7aba | ||
|
|
f0be3ef134 | ||
|
|
77fa426a9a | ||
|
|
b4cbf4c502 | ||
|
|
27bdd33d42 | ||
|
|
5972a5c906 | ||
|
|
da539cf495 | ||
|
|
987ae467ce | ||
|
|
2b5971d1ee | ||
|
|
d735f16ca5 | ||
|
|
9926004401 | ||
|
|
34628390b7 | ||
|
|
1e46df030f | ||
|
|
a6fd297573 | ||
|
|
447dd51e7b | ||
|
|
aebfede89a | ||
|
|
e0a1211140 | ||
|
|
aa8003e679 | ||
|
|
c74b6a44fb | ||
|
|
f21182a7b9 | ||
|
|
fb2dcba707 | ||
|
|
e25525a564 | ||
|
|
f356c05a9c | ||
|
|
4e1428fcef | ||
|
|
ca48f31032 | ||
|
|
b958dd84cc | ||
|
|
f72db4dfa2 | ||
|
|
e9534f57c6 | ||
|
|
31fdd363e3 | ||
|
|
14a55fc476 | ||
|
|
0fc3bb2b1d | ||
|
|
4f670bbe06 | ||
|
|
02f379d9c1 | ||
|
|
588495268c | ||
|
|
230778d887 | ||
|
|
32267f348a | ||
|
|
2aa0fd1433 | ||
|
|
bdc59306ed | ||
|
|
03c1c46f9a | ||
|
|
b4f76fc082 | ||
|
|
d55431dda5 | ||
|
|
6679b6b15d | ||
|
|
ad4dc659a5 | ||
|
|
95fbec86e8 |
@@ -0,0 +1,28 @@
|
||||
name: Build check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build-check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
# Building the docs also means building the core library
|
||||
- name: Build everything
|
||||
run: npm run docs:build
|
||||
@@ -27,7 +27,4 @@ jobs:
|
||||
run: npm run check
|
||||
|
||||
- name: Run ESLint
|
||||
run: npm run lint
|
||||
|
||||
- name: Run build
|
||||
run: npm run build
|
||||
run: npm run lint
|
||||
@@ -25,15 +25,6 @@ jobs:
|
||||
with:
|
||||
script: "core.setFailed('Non-prerelease releases must target the main branch (got: ${{ github.event.release.target_commitish }}).')"
|
||||
|
||||
- name: Merge main into release branch
|
||||
if: ${{ !github.event.release.prerelease }}
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git checkout release
|
||||
git merge origin/main --no-ff -m "Merge main into release for tag ${{ github.event.release.tag_name }}"
|
||||
git push origin release
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -60,8 +51,9 @@ jobs:
|
||||
- name: Run tests
|
||||
run: xvfb-run npm test
|
||||
|
||||
- name: Run build
|
||||
run: npm run build
|
||||
# Building the docs also builds the core library
|
||||
- name: Build everything
|
||||
run: npm run docs:build
|
||||
|
||||
- name: Upload build artifacts
|
||||
env:
|
||||
@@ -106,3 +98,12 @@ jobs:
|
||||
|
||||
- name: Publish workspace packages to npm
|
||||
run: npm publish --access public --workspaces ${{ github.event.release.prerelease && '--tag beta' || '' }}
|
||||
|
||||
- name: Merge main into release branch
|
||||
if: ${{ !github.event.release.prerelease }}
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git checkout release
|
||||
git merge origin/main --no-ff -m "Merge main into release for tag ${{ github.event.release.tag_name }}"
|
||||
git push origin release
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
- Prefer functions declared using const, not using the function keyword
|
||||
- Code style is tab indent with semicolons
|
||||
- Mediabunny core code is contained in src/, extensions are in packages/*/, website is in docs/
|
||||
- Tests: Prefer fewer, longer test files over many small ones. Test files should be named after the general catergory of thing that is being tested, not after any individual single test.
|
||||
- Tests: Prefer fewer, longer test files over many small ones. Test files should be named after the general category of thing that is being tested, not after any individual single test.
|
||||
- Avoid ifs without a {} block. So no if (cond) return;, always do if (cond) { return; }
|
||||
- `type` instead of `interface` for object types
|
||||
- `type` instead of `interface` for object types
|
||||
|
||||
@@ -44,14 +44,6 @@ Mediabunny is a JavaScript library for reading, writing, and converting media (l
|
||||
</a>
|
||||
</div>
|
||||
|
||||
### Silver sponsors
|
||||
|
||||
<div align="center">
|
||||
<a href="https://ponder.ai/" target="_blank" rel="sponsored">
|
||||
<img src="./docs/public/sponsors/ponder.png" width="50" height="50" alt="Ponder">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
### Bronze sponsors
|
||||
|
||||
<div align="center">
|
||||
@@ -70,6 +62,10 @@ Mediabunny is a JavaScript library for reading, writing, and converting media (l
|
||||
<a href="https://jellypod.ai/" target="_blank" rel="sponsored">
|
||||
<img src="./docs/public/sponsors/jellypod.png" width="40" height="40" alt="Jellypod">
|
||||
</a>
|
||||
|
||||
<a href="https://www.photocircleapp.com/" target="_blank" rel="sponsored">
|
||||
<img src="./docs/public/sponsors/photocircle.png" width="40" height="40" alt="PhotoCircle">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
[Sponsor Mediabunny's development](https://github.com/sponsors/Vanilagy)
|
||||
|
||||
@@ -22,7 +22,7 @@ It has the following features:
|
||||
- Audio up/downmixing
|
||||
- User-defined video & audio processing
|
||||
|
||||
The conversion API was built to be simple, versatile and extremely performant.
|
||||
The conversion API was built to be simple, versatile, composable and performant.
|
||||
|
||||
## Basic usage
|
||||
|
||||
@@ -63,7 +63,7 @@ await conversion.execute();
|
||||
That's it! A `Conversion` simply takes an instance of `Input` and `Output`, then reads the data from the input and writes it to the output. If you're unfamiliar with [`Input`](./reading-media-files) and [`Output`](./writing-media-files), check out their respective guides.
|
||||
|
||||
::: info
|
||||
The `Output` passed to the `Conversion` must be *fresh*; that is, it must have no added tracks or metadata tags and be in the `'pending'` state (not started yet).
|
||||
The `Output` passed to the `Conversion` must be *fresh*; that is, it must have no added tracks or metadata tags and be in the `'pending'` state (not started yet). This requirement is relaxed for [composable conversions](#composable-conversions), which allows you to combine the conversion with other tracks.
|
||||
:::
|
||||
|
||||
Unconfigured, the conversion process handles all the details automatically, such as:
|
||||
@@ -114,6 +114,43 @@ await conversion.cancel(); // Resolves once the conversion is canceled
|
||||
|
||||
This automatically frees up all resources used by the conversion process and will cause any ongoing call to `execute` to throw a `ConversionCanceledError`.
|
||||
|
||||
If the conversion is [composable](#composable-conversions), the corresponding `Output` is not canceled and remains usable after conversion cancellation.
|
||||
|
||||
### Pausing a conversion
|
||||
|
||||
You can pause a conversion mid-execution and resume it later. For this, pass a pause signal to the `execute` method:
|
||||
```ts
|
||||
const controller = new AbortController();
|
||||
button.onclick = () => controller.abort();
|
||||
|
||||
await conversion.execute({
|
||||
pauseSignal: controller.signal,
|
||||
});
|
||||
|
||||
if (conversion.state === 'idle') {
|
||||
// Paused before completion
|
||||
} else if (conversion.state === 'done') {
|
||||
// Ran to completion
|
||||
}
|
||||
```
|
||||
|
||||
An unfinished conversion can simply be resumed with another call to `execute`:
|
||||
```ts
|
||||
await conversion.execute();
|
||||
```
|
||||
|
||||
### Partial execution
|
||||
|
||||
Instead of running a conversion in full, you can execute it only until a certain timestamp is reached:
|
||||
```ts
|
||||
await conversion.execute({
|
||||
// Pauses execution once an output timestamp of 10 seconds is reached
|
||||
until: 10,
|
||||
});
|
||||
```
|
||||
|
||||
The conversion can then be resumed and continued by calling `execute` again. This feature is especially useful for [composable conversions](#composable-conversions).
|
||||
|
||||
## Video options
|
||||
|
||||
You can set the `video` property in the conversion options to configure the converter's behavior for video tracks. The options are:
|
||||
@@ -506,6 +543,91 @@ conversion.utilizedTracks; // => InputTrack[]
|
||||
```
|
||||
A track may appear multiple times in this list when [fan-out](#track-fan-out) produces multiple output tracks from it.
|
||||
|
||||
## Composable conversions
|
||||
|
||||
By default, a `Conversion` takes full ownership of its `Output`: it requires a fresh output, then starts it, adds data, and finalizes it for you. Sometimes, however, you want a conversion to be just *one* of several contributors to a single output file - for example, to keep an input's video track while attaching your own, externally-produced audio track. For this, set `composable: true`.
|
||||
|
||||
A composable conversion only adds its own tracks to the output and pumps their media data while `execute()` runs. Everything else about the output's lifecycle is yours: you add any additional tracks, set any metadata tags, and call `start()` and `finalize()` yourself. This enables you to add additional tracks outside of the conversion, or even have multiple conversions target a single `Output`.
|
||||
|
||||
To use it, initialize everything, then start the `Output`, and then execute the conversion:
|
||||
|
||||
```ts
|
||||
import {
|
||||
Input,
|
||||
Output,
|
||||
Mp4OutputFormat,
|
||||
BufferTarget,
|
||||
Conversion,
|
||||
AudioBufferSource,
|
||||
} from 'mediabunny';
|
||||
|
||||
const input = new Input({ ... });
|
||||
const output = new Output({
|
||||
format: new Mp4OutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
// Use the conversion only to copy over the video
|
||||
const conversion = await Conversion.init({
|
||||
input,
|
||||
output,
|
||||
audio: { discard: true },
|
||||
composable: true,
|
||||
});
|
||||
|
||||
// Add our own audio track directly
|
||||
const audioSource = new AudioBufferSource({ codec: 'aac', bitrate: 128e3 });
|
||||
output.addAudioTrack(audioSource);
|
||||
|
||||
// Start the output
|
||||
await output.start();
|
||||
|
||||
// Run the conversion concurrently with feeding our own audio
|
||||
await Promise.all([
|
||||
conversion.execute(),
|
||||
audioSource.add(myAudioBuffer).then(() => audioSource.close()),
|
||||
]);
|
||||
|
||||
// Finalize the output
|
||||
await output.finalize();
|
||||
```
|
||||
|
||||
### Running in lockstep
|
||||
|
||||
To prevent high memory usage due to buffering needs, it's important to add media data at roughly the same speed across all tracks. To achieve this, you can step the conversion deliberately by calling `execute` multiple times:
|
||||
```ts
|
||||
await output.start();
|
||||
|
||||
for (using sample of generateAudioSamples()) {
|
||||
await audioSource.add(sample);
|
||||
await conversion.execute({ until: sample.timestamp });
|
||||
}
|
||||
|
||||
// Convert whatever's left
|
||||
await conversion.execute();
|
||||
|
||||
await output.finalize();
|
||||
```
|
||||
|
||||
When running multiple composable conversions that target the same output, you can use a pattern like this:
|
||||
|
||||
```ts
|
||||
await output.start();
|
||||
|
||||
for (let until = 1; true; until += 1) {
|
||||
await Promise.all([
|
||||
conversion1.execute({ until }),
|
||||
conversion2.execute({ until }),
|
||||
]);
|
||||
|
||||
if (conversion1.state === 'done' && conversion2.state === 'done') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await output.finalize();
|
||||
```
|
||||
|
||||
## Converting live streams
|
||||
|
||||
Live inputs, like HLS live streams, can also be used with the Conversion API. In this case, by default, the conversion will run until the live stream has ended.
|
||||
|
||||
@@ -26,6 +26,25 @@ Alternatively, directly include them using a script tag:
|
||||
|
||||
This will expose the global objects `Mediabunny` and `MediabunnyProres`. Use `mediabunny-prores.d.ts` to provide types for these globals. You can download the built distribution files from the [releases page](https://github.com/Vanilagy/mediabunny/releases).
|
||||
|
||||
## Setup
|
||||
|
||||
`@mediabunny/prores` can make use of shared-memory multithreading to achieve maximum performance. To enable this in browsers, your website must be cross-origin isolated by setting the following response headers:
|
||||
```
|
||||
Cross-Origin-Opener-Policy: same-origin
|
||||
Cross-Origin-Embedder-Policy: require-corp
|
||||
```
|
||||
|
||||
Alternatively, you can use:
|
||||
```
|
||||
Cross-Origin-Opener-Policy: same-origin
|
||||
Cross-Origin-Embedder-Policy: credentialless
|
||||
```
|
||||
which is generally more permissive but is not supported in Safari (of course).
|
||||
|
||||
---
|
||||
|
||||
If you cannot enable cross-origin isolation, `@mediabunny/prores` will fall back to a slower multithreading algorithm.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
|
||||
@@ -60,7 +60,7 @@ To get going with Mediabunny, here are some starting points:
|
||||
|
||||
## Server-side usage
|
||||
|
||||
Mediabunny's simple yet flexible API provides a modern alternative to traditional server-side media processing pipelines which may involve calling out to FFmpeg's CLI manually. Mediabunny was primary built for client-side environments, but when combined with the [`@mediabunny/server`](./extensions/server) extension, the full Mediabunny feature set is available in server-side environments such as Node, Bun, and Deno.
|
||||
Mediabunny's simple yet flexible API provides a modern alternative to traditional server-side media processing pipelines which may involve calling out to FFmpeg's CLI manually. Mediabunny was primarily built for client-side environments, but when combined with the [`@mediabunny/server`](./extensions/server) extension, the full Mediabunny feature set is available in server-side environments such as Node, Bun, and Deno.
|
||||
|
||||
The extension enables:
|
||||
- Video decoders and encoders for AVC (H.264), HEVC (H.265), VP8, VP9, and AV1. Supports both length-prefixed and Annex B AVC/HEVC as well as transparent video via VP9.
|
||||
@@ -74,7 +74,7 @@ For more, see [the corresponding guide](./extensions/server).
|
||||
|
||||
## Motivation
|
||||
|
||||
Mediabunny is the evolution of my previous libraries, [mp4-muxer](https://github.com/Vanilagy/mp4-muxer) and [webm-muxer](https://github.com/Vanilagy/webm-muxer), which were both created due to the advent of the WebCodecs API. While they fulfilled their job just fine, I saw a few painpoints:
|
||||
Mediabunny is the evolution of my previous libraries, [mp4-muxer](https://github.com/Vanilagy/mp4-muxer) and [webm-muxer](https://github.com/Vanilagy/webm-muxer), which were both created due to the advent of the WebCodecs API. While they fulfilled their job just fine, I saw a few pain points:
|
||||
- Lots of duplicated code between the two libraries, otherwise very similar API.
|
||||
- No help with the difficulties of navigating the WebCodecs API & related browser APIs.
|
||||
- "mp4-demuxer when??"
|
||||
@@ -99,4 +99,4 @@ At its core, Mediabunny is a collection of multiplexers and demultiplexers, one
|
||||
|
||||
Mediabunny then provides several wrappers around the WebCodecs API to simplify usage: for reading, it creates decoders with the correct codec configuration and efficiently decodes media data in a pipelined way. For writing, it figures out the necessary codec configuration and sets up encoders which are then used to encode raw media data, while respecting the backpressure applied by the encoder. Extracting the right decoder configuration from a media file can be tricky and sometimes involves diving into encoded media packet bitstreams.
|
||||
|
||||
The conversion abstraction is built on top of Mediabunny's reading and writing primitives and combines them both in a heavily-pipelined way, making sure reading and writing happen in lockstep. It also consists of a lot of conditional logic probing output track compatibility, decoding support, and finding encodable codec configurations. It makes use of the Canvas API for video processing operations, and uses a custom implementation for audio resampling and up/downmixing.
|
||||
The conversion abstraction is built on top of Mediabunny's reading and writing primitives and combines them both in a heavily-pipelined way, making sure reading and writing happen in lockstep. It also consists of a lot of conditional logic probing output track compatibility, decoding support, and finding encodable codec configurations. It makes use of the Canvas API for video processing operations, and uses a custom implementation for audio resampling and up/downmixing.
|
||||
|
||||
@@ -627,6 +627,48 @@ await conversion.execute();
|
||||
// Conversion is complete
|
||||
```
|
||||
|
||||
## Combine multiple files into one
|
||||
|
||||
```ts
|
||||
import {
|
||||
Input,
|
||||
Output,
|
||||
Conversion,
|
||||
} from 'mediabunny';
|
||||
|
||||
// Let's take the video track from one file...
|
||||
const videoInput = new Input(...);
|
||||
// ...and the audio track from another
|
||||
const audioInput = new Input(...);
|
||||
|
||||
const output = new Output(...);
|
||||
|
||||
const videoConversion = await Conversion.init({
|
||||
input: videoInput,
|
||||
output,
|
||||
composable: true, // Ensure the conversion doesn't own the output
|
||||
audio: { discard: true },
|
||||
});
|
||||
const audioConversion = await Conversion.init({
|
||||
input: audioInput,
|
||||
output,
|
||||
composable: true,
|
||||
video: { discard: true },
|
||||
});
|
||||
|
||||
await output.start();
|
||||
await Promise.all([
|
||||
videoConversion.execute(),
|
||||
audioConversion.execute(),
|
||||
]);
|
||||
await output.finalize();
|
||||
// Conversion is complete
|
||||
```
|
||||
|
||||
::: info
|
||||
See [Composable conversions](./converting-media-files#composable-conversions) for the full documentation.
|
||||
:::
|
||||
|
||||
## Reading HLS playlists
|
||||
|
||||
```ts
|
||||
|
||||
@@ -342,7 +342,7 @@ Each chunk written to the `WritableStream` represents a contiguous chunk of byte
|
||||
::: warning
|
||||
Note that some byte regions in the output file may be written to multiple times. It is therefore **incorrect** to construct the final file by simply concatenating all `Uint8Array`s together - you **must** write each chunk of data at the specified byte offset position _in the order_ in which the chunks arrived. If you don't do this, your output file will likely be invalid or corrupted.
|
||||
|
||||
Some [output formats](./output-formats) have *append-only* writing modes in which the byte offset of a written chunk will always be equal to the total number of bytes in all previously written chunks. In other words, when writing is append-only, simply concatening all `Uint8Array`s yields the correct result. Some APIs (like `appendBuffer` of Media Source Extensions) require this, so make sure to configure your output format accordingly for those cases.
|
||||
Some [output formats](./output-formats) have *append-only* writing modes in which the byte offset of a written chunk will always be equal to the total number of bytes in all previously written chunks. In other words, when writing is append-only, simply concatenating all `Uint8Array`s yields the correct result. Some APIs (like `appendBuffer` of Media Source Extensions) require this, so make sure to configure your output format accordingly for those cases.
|
||||
:::
|
||||
|
||||
#### Chunked mode
|
||||
@@ -545,4 +545,4 @@ The promise returned by `getMimeType` only resolves once the precise codec strin
|
||||
If you don't care about specific track codecs, you can instead use the simpler [`mimeType`](./output-formats#output-format-properties) property on the `Output`'s format:
|
||||
```ts
|
||||
output.format.mimeType; // => string
|
||||
```
|
||||
```
|
||||
|
||||
+2
-3
@@ -102,14 +102,13 @@ const sponsors = {
|
||||
{ image: '/sponsors/screen-studio.webp', name: 'Screen Studio', url: 'https://screen.studio/' },
|
||||
{ image: '/sponsors/tella.svg', name: 'Tella', url: 'https://www.tella.com/' },
|
||||
],
|
||||
silver: [
|
||||
{ image: '/sponsors/ponder.png', name: 'Ponder', url: 'https://ponder.ai/' },
|
||||
],
|
||||
silver: [],
|
||||
bronze: [
|
||||
{ image: '/sponsors/elevenlabs.png', name: 'ElevenLabs', url: 'https://elevenlabs.io/' },
|
||||
{ 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/' },
|
||||
{ image: '/sponsors/photocircle.png', name: 'PhotoCircle', url: 'https://www.photocircleapp.com/' },
|
||||
],
|
||||
individual: [
|
||||
{ image: '/sponsors/pintura-labs.png', name: 'Pintura Labs', url: 'https://pqina.nl/pintura/' },
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -9,8 +9,15 @@ import {
|
||||
Conversion,
|
||||
QUALITY_VERY_LOW,
|
||||
} from 'mediabunny';
|
||||
import { registerAc3Decoder } from '@mediabunny/ac3';
|
||||
import { registerProresDecoder } from '@mediabunny/prores';
|
||||
|
||||
import SampleFileUrl from '../../docs/assets/big-buck-bunny-trimmed.mp4';
|
||||
|
||||
// Enable codecs that aren't natively supported by WebCodecs.
|
||||
registerAc3Decoder();
|
||||
registerProresDecoder();
|
||||
|
||||
(document.querySelector('#sample-file-download') as HTMLAnchorElement).href = SampleFileUrl;
|
||||
|
||||
const selectMediaButton = document.querySelector('#select-file') as HTMLButtonElement;
|
||||
@@ -137,7 +144,7 @@ const compressFile = async (resource: File | string) => {
|
||||
selectMediaButton.addEventListener('click', () => {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = 'video/*,video/x-matroska,video/mp2t,.ts,audio/*,audio/aac';
|
||||
fileInput.accept = 'video/*,video/x-matroska,video/mp2t,.mkv,.ts,audio/*,audio/aac,.aac';
|
||||
fileInput.addEventListener('change', () => {
|
||||
const file = fileInput.files?.[0];
|
||||
if (!file) {
|
||||
|
||||
@@ -15,8 +15,15 @@ import {
|
||||
QUALITY_LOW,
|
||||
QUALITY_VERY_LOW,
|
||||
} from 'mediabunny';
|
||||
import { registerAc3Decoder } from '@mediabunny/ac3';
|
||||
import { registerProresDecoder } from '@mediabunny/prores';
|
||||
|
||||
import SampleFileUrl from '../../docs/assets/big-buck-bunny-trimmed.mp4';
|
||||
|
||||
// Enable codecs that aren't natively supported by WebCodecs.
|
||||
registerAc3Decoder();
|
||||
registerProresDecoder();
|
||||
|
||||
(document.querySelector('#sample-file-download') as HTMLAnchorElement).href = SampleFileUrl;
|
||||
|
||||
declare global {
|
||||
@@ -233,7 +240,7 @@ selectDirectoryButton.addEventListener('click', async () => {
|
||||
selectMediaButton.addEventListener('click', () => {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = 'video/*,video/x-matroska,video/mp2t,.ts';
|
||||
fileInput.accept = 'video/*,video/x-matroska,video/mp2t,.mkv,.ts';
|
||||
fileInput.addEventListener('change', () => {
|
||||
const file = fileInput.files![0];
|
||||
if (file) {
|
||||
|
||||
@@ -8,8 +8,15 @@ import {
|
||||
WrappedAudioBuffer,
|
||||
WrappedCanvas,
|
||||
} from 'mediabunny';
|
||||
import { registerAc3Decoder } from '@mediabunny/ac3';
|
||||
import { registerProresDecoder } from '@mediabunny/prores';
|
||||
|
||||
import SampleFileUrl from '../../docs/assets/big-buck-bunny-trimmed.mp4';
|
||||
|
||||
// Enable codecs that aren't natively supported by WebCodecs.
|
||||
registerAc3Decoder();
|
||||
registerProresDecoder();
|
||||
|
||||
(document.querySelector('#sample-file-download') as HTMLAnchorElement).href = SampleFileUrl;
|
||||
|
||||
const selectMediaButton = document.querySelector('#select-file') as HTMLButtonElement;
|
||||
@@ -732,7 +739,7 @@ window.addEventListener('resize', () => {
|
||||
selectMediaButton.addEventListener('click', () => {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = 'video/*,video/x-matroska,video/mp2t,.ts,audio/*,audio/aac';
|
||||
fileInput.accept = 'video/*,video/x-matroska,video/mp2t,.mkv,.ts,audio/*,audio/aac,.aac';
|
||||
fileInput.addEventListener('change', () => {
|
||||
const file = fileInput.files?.[0];
|
||||
if (!file) {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { Input, ALL_FORMATS, BlobSource, UrlSource, CanvasSink } from 'mediabunny';
|
||||
import { registerAc3Decoder } from '@mediabunny/ac3';
|
||||
import { registerProresDecoder } from '@mediabunny/prores';
|
||||
|
||||
import SampleFileUrl from '../../docs/assets/big-buck-bunny-trimmed.mp4';
|
||||
|
||||
// Enable codecs that aren't natively supported by WebCodecs.
|
||||
registerAc3Decoder();
|
||||
registerProresDecoder();
|
||||
|
||||
(document.querySelector('#sample-file-download') as HTMLAnchorElement).href = SampleFileUrl;
|
||||
|
||||
const selectMediaButton = document.querySelector('#select-file') as HTMLButtonElement;
|
||||
@@ -122,7 +129,7 @@ const generateThumbnails = async (resource: File | string) => {
|
||||
selectMediaButton.addEventListener('click', () => {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = 'video/*,video/x-matroska,video/mp2t,.ts,audio/*,audio/aac';
|
||||
fileInput.accept = 'video/*,video/x-matroska,video/mp2t,.mkv,.ts,audio/*,audio/aac,.aac';
|
||||
fileInput.addEventListener('change', () => {
|
||||
const file = fileInput.files?.[0];
|
||||
if (!file) {
|
||||
|
||||
Generated
+73
-13
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mediabunny",
|
||||
"version": "1.50.0",
|
||||
"version": "1.51.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mediabunny",
|
||||
"version": "1.50.0",
|
||||
"version": "1.51.0",
|
||||
"license": "MPL-2.0",
|
||||
"workspaces": [
|
||||
".",
|
||||
@@ -2715,6 +2715,66 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.4.3",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.0.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.4.3",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.0.2",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "0.2.9",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.4.0",
|
||||
"@emnapi/runtime": "^1.4.0",
|
||||
"@tybys/wasm-util": "^0.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.9.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.7.tgz",
|
||||
@@ -10963,9 +11023,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/turbores": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/turbores/-/turbores-1.1.1.tgz",
|
||||
"integrity": "sha512-uC2bBqMgMzeE/K4/zeUJd3IwvDm9+0JtNXkiBuc7Sg1L8OaeAhk+VCgoiqDgDdwVaqF3/lztEjrSgt8k15XyYw==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/turbores/-/turbores-1.2.2.tgz",
|
||||
"integrity": "sha512-dTSKeZ6/BgBKz9T5goMS5ZMcqOiNYO1MbvresYGfWXEF5ZWK+yIZDxNjh86bRj1mnEzhtdsMOJ5GBi8b3Azcpg==",
|
||||
"license": "MPL-2.0",
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
@@ -12894,7 +12954,7 @@
|
||||
},
|
||||
"packages/aac-encoder": {
|
||||
"name": "@mediabunny/aac-encoder",
|
||||
"version": "1.50.0",
|
||||
"version": "1.51.0",
|
||||
"license": "MPL-2.0",
|
||||
"devDependencies": {
|
||||
"@types/emscripten": "^1.40.1"
|
||||
@@ -12909,7 +12969,7 @@
|
||||
},
|
||||
"packages/ac3": {
|
||||
"name": "@mediabunny/ac3",
|
||||
"version": "1.50.0",
|
||||
"version": "1.51.0",
|
||||
"license": "MPL-2.0",
|
||||
"devDependencies": {
|
||||
"@types/emscripten": "^1.40.1"
|
||||
@@ -12924,7 +12984,7 @@
|
||||
},
|
||||
"packages/flac-encoder": {
|
||||
"name": "@mediabunny/flac-encoder",
|
||||
"version": "1.50.0",
|
||||
"version": "1.51.0",
|
||||
"license": "MPL-2.0",
|
||||
"devDependencies": {
|
||||
"@types/emscripten": "^1.40.1"
|
||||
@@ -12939,7 +12999,7 @@
|
||||
},
|
||||
"packages/mp3-encoder": {
|
||||
"name": "@mediabunny/mp3-encoder",
|
||||
"version": "1.50.0",
|
||||
"version": "1.51.0",
|
||||
"license": "MPL-2.0",
|
||||
"devDependencies": {
|
||||
"@types/emscripten": "^1.40.1"
|
||||
@@ -12954,10 +13014,10 @@
|
||||
},
|
||||
"packages/prores": {
|
||||
"name": "@mediabunny/prores",
|
||||
"version": "1.50.0",
|
||||
"version": "1.51.0",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"turbores": "^1.1.1"
|
||||
"turbores": "^1.2.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
@@ -12969,10 +13029,10 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@mediabunny/server",
|
||||
"version": "1.50.0",
|
||||
"version": "1.51.0",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"@mediabunny/prores": "^1.50.0",
|
||||
"@mediabunny/prores": "^1.51.0",
|
||||
"node-av": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
|
||||
+5
-5
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mediabunny",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.50.0",
|
||||
"version": "1.51.0",
|
||||
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
@@ -56,10 +56,10 @@
|
||||
"examples:build": "vite build",
|
||||
"fix-build-import-paths": "tsx scripts/add-import-extensions.ts",
|
||||
"append-namespace": "echo 'export as namespace Mediabunny;' >> dist/mediabunny.d.ts",
|
||||
"bump-patch": "npm version patch --no-git-tag-version --workspaces",
|
||||
"bump-minor": "npm version minor --no-git-tag-version --workspaces",
|
||||
"bump-major": "npm version major --no-git-tag-version --workspaces",
|
||||
"set-version": "npm version --no-git-tag-version --workspaces"
|
||||
"bump-patch": "npm version patch --no-git-tag-version --workspaces && tsx scripts/sync-workspace-deps.ts && npm i --package-lock-only",
|
||||
"bump-minor": "npm version minor --no-git-tag-version --workspaces && tsx scripts/sync-workspace-deps.ts && npm i --package-lock-only",
|
||||
"bump-major": "npm version major --no-git-tag-version --workspaces && tsx scripts/sync-workspace-deps.ts && npm i --package-lock-only",
|
||||
"set-version": "npm version --no-git-tag-version --workspaces && tsx scripts/sync-workspace-deps.ts && npm i --package-lock-only"
|
||||
},
|
||||
"license": "MPL-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@mediabunny/aac-encoder",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.50.0",
|
||||
"version": "1.51.0",
|
||||
"description": "AAC encoder extension for Mediabunny, based on FFmpeg.",
|
||||
"main": "./dist/bundles/mediabunny-aac-encoder.mjs",
|
||||
"module": "./dist/bundles/mediabunny-aac-encoder.mjs",
|
||||
|
||||
@@ -192,3 +192,7 @@ if (parentPort) {
|
||||
} else {
|
||||
self.addEventListener('message', event => onMessage(event.data as { id: number; command: WorkerCommand }));
|
||||
}
|
||||
|
||||
// Prevents the worker for being randomly closed by Firefox
|
||||
// https://github.com/Vanilagy/mediabunny/issues/435
|
||||
setInterval(() => {}, 1000);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@mediabunny/ac3",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.50.0",
|
||||
"version": "1.51.0",
|
||||
"description": "AC-3 and E-AC-3 (Dolby Digital) decoder and encoder extension for Mediabunny, based on FFmpeg.",
|
||||
"main": "./dist/bundles/mediabunny-ac3.mjs",
|
||||
"module": "./dist/bundles/mediabunny-ac3.mjs",
|
||||
|
||||
@@ -304,3 +304,7 @@ if (parentPort) {
|
||||
} else {
|
||||
self.addEventListener('message', event => onMessage(event.data as { id: number; command: WorkerCommand }));
|
||||
}
|
||||
|
||||
// Prevents the worker for being randomly closed by Firefox
|
||||
// https://github.com/Vanilagy/mediabunny/issues/435
|
||||
setInterval(() => {}, 1000);
|
||||
|
||||
@@ -46,6 +46,7 @@ export const unrefWorker = async () => {
|
||||
if (worker) {
|
||||
if (worker.unref) {
|
||||
worker.unref(); // If we don't do this, then the Node process never terminates by itself
|
||||
// Keep the worker around tho
|
||||
} else if (typeof window === 'undefined') {
|
||||
// Non-browser environment without unref - terminate instead
|
||||
worker.terminate();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@mediabunny/flac-encoder",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.50.0",
|
||||
"version": "1.51.0",
|
||||
"description": "FLAC encoder extension for Mediabunny, based on libFLAC.",
|
||||
"main": "./dist/bundles/mediabunny-flac-encoder.mjs",
|
||||
"module": "./dist/bundles/mediabunny-flac-encoder.mjs",
|
||||
|
||||
@@ -192,3 +192,7 @@ if (parentPort) {
|
||||
} else {
|
||||
self.addEventListener('message', event => onMessage(event.data as { id: number; command: WorkerCommand }));
|
||||
}
|
||||
|
||||
// Prevents the worker for being randomly closed by Firefox
|
||||
// https://github.com/Vanilagy/mediabunny/issues/435
|
||||
setInterval(() => {}, 1000);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@mediabunny/mp3-encoder",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.50.0",
|
||||
"version": "1.51.0",
|
||||
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
|
||||
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
|
||||
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
|
||||
|
||||
@@ -219,3 +219,7 @@ if (parentPort) {
|
||||
} else {
|
||||
self.addEventListener('message', event => onMessage(event.data as { id: number; command: WorkerCommand }));
|
||||
}
|
||||
|
||||
// Prevents the worker for being randomly closed by Firefox
|
||||
// https://github.com/Vanilagy/mediabunny/issues/435
|
||||
setInterval(() => {}, 1000);
|
||||
|
||||
@@ -28,6 +28,25 @@ Alternatively, directly include them using a script tag:
|
||||
|
||||
This will expose the global objects `Mediabunny` and `MediabunnyProres`. Use `mediabunny-prores.d.ts` to provide types for these globals. You can download the built distribution files from the [releases page](https://github.com/Vanilagy/mediabunny/releases).
|
||||
|
||||
## Setup
|
||||
|
||||
`@mediabunny/prores` can make use of shared-memory multithreading to achieve maximum performance. To enable this in browsers, your website must be cross-origin isolated by setting the following response headers:
|
||||
```
|
||||
Cross-Origin-Opener-Policy: same-origin
|
||||
Cross-Origin-Embedder-Policy: require-corp
|
||||
```
|
||||
|
||||
Alternatively, you can use:
|
||||
```
|
||||
Cross-Origin-Opener-Policy: same-origin
|
||||
Cross-Origin-Embedder-Policy: credentialless
|
||||
```
|
||||
which is generally more permissive but is not supported in Safari (of course).
|
||||
|
||||
---
|
||||
|
||||
If you cannot enable cross-origin isolation, `@mediabunny/prores` will fall back to a slower multithreading algorithm.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@mediabunny/prores",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.50.0",
|
||||
"version": "1.51.0",
|
||||
"description": "Apple ProRes decoder extension for Mediabunny, based on TurboRes.",
|
||||
"main": "./dist/bundles/mediabunny-prores.mjs",
|
||||
"module": "./dist/bundles/mediabunny-prores.mjs",
|
||||
@@ -19,10 +19,6 @@
|
||||
"src"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"browser": {
|
||||
"node:worker_threads": false,
|
||||
"node:os": false
|
||||
},
|
||||
"license": "MPL-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -38,7 +34,7 @@
|
||||
"url": "https://github.com/sponsors/Vanilagy"
|
||||
},
|
||||
"dependencies": {
|
||||
"turbores": "^1.1.1"
|
||||
"turbores": "^1.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"mediabunny": "^1.49.0"
|
||||
|
||||
@@ -83,16 +83,12 @@ class ProresDecoder extends CustomVideoDecoder {
|
||||
async decode(packet: EncodedPacket) {
|
||||
assert(this.decoder);
|
||||
|
||||
if (this.decoder.useSharedMemory) {
|
||||
await this.runDecode(packet);
|
||||
} else {
|
||||
while (this.decoder.decodeQueueSize >= this.decoder.concurrency) {
|
||||
await this.decoder.dequeued;
|
||||
}
|
||||
|
||||
void this.runDecode(packet)
|
||||
.catch(error => this.onError(error));
|
||||
while (this.decoder.desiredSize <= 0) {
|
||||
await this.decoder.dequeued;
|
||||
}
|
||||
|
||||
void this.runDecode(packet)
|
||||
.catch(error => this.onError(error));
|
||||
}
|
||||
|
||||
private async runDecode(packet: EncodedPacket) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@mediabunny/server",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.50.0",
|
||||
"version": "1.51.0",
|
||||
"description": "Adds full video and audio decoder and encoder support to Mediabunny for use in server-side environments (Node, Bun, Deno). Based on NodeAV.",
|
||||
"main": "./dist/bundles/mediabunny-server.cjs",
|
||||
"module": "./dist/bundles/mediabunny-server.mjs",
|
||||
@@ -35,7 +35,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"node-av": "^6.0.0",
|
||||
"@mediabunny/prores": "^1.50.0"
|
||||
"@mediabunny/prores": "^1.51.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"mediabunny": "^1.45.0"
|
||||
|
||||
@@ -203,6 +203,11 @@ export class NodeAvVideoEncoder extends CustomVideoEncoder {
|
||||
if (isRealtime) {
|
||||
codecContext.setOption('preset', '12');
|
||||
}
|
||||
} else if (this.avCodec.name === 'h264_nvenc') {
|
||||
// When we force a key frame, we want a true IDR frame, not just an I frame
|
||||
codecContext.setOption('forced-idr', '1');
|
||||
} else if (this.avCodec.name === 'hevc_nvenc') {
|
||||
codecContext.setOption('forced-idr', '1');
|
||||
}
|
||||
|
||||
if (this.codec === 'prores') {
|
||||
|
||||
+5
-5
@@ -103,7 +103,7 @@ const mp3EncoderVariants = await createVariants(
|
||||
'packages/mp3-encoder/src/index.ts',
|
||||
'MediabunnyMp3Encoder',
|
||||
'packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder',
|
||||
'js', // The bundles are purely for the browser, not for Node (due to the peer dependecy)
|
||||
'js', // The bundles are purely for the browser, not for Node (due to the peer dependency)
|
||||
{
|
||||
plugins: [
|
||||
PluginExternalGlobal.externalGlobalPlugin({
|
||||
@@ -134,7 +134,7 @@ const ac3Variants = await createVariants(
|
||||
'packages/ac3/src/index.ts',
|
||||
'MediabunnyAc3',
|
||||
'packages/ac3/dist/bundles/mediabunny-ac3',
|
||||
'js', // The bundles are purely for the browser, not for Node (due to the peer dependecy)
|
||||
'js', // The bundles are purely for the browser, not for Node (due to the peer dependency)
|
||||
{
|
||||
plugins: [
|
||||
PluginExternalGlobal.externalGlobalPlugin({
|
||||
@@ -165,7 +165,7 @@ const aacEncoderVariants = await createVariants(
|
||||
'packages/aac-encoder/src/index.ts',
|
||||
'MediabunnyAacEncoder',
|
||||
'packages/aac-encoder/dist/bundles/mediabunny-aac-encoder',
|
||||
'js', // The bundles are purely for the browser, not for Node (due to the peer dependecy)
|
||||
'js', // The bundles are purely for the browser, not for Node (due to the peer dependency)
|
||||
{
|
||||
plugins: [
|
||||
PluginExternalGlobal.externalGlobalPlugin({
|
||||
@@ -196,7 +196,7 @@ const flacEncoderVariants = await createVariants(
|
||||
'packages/flac-encoder/src/index.ts',
|
||||
'MediabunnyFlacEncoder',
|
||||
'packages/flac-encoder/dist/bundles/mediabunny-flac-encoder',
|
||||
'js', // The bundles are purely for the browser, not for Node (due to the peer dependecy)
|
||||
'js', // The bundles are purely for the browser, not for Node (due to the peer dependency)
|
||||
{
|
||||
plugins: [
|
||||
PluginExternalGlobal.externalGlobalPlugin({
|
||||
@@ -227,7 +227,7 @@ const proresVariants = await createVariants(
|
||||
'packages/prores/src/index.ts',
|
||||
'MediabunnyProres',
|
||||
'packages/prores/dist/bundles/mediabunny-prores',
|
||||
'js', // The bundles are purely for the browser, not for Node (due to the peer dependecy)
|
||||
'js', // The bundles are purely for the browser, not for Node (due to the peer dependency)
|
||||
{
|
||||
plugins: [
|
||||
PluginExternalGlobal.externalGlobalPlugin({
|
||||
|
||||
@@ -127,15 +127,22 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
|
||||
const declaration = exportSymbol.valueDeclaration || exportSymbol.declarations?.[0];
|
||||
if (!declaration) return;
|
||||
|
||||
// If it's a reexport, follow it recursively
|
||||
// If it's a reexport, resolve it to the underlying symbol (following chains of aliases,
|
||||
// e.g. a re-export of a re-export) rather than recursing into the whole module it lives in
|
||||
// -- `getExportsOfModule` above already gives us one entry per exported symbol, so a module
|
||||
// recursion here would revisit (and duplicate) every other export of that module too.
|
||||
if (exportSymbol.flags & ts.SymbolFlags.Alias) {
|
||||
const aliasedSymbol = typeChecker.getAliasedSymbol(exportSymbol);
|
||||
let aliasedSymbol = typeChecker.getAliasedSymbol(exportSymbol);
|
||||
while (aliasedSymbol.flags & ts.SymbolFlags.Alias) {
|
||||
aliasedSymbol = typeChecker.getAliasedSymbol(aliasedSymbol);
|
||||
}
|
||||
const aliasedDeclaration = aliasedSymbol.valueDeclaration || aliasedSymbol.declarations?.[0];
|
||||
if (aliasedDeclaration) {
|
||||
const sourceFile = aliasedDeclaration.getSourceFile();
|
||||
const moduleSymbol = typeChecker.getSymbolAtLocation(sourceFile);
|
||||
if (moduleSymbol) {
|
||||
symbols.push(...getAllExportedSymbols(moduleSymbol, visited));
|
||||
// Push the aliased symbol (not the alias) so downstream sees the real
|
||||
// declaration and its JSDoc rather than the empty ExportSpecifier.
|
||||
const hasPublicTag = ts.getJSDocTags(aliasedDeclaration).some(tag => tag.tagName.text === 'public');
|
||||
if (hasPublicTag) {
|
||||
symbols.push(aliasedSymbol);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// After `npm version --workspaces` bumps each package's version, this rewrites dependency ranges that point at sibling
|
||||
// workspaces so they follow along.
|
||||
|
||||
const root = path.join(import.meta.dirname, '..');
|
||||
|
||||
const workspaceDirs = ['.', ...fs.readdirSync(path.join(root, 'packages')).map(x => `packages/${x}`)]
|
||||
.filter(dir => fs.existsSync(path.join(root, dir, 'package.json')));
|
||||
|
||||
type Manifest = {
|
||||
name: string;
|
||||
version: string;
|
||||
dependencies?: Record<string, string>;
|
||||
};
|
||||
|
||||
const manifests = workspaceDirs.map((dir) => {
|
||||
const filePath = path.join(root, dir, 'package.json');
|
||||
return { filePath, json: JSON.parse(fs.readFileSync(filePath, 'utf8')) as Manifest };
|
||||
});
|
||||
|
||||
const versions = new Map(manifests.map(({ json }) => [json.name, json.version]));
|
||||
|
||||
for (const { filePath, json } of manifests) {
|
||||
let changed = false;
|
||||
|
||||
for (const name of Object.keys(json.dependencies ?? {})) {
|
||||
const version = versions.get(name);
|
||||
if (version && json.dependencies![name] !== `^${version}`) {
|
||||
json.dependencies![name] = `^${version}`;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
fs.writeFileSync(filePath, JSON.stringify(json, null, 2) + '\n');
|
||||
console.log(`Synced workspace dependency ranges in ${path.relative(root, filePath)}`);
|
||||
}
|
||||
}
|
||||
+15
-2
@@ -1878,7 +1878,7 @@ export type Av1CodecInfo = {
|
||||
chromaSamplePosition: number;
|
||||
};
|
||||
|
||||
/** Iterates over all OBUs in an AV1 packet bistream. */
|
||||
/** Iterates over all OBUs in an AV1 packet bitstream. */
|
||||
export const iterateAv1PacketObus = function* (packet: Uint8Array) {
|
||||
// https://aomediacodec.github.io/av1-spec/av1-spec.pdf
|
||||
|
||||
@@ -2207,9 +2207,22 @@ const OPUS_FRAME_DURATION_TABLE = [
|
||||
|
||||
export const parseOpusTocByte = (packet: Uint8Array) => {
|
||||
const config = packet[0]! >> 3;
|
||||
const code = packet[0]! & 0b11;
|
||||
|
||||
// A packet may pack more than one frame, in which case its duration is the frame duration times the number of
|
||||
// frames it carries. See https://datatracker.ietf.org/doc/html/rfc6716, section 3.2.
|
||||
let frameCount: number;
|
||||
if (code === 0) {
|
||||
frameCount = 1;
|
||||
} else if (code === 1 || code === 2) {
|
||||
frameCount = 2;
|
||||
} else {
|
||||
// Code 3: the frame count sits in the six low bits of the frame count byte
|
||||
frameCount = packet[1]! & 0b111111;
|
||||
}
|
||||
|
||||
return {
|
||||
durationInSamples: OPUS_FRAME_DURATION_TABLE[config]!,
|
||||
durationInSamples: OPUS_FRAME_DURATION_TABLE[config]! * frameCount,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+359
-147
@@ -48,7 +48,7 @@ import {
|
||||
promiseWithResolvers,
|
||||
Rotation,
|
||||
} from './misc';
|
||||
import { Output, OutputTrackGroup, TrackType } from './output';
|
||||
import { Output, OutputTrackGroup } from './output';
|
||||
import { Mp4OutputFormat } from './output-format';
|
||||
import {
|
||||
AudioSample,
|
||||
@@ -146,6 +146,18 @@ export type ConversionOptions = {
|
||||
* want to keep the console output clean.
|
||||
*/
|
||||
showWarnings?: boolean;
|
||||
|
||||
/**
|
||||
* Whether this conversion is composable, defaults to `false`. A non-composable conversion takes full ownership of
|
||||
* the output: it requires a fresh output and controls its entire lifecycle, meaning it starts it, writes its
|
||||
* metadata tags, and finalizes it.
|
||||
*
|
||||
* A composable conversion only adds tracks to the output and drives their media data; starting and finalizing
|
||||
* the output is an outside responsibility. This is useful when only some output tracks should be driven by a
|
||||
* conversion, and other are to be driven manually. Additionally, it can be used to have multiple conversions target
|
||||
* the same output.
|
||||
*/
|
||||
composable?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -527,6 +539,33 @@ export type DiscardedTrack = {
|
||||
trackOptions: ConversionVideoOptions | ConversionAudioOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Options for controlling a single call to {@link Conversion.execute}.
|
||||
* @group Conversion
|
||||
* @public
|
||||
*/
|
||||
export type ConversionExecuteOptions = {
|
||||
/**
|
||||
* The timestamp in seconds, in the output's timescale, until which the conversion should advance. Defaults to
|
||||
* `Infinity`, meaning the conversion runs until the end.
|
||||
*
|
||||
* This field is especially useful for composable conversions, as it allows you to advance the conversion in
|
||||
* lockstep with other media data sources.
|
||||
*/
|
||||
until?: number;
|
||||
/**
|
||||
* A signal that, when triggered, pauses the conversion as soon as possible.
|
||||
*/
|
||||
pauseSignal?: AbortSignal;
|
||||
};
|
||||
|
||||
type TrackPump = {
|
||||
done: boolean;
|
||||
resolvers: ReturnType<typeof promiseWithResolvers<void>>;
|
||||
wake: (() => void) | null;
|
||||
start: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a media file conversion process, used to convert one media file into another. In addition to conversion,
|
||||
* this class can be used to resize and rotate video, resample audio, drop tracks, or trim to a specific time range.
|
||||
@@ -539,6 +578,16 @@ export class Conversion {
|
||||
/** The output file. */
|
||||
readonly output: Output;
|
||||
|
||||
/**
|
||||
* The current state of the conversion.
|
||||
*
|
||||
* - `'idle'`: The conversion is not currently executing and isn't done; `execute` can be called.
|
||||
* - `'executing'`: A call to `execute` is currently running.
|
||||
* - `'canceled'`: The conversion has been canceled and can no longer be executed.
|
||||
* - `'done'`: The conversion has run to completion. Subsequent calls to `execute` do nothing.
|
||||
*/
|
||||
state: 'idle' | 'executing' | 'canceled' | 'done' = 'idle';
|
||||
|
||||
/** @internal */
|
||||
_options: ConversionOptions;
|
||||
/** @internal */
|
||||
@@ -546,15 +595,6 @@ export class Conversion {
|
||||
/** @internal */
|
||||
_endTimestamp!: number;
|
||||
|
||||
/** @internal */
|
||||
_addedCounts: Record<TrackType, number> = {
|
||||
video: 0,
|
||||
audio: 0,
|
||||
subtitle: 0,
|
||||
};
|
||||
|
||||
/** @internal */
|
||||
_totalTrackCount = 0;
|
||||
/** @internal */
|
||||
_nextOutputTrackId = 0;
|
||||
/** @internal */
|
||||
@@ -563,26 +603,25 @@ export class Conversion {
|
||||
_outputOwnTrackGroups: (OutputTrackGroup | null)[] = [];
|
||||
|
||||
/** @internal */
|
||||
_trackPromises: Promise<void>[] = [];
|
||||
_trackPumps: TrackPump[] = [];
|
||||
/** @internal */
|
||||
_composable = false;
|
||||
|
||||
/** @internal */
|
||||
_started: Promise<void>;
|
||||
/** @internal */
|
||||
_start: () => void;
|
||||
/** @internal */
|
||||
_executed = false;
|
||||
/** @internal */
|
||||
_executionUntil = Infinity;
|
||||
/** @internal */
|
||||
_pauseRequested = false;
|
||||
|
||||
/** @internal */
|
||||
_synchronizer = new TrackSynchronizer();
|
||||
_synchronizer = new TrackSynchronizer(this);
|
||||
|
||||
/** @internal */
|
||||
_totalDuration: number | null = null;
|
||||
/** @internal */
|
||||
_maxTimestamps = new Map<number, number>(); // Track ID -> timestamp
|
||||
|
||||
/** @internal */
|
||||
_canceled = false;
|
||||
|
||||
/**
|
||||
* A callback that is fired whenever the conversion progresses. Gets passed as first argument a number between
|
||||
* 0 and 1, indicating the completion of the conversion. Note that a progress of 1 doesn't necessarily mean the
|
||||
@@ -600,7 +639,8 @@ export class Conversion {
|
||||
|
||||
/**
|
||||
* Whether this conversion, as it has been configured, is valid and can be executed. If this field is `false`, check
|
||||
* the `discardedTracks` field for reasons.
|
||||
* the `discardedTracks` field for reasons. Composable conversions are always valid, even if they utilize
|
||||
* zero tracks.
|
||||
*
|
||||
* Note: a conversion having discarded tracks does not automatically mean it is invalid; if the remaining, utilized
|
||||
* tracks make for a valid output file, the conversion is still allowed.
|
||||
@@ -642,12 +682,30 @@ export class Conversion {
|
||||
'options.tracks, when provided, must be either \'all\' or \'primary\'.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
options.output._tracks.length > 0
|
||||
|| Object.keys(options.output._metadataTags).length > 0
|
||||
|| options.output.state !== 'pending'
|
||||
) {
|
||||
throw new TypeError('options.output must be fresh: no tracks or metadata tags added and not started.');
|
||||
if (options.composable !== undefined && typeof options.composable !== 'boolean') {
|
||||
throw new TypeError('options.composable, when provided, must be a boolean.');
|
||||
}
|
||||
|
||||
const composable = options.composable ?? false;
|
||||
if (!composable) {
|
||||
if (
|
||||
options.output.tracks.length > 0
|
||||
|| Object.keys(options.output._metadataTags).length > 0
|
||||
|| options.output.state !== 'pending'
|
||||
) {
|
||||
throw new TypeError('options.output must be fresh: no tracks or metadata tags added and not started.');
|
||||
}
|
||||
} else {
|
||||
if (options.tags !== undefined) {
|
||||
throw new TypeError(
|
||||
'options.tags cannot be set by a composable conversion; set metadata directly on the output'
|
||||
+ ' instead.',
|
||||
);
|
||||
}
|
||||
|
||||
if (options.output.state !== 'pending') {
|
||||
throw new TypeError('options.output must not have been started yet.');
|
||||
}
|
||||
}
|
||||
|
||||
if (options.video !== undefined && typeof options.video !== 'function') {
|
||||
@@ -704,12 +762,9 @@ export class Conversion {
|
||||
}
|
||||
|
||||
this._options = options;
|
||||
this._composable = composable;
|
||||
this.input = options.input;
|
||||
this.output = options.output;
|
||||
|
||||
const { promise: started, resolve: start } = promiseWithResolvers();
|
||||
this._started = started;
|
||||
this._start = start;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
@@ -838,7 +893,7 @@ export class Conversion {
|
||||
if (this._options.trim?.start !== undefined) {
|
||||
this._startTimestamp = this._options.trim.start;
|
||||
} else {
|
||||
// Compute the start timestamp from the set of filtered tracks. Techncially these can still be narrowed
|
||||
// Compute the start timestamp from the set of filtered tracks. Technically these can still be narrowed
|
||||
// down later due to discarded tracks, but we need to fix the start timestamp now due to track processing
|
||||
// depending on it.
|
||||
this._startTimestamp = Math.max(
|
||||
@@ -857,7 +912,7 @@ export class Conversion {
|
||||
const options = filteredTrackOptions[i]!;
|
||||
|
||||
for (const option of options) {
|
||||
if (this._totalTrackCount === outputTrackCounts.total.max) {
|
||||
if (this.output.tracks.length === outputTrackCounts.total.max) {
|
||||
this.discardedTracks.push({
|
||||
track,
|
||||
reason: 'max_track_count_reached',
|
||||
@@ -866,7 +921,12 @@ export class Conversion {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this._addedCounts[track.type] === outputTrackCounts[track.type].max) {
|
||||
const addedCountOfType = this.output.tracks.reduce(
|
||||
(count, t) => count + (t.type === track.type ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
|
||||
if (addedCountOfType === outputTrackCounts[track.type].max) {
|
||||
this.discardedTracks.push({
|
||||
track,
|
||||
reason: 'max_track_count_of_type_reached',
|
||||
@@ -906,39 +966,44 @@ export class Conversion {
|
||||
}
|
||||
}
|
||||
|
||||
// Now, let's deal with metadata tags
|
||||
// Now, let's deal with metadata tags. A composable conversion does not touch the output's metadata tags; that
|
||||
// remains the responsibility of whoever owns the output.
|
||||
|
||||
const inputTags = await this.input.getMetadataTags();
|
||||
let outputTags: MetadataTags;
|
||||
if (!this._composable) {
|
||||
const inputTags = await this.input.getMetadataTags();
|
||||
let outputTags: MetadataTags;
|
||||
|
||||
if (this._options.tags) {
|
||||
const result = typeof this._options.tags === 'function'
|
||||
? await this._options.tags(inputTags)
|
||||
: this._options.tags;
|
||||
validateMetadataTags(result);
|
||||
if (this._options.tags) {
|
||||
const result = typeof this._options.tags === 'function'
|
||||
? await this._options.tags(inputTags)
|
||||
: this._options.tags;
|
||||
validateMetadataTags(result);
|
||||
|
||||
outputTags = result;
|
||||
} else {
|
||||
outputTags = inputTags;
|
||||
outputTags = result;
|
||||
} else {
|
||||
outputTags = inputTags;
|
||||
}
|
||||
|
||||
// Somewhat dirty but pragmatic
|
||||
const inputAndOutputFormatMatch = inputFormat.mimeType === this.output.format.mimeType;
|
||||
const rawTagsAreUnchanged = inputTags.raw === outputTags.raw;
|
||||
|
||||
if (inputTags.raw && rawTagsAreUnchanged && !inputAndOutputFormatMatch) {
|
||||
// If the input and output formats aren't the same, copying over raw metadata tags makes no sense and
|
||||
// only results in junk tags, so let's cut them out.
|
||||
delete outputTags.raw;
|
||||
}
|
||||
|
||||
this.output.setMetadataTags(outputTags);
|
||||
}
|
||||
|
||||
// Somewhat dirty but pragmatic
|
||||
const inputAndOutputFormatMatch = inputFormat.mimeType === this.output.format.mimeType;
|
||||
const rawTagsAreUnchanged = inputTags.raw === outputTags.raw;
|
||||
|
||||
if (inputTags.raw && rawTagsAreUnchanged && !inputAndOutputFormatMatch) {
|
||||
// If the input and output formats aren't the same, copying over raw metadata tags makes no sense and only
|
||||
// results in junk tags, so let's cut them out.
|
||||
delete outputTags.raw;
|
||||
}
|
||||
|
||||
this.output.setMetadataTags(outputTags);
|
||||
|
||||
// Let's check if the conversion can actually be executed
|
||||
this.isValid = this._totalTrackCount >= outputTrackCounts.total.min
|
||||
&& this._addedCounts.video >= outputTrackCounts.video.min
|
||||
&& this._addedCounts.audio >= outputTrackCounts.audio.min
|
||||
&& this._addedCounts.subtitle >= outputTrackCounts.subtitle.min;
|
||||
if (!this._composable) {
|
||||
this.isValid = this.output.hasEnoughTracks();
|
||||
} else {
|
||||
// Checking Output start validity is not up to us. We consider even zero-track conversions to be valid
|
||||
this.isValid = true;
|
||||
}
|
||||
|
||||
if (this._options.showWarnings ?? true) {
|
||||
const warnElements: unknown[] = [];
|
||||
@@ -1041,11 +1106,24 @@ export class Conversion {
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the conversion process. Resolves once conversion is complete.
|
||||
* Executes the conversion process and resolves when the conversion is complete. When
|
||||
* {@link ConversionExecuteOptions.until} is provided, the conversion will be suspended once that output timestamp
|
||||
* is reached and can be resumed with another call to `execute`. An ongoing execution may also be suspended via
|
||||
* {@link ConversionExecuteOptions.pauseSignal}.
|
||||
*
|
||||
* Will throw if `isValid` is `false`.
|
||||
* Execution will throw if `isValid` is `false`.
|
||||
*/
|
||||
async execute() {
|
||||
async execute(options: ConversionExecuteOptions = {}) {
|
||||
if (!options || typeof options !== 'object') {
|
||||
throw new TypeError('options must be an object.');
|
||||
}
|
||||
if (options.until !== undefined && (typeof options.until !== 'number' || Number.isNaN(options.until))) {
|
||||
throw new TypeError('options.until, when provided, must be a number.');
|
||||
}
|
||||
if (options.pauseSignal !== undefined && !(options.pauseSignal instanceof AbortSignal)) {
|
||||
throw new TypeError('options.pauseSignal, when provided, must be an AbortSignal.');
|
||||
}
|
||||
|
||||
if (!this.isValid) {
|
||||
throw new Error(
|
||||
'Cannot execute this conversion because its output configuration is invalid. Make sure to always check'
|
||||
@@ -1054,63 +1132,123 @@ export class Conversion {
|
||||
);
|
||||
}
|
||||
|
||||
if (this._executed) {
|
||||
throw new Error('Conversion cannot be executed twice.');
|
||||
}
|
||||
this._executed = true;
|
||||
|
||||
for (const id of this._outputTrackIds) {
|
||||
this._synchronizer.declareTrack(id);
|
||||
if (this.state === 'executing') {
|
||||
throw new Error('Cannot call execute() while a previous call to execute() is still running.');
|
||||
}
|
||||
|
||||
if (this.onProgress) {
|
||||
// Compute duration using only the utilized tracks
|
||||
const uniqueUtilizedTracks = new Set(this.utilizedTracks);
|
||||
const durationPromises = [...uniqueUtilizedTracks].map(async (track) => {
|
||||
if (await track.isLive()) {
|
||||
return Infinity; // Upper bound (assuming no universe heat death)
|
||||
}
|
||||
if (this.state === 'canceled') {
|
||||
throw new ConversionCanceledError();
|
||||
}
|
||||
|
||||
return (await track.getDurationFromMetadata()) ?? (await track.computeDuration());
|
||||
});
|
||||
const duration = Math.max(0, ...await Promise.all(durationPromises));
|
||||
if (this.state === 'done') {
|
||||
// The conversion already ran to completion, nothing left to do
|
||||
return;
|
||||
}
|
||||
|
||||
this._computeProgress = true;
|
||||
this._totalDuration = Math.min(
|
||||
duration - this._startTimestamp,
|
||||
this._endTimestamp - this._startTimestamp,
|
||||
if (this._composable && this.output.state === 'pending') {
|
||||
throw new Error(
|
||||
'A composable conversion requires the output to be started. Call start() on the output before executing'
|
||||
+ ' the conversion.',
|
||||
);
|
||||
}
|
||||
|
||||
for (const id of this._outputTrackIds) {
|
||||
this._maxTimestamps.set(id, 0);
|
||||
this.state = 'executing';
|
||||
this._executionUntil = options.until ?? Infinity;
|
||||
this._pauseRequested = options.pauseSignal?.aborted ?? false;
|
||||
|
||||
const onPause = () => {
|
||||
if (this.state !== 'executing') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.onProgress?.(0, 0);
|
||||
this._pauseRequested = true;
|
||||
|
||||
// Release any pumps stuck in the synchronizer so they can reach their next checkpoint and suspend
|
||||
this._synchronizer.resolveAll();
|
||||
};
|
||||
options.pauseSignal?.addEventListener('abort', onPause);
|
||||
|
||||
for (const pump of this._trackPumps) {
|
||||
if (!pump.done) {
|
||||
pump.resolvers = promiseWithResolvers();
|
||||
}
|
||||
}
|
||||
|
||||
await this.output.start();
|
||||
this._start();
|
||||
if (!this._executed) {
|
||||
this._executed = true;
|
||||
|
||||
for (const id of this._outputTrackIds) {
|
||||
this._synchronizer.declareTrack(id);
|
||||
}
|
||||
|
||||
if (this.onProgress) {
|
||||
// Compute duration using only the utilized tracks
|
||||
const uniqueUtilizedTracks = new Set(this.utilizedTracks);
|
||||
const durationPromises = [...uniqueUtilizedTracks].map(async (track) => {
|
||||
if (await track.isLive()) {
|
||||
return Infinity; // Upper bound (assuming no universe heat death)
|
||||
}
|
||||
|
||||
return (await track.getDurationFromMetadata()) ?? (await track.computeDuration());
|
||||
});
|
||||
const duration = Math.max(0, ...await Promise.all(durationPromises));
|
||||
|
||||
this._computeProgress = true;
|
||||
this._totalDuration = Math.min(
|
||||
duration - this._startTimestamp,
|
||||
this._endTimestamp - this._startTimestamp,
|
||||
);
|
||||
|
||||
for (const id of this._outputTrackIds) {
|
||||
this._maxTimestamps.set(id, 0);
|
||||
}
|
||||
|
||||
this.onProgress?.(0, 0);
|
||||
}
|
||||
|
||||
if (!this._composable) {
|
||||
await this.output.start();
|
||||
}
|
||||
|
||||
for (const pump of this._trackPumps) {
|
||||
pump.start();
|
||||
}
|
||||
} else {
|
||||
// Wake all suspended track pumps
|
||||
for (const pump of this._trackPumps) {
|
||||
pump.wake?.();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(this._trackPromises);
|
||||
await Promise.all(this._trackPumps.map(x => x.resolvers.promise));
|
||||
} catch (error) {
|
||||
if (!this._canceled) {
|
||||
if ((this.state as Conversion['state']) !== 'canceled') {
|
||||
// Make sure to cancel to stop other encoding processes and clean up resources
|
||||
void this.cancel();
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
options.pauseSignal?.removeEventListener('abort', onPause);
|
||||
}
|
||||
|
||||
if (this._canceled) {
|
||||
if ((this.state as Conversion['state']) === 'canceled') {
|
||||
throw new ConversionCanceledError();
|
||||
}
|
||||
|
||||
await this.output.finalize();
|
||||
const isDone = this._trackPumps.every(x => x.done);
|
||||
this.state = isDone ? 'done' : 'idle';
|
||||
|
||||
if (this._computeProgress) {
|
||||
const minTimestamp = Math.min(...this._maxTimestamps.values());
|
||||
this.onProgress?.(1, minTimestamp);
|
||||
if (isDone) {
|
||||
if (!this._composable) {
|
||||
await this.output.finalize();
|
||||
}
|
||||
|
||||
if (this._computeProgress) {
|
||||
const minTimestamp = Math.min(...this._maxTimestamps.values());
|
||||
this.onProgress?.(1, minTimestamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1119,17 +1257,27 @@ export class Conversion {
|
||||
* Does nothing if the conversion is already complete.
|
||||
*/
|
||||
async cancel() {
|
||||
if (this.output.state === 'finalizing' || this.output.state === 'finalized') {
|
||||
if (this.state === 'done') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._canceled) {
|
||||
if (this.state === 'canceled') {
|
||||
Logging._warn('Conversion already canceled.');
|
||||
return;
|
||||
}
|
||||
|
||||
this._canceled = true;
|
||||
await this.output.cancel();
|
||||
this.state = 'canceled';
|
||||
|
||||
// Wake all suspended track pumps so they can wind down
|
||||
for (const pump of this._trackPumps) {
|
||||
pump.wake?.();
|
||||
}
|
||||
|
||||
this._synchronizer.resolveAll();
|
||||
|
||||
if (!this._composable) {
|
||||
await this.output.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
@@ -1210,16 +1358,14 @@ export class Conversion {
|
||||
const source = new EncodedVideoPacketSource(sourceCodec);
|
||||
videoSource = source;
|
||||
|
||||
this._trackPromises.push((async () => {
|
||||
await this._started;
|
||||
|
||||
this._registerTrackPump(async (pump) => {
|
||||
const sink = new EncodedPacketSink(track);
|
||||
const decoderConfig = await track.getDecoderConfig();
|
||||
const meta: EncodedVideoChunkMetadata = { decoderConfig: decoderConfig ?? undefined };
|
||||
|
||||
for await (const packet of sink.packets(undefined, undefined, { verifyKeyPackets: true })) {
|
||||
if (this._canceled) {
|
||||
return;
|
||||
if (this.state === 'canceled') {
|
||||
break;
|
||||
}
|
||||
|
||||
if (packet.timestamp >= this._endTimestamp) {
|
||||
@@ -1240,11 +1386,13 @@ export class Conversion {
|
||||
if (this._synchronizer.shouldWait(outputTrackId, modifiedPacket.timestamp)) {
|
||||
await this._synchronizer.wait(modifiedPacket.timestamp);
|
||||
}
|
||||
|
||||
await this._checkpoint(pump, modifiedPacket.timestamp);
|
||||
}
|
||||
|
||||
source.close();
|
||||
this._synchronizer.closeTrack(outputTrackId);
|
||||
})());
|
||||
});
|
||||
} else {
|
||||
// We need to decode & reencode the video
|
||||
|
||||
@@ -1321,7 +1469,7 @@ export class Conversion {
|
||||
await tempOutput.start();
|
||||
|
||||
const sink = new VideoSampleSink(track);
|
||||
const firstSample = await sink.getSample(firstTimestamp); // Let's just use the first sample
|
||||
using firstSample = await sink.getSample(firstTimestamp); // Let's just use the first sample
|
||||
|
||||
if (firstSample) {
|
||||
try {
|
||||
@@ -1329,9 +1477,13 @@ export class Conversion {
|
||||
firstSample.close();
|
||||
await tempOutput.finalize();
|
||||
} catch (error) {
|
||||
Logging._info('Error when probing encoder support. Falling back to rerender path.', error);
|
||||
needsRerender = true;
|
||||
Logging._warn(
|
||||
'An error occurred when probing encoder support. Falling back to rerender path.', error,
|
||||
);
|
||||
void tempOutput.cancel();
|
||||
|
||||
needsRerender = true;
|
||||
encodingConfig.transform.force = true;
|
||||
}
|
||||
} else {
|
||||
await tempOutput.cancel();
|
||||
@@ -1366,15 +1518,12 @@ export class Conversion {
|
||||
const source = new VideoSampleSource(encodingConfig);
|
||||
videoSource = source;
|
||||
|
||||
this._trackPromises.push((async () => {
|
||||
await this._started;
|
||||
|
||||
this._registerTrackPump(async (pump) => {
|
||||
const sink = new VideoSampleSink(track);
|
||||
|
||||
for await (const sample of sink.samples(this._startTimestamp, this._endTimestamp)) {
|
||||
if (this._canceled) {
|
||||
sample.close();
|
||||
return;
|
||||
for await (using sample of sink.samples(this._startTimestamp, this._endTimestamp)) {
|
||||
if (this.state === 'canceled') {
|
||||
break;
|
||||
}
|
||||
|
||||
const adjustedSampleTimestamp = Math.max(sample.timestamp - this._startTimestamp, 0);
|
||||
@@ -1382,23 +1531,26 @@ export class Conversion {
|
||||
|
||||
this._reportProgress(outputTrackId, sample.timestamp + sample.duration);
|
||||
await source.add(sample);
|
||||
sample.close();
|
||||
|
||||
if (lastSampleTimestamp !== null) {
|
||||
if (this._synchronizer.shouldWait(outputTrackId, lastSampleTimestamp)) {
|
||||
await this._synchronizer.wait(lastSampleTimestamp);
|
||||
}
|
||||
}
|
||||
|
||||
sample.close();
|
||||
await this._checkpoint(pump, lastSampleTimestamp);
|
||||
}
|
||||
}
|
||||
|
||||
source.close();
|
||||
this._synchronizer.closeTrack(outputTrackId);
|
||||
})());
|
||||
});
|
||||
}
|
||||
|
||||
let ownGroup: OutputTrackGroup | null = null;
|
||||
if (!trackOptions.group) {
|
||||
if (!trackOptions.group && !this._composable) {
|
||||
// Create per-track groups to replicate the input's pairability graph. Don't do this for composable
|
||||
// conversions.
|
||||
ownGroup = new OutputTrackGroup();
|
||||
}
|
||||
|
||||
@@ -1412,8 +1564,6 @@ export class Conversion {
|
||||
rotation: outputTrackRotation,
|
||||
group: ownGroup ?? trackOptions.group,
|
||||
});
|
||||
this._addedCounts.video++;
|
||||
this._totalTrackCount++;
|
||||
|
||||
this.utilizedTracks.push(track);
|
||||
this._outputTrackIds.push(outputTrackId);
|
||||
@@ -1463,16 +1613,14 @@ export class Conversion {
|
||||
const source = new EncodedAudioPacketSource(sourceCodec);
|
||||
audioSource = source;
|
||||
|
||||
this._trackPromises.push((async () => {
|
||||
await this._started;
|
||||
|
||||
this._registerTrackPump(async (pump) => {
|
||||
const sink = new EncodedPacketSink(track);
|
||||
const decoderConfig = await track.getDecoderConfig();
|
||||
const meta: EncodedAudioChunkMetadata = { decoderConfig: decoderConfig ?? undefined };
|
||||
|
||||
for await (const packet of sink.packets()) {
|
||||
if (this._canceled) {
|
||||
return;
|
||||
if (this.state === 'canceled') {
|
||||
break;
|
||||
}
|
||||
|
||||
if (packet.timestamp >= this._endTimestamp) {
|
||||
@@ -1490,11 +1638,13 @@ export class Conversion {
|
||||
if (this._synchronizer.shouldWait(outputTrackId, modifiedPacket.timestamp)) {
|
||||
await this._synchronizer.wait(modifiedPacket.timestamp);
|
||||
}
|
||||
|
||||
await this._checkpoint(pump, modifiedPacket.timestamp);
|
||||
}
|
||||
|
||||
source.close();
|
||||
this._synchronizer.closeTrack(outputTrackId);
|
||||
})());
|
||||
});
|
||||
} else {
|
||||
// We need to decode & reencode the audio
|
||||
|
||||
@@ -1587,14 +1737,11 @@ export class Conversion {
|
||||
const source = new AudioSampleSource(encodingConfig);
|
||||
audioSource = source;
|
||||
|
||||
this._trackPromises.push((async () => {
|
||||
await this._started;
|
||||
|
||||
this._registerTrackPump(async (pump) => {
|
||||
const sink = new AudioSampleSink(track);
|
||||
for await (let sample of sink.samples(this._startTimestamp, this._endTimestamp)) {
|
||||
if (this._canceled) {
|
||||
sample.close();
|
||||
return;
|
||||
for await (using sample of sink.samples(this._startTimestamp, this._endTimestamp)) {
|
||||
if (this.state === 'canceled') {
|
||||
break;
|
||||
}
|
||||
|
||||
if (needsPadding) {
|
||||
@@ -1608,7 +1755,7 @@ export class Conversion {
|
||||
data.fill(2 ** 7); // Fill it with the silent value
|
||||
}
|
||||
|
||||
const silentSample = new AudioSample({
|
||||
using silentSample = new AudioSample({
|
||||
data,
|
||||
// Use the same format the decoder is spitting out. This avoids feeding changing sample
|
||||
// formats to the audio encoder.
|
||||
@@ -1617,7 +1764,9 @@ export class Conversion {
|
||||
sampleRate: originalSampleRate,
|
||||
timestamp: 0,
|
||||
});
|
||||
await this._registerAudioSample(silentSample, source, outputTrackId, () => lastSampleTimestamp);
|
||||
await this._registerAudioSample(
|
||||
pump, silentSample, source, outputTrackId, () => lastSampleTimestamp,
|
||||
);
|
||||
|
||||
needsPadding = false;
|
||||
}
|
||||
@@ -1632,31 +1781,41 @@ export class Conversion {
|
||||
endFrame = Math.round((this._endTimestamp - sample.timestamp) * sample.sampleRate);
|
||||
}
|
||||
|
||||
// Can't assign to "using" identifiers so we gotta do this
|
||||
let finalSampleLet: AudioSample;
|
||||
if (startFrame > 0 || endFrame < sample.numberOfFrames) {
|
||||
// Trim the sample if it sticks out of the trim region on either end
|
||||
const trimmedSample = sample.trim(startFrame, endFrame);
|
||||
sample.close();
|
||||
sample = trimmedSample;
|
||||
finalSampleLet = trimmedSample;
|
||||
|
||||
if (sample.numberOfFrames === 0) {
|
||||
sample.close();
|
||||
if (trimmedSample.numberOfFrames === 0) {
|
||||
trimmedSample.close();
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
finalSampleLet = sample;
|
||||
}
|
||||
|
||||
// Offset the timestamp as needed
|
||||
sample.setTimestamp(sample.timestamp - this._startTimestamp);
|
||||
using finalSample = finalSampleLet;
|
||||
|
||||
await this._registerAudioSample(sample, source, outputTrackId, () => lastSampleTimestamp);
|
||||
// Offset the timestamp as needed
|
||||
finalSample.setTimestamp(finalSample.timestamp - this._startTimestamp);
|
||||
|
||||
await this._registerAudioSample(
|
||||
pump, finalSample, source, outputTrackId, () => lastSampleTimestamp,
|
||||
);
|
||||
}
|
||||
|
||||
source.close();
|
||||
this._synchronizer.closeTrack(outputTrackId);
|
||||
})());
|
||||
});
|
||||
}
|
||||
|
||||
let ownGroup: OutputTrackGroup | null = null;
|
||||
if (!trackOptions.group) {
|
||||
if (!trackOptions.group && !this._composable) {
|
||||
// Create per-track groups to replicate the input's pairability graph. Don't do this for composable
|
||||
// conversions.
|
||||
ownGroup = new OutputTrackGroup();
|
||||
}
|
||||
|
||||
@@ -1668,8 +1827,6 @@ export class Conversion {
|
||||
disposition: await track.getDisposition(),
|
||||
group: ownGroup ?? trackOptions.group,
|
||||
});
|
||||
this._addedCounts.audio++;
|
||||
this._totalTrackCount++;
|
||||
|
||||
this.utilizedTracks.push(track);
|
||||
this._outputTrackIds.push(outputTrackId);
|
||||
@@ -1678,6 +1835,7 @@ export class Conversion {
|
||||
|
||||
/** @internal */
|
||||
async _registerAudioSample(
|
||||
pump: TrackPump,
|
||||
sample: AudioSample,
|
||||
source: AudioSampleSource,
|
||||
outputTrackId: number,
|
||||
@@ -1693,6 +1851,39 @@ export class Conversion {
|
||||
if (this._synchronizer.shouldWait(outputTrackId, lastSampleTimestamp)) {
|
||||
await this._synchronizer.wait(lastSampleTimestamp);
|
||||
}
|
||||
|
||||
await this._checkpoint(pump, lastSampleTimestamp);
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_registerTrackPump(fn: (pump: TrackPump) => Promise<void>) {
|
||||
const pump: TrackPump = {
|
||||
done: false,
|
||||
resolvers: promiseWithResolvers(),
|
||||
wake: null,
|
||||
start: () => {
|
||||
void fn(pump).then(() => {
|
||||
pump.done = true;
|
||||
pump.resolvers.resolve();
|
||||
}, (error) => {
|
||||
pump.resolvers.reject(error);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
this._trackPumps.push(pump);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
async _checkpoint(pump: TrackPump, timestamp: number) {
|
||||
while (this.state !== 'canceled' && (timestamp >= this._executionUntil || this._pauseRequested)) {
|
||||
// We've reached the target; signal it and suspend until the next execution wakes us up
|
||||
pump.resolvers.resolve();
|
||||
|
||||
const { promise, resolve } = promiseWithResolvers();
|
||||
pump.wake = resolve;
|
||||
await promise;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1740,12 +1931,17 @@ const MAX_TIMESTAMP_GAP = 1; // in seconds
|
||||
* slowest consumer.
|
||||
*/
|
||||
class TrackSynchronizer {
|
||||
conversion: Conversion;
|
||||
maxTimestamps = new Map<number, number>(); // Track ID -> timestamp
|
||||
resolvers: {
|
||||
timestamp: number;
|
||||
resolve: () => void;
|
||||
}[] = [];
|
||||
|
||||
constructor(conversion: Conversion) {
|
||||
this.conversion = conversion;
|
||||
}
|
||||
|
||||
declareTrack(trackId: number) {
|
||||
this.maxTimestamps.set(trackId, 0);
|
||||
}
|
||||
@@ -1757,6 +1953,15 @@ class TrackSynchronizer {
|
||||
this.maxTimestamps.set(trackId, Math.max(timestamp, currentValue));
|
||||
|
||||
const newMin = this.computeMinAndMaybeResolve();
|
||||
if (
|
||||
this.conversion.state === 'canceled'
|
||||
|| this.conversion._pauseRequested
|
||||
|| timestamp >= this.conversion._executionUntil
|
||||
) {
|
||||
// No point in throttling consumers that are about to suspend or wind down anyway
|
||||
return false;
|
||||
}
|
||||
|
||||
return timestamp - newMin > MAX_TIMESTAMP_GAP; // Should wait if it is too far ahead of the slowest consumer
|
||||
}
|
||||
|
||||
@@ -1776,6 +1981,13 @@ class TrackSynchronizer {
|
||||
this.computeMinAndMaybeResolve();
|
||||
}
|
||||
|
||||
resolveAll() {
|
||||
for (const entry of this.resolvers) {
|
||||
entry.resolve();
|
||||
}
|
||||
this.resolvers.length = 0;
|
||||
}
|
||||
|
||||
computeMinAndMaybeResolve() {
|
||||
let newMin = Infinity;
|
||||
for (const [, timestamp] of this.maxTimestamps) {
|
||||
|
||||
@@ -186,7 +186,7 @@ export class FlacDemuxer extends Demuxer {
|
||||
// description is required, and has to be the following:
|
||||
// 1. The bytes 0x66 0x4C 0x61 0x43 ("fLaC" in ASCII)
|
||||
// 2. A metadata block (called the STREAMINFO block) as described in section 7 of [FLAC]
|
||||
// 3. Optionaly (sic) other metadata blocks, that are not used by the specification
|
||||
// 3. Optionally (sic) other metadata blocks, that are not used by the specification
|
||||
|
||||
bitstream.skipBits(16 * 8); // md5 hash
|
||||
|
||||
@@ -296,7 +296,7 @@ export class FlacDemuxer extends Demuxer {
|
||||
// we expect that there are at least `minimumFrameSize` bytes left in the file
|
||||
|
||||
// Ideally we also want to validate the next header is valid
|
||||
// to throw out an accidential sync word
|
||||
// to throw out an accidental sync word
|
||||
|
||||
// The shortest valid FLAC header I can think of, based off the code
|
||||
// of readFlacFrameHeader:
|
||||
|
||||
@@ -148,8 +148,8 @@ export class HlsMuxer extends Muxer {
|
||||
async start(): Promise<void> {
|
||||
const release = await this.mutex.acquire();
|
||||
|
||||
const someRelative = this.output._tracks.some(t => t.metadata.isRelativeToUnixEpoch);
|
||||
const someNotRelative = this.output._tracks.some(t => !t.metadata.isRelativeToUnixEpoch);
|
||||
const someRelative = this.output.tracks.some(t => t.metadata.isRelativeToUnixEpoch);
|
||||
const someNotRelative = this.output.tracks.some(t => !t.metadata.isRelativeToUnixEpoch);
|
||||
if (someRelative && someNotRelative) {
|
||||
throw new Error(
|
||||
'All tracks must agree on `relativeToUnixEpoch`: some tracks are relative to the Unix epoch and some'
|
||||
@@ -180,14 +180,14 @@ export class HlsMuxer extends Muxer {
|
||||
let keyPacketsOnlyPairingWarned = false;
|
||||
|
||||
// First, let's build the "sibling" groups induced by track pairability
|
||||
for (const track of this.output._tracks) {
|
||||
for (const track of this.output.tracks) {
|
||||
if (track.type === 'video') {
|
||||
hasVideo = true;
|
||||
}
|
||||
|
||||
const pairableGroups = new Map<MediaCodec, OutputTrack[]>();
|
||||
|
||||
for (const otherTrack of this.output._tracks) {
|
||||
for (const otherTrack of this.output.tracks) {
|
||||
if (track === otherTrack) {
|
||||
continue;
|
||||
}
|
||||
@@ -264,7 +264,7 @@ export class HlsMuxer extends Muxer {
|
||||
const unpairedAudioTracks: OutputTrack[] = [];
|
||||
|
||||
// Now, create the top-level variant streams
|
||||
for (const track of this.output._tracks) {
|
||||
for (const track of this.output.tracks) {
|
||||
const assignedGroupKeys = groupAssignment.get(track);
|
||||
if (assignedGroupKeys) {
|
||||
assert(assignedGroupKeys.length > 0);
|
||||
|
||||
@@ -287,6 +287,7 @@ export {
|
||||
type ConversionOptions,
|
||||
type ConversionVideoOptions,
|
||||
type ConversionAudioOptions,
|
||||
type ConversionExecuteOptions,
|
||||
ConversionCanceledError,
|
||||
type DiscardedTrack,
|
||||
} from './conversion';
|
||||
|
||||
+2
-1
@@ -544,7 +544,8 @@ export class InputVideoTrack extends InputTrack {
|
||||
}
|
||||
|
||||
async hasOnlyKeyPackets() {
|
||||
return (await this._backing.getHasOnlyKeyPackets?.()) ?? false;
|
||||
return (await this._backing.getHasOnlyKeyPackets?.())
|
||||
?? (await this._backing.getCodec() === 'prores'); // Only ProRes is fully intra-frame
|
||||
}
|
||||
|
||||
/** Returns the width in pixels of the track's coded samples, before any transformations or rotations. */
|
||||
|
||||
@@ -206,6 +206,8 @@ export class IsobmffMuxer extends Muxer {
|
||||
this.isCmaf = format instanceof CmafOutputFormat;
|
||||
this.minimumFragmentDuration = format._options.minimumFragmentDuration
|
||||
?? (format instanceof CmafOutputFormat ? Infinity : 1);
|
||||
|
||||
this.auxWriter.start();
|
||||
}
|
||||
|
||||
async start() {
|
||||
@@ -246,7 +248,7 @@ export class IsobmffMuxer extends Muxer {
|
||||
this.initBoxWriter = new IsobmffBoxWriter(initWriter);
|
||||
}
|
||||
|
||||
const holdsAvc = this.output._tracks.some(x => x.isVideoTrack() && x.source._codec === 'avc');
|
||||
const holdsAvc = this.output.tracks.some(x => x.isVideoTrack() && x.source._codec === 'avc');
|
||||
|
||||
// Write the header
|
||||
{
|
||||
@@ -280,7 +282,7 @@ export class IsobmffMuxer extends Muxer {
|
||||
// We're write at finalization
|
||||
} else if (this.fastStart === 'reserve') {
|
||||
// Validate that all tracks have set maximumPacketCount
|
||||
for (const track of this.output._tracks) {
|
||||
for (const track of this.output.tracks) {
|
||||
if (track.metadata.maximumPacketCount === undefined) {
|
||||
throw new Error(
|
||||
'All tracks must specify maximumPacketCount in their metadata when using'
|
||||
@@ -310,7 +312,7 @@ export class IsobmffMuxer extends Muxer {
|
||||
}
|
||||
|
||||
private allTracksAreKnown() {
|
||||
for (const track of this.output._tracks) {
|
||||
for (const track of this.output.tracks) {
|
||||
if (!track.source._closed && !this.trackDatas.some(x => x.track === track)) {
|
||||
return false; // We haven't seen a sample from this open track yet
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
Rotation,
|
||||
roundIfAlmostInteger,
|
||||
textDecoder,
|
||||
toDataView,
|
||||
TRANSFER_CHARACTERISTICS_MAP_INVERSE,
|
||||
UNDETERMINED_LANGUAGE,
|
||||
} from '../misc';
|
||||
@@ -141,6 +142,7 @@ type ClusterBlock = {
|
||||
data: Uint8Array;
|
||||
lacing: BlockLacing;
|
||||
decoded: boolean;
|
||||
postProcessed: boolean; // For codec-specific processing
|
||||
mainAdditional: Uint8Array | null;
|
||||
};
|
||||
|
||||
@@ -864,6 +866,7 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
data: frameData,
|
||||
lacing: BlockLacing.None,
|
||||
decoded: true,
|
||||
postProcessed: false,
|
||||
mainAdditional: originalBlock.mainAdditional,
|
||||
});
|
||||
}
|
||||
@@ -1502,6 +1505,7 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
data: blockData,
|
||||
lacing,
|
||||
decoded: !hasDecodingInstructions,
|
||||
postProcessed: false,
|
||||
mainAdditional: null,
|
||||
});
|
||||
}; break;
|
||||
@@ -1538,6 +1542,7 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
data: blockData,
|
||||
lacing,
|
||||
decoded: !hasDecodingInstructions,
|
||||
postProcessed: false,
|
||||
mainAdditional: null,
|
||||
};
|
||||
trackData.blocks.push(this.currentBlock);
|
||||
@@ -2194,6 +2199,35 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
|
||||
block.decoded = true;
|
||||
}
|
||||
|
||||
if (!block.postProcessed) {
|
||||
if (this.internalTrack.info?.codec === 'prores') {
|
||||
// For some reason, ProRes packets are stored in Matroska without the frame container atom. FFmpeg cites
|
||||
// the "Matroska spec" but the actual spec says nothing about this.
|
||||
|
||||
const hasFrameContainer = block.data.length >= 8
|
||||
&& block.data[4] === 105 // 'i'
|
||||
&& block.data[5] === 99 // 'c'
|
||||
&& block.data[6] === 112 // 'p'
|
||||
&& block.data[7] === 102; // 'f'
|
||||
|
||||
if (!hasFrameContainer) {
|
||||
// Wrap the frame in a frame container
|
||||
const newData = new Uint8Array(block.data.length + 8);
|
||||
const newDataView = toDataView(newData);
|
||||
|
||||
newDataView.setUint32(0, newData.length, false);
|
||||
newData[4] = 105; // 'i'
|
||||
newData[5] = 99; // 'c'
|
||||
newData[6] = 112; // 'p'
|
||||
newData[7] = 102; // 'f'
|
||||
newData.set(block.data, 8);
|
||||
block.data = newData;
|
||||
}
|
||||
}
|
||||
|
||||
block.postProcessed = true;
|
||||
}
|
||||
|
||||
const data = options.metadataOnly ? PLACEHOLDER_DATA : block.data;
|
||||
const timestamp = block.timestamp / this.internalTrack.segment.timestampFactor;
|
||||
const duration = block.duration / this.internalTrack.segment.timestampFactor;
|
||||
|
||||
@@ -691,7 +691,7 @@ export class MatroskaMuxer extends Muxer {
|
||||
}
|
||||
|
||||
private allTracksAreKnown() {
|
||||
for (const track of this.output._tracks) {
|
||||
for (const track of this.output.tracks) {
|
||||
if (!track.source._closed && !this.trackDatas.some(x => x.track === track)) {
|
||||
return false; // We haven't seen a sample from this open track yet
|
||||
}
|
||||
@@ -897,6 +897,17 @@ export class MatroskaMuxer extends Muxer {
|
||||
try {
|
||||
const trackData = this.getVideoTrackData(track, packet, meta);
|
||||
|
||||
let packetData = packet.data;
|
||||
if (track.source._codec === 'prores') {
|
||||
if (packetData.byteLength < 8) {
|
||||
throw new Error('ProRes packet too small, expected at least 8 bytes.');
|
||||
}
|
||||
|
||||
// Trim off the frame container atom header. FFmpeg does this too and cites the "Matroska spec" as the
|
||||
// reason, despite the spec not saying anything about this.
|
||||
packetData = packetData.subarray(8);
|
||||
}
|
||||
|
||||
const isKeyFrame = packet.type === 'key';
|
||||
this.validateTimestamp(trackData.track, packet.timestamp, isKeyFrame);
|
||||
|
||||
@@ -913,7 +924,7 @@ export class MatroskaMuxer extends Muxer {
|
||||
? packet.sideData.alpha ?? null
|
||||
: null;
|
||||
|
||||
const videoChunk = this.createInternalChunk(packet.data, timestamp, duration, packet.type, additions);
|
||||
const videoChunk = this.createInternalChunk(packetData, timestamp, duration, packet.type, additions);
|
||||
if (track.source._codec === 'vp9') this.fixVP9ColorSpace(trackData, videoChunk);
|
||||
|
||||
trackData.chunkQueue.push(videoChunk);
|
||||
@@ -1157,7 +1168,10 @@ export class MatroskaMuxer extends Muxer {
|
||||
|
||||
const msDuration = Math.round(1000 * chunk.duration);
|
||||
|
||||
if (!chunk.additions) {
|
||||
// Subtitle cues need an explicit BlockDuration (a SimpleBlock has none)
|
||||
const needsBlockGroup = !!chunk.additions || trackData.type === 'subtitle';
|
||||
|
||||
if (!needsBlockGroup) {
|
||||
// No additions, we can write out a SimpleBlock
|
||||
view.setUint8(3, Number(chunk.type === 'key') << 7); // Flags (keyframe flag only present for SimpleBlock)
|
||||
|
||||
|
||||
+11
-4
@@ -1011,15 +1011,22 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
|
||||
if (this.codec === 'avc') {
|
||||
// Workaround for https://issues.chromium.org/issues/470109459
|
||||
const filteredNalUnits: Uint8Array[] = [];
|
||||
let hasFrameData = false;
|
||||
|
||||
for (const loc of iterateAvcNalUnits(packet.data, this.decoderConfig)) {
|
||||
const type = extractNalUnitTypeForAvc(packet.data[loc.offset]!);
|
||||
hasFrameData ||= type >= 1 && type <= 5;
|
||||
|
||||
if (type === AvcNalUnitType.AUD) {
|
||||
// If packets contain an AUD and have NALUs before it, this trips up Chromium's key frame
|
||||
// detector. Clear the NALUs if an AUD is encountered.
|
||||
// https://github.com/Vanilagy/mediabunny/issues/396
|
||||
filteredNalUnits.length = 0;
|
||||
if (hasFrameData) {
|
||||
// Already has actual frame data, so treat an AUD as simply the end of the packet
|
||||
break;
|
||||
} else {
|
||||
// If packets contain an AUD and have NALUs before it, this trips up Chromium's key
|
||||
// frame detector. Clear the NALUs if an AUD is encountered.
|
||||
// https://github.com/Vanilagy/mediabunny/issues/396
|
||||
filteredNalUnits.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// These trip up Chromium's key frame detection, so let's strip them
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
setUint24,
|
||||
toUint8Array,
|
||||
UnthrottledTimerHandle,
|
||||
wait,
|
||||
} from './misc';
|
||||
import { Muxer } from './muxer';
|
||||
import { SubtitleParser } from './subtitles';
|
||||
@@ -878,6 +879,9 @@ class VideoEncoderWrapper {
|
||||
// These are wired in series, therefore they must also be flushed in series
|
||||
await this.encoder.flush();
|
||||
await this.alphaEncoder?.flush();
|
||||
|
||||
// Workaround for https://issues.chromium.org/issues/529852980 to give it time for errors to surface
|
||||
await wait(25);
|
||||
}
|
||||
|
||||
if (this.encoder.state !== 'closed') {
|
||||
|
||||
@@ -451,7 +451,7 @@ export class MpegTsMuxer extends Muxer {
|
||||
}
|
||||
|
||||
private allTracksAreKnown() {
|
||||
for (const track of this.output._tracks) {
|
||||
for (const track of this.output.tracks) {
|
||||
if (!track.source._closed && !this.trackDatas.some(x => x.track === track)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -638,11 +638,11 @@ class OggAudioTrackBacking implements InputAudioTrackBacking {
|
||||
|
||||
const lowPages: Page[] = [lowPage];
|
||||
|
||||
// First, let's perform a binary serach (bisection search) on the file to find the approximate page where
|
||||
// First, let's perform a binary search (bisection search) on the file to find the approximate page where
|
||||
// we'll find the packet. We want to find a page whose end packet position is less than or equal to the
|
||||
// packet position we're searching for.
|
||||
|
||||
// Outer loop: Does the binary serach
|
||||
// Outer loop: Does the binary search
|
||||
outer:
|
||||
while (lowPage.headerStartPos + lowPage.totalSize < high) {
|
||||
const low = lowPage.headerStartPos;
|
||||
|
||||
@@ -297,7 +297,7 @@ export class OggMuxer extends Muxer {
|
||||
}
|
||||
|
||||
allTracksAreKnown() {
|
||||
for (const track of this.output._tracks) {
|
||||
for (const track of this.output.tracks) {
|
||||
if (!track.source._closed && !this.trackDatas.some(x => x.track === track)) {
|
||||
return false; // We haven't seen a sample from this open track yet
|
||||
}
|
||||
|
||||
@@ -799,7 +799,7 @@ export class WavOutputFormat extends OutputFormat {
|
||||
getSupportedCodecs(): MediaCodec[] {
|
||||
return [
|
||||
...PCM_AUDIO_CODECS.filter(codec =>
|
||||
['pcm-s16', 'pcm-s24', 'pcm-s32', 'pcm-f32', 'pcm-u8', 'ulaw', 'alaw'].includes(codec),
|
||||
['pcm-s16', 'pcm-s24', 'pcm-s32', 'pcm-f32', 'pcm-f64', 'pcm-u8', 'ulaw', 'alaw'].includes(codec),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
+41
-13
@@ -372,6 +372,11 @@ export class Output<
|
||||
*/
|
||||
readonly defaultTrackGroup = new OutputTrackGroup();
|
||||
|
||||
/**
|
||||
* The tracks that have been added to this output. Treat it as a readonly field; to add tracks, use the methods.
|
||||
*/
|
||||
readonly tracks: OutputTrack[] = [];
|
||||
|
||||
/** @internal */
|
||||
private _initTarget: T | (() => MaybePromise<T>) | null;
|
||||
/** @internal */
|
||||
@@ -383,8 +388,6 @@ export class Output<
|
||||
/** @internal */
|
||||
_rootWriterPromise: Promise<Writer> | null = null;
|
||||
/** @internal */
|
||||
_tracks: OutputTrack[] = [];
|
||||
/** @internal */
|
||||
_startPromise: Promise<void> | null = null;
|
||||
/** @internal */
|
||||
_cancelPromise: Promise<void> | null = null;
|
||||
@@ -619,7 +622,7 @@ export class Output<
|
||||
metadataCopy.group ??= this.defaultTrackGroup;
|
||||
|
||||
return this._addTrack(new OutputVideoTrack(
|
||||
this._tracks.length + 1, this, source, metadataCopy,
|
||||
this.tracks.length + 1, this, source, metadataCopy,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -634,7 +637,7 @@ export class Output<
|
||||
metadataCopy.group ??= this.defaultTrackGroup;
|
||||
|
||||
return this._addTrack(new OutputAudioTrack(
|
||||
this._tracks.length + 1, this, source, metadataCopy,
|
||||
this.tracks.length + 1, this, source, metadataCopy,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -649,7 +652,7 @@ export class Output<
|
||||
metadataCopy.group ??= this.defaultTrackGroup;
|
||||
|
||||
return this._addTrack(new OutputSubtitleTrack(
|
||||
this._tracks.length + 1, this, source, metadataCopy,
|
||||
this.tracks.length + 1, this, source, metadataCopy,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -680,7 +683,7 @@ export class Output<
|
||||
|
||||
// Verify maximum track count constraints
|
||||
const supportedTrackCounts = this.format.getSupportedTrackCounts();
|
||||
const presentTracksOfThisType = this._tracks.reduce(
|
||||
const presentTracksOfThisType = this.tracks.reduce(
|
||||
(count, t) => count + (t.type === track.type ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
@@ -694,7 +697,7 @@ export class Output<
|
||||
);
|
||||
}
|
||||
const maxTotalCount = supportedTrackCounts.total.max;
|
||||
if (this._tracks.length === maxTotalCount) {
|
||||
if (this.tracks.length === maxTotalCount) {
|
||||
throw new Error(
|
||||
`${this.format._name} does not support more than ${maxTotalCount} tracks`
|
||||
+ `${maxTotalCount === 1 ? '' : 's'} in total.`,
|
||||
@@ -748,12 +751,37 @@ export class Output<
|
||||
}
|
||||
}
|
||||
|
||||
this._tracks.push(track);
|
||||
this.tracks.push(track);
|
||||
track.source._connectedTrack = track;
|
||||
|
||||
return track;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the output has enough tracks (of the correct type) to be started, based on the requirements of the output
|
||||
* format.
|
||||
*/
|
||||
hasEnoughTracks() {
|
||||
const supportedTrackCounts = this.format.getSupportedTrackCounts();
|
||||
for (const trackType of ALL_TRACK_TYPES) {
|
||||
const presentTracksOfThisType = this.tracks.reduce(
|
||||
(count, track) => count + (track.type === trackType ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
const minCount = supportedTrackCounts[trackType].min;
|
||||
if (presentTracksOfThisType < minCount) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const totalMinCount = supportedTrackCounts.total.min;
|
||||
if (this.tracks.length < totalMinCount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the creation of the output file. This method should be called after all tracks have been added. Only after
|
||||
* the output has started can media samples be added to the tracks.
|
||||
@@ -764,7 +792,7 @@ export class Output<
|
||||
// Verify minimum track count constraints
|
||||
const supportedTrackCounts = this.format.getSupportedTrackCounts();
|
||||
for (const trackType of ALL_TRACK_TYPES) {
|
||||
const presentTracksOfThisType = this._tracks.reduce(
|
||||
const presentTracksOfThisType = this.tracks.reduce(
|
||||
(count, track) => count + (track.type === trackType ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
@@ -780,7 +808,7 @@ export class Output<
|
||||
}
|
||||
}
|
||||
const totalMinCount = supportedTrackCounts.total.min;
|
||||
if (this._tracks.length < totalMinCount) {
|
||||
if (this.tracks.length < totalMinCount) {
|
||||
throw new Error(
|
||||
totalMinCount === supportedTrackCounts.total.max
|
||||
? (`${this.format._name} requires exactly ${totalMinCount} track`
|
||||
@@ -807,7 +835,7 @@ export class Output<
|
||||
try {
|
||||
await this._muxer.start();
|
||||
|
||||
const promises = this._tracks.map(track => track.source._start());
|
||||
const promises = this.tracks.map(track => track.source._start());
|
||||
await Promise.all(promises);
|
||||
} finally {
|
||||
release();
|
||||
@@ -850,7 +878,7 @@ export class Output<
|
||||
const release = await this._mutex.acquire();
|
||||
|
||||
try {
|
||||
const promises = this._tracks.map(x => x.source._flushOrWaitForOngoingClose(true)); // Force close
|
||||
const promises = this.tracks.map(x => x.source._flushOrWaitForOngoingClose(true)); // Force close
|
||||
await Promise.all(promises);
|
||||
|
||||
await Promise.all([...this._unfinalizedTargets].map(target => target._close()));
|
||||
@@ -883,7 +911,7 @@ export class Output<
|
||||
const release = await this._mutex.acquire();
|
||||
|
||||
try {
|
||||
const promises = this._tracks.map(x => x.source._flushOrWaitForOngoingClose(false));
|
||||
const promises = this.tracks.map(x => x.source._flushOrWaitForOngoingClose(false));
|
||||
await Promise.all(promises);
|
||||
|
||||
await this._muxer.finalize();
|
||||
|
||||
+2
-2
@@ -172,7 +172,7 @@ export class AudioResampler {
|
||||
|
||||
async add(audioSample: AudioSample) {
|
||||
if (this.sourceSampleRate === null) {
|
||||
// This is the first sample, so let's init the missing data. Initting the sample rate from the decoded
|
||||
// This is the first sample, so let's init the missing data. Initing the sample rate from the decoded
|
||||
// sample is more reliable than using the file's metadata, because decoders are free to emit any sample rate
|
||||
// they see fit.
|
||||
this.sourceSampleRate = audioSample.sampleRate;
|
||||
@@ -199,7 +199,7 @@ export class AudioResampler {
|
||||
const inputEndTime = inputStartTime + audioSample.duration;
|
||||
|
||||
// Compute which output frames are affected by this sample
|
||||
const outputStartFrame = Math.floor(inputStartTime * this.targetSampleRate);
|
||||
const outputStartFrame = Math.floor((inputStartTime - 1 / this.sourceSampleRate) * this.targetSampleRate) + 1;
|
||||
const outputEndFrame = Math.ceil(inputEndTime * this.targetSampleRate);
|
||||
|
||||
for (let outputFrame = outputStartFrame; outputFrame < outputEndFrame; outputFrame++) {
|
||||
|
||||
+7
-3
@@ -2129,7 +2129,7 @@ class ReadOrchestrator {
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (worker.running || this.workers.length >= this.options.maxWorkerCount) {
|
||||
if (worker.running) {
|
||||
// Rare, but can happen with multiple concurrent reads. In this case, don't do anything.
|
||||
return;
|
||||
}
|
||||
@@ -2144,15 +2144,19 @@ class ReadOrchestrator {
|
||||
}
|
||||
|
||||
const queuedRead = this.queuedReads[oldestIndex]!;
|
||||
this.queuedReads.splice(oldestIndex, 1);
|
||||
|
||||
const newWorker = this.createWorker(
|
||||
queuedRead.hole.start,
|
||||
queuedRead.hole.end,
|
||||
queuedRead.strictTarget,
|
||||
);
|
||||
assert(newWorker); // We just freed up a worker, so this should never fail
|
||||
if (!newWorker) {
|
||||
// In high-contention cases, it could be that we've already reached max worker count, so in this
|
||||
// case we don't do anything.
|
||||
return;
|
||||
}
|
||||
|
||||
this.queuedReads.splice(oldestIndex, 1);
|
||||
newWorker.pendingSlices = queuedRead.pendingSlices;
|
||||
this.runWorker(newWorker);
|
||||
}
|
||||
|
||||
@@ -173,6 +173,28 @@ export class WaveDemuxer extends Demuxer {
|
||||
bitsPerSample = 8;
|
||||
}
|
||||
|
||||
if (
|
||||
formatTag !== WaveFormat.PCM
|
||||
&& formatTag !== WaveFormat.IEEE_FLOAT
|
||||
&& formatTag !== WaveFormat.ALAW
|
||||
&& formatTag !== WaveFormat.MULAW
|
||||
) {
|
||||
throw new Error(
|
||||
`Unsupported WAVE codec (format tag ${formatTag}). Only integer/float PCM, A-law, and μ-law are`
|
||||
+ ` supported.`,
|
||||
);
|
||||
}
|
||||
if (formatTag === WaveFormat.PCM && ![8, 16, 24, 32].includes(bitsPerSample)) {
|
||||
throw new Error(
|
||||
`Unsupported WAVE PCM bit depth (${bitsPerSample}). Only 8, 16, 24, and 32 bits are supported.`,
|
||||
);
|
||||
}
|
||||
if (formatTag === WaveFormat.IEEE_FLOAT && ![32, 64].includes(bitsPerSample)) {
|
||||
throw new Error(
|
||||
`Unsupported WAVE float bit depth (${bitsPerSample}). Only 32 and 64 bits are supported.`,
|
||||
);
|
||||
}
|
||||
|
||||
this.audioInfo = {
|
||||
format: formatTag,
|
||||
numberOfChannels: numChannels,
|
||||
@@ -322,10 +344,12 @@ export class WaveDemuxer extends Demuxer {
|
||||
if (this.audioInfo.format === WaveFormat.IEEE_FLOAT) {
|
||||
if (this.audioInfo.sampleSizeInBytes === 4) {
|
||||
return 'pcm-f32';
|
||||
} else if (this.audioInfo.sampleSizeInBytes === 8) {
|
||||
return 'pcm-f64';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
assert(false);
|
||||
}
|
||||
|
||||
async getMimeType() {
|
||||
|
||||
+371
-14
@@ -11,7 +11,7 @@ import { Output, OutputTrackGroup } from '../../src/output.js';
|
||||
import { BufferSource, CustomPathedSource, UrlSource } from '../../src/source.js';
|
||||
import { expect, test } from 'vitest';
|
||||
import { BufferTarget, PathedTarget } from '../../src/target.js';
|
||||
import { Conversion } from '../../src/conversion.js';
|
||||
import { Conversion, ConversionCanceledError } from '../../src/conversion.js';
|
||||
import { assert } from '../../src/misc.js';
|
||||
import { InputVideoTrack } from '../../src/input-track.js';
|
||||
import { CanvasSource, EncodedAudioPacketSource } from '../../src/media-source.js';
|
||||
@@ -177,8 +177,8 @@ test('HLS track assignability is kept #1', async () => {
|
||||
|
||||
await output.finalize();
|
||||
|
||||
const masterPlayist = sanitizeMasterPlaylist(new TextDecoder().decode(files.get('master.m3u8')));
|
||||
expect(masterPlayist.match(/\.m3u8/g)?.length).toBe(1);
|
||||
const masterPlaylist = sanitizeMasterPlaylist(new TextDecoder().decode(files.get('master.m3u8')));
|
||||
expect(masterPlaylist.match(/\.m3u8/g)?.length).toBe(1);
|
||||
|
||||
using input = new Input({
|
||||
formats: ALL_FORMATS,
|
||||
@@ -208,8 +208,8 @@ test('HLS track assignability is kept #1', async () => {
|
||||
const conversion = await Conversion.init({ input, output: newOutput });
|
||||
await conversion.execute();
|
||||
|
||||
const newMasterPlayist = sanitizeMasterPlaylist(new TextDecoder().decode(files.get('new/master.m3u8')));
|
||||
expect(newMasterPlayist).toBe(masterPlayist);
|
||||
const newMasterPlaylist = sanitizeMasterPlaylist(new TextDecoder().decode(files.get('new/master.m3u8')));
|
||||
expect(newMasterPlaylist).toBe(masterPlaylist);
|
||||
});
|
||||
|
||||
test('HLS track assignability is kept #2', async () => {
|
||||
@@ -256,8 +256,8 @@ test('HLS track assignability is kept #2', async () => {
|
||||
|
||||
await output.finalize();
|
||||
|
||||
const masterPlayist = sanitizeMasterPlaylist(new TextDecoder().decode(files.get('master.m3u8')));
|
||||
expect(masterPlayist.match(/\.m3u8/g)?.length).toBe(2);
|
||||
const masterPlaylist = sanitizeMasterPlaylist(new TextDecoder().decode(files.get('master.m3u8')));
|
||||
expect(masterPlaylist.match(/\.m3u8/g)?.length).toBe(2);
|
||||
|
||||
using input = new Input({
|
||||
formats: ALL_FORMATS,
|
||||
@@ -287,8 +287,8 @@ test('HLS track assignability is kept #2', async () => {
|
||||
const conversion = await Conversion.init({ input, output: newOutput });
|
||||
await conversion.execute();
|
||||
|
||||
const newMasterPlayist = sanitizeMasterPlaylist(new TextDecoder().decode(files.get('new/master.m3u8')));
|
||||
expect(newMasterPlayist).toBe(masterPlayist);
|
||||
const newMasterPlaylist = sanitizeMasterPlaylist(new TextDecoder().decode(files.get('new/master.m3u8')));
|
||||
expect(newMasterPlaylist).toBe(masterPlaylist);
|
||||
});
|
||||
|
||||
test('HLS track assignability can be overridden', async () => {
|
||||
@@ -335,8 +335,8 @@ test('HLS track assignability can be overridden', async () => {
|
||||
|
||||
await output.finalize();
|
||||
|
||||
const masterPlayist = sanitizeMasterPlaylist(new TextDecoder().decode(files.get('master.m3u8')));
|
||||
expect(masterPlayist.match(/\.m3u8/g)?.length).toBe(2);
|
||||
const masterPlaylist = sanitizeMasterPlaylist(new TextDecoder().decode(files.get('master.m3u8')));
|
||||
expect(masterPlaylist.match(/\.m3u8/g)?.length).toBe(2);
|
||||
|
||||
using input = new Input({
|
||||
formats: ALL_FORMATS,
|
||||
@@ -371,9 +371,9 @@ test('HLS track assignability can be overridden', async () => {
|
||||
});
|
||||
await conversion.execute();
|
||||
|
||||
const newMasterPlayist = sanitizeMasterPlaylist(new TextDecoder().decode(files.get('new/master.m3u8')));
|
||||
expect(newMasterPlayist).not.toBe(masterPlayist);
|
||||
expect(newMasterPlayist.match(/\.m3u8/g)?.length).toBe(1);
|
||||
const newMasterPlaylist = sanitizeMasterPlaylist(new TextDecoder().decode(files.get('new/master.m3u8')));
|
||||
expect(newMasterPlaylist).not.toBe(masterPlaylist);
|
||||
expect(newMasterPlaylist.match(/\.m3u8/g)?.length).toBe(1);
|
||||
});
|
||||
|
||||
test('Fractional audio sample boundary', async () => {
|
||||
@@ -402,3 +402,360 @@ test('Fractional audio sample boundary', async () => {
|
||||
});
|
||||
await conversion.execute();
|
||||
});
|
||||
|
||||
test('Non-composable conversion requires a fresh output', async () => {
|
||||
using input = new Input({
|
||||
source: new UrlSource('/video.mp4'),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const output = new Output({ format: new Mp4OutputFormat(), target: new BufferTarget() });
|
||||
output.addAudioTrack(new EncodedAudioPacketSource('aac')); // Makes the output non-fresh
|
||||
|
||||
await expect(Conversion.init({ input, output })).rejects.toThrow(/must be fresh/);
|
||||
});
|
||||
|
||||
test('Composable init works on an output that already has a track, but not on a started one', async () => {
|
||||
using input = new Input({
|
||||
source: new UrlSource('/video.mp4'),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const output = new Output({ format: new Mp4OutputFormat(), target: new BufferTarget() });
|
||||
output.addAudioTrack(new EncodedAudioPacketSource('aac')); // A user-added track
|
||||
|
||||
const conversion = await Conversion.init({
|
||||
input,
|
||||
output,
|
||||
composable: true,
|
||||
audio: { discard: true }, // Only contribute the video track
|
||||
showWarnings: false,
|
||||
});
|
||||
expect(conversion.isValid).toBe(true);
|
||||
expect(conversion.utilizedTracks).toHaveLength(1);
|
||||
expect(conversion.utilizedTracks[0]!.type).toBe('video');
|
||||
|
||||
const startedOutput = new Output({ format: new Mp4OutputFormat(), target: new BufferTarget() });
|
||||
startedOutput.addAudioTrack(new EncodedAudioPacketSource('aac'));
|
||||
await startedOutput.start();
|
||||
|
||||
await expect(Conversion.init({ input, output: startedOutput, composable: true }))
|
||||
.rejects.toThrow(/not have been started/);
|
||||
|
||||
await startedOutput.cancel();
|
||||
});
|
||||
|
||||
test('Composable conversion rejects tags', async () => {
|
||||
using input = new Input({
|
||||
source: new UrlSource('/video.mp4'),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const makeOutput = () => new Output({ format: new Mp4OutputFormat(), target: new BufferTarget() });
|
||||
|
||||
await expect(Conversion.init({
|
||||
input,
|
||||
output: makeOutput(),
|
||||
composable: true,
|
||||
tags: { title: 'Not allowed' },
|
||||
})).rejects.toThrow(/tags cannot be set by a composable conversion/);
|
||||
});
|
||||
|
||||
test('Composable conversion composes with a user-added track', async () => {
|
||||
using input = new Input({
|
||||
source: new UrlSource('/video.mp4'),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const output = new Output({ format: new Mp4OutputFormat(), target: new BufferTarget() });
|
||||
|
||||
const conversion = await Conversion.init({
|
||||
input,
|
||||
output,
|
||||
composable: true,
|
||||
audio: { discard: true }, // The user provides their own audio track
|
||||
showWarnings: false,
|
||||
});
|
||||
expect(conversion.utilizedTracks).toHaveLength(1);
|
||||
|
||||
const audioSource = new EncodedAudioPacketSource('aac');
|
||||
output.addAudioTrack(audioSource);
|
||||
|
||||
await output.start();
|
||||
|
||||
await Promise.all([
|
||||
conversion.execute(),
|
||||
(async () => {
|
||||
await addAacPackets(audioSource, 5);
|
||||
audioSource.close();
|
||||
})(),
|
||||
]);
|
||||
|
||||
// The composable conversion must not have finalized the output
|
||||
expect(output.state).toBe('started');
|
||||
|
||||
await output.finalize();
|
||||
expect(output.state).toBe('finalized');
|
||||
|
||||
using result = new Input({ source: new BufferSource(output.target.buffer!), formats: ALL_FORMATS });
|
||||
const tracks = await result.getTracks();
|
||||
expect(tracks.map(t => t.type).sort()).toEqual(['audio', 'video']);
|
||||
|
||||
const videoTrack = await result.getPrimaryVideoTrack();
|
||||
const audioTrack = await result.getPrimaryAudioTrack();
|
||||
expect(videoTrack).not.toBeNull();
|
||||
expect(audioTrack).not.toBeNull();
|
||||
expect(await videoTrack!.getCodec()).toBe('avc');
|
||||
expect(await audioTrack!.getCodec()).toBe('aac');
|
||||
expect(await videoTrack!.computeDuration()).toBeGreaterThan(4);
|
||||
});
|
||||
|
||||
test('Two composable conversions compose into one output', async () => {
|
||||
using input = new Input({
|
||||
source: new UrlSource('/video.mp4'),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const output = new Output({ format: new Mp4OutputFormat(), target: new BufferTarget() });
|
||||
|
||||
const videoConversion = await Conversion.init({
|
||||
input,
|
||||
output,
|
||||
composable: true,
|
||||
audio: { discard: true },
|
||||
showWarnings: false,
|
||||
});
|
||||
const audioConversion = await Conversion.init({
|
||||
input,
|
||||
output,
|
||||
composable: true,
|
||||
video: { discard: true },
|
||||
showWarnings: false,
|
||||
});
|
||||
expect(videoConversion.utilizedTracks).toHaveLength(1);
|
||||
expect(videoConversion.utilizedTracks[0]!.type).toBe('video');
|
||||
expect(audioConversion.utilizedTracks).toHaveLength(1);
|
||||
expect(audioConversion.utilizedTracks[0]!.type).toBe('audio');
|
||||
|
||||
await output.start();
|
||||
await Promise.all([videoConversion.execute(), audioConversion.execute()]);
|
||||
expect(output.state).toBe('started');
|
||||
|
||||
await output.finalize();
|
||||
|
||||
using result = new Input({ source: new BufferSource(output.target.buffer!), formats: ALL_FORMATS });
|
||||
const tracks = await result.getTracks();
|
||||
expect(tracks.map(t => t.type).sort()).toEqual(['audio', 'video']);
|
||||
expect(await (await result.getPrimaryVideoTrack())!.getCodec()).toBe('avc');
|
||||
expect(await (await result.getPrimaryAudioTrack())!.getCodec()).toBe('aac');
|
||||
});
|
||||
|
||||
test('Composable conversion does not write metadata tags', async () => {
|
||||
using input = new Input({
|
||||
source: new UrlSource('/video.mp4'),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
// Sanity check: this input carries metadata tags that a non-composable conversion would copy over
|
||||
const inputTags = await input.getMetadataTags();
|
||||
expect(inputTags.comment).toBeDefined();
|
||||
|
||||
const output = new Output({ format: new Mp4OutputFormat(), target: new BufferTarget() });
|
||||
|
||||
const conversion = await Conversion.init({
|
||||
input,
|
||||
output,
|
||||
composable: true,
|
||||
audio: { discard: true },
|
||||
showWarnings: false,
|
||||
});
|
||||
|
||||
// The conversion must not have touched the output's metadata tags
|
||||
expect(Object.keys(output._metadataTags)).toHaveLength(0);
|
||||
|
||||
const audioSource = new EncodedAudioPacketSource('aac');
|
||||
output.addAudioTrack(audioSource);
|
||||
|
||||
// The user sets their own tags; these must survive
|
||||
output.setMetadataTags({ comment: 'User-owned' });
|
||||
|
||||
await output.start();
|
||||
await Promise.all([
|
||||
conversion.execute(),
|
||||
(async () => {
|
||||
await addAacPackets(audioSource, 5);
|
||||
audioSource.close();
|
||||
})(),
|
||||
]);
|
||||
await output.finalize();
|
||||
|
||||
using result = new Input({ source: new BufferSource(output.target.buffer!), formats: ALL_FORMATS });
|
||||
const outTags = await result.getMetadataTags();
|
||||
// Only the user's tag is present; the input's tags were not copied
|
||||
expect(outTags.comment).toBe('User-owned');
|
||||
});
|
||||
|
||||
test('Canceling a composable conversion leaves the output usable', async () => {
|
||||
using input = new Input({
|
||||
source: new UrlSource('/video.mp4'),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const output = new Output({ format: new Mp4OutputFormat(), target: new BufferTarget() });
|
||||
|
||||
const conversion = await Conversion.init({
|
||||
input,
|
||||
output,
|
||||
composable: true,
|
||||
audio: { discard: true },
|
||||
showWarnings: false,
|
||||
});
|
||||
|
||||
const audioSource = new EncodedAudioPacketSource('aac');
|
||||
output.addAudioTrack(audioSource);
|
||||
|
||||
await output.start();
|
||||
|
||||
const executePromise = conversion.execute();
|
||||
void conversion.cancel();
|
||||
expect(conversion.state).toBe('canceled');
|
||||
|
||||
await expect(executePromise).rejects.toBeInstanceOf(ConversionCanceledError);
|
||||
|
||||
// The output must not have been canceled by the composable conversion
|
||||
expect(output.state).toBe('started');
|
||||
|
||||
// The user's own track can still finish, and the output can still be finalized
|
||||
await addAacPackets(audioSource, 2);
|
||||
audioSource.close();
|
||||
await output.finalize();
|
||||
expect(output.state).toBe('finalized');
|
||||
|
||||
using result = new Input({ source: new BufferSource(output.target.buffer!), formats: ALL_FORMATS });
|
||||
const audioTrack = await result.getPrimaryAudioTrack();
|
||||
expect(audioTrack).not.toBeNull();
|
||||
expect(await audioTrack!.getCodec()).toBe('aac');
|
||||
});
|
||||
|
||||
test('Track capacity works correctly with composable conversions', async () => {
|
||||
using input = new Input({
|
||||
source: new UrlSource('/video.mp4'),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const output = new Output({ format: new WavOutputFormat(), target: new BufferTarget() });
|
||||
// The user already occupies the single audio slot that WAVE allows
|
||||
output.addAudioTrack(new EncodedAudioPacketSource('pcm-s16'));
|
||||
|
||||
const conversion = await Conversion.init({
|
||||
input,
|
||||
output,
|
||||
composable: true,
|
||||
showWarnings: false,
|
||||
});
|
||||
|
||||
// The conversion's audio track has no room left, so it gets discarded
|
||||
expect(conversion.isValid).toBe(true);
|
||||
expect(conversion.utilizedTracks).toHaveLength(0);
|
||||
expect(conversion.discardedTracks).toHaveLength(2);
|
||||
// WAVE allows only one track in total, so the total-count check fires before the per-type one
|
||||
expect(conversion.discardedTracks[0]!.reason).toBe('max_track_count_reached');
|
||||
});
|
||||
|
||||
test('Blank execute', async () => {
|
||||
using input = new Input({
|
||||
source: new UrlSource('/video.mp4'),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const output = new Output({ format: new Mp4OutputFormat(), target: new BufferTarget() });
|
||||
const conversion = await Conversion.init({ input, output });
|
||||
expect(conversion.state).toBe('idle');
|
||||
|
||||
const promise = conversion.execute();
|
||||
expect(conversion.state).toBe('executing');
|
||||
await promise;
|
||||
expect(conversion.state).toBe('done');
|
||||
expect(output.state).toBe('finalized');
|
||||
|
||||
await conversion.execute();
|
||||
expect(conversion.state).toBe('done');
|
||||
});
|
||||
|
||||
test('Stepwise until', async () => {
|
||||
using input = new Input({
|
||||
source: new UrlSource('/video.mp4'),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const output = new Output({ format: new Mp4OutputFormat(), target: new BufferTarget() });
|
||||
const conversion = await Conversion.init({ input, output });
|
||||
|
||||
await conversion.execute({ until: 2 });
|
||||
expect(conversion.state).toBe('idle');
|
||||
expect(output.state).toBe('started');
|
||||
await conversion.execute({ until: 4 });
|
||||
expect(conversion.state).toBe('idle');
|
||||
await conversion.execute({ until: 6 });
|
||||
expect(conversion.state).toBe('done');
|
||||
expect(output.state).toBe('finalized');
|
||||
|
||||
await conversion.execute();
|
||||
expect(conversion.state).toBe('done');
|
||||
|
||||
using result = new Input({ source: new BufferSource(output.target.buffer!), formats: ALL_FORMATS });
|
||||
const videoTrack = await result.getPrimaryVideoTrack();
|
||||
expect(await videoTrack!.computeDuration()).toBeGreaterThan(4);
|
||||
});
|
||||
|
||||
test('Pause signal', async () => {
|
||||
using input = new Input({
|
||||
source: new UrlSource('/video.mp4'),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const output = new Output({ format: new Mp4OutputFormat(), target: new BufferTarget() });
|
||||
const conversion = await Conversion.init({ input, output });
|
||||
|
||||
const controller = new AbortController();
|
||||
conversion.onProgress = (progress) => {
|
||||
if (progress >= 0.5 && !controller.signal.aborted) {
|
||||
controller.abort();
|
||||
}
|
||||
};
|
||||
|
||||
await conversion.execute({ pauseSignal: controller.signal });
|
||||
expect(conversion.state).toBe('idle');
|
||||
expect(output.state).toBe('started');
|
||||
|
||||
await conversion.execute();
|
||||
expect(conversion.state).toBe('done');
|
||||
expect(output.state).toBe('finalized');
|
||||
|
||||
await conversion.execute();
|
||||
expect(conversion.state).toBe('done');
|
||||
});
|
||||
|
||||
test('Pre-signaled pause signal', async () => {
|
||||
using input = new Input({
|
||||
source: new UrlSource('/video.mp4'),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const output = new Output({ format: new Mp4OutputFormat(), target: new BufferTarget() });
|
||||
const conversion = await Conversion.init({ input, output });
|
||||
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
await conversion.execute({ pauseSignal: controller.signal });
|
||||
expect(conversion.state).toBe('idle');
|
||||
expect(output.state).toBe('started');
|
||||
|
||||
await conversion.execute();
|
||||
expect(conversion.state).toBe('done');
|
||||
expect(output.state).toBe('finalized');
|
||||
|
||||
await conversion.execute();
|
||||
expect(conversion.state).toBe('done');
|
||||
});
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { Output } from '../../src/output.js';
|
||||
import { OggOutputFormat } from '../../src/output-format.js';
|
||||
import { NullTarget } from '../../src/target.js';
|
||||
import { AudioBufferSource } from '../../src/media-source.js';
|
||||
import { BufferTarget, NullTarget } from '../../src/target.js';
|
||||
import { AudioBufferSource, EncodedAudioPacketSource } from '../../src/media-source.js';
|
||||
import { EncodedPacket } from '../../src/packet.js';
|
||||
import { assert } from '../../src/misc.js';
|
||||
import { Input } from '../../src/input.js';
|
||||
import { BufferSource } from '../../src/source.js';
|
||||
import { ALL_FORMATS, OggInputFormat } from '../../src/input-format.js';
|
||||
import { EncodedPacketSink } from '../../src/media-sink.js';
|
||||
|
||||
test('maximumPageDuration option', async () => {
|
||||
const sampleRate = 48000;
|
||||
@@ -55,3 +61,82 @@ test('maximumPageDuration option', async () => {
|
||||
expect(pageCountWithoutOption).toBe(3);
|
||||
expect(pageCountWithOption).toBe(23); // It created more pages
|
||||
});
|
||||
|
||||
test('Multi-frame Opus packets', async () => {
|
||||
const SAMPLE_RATE = 48000;
|
||||
const SAMPLES_PER_FRAME = 960; // 20 ms at 48 kHz
|
||||
|
||||
const createOpusHead = () => {
|
||||
const bytes = new Uint8Array(19);
|
||||
const view = new DataView(bytes.buffer);
|
||||
|
||||
bytes.set([0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64], 0); // 'OpusHead'
|
||||
bytes[8] = 1; // Version
|
||||
bytes[9] = 1; // Channel count
|
||||
view.setUint16(10, 312, true); // Pre-skip
|
||||
view.setUint32(12, SAMPLE_RATE, true); // Input sample rate
|
||||
view.setInt16(16, 0, true); // Output gain
|
||||
bytes[18] = 0; // Channel mapping family
|
||||
|
||||
return bytes;
|
||||
};
|
||||
|
||||
const createOpusPacket = (frameCount: number) => {
|
||||
const data = new Uint8Array(2 + 3 * frameCount);
|
||||
|
||||
data[0] = (31 << 3) | 0b11; // TOC byte: config 31 (CELT fullband, 20 ms), code 3
|
||||
data[1] = frameCount; // CBR, no padding, `frameCount` frames
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const framesPerPacket = 3;
|
||||
const packetCount = 10;
|
||||
const packetDuration = (framesPerPacket * SAMPLES_PER_FRAME) / SAMPLE_RATE;
|
||||
|
||||
const output = new Output({
|
||||
format: new OggOutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
const audioSource = new EncodedAudioPacketSource('opus');
|
||||
output.addAudioTrack(audioSource);
|
||||
|
||||
await output.start();
|
||||
|
||||
for (let i = 0; i < packetCount; i++) {
|
||||
await audioSource.add(
|
||||
new EncodedPacket(
|
||||
createOpusPacket(framesPerPacket),
|
||||
'key',
|
||||
i * packetDuration,
|
||||
packetDuration,
|
||||
),
|
||||
{
|
||||
decoderConfig: {
|
||||
codec: 'opus',
|
||||
numberOfChannels: 1,
|
||||
sampleRate: SAMPLE_RATE,
|
||||
description: createOpusHead(),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
audioSource.close();
|
||||
await output.finalize();
|
||||
|
||||
assert(output.target.buffer);
|
||||
|
||||
const input = new Input({
|
||||
source: new BufferSource(output.target.buffer),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
expect(await input.getFormat()).toBeInstanceOf(OggInputFormat);
|
||||
|
||||
const sink = new EncodedPacketSink((await input.getPrimaryAudioTrack())!);
|
||||
const packet = await sink.getFirstPacket();
|
||||
|
||||
expect(packet?.duration).toBe(packetDuration);
|
||||
});
|
||||
|
||||
@@ -354,7 +354,7 @@ test('MPEG-TS seeking race condition test', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('MPEG-TS video key packets', { timeout: 10_000 }, async () => {
|
||||
test('MPEG-TS video key packets', { timeout: 30_000 }, async () => {
|
||||
for (let i = 0; i < 2; i++) {
|
||||
using input = new Input({
|
||||
source: new FilePathSource(path.join(__dirname, '../public/trim-buck-bunny-ffmpeg.ts')),
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Output } from '../../src/output.js';
|
||||
import { MkvOutputFormat, MovOutputFormat } from '../../src/output-format.js';
|
||||
import { BufferTarget } from '../../src/target.js';
|
||||
import { Conversion } from '../../src/conversion.js';
|
||||
import { VideoSampleSink } from '../../src/media-sink.js';
|
||||
import { EncodedPacketSink, VideoSampleSink } from '../../src/media-sink.js';
|
||||
import { assert } from '../../src/misc.js';
|
||||
|
||||
const SAMPLE_URL = 'https://pub-1ee78aacb848486482b20a72b55b3121.r2.dev/turbores-sample.mov';
|
||||
@@ -88,6 +88,10 @@ test.concurrent('ProRes transmuxing into MKV', { timeout: 10_000 }, async () =>
|
||||
});
|
||||
await conversion.execute();
|
||||
|
||||
// No 'icpf' means the frame container atom headers were successfully stripped from the ProRes packets
|
||||
let str = new TextDecoder('ascii').decode(output.target.buffer!);
|
||||
expect(str.includes('icpf')).toBe(false);
|
||||
|
||||
using newInput = new Input({
|
||||
source: new BufferSource(output.target.buffer!),
|
||||
formats: ALL_FORMATS,
|
||||
@@ -100,6 +104,14 @@ test.concurrent('ProRes transmuxing into MKV', { timeout: 10_000 }, async () =>
|
||||
const decoderConfig = (await videoTrack.getDecoderConfig())!;
|
||||
expect(decoderConfig.codec).toBe('apch');
|
||||
expect(decoderConfig.description).toBeUndefined();
|
||||
|
||||
const sink = new EncodedPacketSink(videoTrack);
|
||||
const firstPacket = await sink.getFirstPacket();
|
||||
assert(firstPacket);
|
||||
|
||||
// The frame container atom headers are added back when reading out the packets
|
||||
str = new TextDecoder('ascii').decode(firstPacket.data);
|
||||
expect(str.includes('icpf')).toBe(true);
|
||||
});
|
||||
|
||||
test('Custom coder registration', { timeout: 10_000 }, async () => {
|
||||
|
||||
@@ -801,7 +801,7 @@ describe('Video', async () => {
|
||||
await conversionRoundtrip('av1');
|
||||
});
|
||||
|
||||
test('ProRes conversion roundtrip', { timeout: 20_000 }, async () => {
|
||||
test('ProRes conversion roundtrip', { timeout: 30_000 }, async () => {
|
||||
await conversionRoundtrip('prores');
|
||||
});
|
||||
|
||||
@@ -1061,7 +1061,7 @@ describe('Video', async () => {
|
||||
});
|
||||
|
||||
// https://github.com/Vanilagy/mediabunny/issues/392
|
||||
test('Memory doesn\'t leak', async () => {
|
||||
test('Memory doesn\'t leak', { timeout: 10_000 }, async () => {
|
||||
const encoder = new NodeAvVideoEncoder();
|
||||
// @ts-expect-error Readonly
|
||||
encoder.codec = 'avc';
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { test } from 'vitest';
|
||||
import { Output } from '../../src/output.js';
|
||||
import { MkvOutputFormat, Mp4OutputFormat } from '../../src/output-format.js';
|
||||
import { BufferTarget } from '../../src/target.js';
|
||||
import { TextSubtitleSource } from '../../src/media-source.js';
|
||||
|
||||
test('ISOBMFF muxing', async () => {
|
||||
const output = new Output({
|
||||
format: new Mp4OutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
const source = new TextSubtitleSource('webvtt');
|
||||
output.addSubtitleTrack(source);
|
||||
|
||||
await output.start();
|
||||
|
||||
await source.add(`WEBVTT
|
||||
|
||||
00:00.000 --> 00:00.900
|
||||
Hildy!
|
||||
|
||||
00:01.000 --> 00:01.400
|
||||
How are you?
|
||||
|
||||
00:01.500 --> 00:02.900
|
||||
Tell me, is the lord of the universe in?
|
||||
`);
|
||||
|
||||
await output.finalize();
|
||||
});
|
||||
|
||||
test('Matroska muxing', async () => {
|
||||
const output = new Output({
|
||||
format: new MkvOutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
const source = new TextSubtitleSource('webvtt');
|
||||
output.addSubtitleTrack(source);
|
||||
|
||||
await output.start();
|
||||
|
||||
await source.add(`WEBVTT
|
||||
|
||||
00:00.000 --> 00:00.900
|
||||
Hildy!
|
||||
|
||||
00:01.000 --> 00:01.400
|
||||
How are you?
|
||||
|
||||
00:01.500 --> 00:02.900
|
||||
Tell me, is the lord of the universe in?
|
||||
`);
|
||||
|
||||
await output.finalize();
|
||||
});
|
||||
+5
-3
@@ -19,6 +19,8 @@
|
||||
"./examples/**/*.ts"
|
||||
],
|
||||
"references": [
|
||||
{ "path": "./src" }
|
||||
]
|
||||
}
|
||||
{ "path": "./src" },
|
||||
{ "path": "./packages/ac3" },
|
||||
{ "path": "./packages/prores" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'mediabunny': path.resolve(__dirname, './dist/bundles/mediabunny.mjs'),
|
||||
'@mediabunny/ac3':
|
||||
path.resolve(__dirname, './packages/ac3/dist/bundles/mediabunny-ac3.mjs'),
|
||||
'@mediabunny/aac-encoder':
|
||||
path.resolve(__dirname, './packages/aac-encoder/dist/bundles/mediabunny-aac-encoder.mjs'),
|
||||
'@mediabunny/flac-encoder':
|
||||
|
||||
Reference in New Issue
Block a user