mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-09 08:43:49 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
045fe7652a | ||
|
|
b74854d0a0 | ||
|
|
7d747ae6d9 | ||
|
|
198928088b | ||
|
|
0185383794 | ||
|
|
93a750316a | ||
|
|
a1008b8088 | ||
|
|
be151d3494 | ||
|
|
1b8e4f9f4b | ||
|
|
d708df9a2c | ||
|
|
e7c7a82fe3 | ||
|
|
4819bf6755 | ||
|
|
f71ba8bc9e |
@@ -16,31 +16,39 @@ Mediabunny is a JavaScript library for reading, writing, and converting media fi
|
||||
### Gold sponsors
|
||||
|
||||
<div align="center">
|
||||
<a href="https://remotion.dev/" target="_blank">
|
||||
<a href="https://remotion.dev/" target="_blank" rel="sponsored">
|
||||
<img src="./docs/public/sponsors/remotion.png" width="60" height="60" alt="Remotion">
|
||||
</a>
|
||||
|
||||
<a href="https://www.gling.ai/" target="_blank">
|
||||
<a href="https://www.gling.ai/" target="_blank" rel="sponsored">
|
||||
<img src="./docs/public/sponsors/gling.svg" width="60" height="60" alt="Gling AI">
|
||||
</a>
|
||||
|
||||
<a href="https://diffusion.studio/" target="_blank">
|
||||
<a href="https://diffusion.studio/" target="_blank" rel="sponsored">
|
||||
<img src="./docs/public/sponsors/diffusionstudio.png" width="60" height="60" alt="Diffusion Studio">
|
||||
</a>
|
||||
|
||||
<a href="https://kino.ai/" target="_blank">
|
||||
<a href="https://kino.ai/" target="_blank" rel="sponsored">
|
||||
<img src="./docs/public/sponsors/kino.jpg" width="60" height="60" alt="Kino">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
### Silver sponsors
|
||||
|
||||
<div align="center">
|
||||
<a href="https://pqina.nl/" target="_blank" rel="sponsored">
|
||||
<img src="./docs/public/sponsors/pqina.jpg" width="50" height="50" alt="PQINA">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
### Bronze sponsors
|
||||
|
||||
<div align="center">
|
||||
<a href="https://www.reactvideoeditor.com/" target="_blank">
|
||||
<a href="https://www.reactvideoeditor.com/" target="_blank" rel="sponsored">
|
||||
<img src="./docs/public/sponsors/rve.svg" width="40" height="40" alt="React Video Editor">
|
||||
</a>
|
||||
|
||||
<a href="https://www.mux.com/" target="_blank">
|
||||
<a href="https://www.mux.com/" target="_blank" rel="sponsored">
|
||||
<img src="./docs/public/sponsors/mux.jpg" width="40" height="40" alt="Mux">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
+8
-5
@@ -4,7 +4,7 @@
|
||||
<script src="../packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.js"></script>
|
||||
|
||||
<script type="module">
|
||||
MediabunnyMp3Encoder.registerMp3Encoder();
|
||||
//MediabunnyMp3Encoder.registerMp3Encoder();
|
||||
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
@@ -24,7 +24,7 @@
|
||||
chunked: true,
|
||||
chunkSize: 2**20
|
||||
});
|
||||
const outputFormat = new Mediabunny.Mp3OutputFormat({});
|
||||
const outputFormat = new Mediabunny.Mp4OutputFormat({});
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.textContent = 'Cancel';
|
||||
@@ -92,6 +92,9 @@
|
||||
},
|
||||
*/
|
||||
video: () => ({
|
||||
width: 300,
|
||||
//alpha: 'keep',
|
||||
//width: 320,
|
||||
//discard: true,
|
||||
//discard: true,
|
||||
//crop: {
|
||||
@@ -126,7 +129,7 @@
|
||||
//width: 200,
|
||||
//height: 100,
|
||||
}),
|
||||
tags: {
|
||||
tags: {} ?? {
|
||||
title: 'Bigggy',
|
||||
artist: 'Buck Bunny',
|
||||
images: [{
|
||||
@@ -144,8 +147,8 @@
|
||||
}
|
||||
},
|
||||
trim: {
|
||||
//start: 10,
|
||||
//end: 20
|
||||
start: 0,
|
||||
end: 10
|
||||
},
|
||||
});
|
||||
console.log(conversion);
|
||||
|
||||
+35
-5
@@ -14,13 +14,43 @@
|
||||
source: new Mediabunny.BlobSource(file),
|
||||
});
|
||||
|
||||
const audioTrack = await input.getPrimaryAudioTrack();
|
||||
|
||||
const sink = new Mediabunny.EncodedPacketSink(audioTrack);
|
||||
for await (const packet of sink.packets()) {
|
||||
console.log(packet)
|
||||
let total = 0;
|
||||
input.source.onread = (start, end) => {
|
||||
total += end - start;
|
||||
//console.log(total / file.size, end - start);
|
||||
}
|
||||
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
const sink = new Mediabunny.EncodedPacketSink(videoTrack);
|
||||
|
||||
//console.log(await sink.getPacket(0));
|
||||
/*
|
||||
console.time()
|
||||
console.log(await sink.getPacket(500));
|
||||
console.timeEnd()
|
||||
|
||||
console.time()
|
||||
console.log(await sink.getPacket(400));
|
||||
console.timeEnd()
|
||||
*/
|
||||
//console.log(await sink.getPacket(50));
|
||||
//console.log(await sink.getPacket(8000));
|
||||
|
||||
|
||||
|
||||
const stats = await videoTrack.computePacketStats();
|
||||
|
||||
/*
|
||||
const sink = new Mediabunny.EncodedPacketSink(videoTrack);
|
||||
for await (const packet of sink.packets()) {
|
||||
//console.log(packet)
|
||||
}
|
||||
*/
|
||||
//console.log(await videoTrack.computeDuration());
|
||||
console.log("Done", stats, total, file.size)
|
||||
|
||||
console.log(input);
|
||||
|
||||
/*
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
const sink = new Mediabunny.VideoSampleSink(videoTrack);
|
||||
|
||||
@@ -13,6 +13,7 @@ It has the following features:
|
||||
- Video rotation
|
||||
- Video cropping
|
||||
- Video frame rate adjustment
|
||||
- Video transparency removal/preservation
|
||||
- Audio resampling
|
||||
- Audio up/downmixing
|
||||
|
||||
@@ -41,6 +42,14 @@ const output = new Output({
|
||||
});
|
||||
|
||||
const conversion = await Conversion.init({ input, output });
|
||||
if (!conversion.isValid) {
|
||||
// Conversion is invalid and cannot be executed without error.
|
||||
// This field gives reasons for why tracks were discarded:
|
||||
conversion.discardedTracks; // => DiscardedTrack[]
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await conversion.execute();
|
||||
|
||||
// output.target.buffer contains the final file
|
||||
@@ -57,7 +66,7 @@ Unconfigured, the conversion process handles all the details automatically, such
|
||||
- Copying media data whenever possible, otherwise transcoding it
|
||||
- Dropping tracks that aren't supported in the output format
|
||||
|
||||
You should consider inspecting the [discarded tracks](#discarded-tracks) before executing a `Conversion`.
|
||||
You should consider inspecting `isValid` and the [discarded tracks](#discarded-tracks) before executing a `Conversion`.
|
||||
|
||||
### Monitoring progress
|
||||
|
||||
@@ -114,6 +123,7 @@ type ConversionVideoOptions = {
|
||||
frameRate?: number;
|
||||
codec?: VideoCodec;
|
||||
bitrate?: number | Quality;
|
||||
alpha?: 'discard' | 'keep'; // Defaults to 'discard'
|
||||
forceTranscode?: boolean;
|
||||
};
|
||||
```
|
||||
@@ -352,6 +362,8 @@ type DiscardedTrack = {
|
||||
|
||||
Since you can inspect this list before executing a `Conversion`, this gives you the option to decide if you still want to move forward with the conversion process.
|
||||
|
||||
If `isValid` is `false`, then the discarded tracks caused the `Conversion` to become invalid. For example, this can happen when a format requires a specific codec but that codec cannot be encoded.
|
||||
|
||||
---
|
||||
|
||||
The following reasons exist:
|
||||
|
||||
@@ -419,7 +419,7 @@ const context = canvas.getContext('2d', { alpha: true })!;
|
||||
|
||||
const source = new CanvasSource(canvas, {
|
||||
codec: 'vp9',
|
||||
quality: QUALITY_MEDIUM,
|
||||
bitrate: QUALITY_MEDIUM,
|
||||
alpha: 'keep', // => Also encode alpha data
|
||||
});
|
||||
output.addVideoTrack(source);
|
||||
@@ -484,7 +484,14 @@ const output = new Output({
|
||||
});
|
||||
|
||||
const conversion = await Conversion.init({ input, output });
|
||||
conversion.discardedTracks; // List of tracks that won't make it into the output
|
||||
if (!conversion.isValid) {
|
||||
// The conversion isn't possible and would error upon execution.
|
||||
// Check `discardedTracks` for the reasons.
|
||||
return;
|
||||
}
|
||||
|
||||
// List of tracks that won't make it into the output:
|
||||
conversion.discardedTracks;
|
||||
|
||||
conversion.onProgress = (progress) => {
|
||||
progress; // Number between 0 and 1, inclusive
|
||||
|
||||
+16
-4
@@ -96,6 +96,9 @@ const sponsors = {
|
||||
{ image: '/sponsors/diffusionstudio.png', name: 'Diffusion Studio', url: 'https://diffusion.studio/' },
|
||||
{ image: '/sponsors/kino.jpg', name: 'Kino', url: 'https://kino.ai/' },
|
||||
],
|
||||
silver: [
|
||||
{ image: '/sponsors/pqina.jpg', name: 'PQINA', url: 'https://pqina.nl/' },
|
||||
],
|
||||
bronze: [
|
||||
{ image: '/sponsors/rve.svg', name: 'React Video Editor', url: 'https://www.reactvideoeditor.com/' },
|
||||
{ image: '/sponsors/mux.jpg', name: 'Mux', url: 'https://www.mux.com/' },
|
||||
@@ -343,10 +346,19 @@ await conversion.execute();
|
||||
</h1>
|
||||
<p class="max-w-2xl">Mediabunny is an open-source project released under the <a href="https://choosealicense.com/licenses/mpl-2.0/" target="_blank">MPL-2.0</a> and is therefore free to use for any purpose, including closed-source commercial use. A permissive license is essential for a foundational library like this to truly thrive. That said, this project requires an immense amount of work and care. This is made possible by the generous financial backing of these awesome sponsors:</p>
|
||||
<template v-if="sponsors.gold.length > 0">
|
||||
<h3 class="!text-2xl">Gold sponsors</h3>
|
||||
<h3 class="!text-3xl">Gold sponsors</h3>
|
||||
<div class="flex flex-wrap mt-1 justify-center gap-1">
|
||||
<a v-for="sponsor in sponsors.gold" :href="sponsor.url" target="_blank" class="flex items-center p-2 rounded-full hover:bg-(--vp-c-gray-3) !text-(--vp-c-text-1) !no-underline">
|
||||
<a v-for="sponsor in sponsors.gold" :href="sponsor.url" target="_blank" rel="sponsored" class="flex items-center p-2 rounded-full hover:bg-(--vp-c-gray-3) !text-(--vp-c-text-1) !no-underline">
|
||||
<img :src="sponsor.image" class="size-16 rounded-full">
|
||||
<p class="!my-0 !font-medium px-3 text-lg">{{ sponsor.name }}</p>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="sponsors.silver.length > 0">
|
||||
<h3 class="!text-2xl">Silver sponsors</h3>
|
||||
<div class="flex flex-wrap mt-1 justify-center gap-1">
|
||||
<a v-for="sponsor in sponsors.silver" :href="sponsor.url" target="_blank" rel="sponsored" class="flex items-center p-2 rounded-full hover:bg-(--vp-c-gray-3) !text-(--vp-c-text-1) !no-underline">
|
||||
<img :src="sponsor.image" class="size-13 rounded-full">
|
||||
<p class="!my-0 !font-medium px-3">{{ sponsor.name }}</p>
|
||||
</a>
|
||||
</div>
|
||||
@@ -354,7 +366,7 @@ await conversion.execute();
|
||||
<template v-if="sponsors.bronze.length > 0">
|
||||
<h3 class="!text-xl">Bronze sponsors</h3>
|
||||
<div class="flex flex-wrap mt-1 justify-center gap-1">
|
||||
<a v-for="sponsor in sponsors.bronze" :href="sponsor.url" target="_blank" class="flex items-center p-2 rounded-full hover:bg-(--vp-c-gray-3) !text-(--vp-c-text-1) !no-underline">
|
||||
<a v-for="sponsor in sponsors.bronze" :href="sponsor.url" target="_blank" rel="sponsored" class="flex items-center p-2 rounded-full hover:bg-(--vp-c-gray-3) !text-(--vp-c-text-1) !no-underline">
|
||||
<img :src="sponsor.image" class="size-10 rounded-full">
|
||||
<p class="!my-0 !font-medium px-3 text-sm">{{ sponsor.name }}</p>
|
||||
</a>
|
||||
@@ -363,7 +375,7 @@ await conversion.execute();
|
||||
<template v-if="sponsors.individual.length > 0">
|
||||
<h4 class="!text-base">Individual sponsors</h4>
|
||||
<div class="flex flex-wrap mt-1 justify-center">
|
||||
<a v-for="sponsor in sponsors.individual" :href="sponsor.url" target="_blank" class="flex gap-1 w-24 flex-col items-center p-2 rounded-xl hover:bg-(--vp-c-gray-3) !text-(--vp-c-text-1) !no-underline">
|
||||
<a v-for="sponsor in sponsors.individual" :href="sponsor.url" target="_blank" rel="sponsored" class="flex gap-1 w-24 flex-col items-center p-2 rounded-xl hover:bg-(--vp-c-gray-3) !text-(--vp-c-text-1) !no-underline">
|
||||
<img :src="sponsor.image" class="size-8 rounded-full">
|
||||
<p class="!my-0 !font-medium text-xs !leading-4 text-center">{{ sponsor.name }}</p>
|
||||
</a>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
Generated
+6
-6
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mediabunny",
|
||||
"version": "1.20.0",
|
||||
"version": "1.21.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mediabunny",
|
||||
"version": "1.20.0",
|
||||
"version": "1.21.1",
|
||||
"license": "MPL-2.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
@@ -7749,9 +7749,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mediabunny": {
|
||||
"version": "1.19.1",
|
||||
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.19.1.tgz",
|
||||
"integrity": "sha512-nyaxskRhj7w634RZ/D0+xbc//nclDVBM1eG9hrrPL/lEG4PW1pBSRHsgPRXQX/PvUXNfJTYQsTl0SYFCrch9PA==",
|
||||
"version": "1.21.0",
|
||||
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.21.0.tgz",
|
||||
"integrity": "sha512-/jZ4FoDJVk05wN73GT41rGgsZckeW8CXdSfnjetZN7C4YcWjgsNbN+x5Onw8ybMB5ivSKo+RIdSobRj9xP3hsA==",
|
||||
"license": "MPL-2.0",
|
||||
"peer": true,
|
||||
"workspaces": [
|
||||
@@ -12242,7 +12242,7 @@
|
||||
},
|
||||
"packages/mp3-encoder": {
|
||||
"name": "@mediabunny/mp3-encoder",
|
||||
"version": "1.20.0",
|
||||
"version": "1.21.1",
|
||||
"license": "MPL-2.0",
|
||||
"devDependencies": {
|
||||
"@types/emscripten": "^1.40.1"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mediabunny",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.20.0",
|
||||
"version": "1.21.1",
|
||||
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@mediabunny/mp3-encoder",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.20.0",
|
||||
"version": "1.21.1",
|
||||
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
|
||||
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
|
||||
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
|
||||
|
||||
@@ -13,7 +13,7 @@ const walkDir = (dir: string) => {
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
files.push(...walkDir(fullPath));
|
||||
} else if (item.endsWith('.js')) {
|
||||
} else if (item.endsWith('.js') || item.endsWith('.ts')) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,13 @@ const walkDir = (dir: string) => {
|
||||
|
||||
const fixFile = (filePath: string) => {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const fixed = content.replace(
|
||||
/(\s+from\s+['"])(\.[^'"]*)(['"])/g, // This only matches relative imports
|
||||
// We only match relative imports
|
||||
let fixed = content.replace(
|
||||
/(\s+from\s+['"])(\.[^'"]*)(['"])/g, // This matches static imports
|
||||
'$1$2.js$3',
|
||||
);
|
||||
fixed = fixed.replace(
|
||||
/(import\s*\(\s*['"])(\.[^'"]*)(['"]\s*\))/g, // This matches dynamic imports
|
||||
'$1$2.js$3',
|
||||
);
|
||||
|
||||
|
||||
+128
-9
@@ -96,6 +96,13 @@ export type ConversionOptions = {
|
||||
* If no function is set, the input's metadata tags will be copied to the output.
|
||||
*/
|
||||
tags?: MetadataTags | ((inputTags: MetadataTags) => MaybePromise<MetadataTags>);
|
||||
|
||||
/**
|
||||
* Whether to show potential console warnings about discarded tracks after calling `Conversion.init()`, defaults to
|
||||
* `true`. Set this to `false` if you're properly handling the `discardedTracks` and `isValid` fields already and
|
||||
* want to keep the console output clean.
|
||||
*/
|
||||
showWarnings?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -153,6 +160,12 @@ export type ConversionVideoOptions = {
|
||||
codec?: VideoCodec;
|
||||
/** The desired bitrate of the output video. */
|
||||
bitrate?: number | Quality;
|
||||
/**
|
||||
* Whether to discard or keep the transparency information of the input video. The default is `'discard'`. Note that
|
||||
* for `'keep'` to produce a transparent video, you must use an output config that supports it, such as WebM with
|
||||
* VP9.
|
||||
*/
|
||||
alpha?: 'discard' | 'keep';
|
||||
/** When `true`, video will always be re-encoded instead of directly copying over the encoded samples. */
|
||||
forceTranscode?: boolean;
|
||||
};
|
||||
@@ -212,7 +225,7 @@ const validateVideoOptions = (videoOptions: ConversionVideoOptions | undefined)
|
||||
throw new TypeError('options.video.height, when provided, must be a positive integer.');
|
||||
}
|
||||
if (videoOptions?.fit !== undefined && !['fill', 'contain', 'cover'].includes(videoOptions.fit)) {
|
||||
throw new TypeError('options.video.fit, when provided, must be one of "fill", "contain", or "cover".');
|
||||
throw new TypeError('options.video.fit, when provided, must be one of \'fill\', \'contain\', or \'cover\'.');
|
||||
}
|
||||
if (
|
||||
videoOptions?.width !== undefined
|
||||
@@ -236,6 +249,9 @@ const validateVideoOptions = (videoOptions: ConversionVideoOptions | undefined)
|
||||
) {
|
||||
throw new TypeError('options.video.frameRate, when provided, must be a finite positive number.');
|
||||
}
|
||||
if (videoOptions?.alpha !== undefined && !['discard', 'keep'].includes(videoOptions.alpha)) {
|
||||
throw new TypeError('options.video.alpha, when provided, must be either \'discard\' or \'keep\'.');
|
||||
}
|
||||
};
|
||||
|
||||
const validateAudioOptions = (audioOptions: ConversionAudioOptions | undefined) => {
|
||||
@@ -371,6 +387,11 @@ export class Conversion {
|
||||
/** @internal */
|
||||
_lastProgress = 0;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
isValid = false;
|
||||
/** The list of tracks that are included in the output file. */
|
||||
readonly utilizedTracks: InputTrack[] = [];
|
||||
/** The list of tracks from the input file that have been discarded, alongside the discard reason. */
|
||||
@@ -440,6 +461,9 @@ export class Conversion {
|
||||
if (typeof options.tags === 'object') {
|
||||
validateMetadataTags(options.tags);
|
||||
}
|
||||
if (options.showWarnings !== undefined && typeof options.showWarnings !== 'boolean') {
|
||||
throw new TypeError('options.showWarnings, when provided, must be a boolean.');
|
||||
}
|
||||
|
||||
this._options = options;
|
||||
this.input = options.input;
|
||||
@@ -518,12 +542,6 @@ export class Conversion {
|
||||
}
|
||||
}
|
||||
|
||||
const unintentionallyDiscardedTracks = this.discardedTracks.filter(x => x.reason !== 'discarded_by_user');
|
||||
if (unintentionallyDiscardedTracks.length > 0) {
|
||||
// Let's give the user a notice/warning about discarded tracks so they aren't confused
|
||||
console.warn('Some tracks had to be discarded from the conversion:', unintentionallyDiscardedTracks);
|
||||
}
|
||||
|
||||
// Now, let's deal with metadata tags
|
||||
|
||||
const inputTags = await this.input.getMetadataTags();
|
||||
@@ -551,14 +569,105 @@ export class Conversion {
|
||||
}
|
||||
|
||||
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._options.showWarnings ?? true) {
|
||||
const warnElements: unknown[] = [];
|
||||
|
||||
const unintentionallyDiscardedTracks = this.discardedTracks.filter(x => x.reason !== 'discarded_by_user');
|
||||
if (unintentionallyDiscardedTracks.length > 0) {
|
||||
// Let's give the user a notice/warning about discarded tracks so they aren't confused
|
||||
warnElements.push(
|
||||
'Some tracks had to be discarded from the conversion:', unintentionallyDiscardedTracks,
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.isValid) {
|
||||
warnElements.push('\n\n' + this._getInvalidityExplanation().join(''));
|
||||
}
|
||||
|
||||
if (warnElements.length > 0) {
|
||||
console.warn(...warnElements);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Executes the conversion process. Resolves once conversion is complete. */
|
||||
/** @internal */
|
||||
_getInvalidityExplanation() {
|
||||
const elements: string[] = [];
|
||||
|
||||
if (this.discardedTracks.length === 0) {
|
||||
elements.push(
|
||||
'Due to missing tracks, this conversion cannot be executed.',
|
||||
);
|
||||
} else {
|
||||
const encodabilityIsTheProblem = this.discardedTracks.every(x =>
|
||||
x.reason === 'discarded_by_user' || x.reason === 'no_encodable_target_codec',
|
||||
);
|
||||
|
||||
elements.push(
|
||||
'Due to discarded tracks, this conversion cannot be executed.',
|
||||
);
|
||||
|
||||
if (encodabilityIsTheProblem) {
|
||||
const codecs = this.discardedTracks.flatMap((x) => {
|
||||
if (x.reason === 'discarded_by_user') return [];
|
||||
|
||||
if (x.track.type === 'video') {
|
||||
return this.output.format.getSupportedVideoCodecs();
|
||||
} else if (x.track.type === 'audio') {
|
||||
return this.output.format.getSupportedAudioCodecs();
|
||||
} else {
|
||||
return this.output.format.getSupportedSubtitleCodecs();
|
||||
}
|
||||
});
|
||||
|
||||
if (codecs.length === 1) {
|
||||
elements.push(
|
||||
`\nTracks were discarded because your environment is not able to encode '${codecs[0]}'.`,
|
||||
);
|
||||
} else {
|
||||
elements.push(
|
||||
'\nTracks were discarded because your environment is not able to encode any of the following'
|
||||
+ ` codecs: ${codecs.map(x => `'${x}'`).join(', ')}.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (codecs.includes('mp3')) {
|
||||
elements.push(
|
||||
`\nThe @mediabunny/mp3-encoder extension package provides support for encoding MP3.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
elements.push('\nCheck the discardedTracks field for more info.');
|
||||
}
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the conversion process. Resolves once conversion is complete.
|
||||
*
|
||||
* Will throw if `isValid` is `false`.
|
||||
*/
|
||||
async execute() {
|
||||
if (!this.isValid) {
|
||||
throw new Error(
|
||||
'Cannot execute this conversion because its output configuration is invalid. Make sure to always check'
|
||||
+ ' the isValid field before executing a conversion.\n'
|
||||
+ this._getInvalidityExplanation().join(''),
|
||||
);
|
||||
}
|
||||
|
||||
if (this._executed) {
|
||||
throw new Error('Conversion cannot be executed twice.');
|
||||
}
|
||||
|
||||
this._executed = true;
|
||||
|
||||
if (this.onProgress) {
|
||||
@@ -672,6 +781,8 @@ export class Conversion {
|
||||
|| (totalRotation !== 0 && !outputSupportsRotation)
|
||||
|| !!crop;
|
||||
|
||||
const alpha = trackOptions.alpha ?? 'discard';
|
||||
|
||||
let videoCodecs = this.output.format.getSupportedVideoCodecs();
|
||||
if (
|
||||
!needsTranscode
|
||||
@@ -704,6 +815,12 @@ export class Conversion {
|
||||
return;
|
||||
}
|
||||
|
||||
if (alpha === 'discard') {
|
||||
// Feels hacky given that the rest of the packet is readonly. But, works for now.
|
||||
delete packet.sideData.alpha;
|
||||
delete packet.sideData.alphaByteLength;
|
||||
}
|
||||
|
||||
await source.add(packet, meta);
|
||||
this._reportProgress(track.id, packet.timestamp + packet.duration);
|
||||
}
|
||||
@@ -742,6 +859,7 @@ export class Conversion {
|
||||
codec: encodableCodec,
|
||||
bitrate,
|
||||
sizeChangeBehavior: trackOptions.fit ?? 'passThrough',
|
||||
alpha,
|
||||
onEncodedPacket: sample => this._reportProgress(track.id, sample.timestamp + sample.duration),
|
||||
};
|
||||
|
||||
@@ -796,6 +914,7 @@ export class Conversion {
|
||||
rotation: totalRotation, // Bake the rotation into the output
|
||||
crop: trackOptions.crop,
|
||||
poolSize: 1,
|
||||
alpha: alpha === 'keep',
|
||||
});
|
||||
const iterator = sink.canvases(this._startTimestamp, this._endTimestamp);
|
||||
const frameRate = trackOptions.frameRate;
|
||||
|
||||
+267
-433
@@ -41,13 +41,11 @@ import {
|
||||
import { PacketRetrievalOptions } from '../media-sink';
|
||||
import {
|
||||
assert,
|
||||
AsyncMutex,
|
||||
binarySearchExact,
|
||||
binarySearchLessOrEqual,
|
||||
Bitstream,
|
||||
COLOR_PRIMARIES_MAP_INVERSE,
|
||||
findLastIndex,
|
||||
insertSorted,
|
||||
isIso639Dash2LanguageCode,
|
||||
last,
|
||||
MATRIX_COEFFICIENTS_MAP_INVERSE,
|
||||
@@ -90,19 +88,6 @@ import {
|
||||
} from '../reader';
|
||||
import { MetadataTags, RichImageData } from '../tags';
|
||||
|
||||
// https://exiftool.org/TagNames/QuickTime.html
|
||||
const UDTA_STRING_KEYS = new Set([
|
||||
'@day', '@mak', '@mod', '@swr', '@xyz', 'CAME', 'CNCV', 'CNFV', 'CNMN', 'FIRM', 'FOV\0', 'GoPr', 'LENS', 'PXMN',
|
||||
'SIGM', 'SNum', 'TAGS', 'albm', 'albr', 'angl', 'auth', 'ccid', 'cdis', 'clfn', 'clid', 'clsf', 'cmid', 'cmnm',
|
||||
'coll', 'cprt', 'cver', 'cvru', 'date', 'dscp', 'fsid', 'gnre', 'hinv', 'icnu', 'info', 'infu', 'kgtt', 'loci',
|
||||
'lrcu', 'mcvr', 'name', 'perf', 'pmcc', 'reel', 'rtng', 'scen', 'shot', 'slno', 'thmb', 'titl', 'tnam', 'urat',
|
||||
'uuid', 'vndr', 'yrrc', '©ART', '©TIM', '©TSC', '©TSZ', '©alb', '©arg', '©ark', '©cmt', '©cok', '©com', '©cpy',
|
||||
'©day', '©dir', '©ed1', '©ed2', '©ed3', '©ed4', '©ed5', '©ed6', '©ed7', '©ed8', '©ed9', '©enc', '©fmt', '©fpt',
|
||||
'©frl', '©fyw', '©gen', '©gpt', '©grl', '©grp', '©gyw', '©inf', '©isr', '©lab', '©lal', '©lyr', '©mak', '©mal',
|
||||
'©mdl', '©mod', '©nam', '©pdk', '©phg', '©prd', '©prf', '©prk', '©prl', '©req', '©snk', '©snm', '©src', '©swf',
|
||||
'©swk', '©swr', '©too', '©trk', '©wrt', '©xsp', '©xyz', '©ysp', '©zsp',
|
||||
]);
|
||||
|
||||
type InternalTrack = {
|
||||
id: number;
|
||||
demuxer: IsobmffDemuxer;
|
||||
@@ -116,10 +101,17 @@ type InternalTrack = {
|
||||
languageCode: string;
|
||||
sampleTableByteOffset: number;
|
||||
sampleTable: SampleTable | null;
|
||||
fragmentLookupTable: FragmentLookupTableEntry[] | null;
|
||||
fragmentLookupTable: FragmentLookupTableEntry[];
|
||||
currentFragmentState: FragmentTrackState | null;
|
||||
fragments: Fragment[];
|
||||
fragmentsWithKeyFrame: Fragment[];
|
||||
/**
|
||||
* List of all encountered fragment offsets alongside their timestamps. This list never gets truncated, but memory
|
||||
* consumption should be negligible.
|
||||
*/
|
||||
fragmentPositionCache: {
|
||||
moofOffset: number;
|
||||
startTimestamp: number;
|
||||
endTimestamp: number;
|
||||
}[];
|
||||
/** The segment durations of all edit list entries leading up to the main one (from which the offset is taken.) */
|
||||
editListPreviousSegmentDurations: number;
|
||||
/** The media time offset of the main edit list entry (with media time !== -1) */
|
||||
@@ -211,6 +203,7 @@ type FragmentTrackState = {
|
||||
};
|
||||
|
||||
type FragmentTrackData = {
|
||||
track: InternalTrack;
|
||||
startTimestamp: number;
|
||||
endTimestamp: number;
|
||||
firstKeyFrameTimestamp: number | null;
|
||||
@@ -235,10 +228,6 @@ type Fragment = {
|
||||
moofSize: number;
|
||||
implicitBaseDataOffset: number;
|
||||
trackData: Map<InternalTrack['id'], FragmentTrackData>;
|
||||
dataStart: number;
|
||||
dataEnd: number;
|
||||
nextFragment: Fragment | null;
|
||||
isKnownToBeFirstFragment: boolean;
|
||||
};
|
||||
|
||||
export class IsobmffDemuxer extends Demuxer {
|
||||
@@ -256,9 +245,12 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
|
||||
isFragmented = false;
|
||||
fragmentTrackDefaults: FragmentTrackDefaults[] = [];
|
||||
fragments: Fragment[] = [];
|
||||
currentFragment: Fragment | null = null;
|
||||
fragmentLookupMutex = new AsyncMutex();
|
||||
/**
|
||||
* Caches the last fragment that was read. Based on the assumption that there will be multiple reads to the
|
||||
* same fragment in quick succession.
|
||||
*/
|
||||
lastReadFragment: Fragment | null = null;
|
||||
|
||||
constructor(input: Input) {
|
||||
super(input);
|
||||
@@ -515,6 +507,10 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
}
|
||||
|
||||
async readFragment(startPos: number): Promise<Fragment> {
|
||||
if (this.lastReadFragment?.moofOffset === startPos) {
|
||||
return this.lastReadFragment;
|
||||
}
|
||||
|
||||
let headerSlice = this.reader.requestSliceRange(startPos, MIN_BOX_HEADER_SIZE, MAX_BOX_HEADER_SIZE);
|
||||
if (headerSlice instanceof Promise) headerSlice = await headerSlice;
|
||||
assert(headerSlice);
|
||||
@@ -528,92 +524,65 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
|
||||
this.traverseBox(entireSlice);
|
||||
|
||||
const index = binarySearchExact(this.fragments, startPos, x => x.moofOffset);
|
||||
assert(index !== -1);
|
||||
const fragment = this.lastReadFragment;
|
||||
assert(fragment && fragment.moofOffset === startPos);
|
||||
|
||||
const fragment = this.fragments[index]!;
|
||||
assert(fragment.moofOffset === startPos);
|
||||
for (const [, trackData] of fragment.trackData) {
|
||||
const track = trackData.track;
|
||||
const { fragmentPositionCache } = track;
|
||||
|
||||
// It may be that some tracks don't define the base decode time, i.e. when the fragment begins. This means the
|
||||
// only other option is to sum up the duration of all previous fragments.
|
||||
for (const [trackId, trackData] of fragment.trackData) {
|
||||
if (trackData.startTimestampIsFinal) {
|
||||
continue;
|
||||
}
|
||||
if (!trackData.startTimestampIsFinal) {
|
||||
// It may be that some tracks don't define the base decode time, i.e. when the fragment begins. This
|
||||
// we'll need to figure out the start timestamp another way. We'll compute the timestamp by accessing
|
||||
// the lookup entries and fragment cache, which works out nicely with the lookup algorithm: If these
|
||||
// exist, then the lookup will automatically start at the furthest possible point. If they don't, the
|
||||
// lookup starts sequentially from the start, incrementally summing up all fragment durations. It's sort
|
||||
// of implicit, but it ends up working nicely.
|
||||
|
||||
const internalTrack = this.tracks.find(x => x.id === trackId)!;
|
||||
|
||||
let currentPos = 0;
|
||||
let currentFragment: Fragment | null = null;
|
||||
let lastFragment: Fragment | null = null;
|
||||
|
||||
const index = binarySearchLessOrEqual(
|
||||
internalTrack.fragments,
|
||||
startPos - 1,
|
||||
x => x.moofOffset,
|
||||
);
|
||||
if (index !== -1) {
|
||||
// Instead of starting at the start of the file, let's start at the previous fragment instead (which
|
||||
// already has final timestamps).
|
||||
currentFragment = internalTrack.fragments[index]!;
|
||||
lastFragment = currentFragment;
|
||||
currentPos = currentFragment.moofOffset + currentFragment.moofSize;
|
||||
}
|
||||
|
||||
let nextFragmentIsFirstFragment = currentPos === 0;
|
||||
|
||||
while (currentPos <= startPos - MIN_BOX_HEADER_SIZE) {
|
||||
if (currentFragment?.nextFragment) {
|
||||
currentFragment = currentFragment.nextFragment;
|
||||
currentPos = currentFragment.moofOffset + currentFragment.moofSize;
|
||||
const lookupEntryIndex = binarySearchExact(
|
||||
track.fragmentLookupTable,
|
||||
fragment.moofOffset,
|
||||
x => x.moofOffset,
|
||||
);
|
||||
if (lookupEntryIndex !== -1) {
|
||||
// There's a lookup entry, let's use its timestamp
|
||||
const lookupEntry = track.fragmentLookupTable[lookupEntryIndex]!;
|
||||
offsetFragmentTrackDataByTimestamp(trackData, lookupEntry.timestamp);
|
||||
} else {
|
||||
let slice = this.reader.requestSliceRange(currentPos, MIN_BOX_HEADER_SIZE, MAX_BOX_HEADER_SIZE);
|
||||
if (slice instanceof Promise) slice = await slice;
|
||||
if (!slice) break;
|
||||
|
||||
const boxStartPos = currentPos;
|
||||
const boxInfo = readBoxHeader(slice);
|
||||
if (!boxInfo) {
|
||||
break;
|
||||
const lastCacheIndex = binarySearchLessOrEqual(
|
||||
fragmentPositionCache,
|
||||
fragment.moofOffset - 1,
|
||||
x => x.moofOffset,
|
||||
);
|
||||
if (lastCacheIndex !== -1) {
|
||||
// Let's use the timestamp of the previous fragment in the cache
|
||||
const lastCache = fragmentPositionCache[lastCacheIndex]!;
|
||||
offsetFragmentTrackDataByTimestamp(trackData, lastCache.endTimestamp);
|
||||
} else {
|
||||
// We're the first fragment I guess, "offset by 0"
|
||||
}
|
||||
|
||||
if (boxInfo.name === 'moof') {
|
||||
const index = binarySearchExact(this.fragments, boxStartPos, x => x.moofOffset);
|
||||
|
||||
let fragment: Fragment;
|
||||
if (index === -1) {
|
||||
fragment = await this.readFragment(boxStartPos); // Recursive call
|
||||
} else {
|
||||
// We already know this fragment
|
||||
fragment = this.fragments[index]!;
|
||||
}
|
||||
|
||||
// Even if we already know the fragment, we might not yet know its predecessor; always do this
|
||||
if (currentFragment) currentFragment.nextFragment = fragment;
|
||||
currentFragment = fragment;
|
||||
|
||||
if (nextFragmentIsFirstFragment) {
|
||||
fragment.isKnownToBeFirstFragment = true;
|
||||
nextFragmentIsFirstFragment = false;
|
||||
}
|
||||
}
|
||||
|
||||
currentPos = boxStartPos + boxInfo.totalSize;
|
||||
}
|
||||
|
||||
if (currentFragment && currentFragment.trackData.has(trackId)) {
|
||||
lastFragment = currentFragment;
|
||||
}
|
||||
trackData.startTimestampIsFinal = true;
|
||||
}
|
||||
|
||||
if (lastFragment) {
|
||||
const otherTrackData = lastFragment.trackData.get(trackId)!;
|
||||
assert(otherTrackData.startTimestampIsFinal);
|
||||
|
||||
offsetFragmentTrackDataByTimestamp(trackData, otherTrackData.endTimestamp);
|
||||
// Let's remember that a fragment with a given timestamp is here, speeding up future lookups if no
|
||||
// lookup table exists
|
||||
const insertionIndex = binarySearchLessOrEqual(
|
||||
fragmentPositionCache,
|
||||
trackData.startTimestamp,
|
||||
x => x.startTimestamp,
|
||||
);
|
||||
if (
|
||||
insertionIndex === -1
|
||||
|| fragmentPositionCache[insertionIndex]!.moofOffset !== fragment.moofOffset
|
||||
) {
|
||||
fragmentPositionCache.splice(insertionIndex + 1, 0, {
|
||||
moofOffset: fragment.moofOffset,
|
||||
startTimestamp: trackData.startTimestamp,
|
||||
endTimestamp: trackData.endTimestamp,
|
||||
});
|
||||
}
|
||||
|
||||
trackData.startTimestampIsFinal = true;
|
||||
}
|
||||
|
||||
return fragment;
|
||||
@@ -696,10 +665,9 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
languageCode: UNDETERMINED_LANGUAGE,
|
||||
sampleTableByteOffset: -1,
|
||||
sampleTable: null,
|
||||
fragmentLookupTable: null,
|
||||
fragmentLookupTable: [],
|
||||
currentFragmentState: null,
|
||||
fragments: [],
|
||||
fragmentsWithKeyFrame: [],
|
||||
fragmentPositionCache: [],
|
||||
editListPreviousSegmentDurations: 0,
|
||||
editListOffset: 0,
|
||||
} satisfies InternalTrack as InternalTrack;
|
||||
@@ -1748,8 +1716,6 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
break;
|
||||
}
|
||||
|
||||
track.fragmentLookupTable = [];
|
||||
|
||||
const word = readU32Be(slice);
|
||||
|
||||
const lengthSizeOfTrafNum = (word & 0b110000) >> 4;
|
||||
@@ -1784,31 +1750,11 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
moofSize: boxInfo.totalSize,
|
||||
implicitBaseDataOffset: startPos,
|
||||
trackData: new Map(),
|
||||
dataStart: Infinity,
|
||||
dataEnd: 0,
|
||||
nextFragment: null,
|
||||
isKnownToBeFirstFragment: false,
|
||||
};
|
||||
|
||||
this.readContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));
|
||||
|
||||
insertSorted(this.fragments, this.currentFragment, x => x.moofOffset);
|
||||
|
||||
// Compute the byte range of the sample data in this fragment, so we can load the whole fragment at once
|
||||
for (const [, trackData] of this.currentFragment.trackData) {
|
||||
const firstSample = trackData.samples[0]!;
|
||||
const lastSample = last(trackData.samples)!;
|
||||
|
||||
this.currentFragment.dataStart = Math.min(
|
||||
this.currentFragment.dataStart,
|
||||
firstSample.byteOffset,
|
||||
);
|
||||
this.currentFragment.dataEnd = Math.max(
|
||||
this.currentFragment.dataEnd,
|
||||
lastSample.byteOffset + lastSample.byteSize,
|
||||
);
|
||||
}
|
||||
|
||||
this.lastReadFragment = this.currentFragment;
|
||||
this.currentFragment = null;
|
||||
}; break;
|
||||
|
||||
@@ -1822,19 +1768,6 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
if (this.currentTrack) {
|
||||
const trackData = this.currentFragment.trackData.get(this.currentTrack.id);
|
||||
if (trackData) {
|
||||
// We know there is sample data for this track in this fragment, so let's add it to the
|
||||
// track's fragments:
|
||||
insertSorted(this.currentTrack.fragments, this.currentFragment, x => x.moofOffset);
|
||||
|
||||
const hasKeyFrame = trackData.firstKeyFrameTimestamp !== null;
|
||||
if (hasKeyFrame) {
|
||||
insertSorted(
|
||||
this.currentTrack.fragmentsWithKeyFrame,
|
||||
this.currentFragment,
|
||||
x => x.moofOffset,
|
||||
);
|
||||
}
|
||||
|
||||
const { currentFragmentState } = this.currentTrack;
|
||||
assert(currentFragmentState);
|
||||
|
||||
@@ -1965,6 +1898,7 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
let currentTimestamp = 0;
|
||||
|
||||
const trackData: FragmentTrackData = {
|
||||
track,
|
||||
startTimestamp: 0,
|
||||
endTimestamp: 0,
|
||||
firstKeyFrameTimestamp: null,
|
||||
@@ -2065,7 +1999,9 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
const startPos = slice.filePos;
|
||||
this.metadataTags.raw ??= {};
|
||||
|
||||
if (UDTA_STRING_KEYS.has(boxInfo.name)) {
|
||||
if (boxInfo.name[0] === '©') {
|
||||
// https://mp4workshop.com/about
|
||||
// Box name starting with © indicates "international text"
|
||||
this.metadataTags.raw[boxInfo.name] ??= readMetadataStringShort(slice);
|
||||
} else {
|
||||
this.metadataTags.raw[boxInfo.name] ??= readBytes(slice, boxInfo.contentSize);
|
||||
@@ -2415,31 +2351,17 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
|
||||
}
|
||||
|
||||
return this.performFragmentedLookup(
|
||||
() => {
|
||||
const startFragment = this.internalTrack.demuxer.fragments[0] ?? null;
|
||||
if (startFragment?.isKnownToBeFirstFragment) {
|
||||
// Walk from the very first fragment in the file until we find one with our track in it
|
||||
let currentFragment: Fragment | null = startFragment;
|
||||
while (currentFragment) {
|
||||
const trackData = currentFragment.trackData.get(this.internalTrack.id);
|
||||
if (trackData) {
|
||||
return {
|
||||
fragmentIndex: binarySearchExact(
|
||||
this.internalTrack.fragments,
|
||||
currentFragment.moofOffset,
|
||||
x => x.moofOffset,
|
||||
),
|
||||
sampleIndex: 0,
|
||||
correctSampleFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
currentFragment = currentFragment.nextFragment;
|
||||
}
|
||||
null,
|
||||
(fragment) => {
|
||||
const trackData = fragment.trackData.get(this.internalTrack.id);
|
||||
if (trackData) {
|
||||
return {
|
||||
sampleIndex: 0,
|
||||
correctSampleFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
fragmentIndex: -1,
|
||||
sampleIndex: -1,
|
||||
correctSampleFound: false,
|
||||
};
|
||||
@@ -2470,7 +2392,24 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
|
||||
}
|
||||
|
||||
return this.performFragmentedLookup(
|
||||
() => this.findSampleInFragmentsForTimestamp(timestampInTimescale),
|
||||
null,
|
||||
(fragment) => {
|
||||
const trackData = fragment.trackData.get(this.internalTrack.id);
|
||||
if (!trackData) {
|
||||
return { sampleIndex: -1, correctSampleFound: false };
|
||||
}
|
||||
|
||||
const index = binarySearchLessOrEqual(
|
||||
trackData.presentationTimestamps,
|
||||
timestampInTimescale,
|
||||
x => x.presentationTimestamp,
|
||||
);
|
||||
|
||||
const sampleIndex = index !== -1 ? trackData.presentationTimestamps[index]!.sampleIndex : -1;
|
||||
const correctSampleFound = index !== -1 && timestampInTimescale < trackData.endTimestamp;
|
||||
|
||||
return { sampleIndex, correctSampleFound };
|
||||
},
|
||||
timestampInTimescale,
|
||||
timestampInTimescale,
|
||||
options,
|
||||
@@ -2490,53 +2429,32 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
|
||||
throw new Error('Packet was not created from this track.');
|
||||
}
|
||||
|
||||
const trackData = locationInFragment.fragment.trackData.get(this.internalTrack.id)!;
|
||||
|
||||
const fragmentIndex = binarySearchExact(
|
||||
this.internalTrack.fragments,
|
||||
locationInFragment.fragment.moofOffset,
|
||||
x => x.moofOffset,
|
||||
);
|
||||
assert(fragmentIndex !== -1);
|
||||
|
||||
return this.performFragmentedLookup(
|
||||
() => {
|
||||
if (locationInFragment.sampleIndex + 1 < trackData.samples.length) {
|
||||
// We can simply take the next sample in the fragment
|
||||
return {
|
||||
fragmentIndex,
|
||||
sampleIndex: locationInFragment.sampleIndex + 1,
|
||||
correctSampleFound: true,
|
||||
};
|
||||
locationInFragment.fragment,
|
||||
(fragment) => {
|
||||
if (fragment === locationInFragment.fragment) {
|
||||
const trackData = fragment.trackData.get(this.internalTrack.id)!;
|
||||
if (locationInFragment.sampleIndex + 1 < trackData.samples.length) {
|
||||
// We can simply take the next sample in the fragment
|
||||
return {
|
||||
sampleIndex: locationInFragment.sampleIndex + 1,
|
||||
correctSampleFound: true,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Walk the list of fragments until we find the next fragment for this track
|
||||
let currentFragment = locationInFragment.fragment;
|
||||
while (currentFragment.nextFragment) {
|
||||
currentFragment = currentFragment.nextFragment;
|
||||
|
||||
const trackData = currentFragment.trackData.get(this.internalTrack.id);
|
||||
if (trackData) {
|
||||
const fragmentIndex = binarySearchExact(
|
||||
this.internalTrack.fragments,
|
||||
currentFragment.moofOffset,
|
||||
x => x.moofOffset,
|
||||
);
|
||||
assert(fragmentIndex !== -1);
|
||||
|
||||
return {
|
||||
fragmentIndex,
|
||||
sampleIndex: 0,
|
||||
correctSampleFound: true,
|
||||
};
|
||||
}
|
||||
const trackData = fragment.trackData.get(this.internalTrack.id);
|
||||
if (trackData) {
|
||||
return {
|
||||
sampleIndex: 0,
|
||||
correctSampleFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
fragmentIndex,
|
||||
sampleIndex: -1,
|
||||
correctSampleFound: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
sampleIndex: -1,
|
||||
correctSampleFound: false,
|
||||
};
|
||||
},
|
||||
-Infinity, // Use -Infinity as a search timestamp to avoid using the lookup entries
|
||||
Infinity,
|
||||
@@ -2560,7 +2478,23 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
|
||||
}
|
||||
|
||||
return this.performFragmentedLookup(
|
||||
() => this.findKeySampleInFragmentsForTimestamp(timestampInTimescale),
|
||||
null,
|
||||
(fragment) => {
|
||||
const trackData = fragment.trackData.get(this.internalTrack.id);
|
||||
if (!trackData) {
|
||||
return { sampleIndex: -1, correctSampleFound: false };
|
||||
}
|
||||
|
||||
const index = findLastIndex(trackData.presentationTimestamps, (x) => {
|
||||
const sample = trackData.samples[x.sampleIndex]!;
|
||||
return sample.isKeyFrame && x.presentationTimestamp <= timestampInTimescale;
|
||||
});
|
||||
|
||||
const sampleIndex = index !== -1 ? trackData.presentationTimestamps[index]!.sampleIndex : -1;
|
||||
const correctSampleFound = index !== -1 && timestampInTimescale < trackData.endTimestamp;
|
||||
|
||||
return { sampleIndex, correctSampleFound };
|
||||
},
|
||||
timestampInTimescale,
|
||||
timestampInTimescale,
|
||||
options,
|
||||
@@ -2581,60 +2515,39 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
|
||||
throw new Error('Packet was not created from this track.');
|
||||
}
|
||||
|
||||
const trackData = locationInFragment.fragment.trackData.get(this.internalTrack.id)!;
|
||||
|
||||
const fragmentIndex = binarySearchExact(
|
||||
this.internalTrack.fragments,
|
||||
locationInFragment.fragment.moofOffset,
|
||||
x => x.moofOffset,
|
||||
);
|
||||
assert(fragmentIndex !== -1);
|
||||
|
||||
return this.performFragmentedLookup(
|
||||
() => {
|
||||
const nextKeyFrameIndex = trackData.samples.findIndex(
|
||||
(x, i) => x.isKeyFrame && i > locationInFragment.sampleIndex,
|
||||
);
|
||||
locationInFragment.fragment,
|
||||
(fragment) => {
|
||||
if (fragment === locationInFragment.fragment) {
|
||||
const trackData = fragment.trackData.get(this.internalTrack.id)!;
|
||||
const nextKeyFrameIndex = trackData.samples.findIndex(
|
||||
(x, i) => x.isKeyFrame && i > locationInFragment.sampleIndex,
|
||||
);
|
||||
|
||||
if (nextKeyFrameIndex !== -1) {
|
||||
// We can simply take the next key frame in the fragment
|
||||
return {
|
||||
fragmentIndex,
|
||||
sampleIndex: nextKeyFrameIndex,
|
||||
correctSampleFound: true,
|
||||
};
|
||||
} else {
|
||||
// Walk the list of fragments until we find the next fragment for this track with a key frame
|
||||
let currentFragment = locationInFragment.fragment;
|
||||
while (currentFragment.nextFragment) {
|
||||
currentFragment = currentFragment.nextFragment;
|
||||
|
||||
const trackData = currentFragment.trackData.get(this.internalTrack.id);
|
||||
if (trackData && trackData.firstKeyFrameTimestamp !== null) {
|
||||
const fragmentIndex = binarySearchExact(
|
||||
this.internalTrack.fragments,
|
||||
currentFragment.moofOffset,
|
||||
x => x.moofOffset,
|
||||
);
|
||||
assert(fragmentIndex !== -1);
|
||||
|
||||
const keyFrameIndex = trackData.samples.findIndex(x => x.isKeyFrame);
|
||||
assert(keyFrameIndex !== -1); // There must be one
|
||||
|
||||
return {
|
||||
fragmentIndex,
|
||||
sampleIndex: keyFrameIndex,
|
||||
correctSampleFound: true,
|
||||
};
|
||||
}
|
||||
if (nextKeyFrameIndex !== -1) {
|
||||
// We can simply take the next key frame in the fragment
|
||||
return {
|
||||
sampleIndex: nextKeyFrameIndex,
|
||||
correctSampleFound: true,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const trackData = fragment.trackData.get(this.internalTrack.id);
|
||||
if (trackData && trackData.firstKeyFrameTimestamp !== null) {
|
||||
const keyFrameIndex = trackData.samples.findIndex(x => x.isKeyFrame);
|
||||
assert(keyFrameIndex !== -1); // There must be one
|
||||
|
||||
return {
|
||||
fragmentIndex,
|
||||
sampleIndex: -1,
|
||||
correctSampleFound: false,
|
||||
};
|
||||
return {
|
||||
sampleIndex: keyFrameIndex,
|
||||
correctSampleFound: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sampleIndex: -1,
|
||||
correctSampleFound: false,
|
||||
};
|
||||
},
|
||||
-Infinity, // Use -Infinity as a search timestamp to avoid using the lookup entries
|
||||
Infinity,
|
||||
@@ -2724,77 +2637,12 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
|
||||
return packet;
|
||||
}
|
||||
|
||||
private findSampleInFragmentsForTimestamp(timestampInTimescale: number) {
|
||||
const fragmentIndex = binarySearchLessOrEqual(
|
||||
// This array is technically not sorted by start timestamp, but for any reasonable file, it basically is.
|
||||
this.internalTrack.fragments,
|
||||
timestampInTimescale,
|
||||
x => x.trackData.get(this.internalTrack.id)!.startTimestamp,
|
||||
);
|
||||
let sampleIndex = -1;
|
||||
let correctSampleFound = false;
|
||||
|
||||
if (fragmentIndex !== -1) {
|
||||
const fragment = this.internalTrack.fragments[fragmentIndex]!;
|
||||
const trackData = fragment.trackData.get(this.internalTrack.id)!;
|
||||
|
||||
const index = binarySearchLessOrEqual(
|
||||
trackData.presentationTimestamps,
|
||||
timestampInTimescale,
|
||||
x => x.presentationTimestamp,
|
||||
);
|
||||
assert(index !== -1);
|
||||
|
||||
sampleIndex = trackData.presentationTimestamps[index]!.sampleIndex;
|
||||
correctSampleFound = timestampInTimescale < trackData.endTimestamp;
|
||||
}
|
||||
|
||||
return { fragmentIndex, sampleIndex, correctSampleFound };
|
||||
}
|
||||
|
||||
private findKeySampleInFragmentsForTimestamp(timestampInTimescale: number) {
|
||||
const indexInKeyFrameFragments = binarySearchLessOrEqual(
|
||||
// This array is technically not sorted by start timestamp, but for any reasonable file, it basically is.
|
||||
this.internalTrack.fragmentsWithKeyFrame,
|
||||
timestampInTimescale,
|
||||
x => x.trackData.get(this.internalTrack.id)!.startTimestamp,
|
||||
);
|
||||
|
||||
let fragmentIndex = -1;
|
||||
let sampleIndex = -1;
|
||||
let correctSampleFound = false;
|
||||
|
||||
if (indexInKeyFrameFragments !== -1) {
|
||||
const fragment = this.internalTrack.fragmentsWithKeyFrame[indexInKeyFrameFragments]!;
|
||||
|
||||
// Now, let's find the actual index of the fragment in the list of ALL fragments, not just key frame ones
|
||||
fragmentIndex = binarySearchExact(
|
||||
this.internalTrack.fragments,
|
||||
fragment.moofOffset,
|
||||
x => x.moofOffset,
|
||||
);
|
||||
assert(fragmentIndex !== -1);
|
||||
|
||||
const trackData = fragment.trackData.get(this.internalTrack.id)!;
|
||||
const index = findLastIndex(trackData.presentationTimestamps, (x) => {
|
||||
const sample = trackData.samples[x.sampleIndex]!;
|
||||
return sample.isKeyFrame && x.presentationTimestamp <= timestampInTimescale;
|
||||
});
|
||||
assert(index !== -1); // It's a key frame fragment, so there must be a key frame
|
||||
|
||||
const entry = trackData.presentationTimestamps[index]!;
|
||||
sampleIndex = entry.sampleIndex;
|
||||
correctSampleFound = timestampInTimescale < trackData.endTimestamp;
|
||||
}
|
||||
|
||||
return { fragmentIndex, sampleIndex, correctSampleFound };
|
||||
}
|
||||
|
||||
/** Looks for a packet in the fragments while trying to load as few fragments as possible to retrieve it. */
|
||||
private async performFragmentedLookup(
|
||||
// This function returns the best-matching sample that is currently loaded. Based on this information, we know
|
||||
// which fragments we need to load to find the actual match.
|
||||
getBestMatch: () => { fragmentIndex: number; sampleIndex: number; correctSampleFound: boolean },
|
||||
// The fragment where we start looking
|
||||
startFragment: Fragment | null,
|
||||
// This function returns the best-matching sample in a given fragment
|
||||
getMatchInFragment: (fragment: Fragment) => { sampleIndex: number; correctSampleFound: boolean },
|
||||
// The timestamp with which we can search the lookup table
|
||||
searchTimestamp: number,
|
||||
// The timestamp for which we know the correct sample will not come after it
|
||||
@@ -2802,133 +2650,119 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
|
||||
options: PacketRetrievalOptions,
|
||||
): Promise<EncodedPacket | null> {
|
||||
const demuxer = this.internalTrack.demuxer;
|
||||
const release = await demuxer.fragmentLookupMutex.acquire(); // The algorithm requires exclusivity
|
||||
|
||||
try {
|
||||
const { fragmentIndex, sampleIndex, correctSampleFound } = getBestMatch();
|
||||
let currentFragment: Fragment | null = null;
|
||||
let bestFragment: Fragment | null = null;
|
||||
let bestSampleIndex = -1;
|
||||
|
||||
if (startFragment) {
|
||||
const { sampleIndex, correctSampleFound } = getMatchInFragment(startFragment);
|
||||
|
||||
if (correctSampleFound) {
|
||||
// The correct sample already exists, easy path.
|
||||
const fragment = this.internalTrack.fragments[fragmentIndex]!;
|
||||
return this.fetchPacketInFragment(fragment, sampleIndex, options);
|
||||
return this.fetchPacketInFragment(startFragment, sampleIndex, options);
|
||||
}
|
||||
|
||||
let prevFragment: Fragment | null = null;
|
||||
let bestFragmentIndex = fragmentIndex;
|
||||
let bestSampleIndex = sampleIndex;
|
||||
if (sampleIndex !== -1) {
|
||||
bestFragment = startFragment;
|
||||
bestSampleIndex = sampleIndex;
|
||||
}
|
||||
}
|
||||
|
||||
// Search for a lookup entry; this way, we won't need to start searching from the start of the file
|
||||
// but can jump right into the correct fragment (or at least nearby).
|
||||
const lookupEntryIndex = this.internalTrack.fragmentLookupTable
|
||||
? binarySearchLessOrEqual(
|
||||
this.internalTrack.fragmentLookupTable,
|
||||
searchTimestamp,
|
||||
x => x.timestamp,
|
||||
)
|
||||
: -1;
|
||||
const lookupEntry = lookupEntryIndex !== -1
|
||||
? this.internalTrack.fragmentLookupTable![lookupEntryIndex]!
|
||||
: null;
|
||||
// Search for a lookup entry; this way, we won't need to start searching from the start of the file
|
||||
// but can jump right into the correct fragment (or at least nearby).
|
||||
const lookupEntryIndex = binarySearchLessOrEqual(
|
||||
this.internalTrack.fragmentLookupTable,
|
||||
searchTimestamp,
|
||||
x => x.timestamp,
|
||||
);
|
||||
const lookupEntry = lookupEntryIndex !== -1
|
||||
? this.internalTrack.fragmentLookupTable[lookupEntryIndex]!
|
||||
: null;
|
||||
|
||||
let currentPos: number;
|
||||
let nextFragmentIsFirstFragment = false;
|
||||
const positionCacheIndex = binarySearchLessOrEqual(
|
||||
this.internalTrack.fragmentPositionCache,
|
||||
searchTimestamp,
|
||||
x => x.startTimestamp,
|
||||
);
|
||||
const positionCacheEntry = positionCacheIndex !== -1
|
||||
? this.internalTrack.fragmentPositionCache[positionCacheIndex]!
|
||||
: null;
|
||||
|
||||
if (fragmentIndex === -1) {
|
||||
currentPos = lookupEntry?.moofOffset ?? 0;
|
||||
nextFragmentIsFirstFragment = currentPos === 0;
|
||||
const lookupEntryPosition = Math.max(
|
||||
lookupEntry?.moofOffset ?? 0,
|
||||
positionCacheEntry?.moofOffset ?? 0,
|
||||
) || null;
|
||||
|
||||
let currentPos: number;
|
||||
|
||||
if (!startFragment) {
|
||||
currentPos = lookupEntryPosition ?? 0;
|
||||
} else {
|
||||
if (lookupEntryPosition === null || startFragment.moofOffset >= lookupEntryPosition) {
|
||||
currentPos = startFragment.moofOffset + startFragment.moofSize;
|
||||
currentFragment = startFragment;
|
||||
} else {
|
||||
const fragment = this.internalTrack.fragments[fragmentIndex]!;
|
||||
|
||||
if (!lookupEntry || fragment.moofOffset >= lookupEntry.moofOffset) {
|
||||
currentPos = fragment.moofOffset + fragment.moofSize;
|
||||
prevFragment = fragment;
|
||||
} else {
|
||||
// Use the lookup entry
|
||||
currentPos = lookupEntry.moofOffset;
|
||||
}
|
||||
// Use the lookup entry
|
||||
currentPos = lookupEntryPosition;
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
if (prevFragment) {
|
||||
const trackData = prevFragment.trackData.get(this.internalTrack.id);
|
||||
if (trackData && trackData.startTimestamp > latestTimestamp) {
|
||||
// We're already past the upper bound, no need to keep searching
|
||||
break;
|
||||
}
|
||||
|
||||
if (prevFragment.nextFragment) {
|
||||
// Skip ahead quickly without needing to read the file again
|
||||
currentPos = prevFragment.nextFragment.moofOffset + prevFragment.nextFragment.moofSize;
|
||||
prevFragment = prevFragment.nextFragment;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Load the header
|
||||
let slice = demuxer.reader.requestSliceRange(currentPos, MIN_BOX_HEADER_SIZE, MAX_BOX_HEADER_SIZE);
|
||||
if (slice instanceof Promise) slice = await slice;
|
||||
if (!slice) break;
|
||||
|
||||
const startPos = currentPos;
|
||||
const boxInfo = readBoxHeader(slice);
|
||||
if (!boxInfo) {
|
||||
while (true) {
|
||||
if (currentFragment) {
|
||||
const trackData = currentFragment.trackData.get(this.internalTrack.id);
|
||||
if (trackData && trackData.startTimestamp > latestTimestamp) {
|
||||
// We're already past the upper bound, no need to keep searching
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (boxInfo.name === 'moof') {
|
||||
const index = binarySearchExact(demuxer.fragments, startPos, x => x.moofOffset);
|
||||
// Load the header
|
||||
let slice = demuxer.reader.requestSliceRange(currentPos, MIN_BOX_HEADER_SIZE, MAX_BOX_HEADER_SIZE);
|
||||
if (slice instanceof Promise) slice = await slice;
|
||||
if (!slice) break;
|
||||
|
||||
let fragment: Fragment;
|
||||
if (index === -1) {
|
||||
// This is the first time we've seen this fragment
|
||||
fragment = await demuxer.readFragment(startPos);
|
||||
} else {
|
||||
// We already know this fragment
|
||||
fragment = demuxer.fragments[index]!;
|
||||
}
|
||||
const boxStartPos = currentPos;
|
||||
const boxInfo = readBoxHeader(slice);
|
||||
if (!boxInfo) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Even if we already know the fragment, we might not yet know its predecessor, so always do this
|
||||
if (prevFragment) prevFragment.nextFragment = fragment;
|
||||
prevFragment = fragment;
|
||||
|
||||
if (nextFragmentIsFirstFragment) {
|
||||
fragment.isKnownToBeFirstFragment = true;
|
||||
nextFragmentIsFirstFragment = false;
|
||||
}
|
||||
|
||||
const { fragmentIndex, sampleIndex, correctSampleFound } = getBestMatch();
|
||||
if (correctSampleFound) {
|
||||
const fragment = this.internalTrack.fragments[fragmentIndex]!;
|
||||
return this.fetchPacketInFragment(fragment, sampleIndex, options);
|
||||
}
|
||||
if (fragmentIndex !== -1) {
|
||||
bestFragmentIndex = fragmentIndex;
|
||||
bestSampleIndex = sampleIndex;
|
||||
}
|
||||
if (boxInfo.name === 'moof') {
|
||||
currentFragment = await demuxer.readFragment(boxStartPos);
|
||||
const { sampleIndex, correctSampleFound } = getMatchInFragment(currentFragment);
|
||||
if (correctSampleFound) {
|
||||
return this.fetchPacketInFragment(currentFragment, sampleIndex, options);
|
||||
}
|
||||
if (sampleIndex !== -1) {
|
||||
bestFragment = currentFragment;
|
||||
bestSampleIndex = sampleIndex;
|
||||
}
|
||||
|
||||
currentPos = startPos + boxInfo.totalSize;
|
||||
}
|
||||
|
||||
const bestFragment = bestFragmentIndex !== -1 ? this.internalTrack.fragments[bestFragmentIndex]! : null;
|
||||
|
||||
// Catch faulty lookup table entries
|
||||
if (lookupEntry && (!bestFragment || bestFragment.moofOffset < lookupEntry.moofOffset)) {
|
||||
// The lookup table entry lied to us! We found a lookup entry but no fragment there that satisfied
|
||||
// the match. In this case, let's search again but using the lookup entry before that.
|
||||
const previousLookupEntry = this.internalTrack.fragmentLookupTable![lookupEntryIndex - 1];
|
||||
const newSearchTimestamp = previousLookupEntry?.timestamp ?? -Infinity;
|
||||
return this.performFragmentedLookup(getBestMatch, newSearchTimestamp, latestTimestamp, options);
|
||||
}
|
||||
|
||||
if (bestFragment) {
|
||||
// If we finished looping but didn't find a perfect match, still return the best match we found
|
||||
return this.fetchPacketInFragment(bestFragment, bestSampleIndex, options);
|
||||
}
|
||||
|
||||
return null;
|
||||
} finally {
|
||||
release();
|
||||
currentPos = boxStartPos + boxInfo.totalSize;
|
||||
}
|
||||
|
||||
// Catch faulty lookup table entries
|
||||
if (lookupEntry && (!bestFragment || bestFragment.moofOffset < lookupEntry.moofOffset)) {
|
||||
// The lookup table entry lied to us! We found a lookup entry but no fragment there that satisfied
|
||||
// the match. In this case, let's search again but using the lookup entry before that.
|
||||
const previousLookupEntry = this.internalTrack.fragmentLookupTable[lookupEntryIndex - 1];
|
||||
const newSearchTimestamp = previousLookupEntry?.timestamp ?? -Infinity;
|
||||
return this.performFragmentedLookup(
|
||||
null,
|
||||
getMatchInFragment,
|
||||
newSearchTimestamp,
|
||||
latestTimestamp,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
if (bestFragment) {
|
||||
// If we finished looping but didn't find a perfect match, still return the best match we found
|
||||
return this.fetchPacketInFragment(bestFragment, bestSampleIndex, options);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,8 +57,10 @@ export const readIsomVariableInteger = (slice: FileSlice) => {
|
||||
};
|
||||
|
||||
export const readMetadataStringShort = (slice: FileSlice) => {
|
||||
const stringLength = readU16Be(slice);
|
||||
let stringLength = readU16Be(slice);
|
||||
slice.skip(2); // Language
|
||||
|
||||
stringLength = Math.min(stringLength, slice.remainingLength);
|
||||
return textDecoder.decode(readBytes(slice, stringLength));
|
||||
};
|
||||
|
||||
|
||||
+276
-366
@@ -35,12 +35,9 @@ import { AttachedFile, MetadataTags } from '../tags';
|
||||
import { PacketRetrievalOptions } from '../media-sink';
|
||||
import {
|
||||
assert,
|
||||
AsyncMutex,
|
||||
binarySearchExact,
|
||||
binarySearchLessOrEqual,
|
||||
COLOR_PRIMARIES_MAP_INVERSE,
|
||||
findLastIndex,
|
||||
insertSorted,
|
||||
isIso639Dash2LanguageCode,
|
||||
last,
|
||||
MATRIX_COEFFICIENTS_MAP_INVERSE,
|
||||
@@ -93,8 +90,11 @@ type Segment = {
|
||||
elementEndPos: number | null;
|
||||
clusterSeekStartPos: number;
|
||||
|
||||
clusters: Cluster[];
|
||||
clusterLookupMutex: AsyncMutex;
|
||||
/**
|
||||
* Caches the last cluster that was read. Based on the assumption that there will be multiple reads to the
|
||||
* same cluster in quick succession.
|
||||
*/
|
||||
lastReadCluster: Cluster | null;
|
||||
|
||||
metadataTags: MetadataTags;
|
||||
metadataTagsCollected: boolean;
|
||||
@@ -112,8 +112,6 @@ type Cluster = {
|
||||
dataStartPos: number;
|
||||
timestamp: number;
|
||||
trackData: Map<number, ClusterTrackData>;
|
||||
nextCluster: Cluster | null;
|
||||
isKnownToBeFirstCluster: boolean;
|
||||
};
|
||||
|
||||
type ClusterTrackData = {
|
||||
@@ -182,8 +180,14 @@ type InternalTrack = {
|
||||
id: number;
|
||||
demuxer: MatroskaDemuxer;
|
||||
segment: Segment;
|
||||
clusters: Cluster[];
|
||||
clustersWithKeyFrame: Cluster[];
|
||||
/**
|
||||
* List of all encountered cluster offsets alongside their timestamps. This list never gets truncated, but memory
|
||||
* consumption should be negligible.
|
||||
*/
|
||||
clusterPositionCache: {
|
||||
elementStartPos: number;
|
||||
startTimestamp: number;
|
||||
}[];
|
||||
cuePoints: CuePoint[];
|
||||
|
||||
isDefault: boolean;
|
||||
@@ -409,8 +413,7 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
: segmentDataStart + dataSize,
|
||||
clusterSeekStartPos: segmentDataStart,
|
||||
|
||||
clusters: [],
|
||||
clusterLookupMutex: new AsyncMutex(),
|
||||
lastReadCluster: null,
|
||||
|
||||
metadataTags: {},
|
||||
metadataTagsCollected: false,
|
||||
@@ -591,6 +594,10 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
}
|
||||
|
||||
async readCluster(startPos: number, segment: Segment) {
|
||||
if (segment.lastReadCluster?.elementStartPos === startPos) {
|
||||
return segment.lastReadCluster;
|
||||
}
|
||||
|
||||
let headerSlice = this.reader.requestSliceRange(startPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);
|
||||
if (headerSlice instanceof Promise) headerSlice = await headerSlice;
|
||||
assert(headerSlice);
|
||||
@@ -600,6 +607,8 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
assert(elementHeader);
|
||||
|
||||
const id = elementHeader.id;
|
||||
assert(id === EBMLId.Cluster);
|
||||
|
||||
let size = elementHeader.size;
|
||||
const dataStartPos = headerSlice.filePos;
|
||||
|
||||
@@ -617,8 +626,6 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
size = nextElementPos.pos - dataStartPos;
|
||||
}
|
||||
|
||||
assert(id === EBMLId.Cluster);
|
||||
|
||||
// Load the entire cluster
|
||||
let dataSlice = this.reader.requestSlice(dataStartPos, size);
|
||||
if (dataSlice instanceof Promise) dataSlice = await dataSlice;
|
||||
@@ -630,8 +637,6 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
dataStartPos,
|
||||
timestamp: -1,
|
||||
trackData: new Map(),
|
||||
nextCluster: null,
|
||||
isKnownToBeFirstCluster: false,
|
||||
};
|
||||
this.currentCluster = cluster;
|
||||
|
||||
@@ -705,17 +710,24 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
trackData.startTimestamp = firstBlock.timestamp;
|
||||
trackData.endTimestamp = lastBlock.timestamp + lastBlock.duration;
|
||||
|
||||
insertSorted(track.clusters, cluster, x => x.elementStartPos);
|
||||
|
||||
const hasKeyFrame = trackData.firstKeyFrameTimestamp !== null;
|
||||
if (hasKeyFrame) {
|
||||
insertSorted(track.clustersWithKeyFrame, cluster, x => x.elementStartPos);
|
||||
// Let's remember that a cluster with a given timestamp is here, speeding up future lookups if no cues exist
|
||||
const insertionIndex = binarySearchLessOrEqual(
|
||||
track.clusterPositionCache,
|
||||
trackData.startTimestamp,
|
||||
x => x.startTimestamp,
|
||||
);
|
||||
if (
|
||||
insertionIndex === -1
|
||||
|| track.clusterPositionCache[insertionIndex]!.elementStartPos !== elementStartPos
|
||||
) {
|
||||
track.clusterPositionCache.splice(insertionIndex + 1, 0, {
|
||||
elementStartPos: cluster.elementStartPos,
|
||||
startTimestamp: trackData.startTimestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
insertSorted(segment.clusters, cluster, x => x.elementStartPos);
|
||||
this.currentCluster = null;
|
||||
|
||||
segment.lastReadCluster = cluster;
|
||||
return cluster;
|
||||
}
|
||||
|
||||
@@ -977,8 +989,7 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
id: -1,
|
||||
segment: this.currentSegment,
|
||||
demuxer: this,
|
||||
clusters: [],
|
||||
clustersWithKeyFrame: [],
|
||||
clusterPositionCache: [],
|
||||
cuePoints: [],
|
||||
|
||||
isDefault: false,
|
||||
@@ -1844,31 +1855,17 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
|
||||
|
||||
async getFirstPacket(options: PacketRetrievalOptions) {
|
||||
return this.performClusterLookup(
|
||||
() => {
|
||||
const startCluster = this.internalTrack.segment.clusters[0] ?? null;
|
||||
if (startCluster?.isKnownToBeFirstCluster) {
|
||||
// Walk from the very first cluster in the file until we find one with our track in it
|
||||
let currentCluster: Cluster | null = startCluster;
|
||||
while (currentCluster) {
|
||||
const trackData = currentCluster.trackData.get(this.internalTrack.id);
|
||||
if (trackData) {
|
||||
return {
|
||||
clusterIndex: binarySearchExact(
|
||||
this.internalTrack.clusters,
|
||||
currentCluster.elementStartPos,
|
||||
x => x.elementStartPos,
|
||||
),
|
||||
blockIndex: 0,
|
||||
correctBlockFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
currentCluster = currentCluster.nextCluster;
|
||||
}
|
||||
null,
|
||||
(cluster) => {
|
||||
const trackData = cluster.trackData.get(this.internalTrack.id);
|
||||
if (trackData) {
|
||||
return {
|
||||
blockIndex: 0,
|
||||
correctBlockFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
clusterIndex: -1,
|
||||
blockIndex: -1,
|
||||
correctBlockFound: false,
|
||||
};
|
||||
@@ -1890,7 +1887,24 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
|
||||
const timestampInTimescale = this.intoTimescale(timestamp);
|
||||
|
||||
return this.performClusterLookup(
|
||||
() => this.findBlockInClustersForTimestamp(timestampInTimescale),
|
||||
null,
|
||||
(cluster) => {
|
||||
const trackData = cluster.trackData.get(this.internalTrack.id);
|
||||
if (!trackData) {
|
||||
return { blockIndex: -1, correctBlockFound: false };
|
||||
}
|
||||
|
||||
const index = binarySearchLessOrEqual(
|
||||
trackData.presentationTimestamps,
|
||||
timestampInTimescale,
|
||||
x => x.timestamp,
|
||||
);
|
||||
|
||||
const blockIndex = index !== -1 ? trackData.presentationTimestamps[index]!.blockIndex : -1;
|
||||
const correctBlockFound = index !== -1 && timestampInTimescale < trackData.endTimestamp;
|
||||
|
||||
return { blockIndex, correctBlockFound };
|
||||
},
|
||||
timestampInTimescale,
|
||||
timestampInTimescale,
|
||||
options,
|
||||
@@ -1903,53 +1917,32 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
|
||||
throw new Error('Packet was not created from this track.');
|
||||
}
|
||||
|
||||
const trackData = locationInCluster.cluster.trackData.get(this.internalTrack.id)!;
|
||||
|
||||
const clusterIndex = binarySearchExact(
|
||||
this.internalTrack.clusters,
|
||||
locationInCluster.cluster.elementStartPos,
|
||||
x => x.elementStartPos,
|
||||
);
|
||||
assert(clusterIndex !== -1);
|
||||
|
||||
return this.performClusterLookup(
|
||||
() => {
|
||||
if (locationInCluster.blockIndex + 1 < trackData.blocks.length) {
|
||||
// We can simply take the next block in the cluster
|
||||
return {
|
||||
clusterIndex,
|
||||
blockIndex: locationInCluster.blockIndex + 1,
|
||||
correctBlockFound: true,
|
||||
};
|
||||
locationInCluster.cluster,
|
||||
(cluster) => {
|
||||
if (cluster === locationInCluster.cluster) {
|
||||
const trackData = cluster.trackData.get(this.internalTrack.id)!;
|
||||
if (locationInCluster.blockIndex + 1 < trackData.blocks.length) {
|
||||
// We can simply take the next block in the cluster
|
||||
return {
|
||||
blockIndex: locationInCluster.blockIndex + 1,
|
||||
correctBlockFound: true,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Walk the list of clusters until we find the next cluster for this track
|
||||
let currentCluster = locationInCluster.cluster;
|
||||
while (currentCluster.nextCluster) {
|
||||
currentCluster = currentCluster.nextCluster;
|
||||
|
||||
const trackData = currentCluster.trackData.get(this.internalTrack.id);
|
||||
if (trackData) {
|
||||
const clusterIndex = binarySearchExact(
|
||||
this.internalTrack.clusters,
|
||||
currentCluster.elementStartPos,
|
||||
x => x.elementStartPos,
|
||||
);
|
||||
assert(clusterIndex !== -1);
|
||||
|
||||
return {
|
||||
clusterIndex,
|
||||
blockIndex: 0,
|
||||
correctBlockFound: true,
|
||||
};
|
||||
}
|
||||
const trackData = cluster.trackData.get(this.internalTrack.id);
|
||||
if (trackData) {
|
||||
return {
|
||||
blockIndex: 0,
|
||||
correctBlockFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
clusterIndex,
|
||||
blockIndex: -1,
|
||||
correctBlockFound: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
blockIndex: -1,
|
||||
correctBlockFound: false,
|
||||
};
|
||||
},
|
||||
-Infinity, // Use -Infinity as a search timestamp to avoid using the cues
|
||||
Infinity,
|
||||
@@ -1961,7 +1954,23 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
|
||||
const timestampInTimescale = this.intoTimescale(timestamp);
|
||||
|
||||
return this.performClusterLookup(
|
||||
() => this.findKeyBlockInClustersForTimestamp(timestampInTimescale),
|
||||
null,
|
||||
(cluster) => {
|
||||
const trackData = cluster.trackData.get(this.internalTrack.id);
|
||||
if (!trackData) {
|
||||
return { blockIndex: -1, correctBlockFound: false };
|
||||
}
|
||||
|
||||
const index = findLastIndex(trackData.presentationTimestamps, (x) => {
|
||||
const block = trackData.blocks[x.blockIndex]!;
|
||||
return block.isKeyFrame && x.timestamp <= timestampInTimescale;
|
||||
});
|
||||
|
||||
const blockIndex = index !== -1 ? trackData.presentationTimestamps[index]!.blockIndex : -1;
|
||||
const correctBlockFound = index !== -1 && timestampInTimescale < trackData.endTimestamp;
|
||||
|
||||
return { blockIndex, correctBlockFound };
|
||||
},
|
||||
timestampInTimescale,
|
||||
timestampInTimescale,
|
||||
options,
|
||||
@@ -1974,60 +1983,39 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
|
||||
throw new Error('Packet was not created from this track.');
|
||||
}
|
||||
|
||||
const trackData = locationInCluster.cluster.trackData.get(this.internalTrack.id)!;
|
||||
|
||||
const clusterIndex = binarySearchExact(
|
||||
this.internalTrack.clusters,
|
||||
locationInCluster.cluster.elementStartPos,
|
||||
x => x.elementStartPos,
|
||||
);
|
||||
assert(clusterIndex !== -1);
|
||||
|
||||
return this.performClusterLookup(
|
||||
() => {
|
||||
const nextKeyFrameIndex = trackData.blocks.findIndex(
|
||||
(x, i) => x.isKeyFrame && i > locationInCluster.blockIndex,
|
||||
);
|
||||
locationInCluster.cluster,
|
||||
(cluster) => {
|
||||
if (cluster === locationInCluster.cluster) {
|
||||
const trackData = cluster.trackData.get(this.internalTrack.id)!;
|
||||
const nextKeyFrameIndex = trackData.blocks.findIndex(
|
||||
(x, i) => x.isKeyFrame && i > locationInCluster.blockIndex,
|
||||
);
|
||||
|
||||
if (nextKeyFrameIndex !== -1) {
|
||||
// We can simply take the next key frame in the cluster
|
||||
return {
|
||||
clusterIndex,
|
||||
blockIndex: nextKeyFrameIndex,
|
||||
correctBlockFound: true,
|
||||
};
|
||||
} else {
|
||||
// Walk the list of clusters until we find the next cluster for this track with a key frame
|
||||
let currentCluster = locationInCluster.cluster;
|
||||
while (currentCluster.nextCluster) {
|
||||
currentCluster = currentCluster.nextCluster;
|
||||
|
||||
const trackData = currentCluster.trackData.get(this.internalTrack.id);
|
||||
if (trackData && trackData.firstKeyFrameTimestamp !== null) {
|
||||
const clusterIndex = binarySearchExact(
|
||||
this.internalTrack.clusters,
|
||||
currentCluster.elementStartPos,
|
||||
x => x.elementStartPos,
|
||||
);
|
||||
assert(clusterIndex !== -1);
|
||||
|
||||
const keyFrameIndex = trackData.blocks.findIndex(x => x.isKeyFrame);
|
||||
assert(keyFrameIndex !== -1); // There must be one
|
||||
|
||||
return {
|
||||
clusterIndex,
|
||||
blockIndex: keyFrameIndex,
|
||||
correctBlockFound: true,
|
||||
};
|
||||
}
|
||||
if (nextKeyFrameIndex !== -1) {
|
||||
// We can simply take the next key frame in the cluster
|
||||
return {
|
||||
blockIndex: nextKeyFrameIndex,
|
||||
correctBlockFound: true,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const trackData = cluster.trackData.get(this.internalTrack.id);
|
||||
if (trackData && trackData.firstKeyFrameTimestamp !== null) {
|
||||
const keyFrameIndex = trackData.blocks.findIndex(x => x.isKeyFrame);
|
||||
assert(keyFrameIndex !== -1); // There must be one
|
||||
|
||||
return {
|
||||
clusterIndex,
|
||||
blockIndex: -1,
|
||||
correctBlockFound: false,
|
||||
};
|
||||
return {
|
||||
blockIndex: keyFrameIndex,
|
||||
correctBlockFound: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
blockIndex: -1,
|
||||
correctBlockFound: false,
|
||||
};
|
||||
},
|
||||
-Infinity, // Use -Infinity as a search timestamp to avoid using the cues
|
||||
Infinity,
|
||||
@@ -2075,77 +2063,12 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
|
||||
return packet;
|
||||
}
|
||||
|
||||
private findBlockInClustersForTimestamp(timestampInTimescale: number) {
|
||||
const clusterIndex = binarySearchLessOrEqual(
|
||||
// This array is technically not sorted by start timestamp, but for any reasonable file, it basically is.
|
||||
this.internalTrack.clusters,
|
||||
timestampInTimescale,
|
||||
x => x.trackData.get(this.internalTrack.id)!.startTimestamp,
|
||||
);
|
||||
let blockIndex = -1;
|
||||
let correctBlockFound = false;
|
||||
|
||||
if (clusterIndex !== -1) {
|
||||
const cluster = this.internalTrack.clusters[clusterIndex]!;
|
||||
const trackData = cluster.trackData.get(this.internalTrack.id)!;
|
||||
|
||||
const index = binarySearchLessOrEqual(
|
||||
trackData.presentationTimestamps,
|
||||
timestampInTimescale,
|
||||
x => x.timestamp,
|
||||
);
|
||||
assert(index !== -1);
|
||||
|
||||
blockIndex = trackData.presentationTimestamps[index]!.blockIndex;
|
||||
correctBlockFound = timestampInTimescale < trackData.endTimestamp;
|
||||
}
|
||||
|
||||
return { clusterIndex, blockIndex, correctBlockFound };
|
||||
}
|
||||
|
||||
private findKeyBlockInClustersForTimestamp(timestampInTimescale: number) {
|
||||
const indexInKeyFrameClusters = binarySearchLessOrEqual(
|
||||
// This array is technically not sorted by start timestamp, but for any reasonable file, it basically is.
|
||||
this.internalTrack.clustersWithKeyFrame,
|
||||
timestampInTimescale,
|
||||
x => x.trackData.get(this.internalTrack.id)!.firstKeyFrameTimestamp!,
|
||||
);
|
||||
|
||||
let clusterIndex = -1;
|
||||
let blockIndex = -1;
|
||||
let correctBlockFound = false;
|
||||
|
||||
if (indexInKeyFrameClusters !== -1) {
|
||||
const cluster = this.internalTrack.clustersWithKeyFrame[indexInKeyFrameClusters]!;
|
||||
|
||||
// Now, let's find the actual index of the cluster in the list of ALL clusters, not just key frame ones
|
||||
clusterIndex = binarySearchExact(
|
||||
this.internalTrack.clusters,
|
||||
cluster.elementStartPos,
|
||||
x => x.elementStartPos,
|
||||
);
|
||||
assert(clusterIndex !== -1);
|
||||
|
||||
const trackData = cluster.trackData.get(this.internalTrack.id)!;
|
||||
const index = findLastIndex(trackData.presentationTimestamps, (x) => {
|
||||
const block = trackData.blocks[x.blockIndex]!;
|
||||
return block.isKeyFrame && x.timestamp <= timestampInTimescale;
|
||||
});
|
||||
assert(index !== -1); // It's a key frame cluster, so there must be a key frame
|
||||
|
||||
const entry = trackData.presentationTimestamps[index]!;
|
||||
blockIndex = entry.blockIndex;
|
||||
correctBlockFound = timestampInTimescale < trackData.endTimestamp;
|
||||
}
|
||||
|
||||
return { clusterIndex, blockIndex, correctBlockFound };
|
||||
}
|
||||
|
||||
/** Looks for a packet in the clusters while trying to load as few clusters as possible to retrieve it. */
|
||||
private async performClusterLookup(
|
||||
// This function returns the best-matching block that is currently loaded. Based on this information, we know
|
||||
// which clusters we need to load to find the actual match.
|
||||
getBestMatch: () => { clusterIndex: number; blockIndex: number; correctBlockFound: boolean },
|
||||
// The cluster where we start looking
|
||||
startCluster: Cluster | null,
|
||||
// This function returns the best-matching block in a given cluster
|
||||
getMatchInCluster: (cluster: Cluster) => { blockIndex: number; correctBlockFound: boolean },
|
||||
// The timestamp with which we can search the lookup table
|
||||
searchTimestamp: number,
|
||||
// The timestamp for which we know the correct block will not come after it
|
||||
@@ -2153,192 +2076,179 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
|
||||
options: PacketRetrievalOptions,
|
||||
): Promise<EncodedPacket | null> {
|
||||
const { demuxer, segment } = this.internalTrack;
|
||||
const release = await segment.clusterLookupMutex.acquire(); // The algorithm requires exclusivity
|
||||
|
||||
try {
|
||||
const { clusterIndex, blockIndex, correctBlockFound } = getBestMatch();
|
||||
let currentCluster: Cluster | null = null;
|
||||
let bestCluster: Cluster | null = null;
|
||||
let bestBlockIndex = -1;
|
||||
|
||||
if (startCluster) {
|
||||
const { blockIndex, correctBlockFound } = getMatchInCluster(startCluster);
|
||||
|
||||
if (correctBlockFound) {
|
||||
// The correct block already exists, easy path.
|
||||
const cluster = this.internalTrack.clusters[clusterIndex]!;
|
||||
return this.fetchPacketInCluster(cluster, blockIndex, options);
|
||||
return this.fetchPacketInCluster(startCluster, blockIndex, options);
|
||||
}
|
||||
|
||||
let prevCluster: Cluster | null = null;
|
||||
let bestClusterIndex = clusterIndex;
|
||||
let bestBlockIndex = blockIndex;
|
||||
if (blockIndex !== -1) {
|
||||
bestCluster = startCluster;
|
||||
bestBlockIndex = blockIndex;
|
||||
}
|
||||
}
|
||||
|
||||
// Search for a cue point; this way, we won't need to start searching from the start of the file
|
||||
// but can jump right into the correct cluster (or at least nearby).
|
||||
const cuePointIndex = binarySearchLessOrEqual(
|
||||
this.internalTrack.cuePoints,
|
||||
searchTimestamp,
|
||||
x => x.time,
|
||||
);
|
||||
const cuePoint = cuePointIndex !== -1 ? this.internalTrack.cuePoints[cuePointIndex]! : null;
|
||||
// Search for a cue point; this way, we won't need to start searching from the start of the file
|
||||
// but can jump right into the correct cluster (or at least nearby).
|
||||
const cuePointIndex = binarySearchLessOrEqual(
|
||||
this.internalTrack.cuePoints,
|
||||
searchTimestamp,
|
||||
x => x.time,
|
||||
);
|
||||
const cuePoint = cuePointIndex !== -1
|
||||
? this.internalTrack.cuePoints[cuePointIndex]!
|
||||
: null;
|
||||
|
||||
let currentPos: number;
|
||||
let nextClusterIsFirstCluster = false;
|
||||
// Also check the position cache
|
||||
const positionCacheIndex = binarySearchLessOrEqual(
|
||||
this.internalTrack.clusterPositionCache,
|
||||
searchTimestamp,
|
||||
x => x.startTimestamp,
|
||||
);
|
||||
const positionCacheEntry = positionCacheIndex !== -1
|
||||
? this.internalTrack.clusterPositionCache[positionCacheIndex]!
|
||||
: null;
|
||||
|
||||
if (clusterIndex === -1) {
|
||||
currentPos = cuePoint?.clusterPosition ?? segment.clusterSeekStartPos;
|
||||
nextClusterIsFirstCluster = currentPos === segment.clusterSeekStartPos;
|
||||
const lookupEntryPosition = Math.max(
|
||||
cuePoint?.clusterPosition ?? 0,
|
||||
positionCacheEntry?.elementStartPos ?? 0,
|
||||
) || null;
|
||||
|
||||
let currentPos: number;
|
||||
|
||||
if (!startCluster) {
|
||||
currentPos = lookupEntryPosition ?? segment.clusterSeekStartPos;
|
||||
} else {
|
||||
if (lookupEntryPosition === null || startCluster.elementStartPos >= lookupEntryPosition) {
|
||||
currentPos = startCluster.elementEndPos;
|
||||
currentCluster = startCluster;
|
||||
} else {
|
||||
const cluster = this.internalTrack.clusters[clusterIndex]!;
|
||||
// Use the lookup entry
|
||||
currentPos = lookupEntryPosition;
|
||||
}
|
||||
}
|
||||
|
||||
if (!cuePoint || cluster.elementStartPos >= cuePoint.clusterPosition) {
|
||||
currentPos = cluster.elementEndPos;
|
||||
prevCluster = cluster;
|
||||
} else {
|
||||
// Use the lookup entry
|
||||
currentPos = cuePoint.clusterPosition;
|
||||
while (segment.elementEndPos === null || currentPos <= segment.elementEndPos - MIN_HEADER_SIZE) {
|
||||
if (currentCluster) {
|
||||
const trackData = currentCluster.trackData.get(this.internalTrack.id);
|
||||
if (trackData && trackData.startTimestamp > latestTimestamp) {
|
||||
// We're already past the upper bound, no need to keep searching
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
while (segment.elementEndPos === null || currentPos <= segment.elementEndPos - MIN_HEADER_SIZE) {
|
||||
if (prevCluster) {
|
||||
const trackData = prevCluster.trackData.get(this.internalTrack.id);
|
||||
if (trackData && trackData.startTimestamp > latestTimestamp) {
|
||||
// We're already past the upper bound, no need to keep searching
|
||||
break;
|
||||
}
|
||||
// Load the header
|
||||
let slice = demuxer.reader.requestSliceRange(currentPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);
|
||||
if (slice instanceof Promise) slice = await slice;
|
||||
if (!slice) break;
|
||||
|
||||
if (prevCluster.nextCluster) {
|
||||
// Skip ahead quickly without needing to read the file again
|
||||
currentPos = prevCluster.nextCluster.elementEndPos;
|
||||
prevCluster = prevCluster.nextCluster;
|
||||
continue;
|
||||
}
|
||||
const elementStartPos = currentPos;
|
||||
const elementHeader = readElementHeader(slice);
|
||||
|
||||
if (
|
||||
!elementHeader
|
||||
|| (!LEVEL_1_EBML_IDS.includes(elementHeader.id) && elementHeader.id !== EBMLId.Void)
|
||||
) {
|
||||
// There's an element here that shouldn't be here. Might be garbage. In this case, let's
|
||||
// try and resync to the next valid element.
|
||||
const nextPos = await resync(
|
||||
demuxer.reader,
|
||||
elementStartPos,
|
||||
LEVEL_1_EBML_IDS,
|
||||
Math.min(segment.elementEndPos ?? Infinity, elementStartPos + MAX_RESYNC_LENGTH),
|
||||
);
|
||||
|
||||
if (nextPos) {
|
||||
currentPos = nextPos;
|
||||
continue;
|
||||
} else {
|
||||
break; // Resync failed
|
||||
}
|
||||
}
|
||||
|
||||
const id = elementHeader.id;
|
||||
let size = elementHeader.size;
|
||||
const dataStartPos = slice.filePos;
|
||||
|
||||
if (id === EBMLId.Cluster) {
|
||||
currentCluster = await demuxer.readCluster(elementStartPos, segment);
|
||||
|
||||
const { blockIndex, correctBlockFound } = getMatchInCluster(currentCluster);
|
||||
if (correctBlockFound) {
|
||||
return this.fetchPacketInCluster(currentCluster, blockIndex, options);
|
||||
}
|
||||
|
||||
// Load the header
|
||||
let slice = demuxer.reader.requestSliceRange(currentPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);
|
||||
if (slice instanceof Promise) slice = await slice;
|
||||
if (!slice) break;
|
||||
|
||||
const elementStartPos = currentPos;
|
||||
const elementHeader = readElementHeader(slice);
|
||||
|
||||
if (
|
||||
!elementHeader
|
||||
|| (!LEVEL_1_EBML_IDS.includes(elementHeader.id) && elementHeader.id !== EBMLId.Void)
|
||||
) {
|
||||
// There's an element here that shouldn't be here. Might be garbage. In this case, let's
|
||||
// try and resync to the next valid element.
|
||||
const nextPos = await resync(
|
||||
demuxer.reader,
|
||||
elementStartPos,
|
||||
LEVEL_1_EBML_IDS,
|
||||
Math.min(segment.elementEndPos ?? Infinity, elementStartPos + MAX_RESYNC_LENGTH),
|
||||
);
|
||||
|
||||
if (nextPos) {
|
||||
currentPos = nextPos;
|
||||
continue;
|
||||
} else {
|
||||
break; // Resync failed
|
||||
}
|
||||
if (blockIndex !== -1) {
|
||||
bestCluster = currentCluster;
|
||||
bestBlockIndex = blockIndex;
|
||||
}
|
||||
}
|
||||
|
||||
const id = elementHeader.id;
|
||||
let size = elementHeader.size;
|
||||
const dataStartPos = slice.filePos;
|
||||
if (size === null) {
|
||||
// Undefined element size (can happen in livestreamed files). In this case, we need to do some
|
||||
// searching to determine the actual size of the element.
|
||||
|
||||
if (id === EBMLId.Cluster) {
|
||||
const index = binarySearchExact(segment.clusters, elementStartPos, x => x.elementStartPos);
|
||||
// The cluster should have already computed its length, we can just copy that result
|
||||
assert(currentCluster);
|
||||
size = currentCluster.elementEndPos - dataStartPos;
|
||||
} else {
|
||||
// Search for the next element at level 0 or 1
|
||||
const nextElementPos = await searchForNextElementId(
|
||||
demuxer.reader,
|
||||
dataStartPos,
|
||||
LEVEL_0_AND_1_EBML_IDS,
|
||||
segment.elementEndPos,
|
||||
);
|
||||
|
||||
let cluster: Cluster;
|
||||
if (index === -1) {
|
||||
// This is the first time we've seen this cluster
|
||||
cluster = await demuxer.readCluster(elementStartPos, segment);
|
||||
} else {
|
||||
// We already know this cluster
|
||||
cluster = segment.clusters[index]!;
|
||||
}
|
||||
|
||||
// Even if we already know the cluster, we might not yet know its predecessor, so always do this
|
||||
if (prevCluster) prevCluster.nextCluster = cluster;
|
||||
prevCluster = cluster;
|
||||
|
||||
if (nextClusterIsFirstCluster) {
|
||||
cluster.isKnownToBeFirstCluster = true;
|
||||
nextClusterIsFirstCluster = false;
|
||||
}
|
||||
|
||||
const { clusterIndex, blockIndex, correctBlockFound } = getBestMatch();
|
||||
if (correctBlockFound) {
|
||||
const cluster = this.internalTrack.clusters[clusterIndex]!;
|
||||
return this.fetchPacketInCluster(cluster, blockIndex, options);
|
||||
}
|
||||
if (clusterIndex !== -1) {
|
||||
bestClusterIndex = clusterIndex;
|
||||
bestBlockIndex = blockIndex;
|
||||
}
|
||||
size = nextElementPos.pos - dataStartPos;
|
||||
}
|
||||
|
||||
if (size === null) {
|
||||
// Undefined element size (can happen in livestreamed files). In this case, we need to do some
|
||||
// searching to determine the actual size of the element.
|
||||
const endPos = dataStartPos + size;
|
||||
if (segment.elementEndPos !== null && endPos > segment.elementEndPos - MIN_HEADER_SIZE) {
|
||||
// No more elements fit in this segment
|
||||
break;
|
||||
} else {
|
||||
// Check the next element. If it's a new segment, we know this segment ends here. The new
|
||||
// segment is just ignored, since we're likely in a livestreamed file and thus only care about
|
||||
// the first segment.
|
||||
|
||||
if (id === EBMLId.Cluster) {
|
||||
// The cluster should have already computed its length, we can just copy that result
|
||||
assert(prevCluster);
|
||||
size = prevCluster.elementEndPos - dataStartPos;
|
||||
} else {
|
||||
// Search for the next element at level 0 or 1
|
||||
const nextElementPos = await searchForNextElementId(
|
||||
demuxer.reader,
|
||||
dataStartPos,
|
||||
LEVEL_0_AND_1_EBML_IDS,
|
||||
segment.elementEndPos,
|
||||
);
|
||||
let slice = demuxer.reader.requestSliceRange(endPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);
|
||||
if (slice instanceof Promise) slice = await slice;
|
||||
if (!slice) break;
|
||||
|
||||
size = nextElementPos.pos - dataStartPos;
|
||||
}
|
||||
|
||||
const endPos = dataStartPos + size;
|
||||
if (segment.elementEndPos !== null && endPos > segment.elementEndPos - MIN_HEADER_SIZE) {
|
||||
// No more elements fit in this segment
|
||||
const elementId = readElementId(slice);
|
||||
if (elementId === EBMLId.Segment) {
|
||||
segment.elementEndPos = endPos;
|
||||
break;
|
||||
} else {
|
||||
// Check the next element. If it's a new segment, we know this segment ends here. The new
|
||||
// segment is just ignored, since we're likely in a livestreamed file and thus only care about
|
||||
// the first segment.
|
||||
|
||||
let slice = demuxer.reader.requestSliceRange(endPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);
|
||||
if (slice instanceof Promise) slice = await slice;
|
||||
if (!slice) break;
|
||||
|
||||
const elementId = readElementId(slice);
|
||||
if (elementId === EBMLId.Segment) {
|
||||
segment.elementEndPos = endPos;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentPos = dataStartPos + size;
|
||||
}
|
||||
|
||||
const bestCluster = bestClusterIndex !== -1 ? this.internalTrack.clusters[bestClusterIndex]! : null;
|
||||
|
||||
// Catch faulty cue points
|
||||
if (cuePoint && (!bestCluster || bestCluster.elementStartPos < cuePoint.clusterPosition)) {
|
||||
// The cue point lied to us! We found a cue point but no cluster there that satisfied the match. In this
|
||||
// case, let's search again but using the cue point before that.
|
||||
const previousCuePoint = this.internalTrack.cuePoints[cuePointIndex - 1];
|
||||
const newSearchTimestamp = previousCuePoint?.time ?? -Infinity;
|
||||
return this.performClusterLookup(getBestMatch, newSearchTimestamp, latestTimestamp, options);
|
||||
}
|
||||
|
||||
if (bestCluster) {
|
||||
// If we finished looping but didn't find a perfect match, still return the best match we found
|
||||
return this.fetchPacketInCluster(bestCluster, bestBlockIndex, options);
|
||||
}
|
||||
|
||||
return null;
|
||||
} finally {
|
||||
release();
|
||||
currentPos = dataStartPos + size;
|
||||
}
|
||||
|
||||
// Catch faulty cue points
|
||||
if (cuePoint && (!bestCluster || bestCluster.elementStartPos < cuePoint.clusterPosition)) {
|
||||
// The cue point lied to us! We found a cue point but no cluster there that satisfied the match. In this
|
||||
// case, let's search again but using the cue point before that.
|
||||
const previousCuePoint = this.internalTrack.cuePoints[cuePointIndex - 1];
|
||||
const newSearchTimestamp = previousCuePoint?.time ?? -Infinity;
|
||||
return this.performClusterLookup(null, getMatchInCluster, newSearchTimestamp, latestTimestamp, options);
|
||||
}
|
||||
|
||||
if (bestCluster) {
|
||||
// If we finished looping but didn't find a perfect match, still return the best match we found
|
||||
return this.fetchPacketInCluster(bestCluster, bestBlockIndex, options);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -25,6 +25,7 @@ import {
|
||||
getUint24,
|
||||
insertSorted,
|
||||
isFirefox,
|
||||
isNumber,
|
||||
isSafari,
|
||||
last,
|
||||
mapAsyncGenerator,
|
||||
@@ -75,7 +76,7 @@ const validatePacketRetrievalOptions = (options: PacketRetrievalOptions) => {
|
||||
};
|
||||
|
||||
const validateTimestamp = (timestamp: number) => {
|
||||
if (typeof timestamp !== 'number' || Number.isNaN(timestamp)) {
|
||||
if (!isNumber(timestamp)) {
|
||||
throw new TypeError('timestamp must be a number.'); // It can be non-finite, that's fine
|
||||
}
|
||||
};
|
||||
|
||||
@@ -786,3 +786,7 @@ export const polyfillSymbolDispose = () => {
|
||||
// @ts-expect-error Readonly
|
||||
Symbol.dispose ??= Symbol('Symbol.dispose');
|
||||
};
|
||||
|
||||
export const isNumber = (x: unknown) => {
|
||||
return typeof x === 'number' && !Number.isNaN(x);
|
||||
};
|
||||
|
||||
+7
-1
@@ -129,6 +129,11 @@ export class FileSlice {
|
||||
this.bufferPos = value - this.offset;
|
||||
}
|
||||
|
||||
/** The number of bytes left from the current pos to the end of the slice. */
|
||||
get remainingLength() {
|
||||
return Math.max(this.end - this.filePos, 0);
|
||||
}
|
||||
|
||||
skip(byteCount: number) {
|
||||
this.bufferPos += byteCount;
|
||||
}
|
||||
@@ -153,7 +158,8 @@ const checkIsInRange = (slice: FileSlice, bytesToRead: number) => {
|
||||
if (slice.filePos < slice.start || slice.filePos + bytesToRead > slice.end) {
|
||||
throw new RangeError(
|
||||
`Tried reading [${slice.filePos}, ${slice.filePos + bytesToRead}), but slice is`
|
||||
+ ` [${slice.start}, ${slice.end}).`,
|
||||
+ ` [${slice.start}, ${slice.end}). This is likely an internal error, please report it alongside the file`
|
||||
+ ` that caused it.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
+2
-2
@@ -620,8 +620,8 @@ export class VideoSample implements Disposable {
|
||||
const { sx, sy, sWidth, sHeight } = this._rotateSourceRegion(
|
||||
options.crop?.left ?? 0,
|
||||
options.crop?.top ?? 0,
|
||||
options.crop?.width ?? this.codedWidth,
|
||||
options.crop?.height ?? this.codedHeight,
|
||||
options.crop?.width ?? rotatedWidth,
|
||||
options.crop?.height ?? rotatedHeight,
|
||||
rotation,
|
||||
);
|
||||
|
||||
|
||||
+11
-10
@@ -11,6 +11,7 @@ import {
|
||||
assert,
|
||||
binarySearchLessOrEqual,
|
||||
closedIntervalsOverlap,
|
||||
isNumber,
|
||||
MaybePromise,
|
||||
mergeRequestInit,
|
||||
promiseWithResolvers,
|
||||
@@ -172,9 +173,9 @@ export class BlobSource extends Source {
|
||||
}
|
||||
if (
|
||||
options.maxCacheSize !== undefined
|
||||
&& (!Number.isInteger(options.maxCacheSize) || options.maxCacheSize < 0)
|
||||
&& (!isNumber(options.maxCacheSize) || options.maxCacheSize < 0)
|
||||
) {
|
||||
throw new TypeError('options.maxCacheSize, when provided, must be a non-negative integer.');
|
||||
throw new TypeError('options.maxCacheSize, when provided, must be a non-negative number.');
|
||||
}
|
||||
|
||||
super();
|
||||
@@ -331,9 +332,9 @@ export class UrlSource extends Source {
|
||||
}
|
||||
if (
|
||||
options.maxCacheSize !== undefined
|
||||
&& (!Number.isInteger(options.maxCacheSize) || options.maxCacheSize < 0)
|
||||
&& (!isNumber(options.maxCacheSize) || options.maxCacheSize < 0)
|
||||
) {
|
||||
throw new TypeError('options.maxCacheSize, when provided, must be a non-negative integer.');
|
||||
throw new TypeError('options.maxCacheSize, when provided, must be a non-negative number.');
|
||||
}
|
||||
if (options.fetchFn !== undefined && typeof options.fetchFn !== 'function') {
|
||||
throw new TypeError('options.fetchFn, when provided, must be a function.');
|
||||
@@ -591,9 +592,9 @@ export class FilePathSource extends Source {
|
||||
}
|
||||
if (
|
||||
options.maxCacheSize !== undefined
|
||||
&& (!Number.isInteger(options.maxCacheSize) || options.maxCacheSize < 0)
|
||||
&& (!isNumber(options.maxCacheSize) || options.maxCacheSize < 0)
|
||||
) {
|
||||
throw new TypeError('options.maxCacheSize, when provided, must be a non-negative integer.');
|
||||
throw new TypeError('options.maxCacheSize, when provided, must be a non-negative number.');
|
||||
}
|
||||
|
||||
super();
|
||||
@@ -704,9 +705,9 @@ export class StreamSource extends Source {
|
||||
}
|
||||
if (
|
||||
options.maxCacheSize !== undefined
|
||||
&& (!Number.isInteger(options.maxCacheSize) || options.maxCacheSize < 0)
|
||||
&& (!isNumber(options.maxCacheSize) || options.maxCacheSize < 0)
|
||||
) {
|
||||
throw new TypeError('options.maxCacheSize, when provided, must be a non-negative integer.');
|
||||
throw new TypeError('options.maxCacheSize, when provided, must be a non-negative number.');
|
||||
}
|
||||
if (options.prefetchProfile && !['none', 'fileSystem', 'network'].includes(options.prefetchProfile)) {
|
||||
throw new TypeError(
|
||||
@@ -884,9 +885,9 @@ export class ReadableStreamSource extends Source {
|
||||
}
|
||||
if (
|
||||
options.maxCacheSize !== undefined
|
||||
&& (!Number.isInteger(options.maxCacheSize) || options.maxCacheSize < 0)
|
||||
&& (!isNumber(options.maxCacheSize) || options.maxCacheSize < 0)
|
||||
) {
|
||||
throw new TypeError('options.maxCacheSize, when provided, must be a non-negative integer.');
|
||||
throw new TypeError('options.maxCacheSize, when provided, must be a non-negative number.');
|
||||
}
|
||||
|
||||
super();
|
||||
|
||||
@@ -9,6 +9,7 @@ import { BufferTarget } from '../../src/target.js';
|
||||
import { CanvasSource, VideoSampleSource } from '../../src/media-source.js';
|
||||
import { canEncodeVideo, QUALITY_HIGH } from '../../src/encode.js';
|
||||
import { VideoSample } from '../../src/sample.js';
|
||||
import { Conversion } from '../../src/conversion.js';
|
||||
|
||||
test('Can decode transparent video', async () => {
|
||||
using input = new Input({
|
||||
@@ -269,3 +270,104 @@ test('Positive encodability check with alpha', async () => {
|
||||
const result = await canEncodeVideo('vp9', { alpha: 'keep' });
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test('Can transmux transparent video, discards alpha by default', async () => {
|
||||
using input = new Input({
|
||||
source: new UrlSource('/transparency.webm'),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
const output = new Output({
|
||||
format: new WebMOutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
const conversion = await Conversion.init({
|
||||
input,
|
||||
output,
|
||||
});
|
||||
await conversion.execute();
|
||||
|
||||
using outputInput = new Input({
|
||||
source: new BufferSource(output.target.buffer!),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const videoTrack = (await outputInput.getPrimaryVideoTrack())!;
|
||||
expect(await videoTrack.canBeTransparent()).toBe(false);
|
||||
|
||||
const sink = new VideoSampleSink(videoTrack);
|
||||
const sample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!;
|
||||
expect(sample.hasAlpha).toBe(false);
|
||||
});
|
||||
|
||||
test('Can transmux transparent video, can keep alpha', async () => {
|
||||
using input = new Input({
|
||||
source: new UrlSource('/transparency.webm'),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
const output = new Output({
|
||||
format: new WebMOutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
const conversion = await Conversion.init({
|
||||
input,
|
||||
output,
|
||||
video: {
|
||||
alpha: 'keep',
|
||||
},
|
||||
});
|
||||
await conversion.execute();
|
||||
|
||||
using outputInput = new Input({
|
||||
source: new BufferSource(output.target.buffer!),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const videoTrack = (await outputInput.getPrimaryVideoTrack())!;
|
||||
expect(await videoTrack.canBeTransparent()).toBe(true);
|
||||
|
||||
const sink = new VideoSampleSink(videoTrack);
|
||||
const sample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!;
|
||||
expect(sample.format).toContain('A');
|
||||
expect(sample.hasAlpha).toBe(true);
|
||||
});
|
||||
|
||||
test('Can reencode transparent video, keeping alpha', async () => {
|
||||
using input = new Input({
|
||||
source: new UrlSource('/transparency.webm'),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
const output = new Output({
|
||||
format: new WebMOutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
const conversion = await Conversion.init({
|
||||
input,
|
||||
output,
|
||||
video: {
|
||||
width: 320,
|
||||
alpha: 'keep',
|
||||
},
|
||||
trim: {
|
||||
start: 0,
|
||||
end: 0.5,
|
||||
},
|
||||
});
|
||||
await conversion.execute();
|
||||
|
||||
using outputInput = new Input({
|
||||
source: new BufferSource(output.target.buffer!),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const videoTrack = (await outputInput.getPrimaryVideoTrack())!;
|
||||
expect(await videoTrack.canBeTransparent()).toBe(true);
|
||||
expect(videoTrack.displayWidth).toBe(320);
|
||||
|
||||
const sink = new VideoSampleSink(videoTrack);
|
||||
const sample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!;
|
||||
expect(sample.format).toContain('A');
|
||||
expect(sample.hasAlpha).toBe(true);
|
||||
});
|
||||
|
||||
@@ -147,6 +147,7 @@ test('Read and write metadata, QuickTime', async () => {
|
||||
...songMetadata,
|
||||
raw: {
|
||||
'©sic': 'ko mode',
|
||||
'cust': new Uint8Array([1, 2, 3, 4]),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -179,11 +180,62 @@ test('Read and write metadata, QuickTime', async () => {
|
||||
|
||||
expect(readTags.raw!['©nam']).toBe(songMetadata.title);
|
||||
// We don't know what the data type is, so the demuxer just returns Uint8Array
|
||||
expect(readTags.raw!['©sic']).toEqual(new Uint8Array([
|
||||
0, 7, // String length 7
|
||||
85, 196, // Language code for 'und'
|
||||
107, 111, 32, 109, 111, 100, 101, // 'ko mode'
|
||||
]));
|
||||
expect(readTags.raw!['©sic']).toBe('ko mode'); // Parsed as string because it begins with ©
|
||||
expect(readTags.raw!['cust']).toEqual(new Uint8Array([1, 2, 3, 4]));
|
||||
});
|
||||
|
||||
test('Read and write metadata, MP4 with mdta format', async () => {
|
||||
const output = new Output({
|
||||
format: new Mp4OutputFormat({
|
||||
metadataFormat: 'mdta',
|
||||
}),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
output.setMetadataTags({
|
||||
...songMetadata,
|
||||
raw: {
|
||||
'AIGC': 'yes',
|
||||
'this key is': 'crazy long',
|
||||
'bytes': new Uint8Array([2, 3, 4, 5]),
|
||||
},
|
||||
});
|
||||
|
||||
const dummyTrack = createDummyAudioTrack('aac', output);
|
||||
|
||||
await output.start();
|
||||
await dummyTrack.addPacket();
|
||||
await output.finalize();
|
||||
|
||||
using input = new Input({
|
||||
source: new BufferSource(output.target.buffer!),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const readTags = await input.getMetadataTags();
|
||||
|
||||
expect(readTags.title).toBe(songMetadata.title);
|
||||
expect(readTags.description).toBe(songMetadata.description);
|
||||
expect(readTags.artist).toBe(songMetadata.artist);
|
||||
expect(readTags.album).toBe(songMetadata.album);
|
||||
expect(readTags.albumArtist).toBe(songMetadata.albumArtist);
|
||||
expect(readTags.comment).toBe(songMetadata.comment);
|
||||
expect(readTags.lyrics).toBe(songMetadata.lyrics);
|
||||
expect(readTags.trackNumber).toBe(songMetadata.trackNumber);
|
||||
expect(readTags.tracksTotal).toBe(songMetadata.tracksTotal);
|
||||
expect(readTags.discNumber).toBeUndefined(); // Not written
|
||||
expect(readTags.discsTotal).toBeUndefined();
|
||||
expect(readTags.date).toEqual(readTags.date);
|
||||
expect(readTags.images).toHaveLength(1);
|
||||
expect(readTags.images![0]!.data).toEqual(coverArt);
|
||||
expect(readTags.images![0]!.mimeType).toEqual('image/jpeg');
|
||||
expect(readTags.images![0]!.kind).toEqual('coverFront');
|
||||
expect(readTags.images![0]!.description).toBeUndefined(); // Lost in MP4
|
||||
|
||||
expect(readTags.raw!['title']).toBe(songMetadata.title);
|
||||
expect(readTags.raw!['AIGC']).toBe('yes');
|
||||
expect(readTags.raw!['this key is']).toBe('crazy long');
|
||||
expect(readTags.raw!['bytes']).toEqual(new Uint8Array([2, 3, 4, 5]));
|
||||
});
|
||||
|
||||
test('Read MOV metadata tags, ilst with keys', async () => {
|
||||
|
||||
Reference in New Issue
Block a user