mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-09 00:33:46 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9acc97da7a | ||
|
|
a08e262dfc | ||
|
|
e9d7bc4e83 | ||
|
|
5b22c8c17d | ||
|
|
7ec538e175 | ||
|
|
eceb350806 | ||
|
|
f8f660b31a | ||
|
|
b0a8802918 | ||
|
|
c159798c29 | ||
|
|
3023c5f7bd | ||
|
|
9b155cc8f2 | ||
|
|
4e4e6eed0c | ||
|
|
ce10bb774a | ||
|
|
445911edd0 | ||
|
|
7a871cec49 | ||
|
|
593cd6e2f5 | ||
|
|
a0e18ffde0 |
@@ -130,7 +130,7 @@ const { title, artist, album } = await input.getMetadataTags();
|
||||
### Create new media files
|
||||
|
||||
```js
|
||||
import { Output, Mp4OutputFormat, BufferTarget, CanvasSource, QUALITY_HIGH } from 'mediabunny';
|
||||
import { Output, Mp4OutputFormat, BufferTarget, CanvasSource, Quality } from 'mediabunny';
|
||||
|
||||
const output = new Output({
|
||||
format: new Mp4OutputFormat(),
|
||||
@@ -140,7 +140,7 @@ const output = new Output({
|
||||
// Add a video track backed by a canvas element
|
||||
const videoSource = new CanvasSource(canvas, {
|
||||
codec: 'avc',
|
||||
bitrate: QUALITY_HIGH,
|
||||
bitrate: new Quality('high'),
|
||||
});
|
||||
output.addVideoTrack(videoSource);
|
||||
|
||||
|
||||
+3
-2
@@ -129,6 +129,7 @@
|
||||
},
|
||||
*/
|
||||
video: {
|
||||
height: 320,
|
||||
//codec: 'avc',
|
||||
//bitrate: new Mediabunny.Quality({
|
||||
// quality: Infinity,
|
||||
@@ -157,7 +158,7 @@
|
||||
}
|
||||
},
|
||||
trim: {
|
||||
end: 0.5,
|
||||
end: 5,
|
||||
//start: -2,
|
||||
//end: 10,
|
||||
//start: 300.14984567374756 - 100,
|
||||
@@ -197,7 +198,7 @@
|
||||
document.body.append(video);
|
||||
video.play();
|
||||
|
||||
download(new Blob([target.buffer]), 'converted' + outputFormat.fileExtension);
|
||||
//download(new Blob([target.buffer]), 'converted' + outputFormat.fileExtension);
|
||||
|
||||
function download(blob, filename) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
+7
-7
@@ -47,7 +47,7 @@
|
||||
format = new Mediabunny.MkvOutputFormat();
|
||||
format = new Mediabunny.MovOutputFormat();
|
||||
format = new Mediabunny.Mp4OutputFormat({ fastStart: 'reserve' });
|
||||
format = new Mediabunny.WebMOutputFormat();
|
||||
format = new Mediabunny.Mp4OutputFormat();
|
||||
let target = new Mediabunny.BufferTarget();
|
||||
|
||||
/*
|
||||
@@ -118,21 +118,21 @@
|
||||
*/
|
||||
|
||||
let videoSource = new Mediabunny.CanvasSource(canvas, {
|
||||
codec: 'vp9',
|
||||
codec: 'avc',
|
||||
//fullCodecString: 'avc1.42001f',
|
||||
bitrate: 1e6,
|
||||
alpha: 'keep',
|
||||
onEncoderConfig: console.log,
|
||||
});
|
||||
let audioSource = new Mediabunny.AudioBufferSource({
|
||||
codec: 'opus',
|
||||
codec: 'aac',
|
||||
bitrate: 128e3,
|
||||
});
|
||||
let subtitleSource = new Mediabunny.TextSubtitleSource('webvtt');
|
||||
|
||||
output.addVideoTrack(videoSource, { languageCode: 'eng', name: 'Mononoké', maximumPacketCount: 100 });
|
||||
output.addAudioTrack(audioSource, { name: 'Yooo', maximumPacketCount: 1000 });
|
||||
//output.addSubtitleTrack(subtitleSource);
|
||||
output.addSubtitleTrack(subtitleSource);
|
||||
|
||||
output.start();
|
||||
|
||||
@@ -191,8 +191,8 @@ Testing... <00:17.350>One... <00:18.125>Two...
|
||||
9. <b>justify (bottom, right)</b>.
|
||||
`;
|
||||
|
||||
//subtitleSource.add(simpleWebvttFile);
|
||||
//subtitleSource.close();
|
||||
subtitleSource.add(simpleWebvttFile);
|
||||
subtitleSource.close();
|
||||
|
||||
const p = document.createElement('p');
|
||||
document.body.append(p);
|
||||
@@ -216,5 +216,5 @@ Testing... <00:17.350>One... <00:18.125>Two...
|
||||
await output.finalize();
|
||||
|
||||
console.log(target);
|
||||
//download(new Blob([target.buffer]), 'test' + format.fileExtension);
|
||||
download(new Blob([target.buffer]), 'test' + format.fileExtension);
|
||||
</script>
|
||||
@@ -648,6 +648,8 @@ segments-1.ts
|
||||
...
|
||||
```
|
||||
|
||||
This option is special-cased when used in conjunction with a fragmented MP4 segment format: the resulting segments file will be a standalone valid fMP4 file. This way, you get both an HLS playlist as well as a single-file representation of the media.
|
||||
|
||||
## Live HLS
|
||||
|
||||
By default, created HLS playlists are assumed to be VODs. If you instead want to create HLS live streams that are to be consumed while they are being produced, you can use live mode:
|
||||
|
||||
Generated
+9
-9
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mediabunny",
|
||||
"version": "1.52.1",
|
||||
"version": "1.53.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mediabunny",
|
||||
"version": "1.52.1",
|
||||
"version": "1.53.1",
|
||||
"license": "MPL-2.0",
|
||||
"workspaces": [
|
||||
".",
|
||||
@@ -12954,7 +12954,7 @@
|
||||
},
|
||||
"packages/aac-encoder": {
|
||||
"name": "@mediabunny/aac-encoder",
|
||||
"version": "1.52.1",
|
||||
"version": "1.53.1",
|
||||
"license": "MPL-2.0",
|
||||
"devDependencies": {
|
||||
"@types/emscripten": "^1.40.1"
|
||||
@@ -12969,7 +12969,7 @@
|
||||
},
|
||||
"packages/ac3": {
|
||||
"name": "@mediabunny/ac3",
|
||||
"version": "1.52.1",
|
||||
"version": "1.53.1",
|
||||
"license": "MPL-2.0",
|
||||
"devDependencies": {
|
||||
"@types/emscripten": "^1.40.1"
|
||||
@@ -12984,7 +12984,7 @@
|
||||
},
|
||||
"packages/flac-encoder": {
|
||||
"name": "@mediabunny/flac-encoder",
|
||||
"version": "1.52.1",
|
||||
"version": "1.53.1",
|
||||
"license": "MPL-2.0",
|
||||
"devDependencies": {
|
||||
"@types/emscripten": "^1.40.1"
|
||||
@@ -12999,7 +12999,7 @@
|
||||
},
|
||||
"packages/mp3-encoder": {
|
||||
"name": "@mediabunny/mp3-encoder",
|
||||
"version": "1.52.1",
|
||||
"version": "1.53.1",
|
||||
"license": "MPL-2.0",
|
||||
"devDependencies": {
|
||||
"@types/emscripten": "^1.40.1"
|
||||
@@ -13014,7 +13014,7 @@
|
||||
},
|
||||
"packages/prores": {
|
||||
"name": "@mediabunny/prores",
|
||||
"version": "1.52.1",
|
||||
"version": "1.53.1",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"turbores": "^1.2.2"
|
||||
@@ -13029,10 +13029,10 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@mediabunny/server",
|
||||
"version": "1.52.1",
|
||||
"version": "1.53.1",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"@mediabunny/prores": "^1.52.1",
|
||||
"@mediabunny/prores": "^1.53.1",
|
||||
"node-av": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mediabunny",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.52.1",
|
||||
"version": "1.53.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/aac-encoder",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.52.1",
|
||||
"version": "1.53.1",
|
||||
"description": "AAC encoder extension for Mediabunny, based on FFmpeg.",
|
||||
"main": "./dist/bundles/mediabunny-aac-encoder.mjs",
|
||||
"module": "./dist/bundles/mediabunny-aac-encoder.mjs",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@mediabunny/ac3",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.52.1",
|
||||
"version": "1.53.1",
|
||||
"description": "AC-3 and E-AC-3 (Dolby Digital) decoder and encoder extension for Mediabunny, based on FFmpeg.",
|
||||
"main": "./dist/bundles/mediabunny-ac3.mjs",
|
||||
"module": "./dist/bundles/mediabunny-ac3.mjs",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@mediabunny/flac-encoder",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.52.1",
|
||||
"version": "1.53.1",
|
||||
"description": "FLAC encoder extension for Mediabunny, based on libFLAC.",
|
||||
"main": "./dist/bundles/mediabunny-flac-encoder.mjs",
|
||||
"module": "./dist/bundles/mediabunny-flac-encoder.mjs",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@mediabunny/mp3-encoder",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.52.1",
|
||||
"version": "1.53.1",
|
||||
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
|
||||
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
|
||||
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@mediabunny/prores",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.52.1",
|
||||
"version": "1.53.1",
|
||||
"description": "Apple ProRes decoder extension for Mediabunny, based on TurboRes.",
|
||||
"main": "./dist/bundles/mediabunny-prores.mjs",
|
||||
"module": "./dist/bundles/mediabunny-prores.mjs",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@mediabunny/server",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.52.1",
|
||||
"version": "1.53.1",
|
||||
"description": "Adds full video and audio decoder and encoder support to Mediabunny for use in server-side environments (Node, Bun, Deno). Based on NodeAV.",
|
||||
"main": "./dist/bundles/mediabunny-server.cjs",
|
||||
"module": "./dist/bundles/mediabunny-server.mjs",
|
||||
@@ -35,7 +35,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"node-av": "^6.0.0",
|
||||
"@mediabunny/prores": "^1.52.1"
|
||||
"@mediabunny/prores": "^1.53.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"mediabunny": "^1.45.0"
|
||||
|
||||
@@ -64,7 +64,7 @@ export class AdtsMuxer extends Muxer {
|
||||
|
||||
// First packet - determine input format from metadata
|
||||
if (this.inputIsAdts === null) {
|
||||
validateAudioChunkMetadata(meta);
|
||||
validateAudioChunkMetadata(meta, track.source._codec);
|
||||
|
||||
const description = meta?.decoderConfig?.description;
|
||||
|
||||
@@ -119,6 +119,11 @@ export class AdtsMuxer extends Muxer {
|
||||
|
||||
async finalize() {
|
||||
const release = await this.mutex.acquire(); // Required so that finalize() can't resolve before other calls
|
||||
|
||||
if (this.inputIsAdts === null) {
|
||||
throw new Error('Cannot finalize an empty ADTS file: not a single packet was added.');
|
||||
}
|
||||
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
+22
-2
@@ -814,7 +814,10 @@ const HEVC_CODEC_STRING_REGEX = /^(hev1|hvc1)\.(?:[ABC]?\d+)\.[0-9a-fA-F]{1,8}\.
|
||||
const VP9_CODEC_STRING_REGEX = /^vp09(?:\.\d{2}){3}(?:(?:\.\d{2}){5})?$/;
|
||||
const AV1_CODEC_STRING_REGEX = /^av01\.\d\.\d{2}[MH]\.\d{2}(?:\.\d\.\d{3}\.\d{2}\.\d{2}\.\d{2}\.\d)?$/;
|
||||
|
||||
export const validateVideoChunkMetadata = (metadata: EncodedVideoChunkMetadata | undefined) => {
|
||||
export const validateVideoChunkMetadata = (
|
||||
metadata: EncodedVideoChunkMetadata | undefined,
|
||||
trackCodec: VideoCodec | null,
|
||||
) => {
|
||||
if (!metadata) {
|
||||
throw new TypeError('Video chunk metadata must be provided.');
|
||||
}
|
||||
@@ -985,13 +988,23 @@ export const validateVideoChunkMetadata = (metadata: EncodedVideoChunkMetadata |
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (trackCodec !== null && inferCodecFromCodecString(metadata.decoderConfig.codec) !== trackCodec) {
|
||||
throw new TypeError(
|
||||
`Video chunk metadata decoder configuration codec string '${metadata.decoderConfig.codec}' does not fit to`
|
||||
+ ` the track codec '${trackCodec}'.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const VALID_AUDIO_CODEC_STRING_PREFIXES = [
|
||||
'mp4a', 'mp3', 'opus', 'vorbis', 'flac', 'ulaw', 'alaw', 'pcm', 'ac-3', 'ec-3',
|
||||
];
|
||||
|
||||
export const validateAudioChunkMetadata = (metadata: EncodedAudioChunkMetadata | undefined) => {
|
||||
export const validateAudioChunkMetadata = (
|
||||
metadata: EncodedAudioChunkMetadata | undefined,
|
||||
trackCodec: AudioCodec | null,
|
||||
) => {
|
||||
if (!metadata) {
|
||||
throw new TypeError('Audio chunk metadata must be provided.');
|
||||
}
|
||||
@@ -1133,6 +1146,13 @@ export const validateAudioChunkMetadata = (metadata: EncodedAudioChunkMetadata |
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (trackCodec !== null && inferCodecFromCodecString(metadata.decoderConfig.codec) !== trackCodec) {
|
||||
throw new TypeError(
|
||||
`Audio chunk metadata decoder configuration codec string '${metadata.decoderConfig.codec}' does not fit to`
|
||||
+ ` the track codec '${trackCodec}'.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const validateSubtitleMetadata = (metadata: SubtitleMetadata | undefined) => {
|
||||
|
||||
+2
-2
@@ -1018,9 +1018,9 @@ export class Conversion {
|
||||
|
||||
// Let's check if the conversion can actually be executed
|
||||
if (!this._composable) {
|
||||
this.isValid = this.output.hasEnoughTracks();
|
||||
this.isValid = this.output.hasEnoughTracks() && this.output.tracks.length > 0;
|
||||
} else {
|
||||
// Checking Output start validity is not up to us. We consider even zero-track conversions to be valid
|
||||
// Checking Output start validity is not up to us. We even consider zero-track conversions to be valid
|
||||
this.isValid = true;
|
||||
}
|
||||
|
||||
|
||||
+14
-4
@@ -1117,8 +1117,13 @@ export const canEncodeVideo = async (
|
||||
}
|
||||
|
||||
for (const { config, quantizer } of candidates) {
|
||||
const support = await VideoEncoder.isConfigSupported(config);
|
||||
if (!support.supported) {
|
||||
try {
|
||||
const support = await VideoEncoder.isConfigSupported(config);
|
||||
if (!support.supported) {
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
// Can type-error when unknown config features are used
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1245,8 +1250,13 @@ export const canEncodeAudio = async (
|
||||
return false;
|
||||
}
|
||||
|
||||
const support = await AudioEncoder.isConfigSupported(encoderConfig);
|
||||
return support.supported === true;
|
||||
try {
|
||||
const support = await AudioEncoder.isConfigSupported(encoderConfig);
|
||||
return support.supported === true;
|
||||
} catch {
|
||||
// Can type-error when unknown config features are used
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
canEncodeAudioMemo.set(key, promise);
|
||||
|
||||
|
||||
+71
-38
@@ -57,9 +57,57 @@ export class FlacMuxer extends Muxer {
|
||||
this.writer = await this.output._getRootWriter(!!this.format._options.appendOnly);
|
||||
this.writer.write(FLAC_HEADER);
|
||||
|
||||
// If the track already tells us what the stream looks like, we can pin the stream info down right now
|
||||
const track = this.output.tracks[0];
|
||||
assert(track?.isAudioTrack());
|
||||
|
||||
if (track.metadata.decoderConfig) {
|
||||
validateAudioChunkMetadata({ decoderConfig: track.metadata.decoderConfig }, track.source._codec);
|
||||
this.applyDecoderConfig(track.metadata.decoderConfig);
|
||||
}
|
||||
|
||||
release();
|
||||
}
|
||||
|
||||
applyDecoderConfig(decoderConfig: AudioDecoderConfig) {
|
||||
assert(decoderConfig.description);
|
||||
|
||||
this.sampleRate = decoderConfig.sampleRate;
|
||||
this.channels = decoderConfig.numberOfChannels;
|
||||
|
||||
const descriptionBitstream = new Bitstream(
|
||||
toUint8Array(decoderConfig.description),
|
||||
);
|
||||
// skip 'fLaC' + block size + frame size + sample rate + number of channels
|
||||
// See demuxer for the exact structure
|
||||
descriptionBitstream.skipBits(103 + 64);
|
||||
this.bitsPerSample = descriptionBitstream.readBits(5) + 1;
|
||||
|
||||
if (this.format._options.appendOnly) {
|
||||
// Write STREAMINFO immediately since we can't seek back later.
|
||||
this.writeHeader({
|
||||
// https://www.rfc-editor.org/rfc/rfc9639.html#name-streaminfo
|
||||
// Per RFC 9639, min/max block sizes can be looser than
|
||||
// actual values, so we use the full valid range (16–65535).
|
||||
// "The actual max block size MAY be smaller than what's
|
||||
// listed, and the actual min (excluding last block) MAY be
|
||||
// larger. This is because the encoder has to write these
|
||||
// fields before receiving any input audio data and cannot
|
||||
// know beforehand what block sizes it will use."
|
||||
minimumBlockSize: 16,
|
||||
maximumBlockSize: 65535,
|
||||
// https://www.rfc-editor.org/rfc/rfc9639.html#name-streaminfo
|
||||
// "A value of 0 signifies that the value is not known."
|
||||
minimumFrameSize: 0,
|
||||
maximumFrameSize: 0,
|
||||
sampleRate: this.sampleRate,
|
||||
channels: this.channels,
|
||||
bitsPerSample: this.bitsPerSample,
|
||||
totalSamples: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
writeHeader({
|
||||
bitsPerSample,
|
||||
minimumBlockSize,
|
||||
@@ -222,47 +270,12 @@ export class FlacMuxer extends Muxer {
|
||||
|
||||
if (this.sampleRate === null) {
|
||||
// It's the first packet
|
||||
validateAudioChunkMetadata(meta);
|
||||
validateAudioChunkMetadata(meta, track.source._codec);
|
||||
|
||||
assert(meta);
|
||||
assert(meta.decoderConfig);
|
||||
assert(meta.decoderConfig.description);
|
||||
|
||||
this.sampleRate = meta.decoderConfig.sampleRate;
|
||||
this.channels = meta.decoderConfig.numberOfChannels;
|
||||
|
||||
const descriptionBitstream = new Bitstream(
|
||||
toUint8Array(meta.decoderConfig.description),
|
||||
);
|
||||
// skip 'fLaC' + block size + frame size + sample rate + number of channels
|
||||
// See demuxer for the exact structure
|
||||
descriptionBitstream.skipBits(103 + 64);
|
||||
const bitsPerSample = descriptionBitstream.readBits(5) + 1;
|
||||
this.bitsPerSample = bitsPerSample;
|
||||
|
||||
if (this.format._options.appendOnly) {
|
||||
// Write STREAMINFO immediately since we can't seek back later.
|
||||
this.writeHeader({
|
||||
// https://www.rfc-editor.org/rfc/rfc9639.html#name-streaminfo
|
||||
// Per RFC 9639, min/max block sizes can be looser than
|
||||
// actual values, so we use the full valid range (16–65535).
|
||||
// "The actual max block size MAY be smaller than what's
|
||||
// listed, and the actual min (excluding last block) MAY be
|
||||
// larger. This is because the encoder has to write these
|
||||
// fields before receiving any input audio data and cannot
|
||||
// know beforehand what block sizes it will use."
|
||||
minimumBlockSize: 16,
|
||||
maximumBlockSize: 65535,
|
||||
// https://www.rfc-editor.org/rfc/rfc9639.html#name-streaminfo
|
||||
// "A value of 0 signifies that the value is not known."
|
||||
minimumFrameSize: 0,
|
||||
maximumFrameSize: 0,
|
||||
sampleRate: this.sampleRate,
|
||||
channels: this.channels,
|
||||
bitsPerSample: this.bitsPerSample,
|
||||
totalSamples: 0,
|
||||
});
|
||||
}
|
||||
this.applyDecoderConfig(meta.decoderConfig);
|
||||
}
|
||||
|
||||
if (!this.metadataWritten) {
|
||||
@@ -306,6 +319,18 @@ export class FlacMuxer extends Muxer {
|
||||
async finalize(): Promise<void> {
|
||||
const release = await this.mutex.acquire();
|
||||
|
||||
if (this.sampleRate === null) {
|
||||
throw new Error(
|
||||
'Cannot finalize an empty FLAC file: no packets were added and the track specified no decoderConfig in'
|
||||
+ ' its metadata, so there\'s no telling what the file should look like.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.metadataWritten) {
|
||||
// Not a single packet came in, so this never happened yet
|
||||
this.writeVorbisCommentAndPictureBlock();
|
||||
}
|
||||
|
||||
if (!this.format._options.appendOnly) {
|
||||
let minimumBlockSize = Infinity;
|
||||
let maximumBlockSize = 0;
|
||||
@@ -328,7 +353,15 @@ export class FlacMuxer extends Muxer {
|
||||
minimumBlockSize = Math.min(minimumBlockSize, this.blockSizes[i]!);
|
||||
}
|
||||
|
||||
assert(this.sampleRate !== null);
|
||||
if (this.blockSizes.length === 0) {
|
||||
// There are no frames to derive these from, so let's use the full valid range like we do for
|
||||
// append-only output
|
||||
minimumBlockSize = 16;
|
||||
maximumBlockSize = 65535;
|
||||
minimumFrameSize = 0;
|
||||
maximumFrameSize = 0;
|
||||
}
|
||||
|
||||
assert(this.channels !== null);
|
||||
assert(this.bitsPerSample !== null);
|
||||
|
||||
|
||||
+262
-105
@@ -40,6 +40,7 @@ import { EncodedPacket } from '../packet';
|
||||
import { SubtitleCue, SubtitleMetadata } from '../subtitles';
|
||||
import { NullTarget, PathedTarget, Target, TargetRequest } from '../target';
|
||||
import { HLS_MIME_TYPE } from './hls-misc';
|
||||
import type { IsobmffMuxer } from '../isobmff/isobmff-muxer';
|
||||
|
||||
type HlsTrackData = {
|
||||
track: OutputTrack;
|
||||
@@ -50,9 +51,11 @@ type HlsTrackData = {
|
||||
info: {
|
||||
type: 'video';
|
||||
decoderConfig: VideoDecoderConfig;
|
||||
primingPacket: EncodedPacket | null;
|
||||
} | {
|
||||
type: 'audio';
|
||||
decoderConfig: AudioDecoderConfig;
|
||||
primingPacket: EncodedPacket | null;
|
||||
};
|
||||
};
|
||||
type HlsVideoTrackData = HlsTrackData & { info: { type: 'video' } };
|
||||
@@ -88,6 +91,11 @@ type Playlist = {
|
||||
path: string;
|
||||
nextOffset: number;
|
||||
info: HlsOutputSegmentInfo;
|
||||
/**
|
||||
* Used for the special-cased logic where single file mode is enabled with fMP4. In this case, we write out a
|
||||
* segments file which is also a perfectly valid standalone fMP4 valid.
|
||||
*/
|
||||
fragmentedIsobmffOutput: FragmentedIsobmffOutput | null;
|
||||
} | null;
|
||||
|
||||
// For HLS, having a single mutex is too coarse. Every playlist is basically independent and therefore we can have
|
||||
@@ -103,6 +111,14 @@ type PlaylistDeclaration = {
|
||||
references: PlaylistDeclaration[];
|
||||
};
|
||||
|
||||
type FragmentedIsobmffOutput = {
|
||||
output: Output;
|
||||
videoSource: EncodedVideoPacketSource | null;
|
||||
audioSource: EncodedAudioPacketSource | null;
|
||||
firstMoofPosition: number | null;
|
||||
currentFileSize: number;
|
||||
};
|
||||
|
||||
export class HlsMuxer extends Muxer {
|
||||
format: HlsOutputFormat;
|
||||
getPlaylistPath: NonNullable<HlsOutputFormatOptions['getPlaylistPath']>;
|
||||
@@ -510,6 +526,22 @@ export class HlsMuxer extends Muxer {
|
||||
});
|
||||
}
|
||||
|
||||
for (const track of this.output.tracks) {
|
||||
if (track.isVideoTrack() && track.metadata.decoderConfig) {
|
||||
this.getVideoTrackData(
|
||||
track,
|
||||
track.metadata.primingPacket ?? null,
|
||||
{ decoderConfig: track.metadata.decoderConfig },
|
||||
);
|
||||
} else if (track.isAudioTrack() && track.metadata.decoderConfig) {
|
||||
this.getAudioTrackData(
|
||||
track,
|
||||
track.metadata.primingPacket ?? null,
|
||||
{ decoderConfig: track.metadata.decoderConfig },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
release();
|
||||
}
|
||||
|
||||
@@ -546,13 +578,13 @@ export class HlsMuxer extends Muxer {
|
||||
}
|
||||
}
|
||||
|
||||
getVideoTrackData(track: OutputVideoTrack, meta?: EncodedVideoChunkMetadata) {
|
||||
getVideoTrackData(track: OutputVideoTrack, packet: EncodedPacket | null, meta?: EncodedVideoChunkMetadata) {
|
||||
let trackData = this.trackDatas.find(x => x.track === track) as HlsVideoTrackData;
|
||||
if (trackData) {
|
||||
return trackData;
|
||||
}
|
||||
|
||||
validateVideoChunkMetadata(meta);
|
||||
validateVideoChunkMetadata(meta, track.source._codec);
|
||||
|
||||
assert(meta);
|
||||
assert(meta?.decoderConfig);
|
||||
@@ -568,6 +600,7 @@ export class HlsMuxer extends Muxer {
|
||||
info: {
|
||||
type: 'video',
|
||||
decoderConfig: meta.decoderConfig,
|
||||
primingPacket: packet,
|
||||
},
|
||||
};
|
||||
this.trackDatas.push(trackData);
|
||||
@@ -575,13 +608,13 @@ export class HlsMuxer extends Muxer {
|
||||
return trackData;
|
||||
}
|
||||
|
||||
getAudioTrackData(track: OutputAudioTrack, meta?: EncodedAudioChunkMetadata) {
|
||||
getAudioTrackData(track: OutputAudioTrack, packet: EncodedPacket | null, meta?: EncodedAudioChunkMetadata) {
|
||||
let trackData = this.trackDatas.find(x => x.track === track) as HlsAudioTrackData;
|
||||
if (trackData) {
|
||||
return trackData;
|
||||
}
|
||||
|
||||
validateAudioChunkMetadata(meta);
|
||||
validateAudioChunkMetadata(meta, track.source._codec);
|
||||
|
||||
assert(meta);
|
||||
assert(meta?.decoderConfig);
|
||||
@@ -597,6 +630,7 @@ export class HlsMuxer extends Muxer {
|
||||
info: {
|
||||
type: 'audio',
|
||||
decoderConfig: meta.decoderConfig,
|
||||
primingPacket: packet,
|
||||
},
|
||||
};
|
||||
this.trackDatas.push(trackData);
|
||||
@@ -609,7 +643,7 @@ export class HlsMuxer extends Muxer {
|
||||
packet: EncodedPacket,
|
||||
meta?: EncodedVideoChunkMetadata,
|
||||
) {
|
||||
const trackData = this.getVideoTrackData(track, meta);
|
||||
const trackData = this.getVideoTrackData(track, packet, meta);
|
||||
const playlist = trackData.playlist;
|
||||
|
||||
const release = await playlist.mutex.acquire();
|
||||
@@ -638,7 +672,7 @@ export class HlsMuxer extends Muxer {
|
||||
packet: EncodedPacket,
|
||||
meta?: EncodedAudioChunkMetadata,
|
||||
) {
|
||||
const trackData = this.getAudioTrackData(track, meta);
|
||||
const trackData = this.getAudioTrackData(track, packet, meta);
|
||||
const playlist = trackData.playlist;
|
||||
|
||||
const release = await playlist.mutex.acquire();
|
||||
@@ -680,14 +714,18 @@ export class HlsMuxer extends Muxer {
|
||||
return;
|
||||
}
|
||||
|
||||
const trackDatas = this.trackDatas.filter(x => playlist.tracks.includes(x.track));
|
||||
|
||||
if (playlist.currentSegmentStartTimestamp === null) {
|
||||
// All tracks are known but we never received any data - all tracks must be closed already
|
||||
await this.onPlaylistDone(playlist);
|
||||
// All tracks are known but we never received any data. Tracks that declared themselves up front are known
|
||||
// before their first packet, so we can only call it a day once they're actually closed.
|
||||
if (trackDatas.every(x => x.closed)) {
|
||||
await this.onPlaylistDone(playlist);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const trackDatas = this.trackDatas.filter(x => playlist.tracks.includes(x.track));
|
||||
const videoTrack = trackDatas.find(x => x.info.type === 'video') as HlsVideoTrackData | undefined;
|
||||
const audioTrack = trackDatas.find(x => x.info.type === 'audio') as HlsAudioTrackData | undefined;
|
||||
|
||||
@@ -857,13 +895,86 @@ export class HlsMuxer extends Muxer {
|
||||
isRoot: false,
|
||||
mimeType: playlist.segmentFormat.mimeType,
|
||||
});
|
||||
target._start();
|
||||
|
||||
let fragmentedIsobmffOutput: FragmentedIsobmffOutput | null = null;
|
||||
if (playlist.segmentFormat._isFragmentedIsobmff()) {
|
||||
// HARDCODED SPECIAL CASE: Single file mode with fragmented ISOBMFF. Instead of merely creating
|
||||
// a single file that's the concatenation of a bunch of smaller files, here we actually produce
|
||||
// one single fMP4 file that holds all segment media data. The result is a segments file that is
|
||||
// playable standalone!
|
||||
|
||||
fragmentedIsobmffOutput = {
|
||||
output: new Output({
|
||||
format: playlist.segmentFormat,
|
||||
target,
|
||||
}),
|
||||
videoSource: null,
|
||||
audioSource: null,
|
||||
firstMoofPosition: null,
|
||||
currentFileSize: 0,
|
||||
};
|
||||
|
||||
target.on('write', ({ end }) => {
|
||||
fragmentedIsobmffOutput!.currentFileSize = Math.max(
|
||||
fragmentedIsobmffOutput!.currentFileSize,
|
||||
end,
|
||||
);
|
||||
});
|
||||
|
||||
// Make sure it never auto-finalizes fragments for us; we take full control of fragment
|
||||
// finalization to line it up perfectly with segments
|
||||
const muxer = fragmentedIsobmffOutput.output._muxer as IsobmffMuxer;
|
||||
muxer.minimumFragmentDuration = Infinity;
|
||||
|
||||
// Intercept the first moof to determine init segment size
|
||||
const originalOnMoof = muxer.formatOptions.onMoof;
|
||||
muxer.formatOptions.onMoof = (data, position, timestamp) => {
|
||||
fragmentedIsobmffOutput!.firstMoofPosition = position;
|
||||
originalOnMoof?.(data, position, timestamp);
|
||||
muxer.formatOptions.onMoof = originalOnMoof;
|
||||
};
|
||||
|
||||
// Add video track
|
||||
if (videoTrack) {
|
||||
fragmentedIsobmffOutput.videoSource = new EncodedVideoPacketSource(
|
||||
(videoTrack.track as OutputVideoTrack).source._codec,
|
||||
);
|
||||
fragmentedIsobmffOutput.output.addVideoTrack(
|
||||
fragmentedIsobmffOutput.videoSource,
|
||||
{
|
||||
...videoTrack.track.metadata,
|
||||
decoderConfig: videoTrack.info.decoderConfig,
|
||||
primingPacket: videoTrack.info.primingPacket ?? undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Add audio track
|
||||
if (audioTrack) {
|
||||
fragmentedIsobmffOutput.audioSource = new EncodedAudioPacketSource(
|
||||
(audioTrack.track as OutputAudioTrack).source._codec,
|
||||
);
|
||||
fragmentedIsobmffOutput.output.addAudioTrack(
|
||||
fragmentedIsobmffOutput.audioSource,
|
||||
{
|
||||
...audioTrack.track.metadata,
|
||||
decoderConfig: audioTrack.info.decoderConfig,
|
||||
primingPacket: audioTrack.info.primingPacket ?? undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await fragmentedIsobmffOutput.output.start();
|
||||
} else {
|
||||
target._start();
|
||||
}
|
||||
|
||||
playlist.singleFile = {
|
||||
target,
|
||||
path: relativeSegmentPath,
|
||||
nextOffset: 0,
|
||||
info: segmentInfo,
|
||||
fragmentedIsobmffOutput,
|
||||
};
|
||||
} else {
|
||||
relativeSegmentPath = playlist.singleFile.path;
|
||||
@@ -889,115 +1000,134 @@ export class HlsMuxer extends Muxer {
|
||||
|
||||
let segmentSize = 0;
|
||||
let outputTarget: Target | null = null;
|
||||
let maxEndTimestamp = -Infinity;
|
||||
|
||||
const output = new Output({
|
||||
format: playlist.segmentFormat,
|
||||
target: new PathedTarget(
|
||||
fullSegmentPath,
|
||||
async (request: TargetRequest) => {
|
||||
const proxiedRequest: TargetRequest = {
|
||||
...request,
|
||||
isRoot: false,
|
||||
};
|
||||
let output: Output | null = null;
|
||||
let videoSource: EncodedVideoPacketSource | null = null;
|
||||
let audioSource: EncodedAudioPacketSource | null = null;
|
||||
|
||||
try {
|
||||
if (playlist.singleFile?.fragmentedIsobmffOutput) {
|
||||
output = playlist.singleFile.fragmentedIsobmffOutput.output;
|
||||
videoSource = playlist.singleFile.fragmentedIsobmffOutput.videoSource;
|
||||
audioSource = playlist.singleFile.fragmentedIsobmffOutput.audioSource;
|
||||
} else {
|
||||
// Create the output for this segment
|
||||
output = new Output({
|
||||
format: playlist.segmentFormat,
|
||||
target: new PathedTarget(
|
||||
fullSegmentPath,
|
||||
async (request: TargetRequest) => {
|
||||
const proxiedRequest: TargetRequest = {
|
||||
...request,
|
||||
isRoot: false,
|
||||
};
|
||||
|
||||
if (request.isRoot) {
|
||||
if (playlist.singleFile) {
|
||||
const slice = playlist.singleFile.target.slice(playlist.singleFile.nextOffset);
|
||||
slice.on('write', ({ end }) => segmentSize = Math.max(segmentSize, end));
|
||||
|
||||
return slice;
|
||||
} else {
|
||||
const target = await this.output._getTarget(proxiedRequest);
|
||||
outputTarget = target;
|
||||
target.on('write', ({ end }) => segmentSize = Math.max(segmentSize, end));
|
||||
|
||||
return target;
|
||||
}
|
||||
}
|
||||
|
||||
return this.output._getTarget(proxiedRequest);
|
||||
},
|
||||
),
|
||||
initTarget: async () => {
|
||||
if (playlist.initSegment) {
|
||||
// We already have an init segment from a previous segment
|
||||
return new NullTarget();
|
||||
}
|
||||
|
||||
if (request.isRoot) {
|
||||
if (playlist.singleFile) {
|
||||
playlist.initSegment = {
|
||||
path: playlist.singleFile.path,
|
||||
duration: 0,
|
||||
timestamp: 0,
|
||||
byteSize: 0,
|
||||
byteOffset: 0,
|
||||
info: null,
|
||||
};
|
||||
|
||||
const slice = playlist.singleFile.target.slice(playlist.singleFile.nextOffset);
|
||||
slice.on('write', ({ end }) => segmentSize = Math.max(segmentSize, end));
|
||||
slice.on('write', ({ end }) => {
|
||||
playlist.initSegment!.byteSize = Math.max(playlist.initSegment!.byteSize, end);
|
||||
});
|
||||
slice.on('finalized', () => {
|
||||
playlist.singleFile!.nextOffset = playlist.initSegment!.byteSize;
|
||||
});
|
||||
|
||||
return slice;
|
||||
} else {
|
||||
const target = await this.output._getTarget(proxiedRequest);
|
||||
outputTarget = target;
|
||||
target.on('write', ({ end }) => segmentSize = Math.max(segmentSize, end));
|
||||
const playlistInfo = toPlaylistInfo(playlist);
|
||||
const initPath = await this.getInitPath(playlistInfo);
|
||||
validateInitPath(initPath);
|
||||
|
||||
playlist.initSegment = {
|
||||
path: initPath,
|
||||
duration: 0,
|
||||
timestamp: 0,
|
||||
byteSize: 0,
|
||||
byteOffset: null,
|
||||
info: null,
|
||||
};
|
||||
|
||||
const fullInitPath = joinPaths(
|
||||
joinPaths(pathedTarget.rootPath, playlist.path),
|
||||
initPath,
|
||||
);
|
||||
const target = await this.output._getTarget({
|
||||
path: fullInitPath,
|
||||
isRoot: false,
|
||||
mimeType: playlist.segmentFormat.mimeType,
|
||||
});
|
||||
target.on('write', ({ end }) => {
|
||||
playlist.initSegment!.byteSize = Math.max(playlist.initSegment!.byteSize, end);
|
||||
});
|
||||
target.on('finalized', () => {
|
||||
this.format._options.onInit?.(target, playlistInfo);
|
||||
});
|
||||
|
||||
return target;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return this.output._getTarget(proxiedRequest);
|
||||
},
|
||||
),
|
||||
initTarget: async () => {
|
||||
if (playlist.initSegment) {
|
||||
// We already have an init segment from a previous segment
|
||||
return new NullTarget();
|
||||
}
|
||||
|
||||
if (playlist.singleFile) {
|
||||
playlist.initSegment = {
|
||||
path: playlist.singleFile.path,
|
||||
duration: 0,
|
||||
timestamp: 0,
|
||||
byteSize: 0,
|
||||
byteOffset: 0,
|
||||
info: null,
|
||||
};
|
||||
|
||||
const slice = playlist.singleFile.target.slice(playlist.singleFile.nextOffset);
|
||||
slice.on('write', ({ end }) => {
|
||||
playlist.initSegment!.byteSize = Math.max(playlist.initSegment!.byteSize, end);
|
||||
});
|
||||
slice.on('finalized', () => {
|
||||
playlist.singleFile!.nextOffset = playlist.initSegment!.byteSize;
|
||||
});
|
||||
|
||||
return slice;
|
||||
} else {
|
||||
const playlistInfo = toPlaylistInfo(playlist);
|
||||
const initPath = await this.getInitPath(playlistInfo);
|
||||
validateInitPath(initPath);
|
||||
|
||||
playlist.initSegment = {
|
||||
path: initPath,
|
||||
duration: 0,
|
||||
timestamp: 0,
|
||||
byteSize: 0,
|
||||
byteOffset: null,
|
||||
info: null,
|
||||
};
|
||||
|
||||
const fullInitPath = joinPaths(
|
||||
joinPaths(pathedTarget.rootPath, playlist.path),
|
||||
initPath,
|
||||
if (videoTrack) {
|
||||
// Always add the track, no matter if it has packets or not (maintains underlying IDs)
|
||||
videoSource = new EncodedVideoPacketSource(
|
||||
(videoTrack.track as OutputVideoTrack).source._codec,
|
||||
);
|
||||
const target = await this.output._getTarget({
|
||||
path: fullInitPath,
|
||||
isRoot: false,
|
||||
mimeType: playlist.segmentFormat.mimeType,
|
||||
output.addVideoTrack(videoSource, {
|
||||
...videoTrack.track.metadata,
|
||||
decoderConfig: videoTrack.info.decoderConfig,
|
||||
primingPacket: videoTrack.info.primingPacket ?? undefined,
|
||||
});
|
||||
target.on('write', ({ end }) => {
|
||||
playlist.initSegment!.byteSize = Math.max(playlist.initSegment!.byteSize, end);
|
||||
});
|
||||
target.on('finalized', () => {
|
||||
this.format._options.onInit?.(target, playlistInfo);
|
||||
});
|
||||
|
||||
return target;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
let maxEndTimestamp = -Infinity;
|
||||
if (audioTrack) {
|
||||
// Always add the track, no matter if it has packets or not (maintains underlying IDs)
|
||||
audioSource = new EncodedAudioPacketSource(
|
||||
(audioTrack.track as OutputAudioTrack).source._codec,
|
||||
);
|
||||
output.addAudioTrack(audioSource, {
|
||||
...audioTrack.track.metadata,
|
||||
decoderConfig: audioTrack.info.decoderConfig,
|
||||
primingPacket: audioTrack.info.primingPacket ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
let videoSource: EncodedVideoPacketSource | null = null;
|
||||
let audioSource: EncodedAudioPacketSource | null = null;
|
||||
|
||||
if (videoTrack) {
|
||||
// Always add the track, no matter if it has packets or not (maintains underlying IDs)
|
||||
videoSource = new EncodedVideoPacketSource((videoTrack.track as OutputVideoTrack).source._codec);
|
||||
output.addVideoTrack(videoSource, videoTrack.track.metadata);
|
||||
await output.start();
|
||||
}
|
||||
|
||||
if (audioTrack) {
|
||||
// Always add the track, no matter if it has packets or not (maintains underlying IDs)
|
||||
audioSource = new EncodedAudioPacketSource((audioTrack.track as OutputAudioTrack).source._codec);
|
||||
output.addAudioTrack(audioSource, audioTrack.track.metadata);
|
||||
}
|
||||
|
||||
await output.start();
|
||||
|
||||
// Add all of the packets
|
||||
|
||||
if (videoTrack) {
|
||||
@@ -1024,9 +1154,32 @@ export class HlsMuxer extends Muxer {
|
||||
}
|
||||
}
|
||||
|
||||
await output.finalize();
|
||||
if (playlist.singleFile?.fragmentedIsobmffOutput) {
|
||||
const muxer = playlist.singleFile.fragmentedIsobmffOutput.output._muxer as IsobmffMuxer;
|
||||
await muxer.forceFragmentFinalization();
|
||||
|
||||
if (
|
||||
playlist.singleFile.fragmentedIsobmffOutput.firstMoofPosition !== null
|
||||
&& !playlist.initSegment
|
||||
) {
|
||||
playlist.initSegment = {
|
||||
path: playlist.singleFile.path,
|
||||
duration: 0,
|
||||
timestamp: 0,
|
||||
byteSize: playlist.singleFile.fragmentedIsobmffOutput.firstMoofPosition,
|
||||
byteOffset: 0,
|
||||
info: null,
|
||||
};
|
||||
playlist.singleFile.nextOffset = playlist.singleFile.fragmentedIsobmffOutput.firstMoofPosition;
|
||||
}
|
||||
|
||||
segmentSize
|
||||
= playlist.singleFile.fragmentedIsobmffOutput.currentFileSize - playlist.singleFile.nextOffset;
|
||||
} else {
|
||||
await output.finalize();
|
||||
}
|
||||
} catch (e) {
|
||||
await output.cancel();
|
||||
await output?.cancel();
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -1102,8 +1255,12 @@ export class HlsMuxer extends Muxer {
|
||||
playlist.done = true;
|
||||
|
||||
if (playlist.singleFile) {
|
||||
await playlist.singleFile.target._flush();
|
||||
await playlist.singleFile.target._finalize();
|
||||
if (playlist.singleFile.fragmentedIsobmffOutput) {
|
||||
await playlist.singleFile.fragmentedIsobmffOutput.output.finalize();
|
||||
} else {
|
||||
await playlist.singleFile.target._flush();
|
||||
await playlist.singleFile.target._finalize();
|
||||
}
|
||||
|
||||
this.format._options.onSegment?.(playlist.singleFile.target, playlist.singleFile.info);
|
||||
}
|
||||
|
||||
+5
-2
@@ -336,8 +336,11 @@ export class Input<S extends Source = Source> extends EventEmitter<InputEvents>
|
||||
return 0;
|
||||
}
|
||||
|
||||
const firstTimestamps = await Promise.all(filtered.map(x => x.getFirstTimestamp()));
|
||||
return Math.min(...firstTimestamps);
|
||||
// Only count the timestamps of tracks that have at least one packet
|
||||
const firstPackets = await Promise.all(filtered.map(x => x._backing.getFirstPacket({ metadataOnly: true })));
|
||||
const result = Math.min(...firstPackets.map(x => x?.timestamp ?? Infinity));
|
||||
|
||||
return result === Infinity ? 0 : result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -493,6 +493,17 @@ export const tkhd = (
|
||||
flags |= 0x1; // Track enabled
|
||||
}
|
||||
|
||||
// Set the alternate group based on the track type; this mirror's how FFmpeg does it. A more advanced version would
|
||||
// determine the alternate groups based on the actual track pairability graph. Note that it appears important that
|
||||
// video get assigned to group 0, see https://github.com/Vanilagy/mediabunny/issues/454.
|
||||
const alternateGroup = trackData.type === 'video'
|
||||
? 0
|
||||
: trackData.type === 'audio'
|
||||
? 1
|
||||
: trackData.type === 'subtitle'
|
||||
? 2
|
||||
: assertNever(trackData);
|
||||
|
||||
return fullBox('tkhd', +needsU64, flags, [
|
||||
u32OrU64(creationTime), // Creation time
|
||||
u32OrU64(creationTime), // Modification time
|
||||
@@ -501,7 +512,7 @@ export const tkhd = (
|
||||
u32OrU64(durationInGlobalTimescale), // Duration
|
||||
Array(8).fill(0), // Reserved
|
||||
u16(0), // Layer
|
||||
u16(trackData.track.id), // Alternate group
|
||||
u16(alternateGroup), // Alternate group
|
||||
fixed_8_8(trackData.type === 'audio' ? 1 : 0), // Volume
|
||||
u16(0), // Reserved
|
||||
matrixToBytes(matrix), // Matrix
|
||||
@@ -1037,7 +1048,9 @@ const pcmC = (trackData: IsobmffAudioTrackData) => {
|
||||
|
||||
/** AC3SpecificBox */
|
||||
const dac3 = (trackData: IsobmffAudioTrackData) => {
|
||||
const frameInfo = parseAc3SyncFrame(trackData.info.firstPacket.data);
|
||||
assert(trackData.info.primingPacket);
|
||||
|
||||
const frameInfo = parseAc3SyncFrame(trackData.info.primingPacket.data);
|
||||
if (!frameInfo) {
|
||||
throw new Error(
|
||||
'Couldn\'t extract AC-3 frame info from the audio packet. '
|
||||
@@ -1061,7 +1074,9 @@ const dac3 = (trackData: IsobmffAudioTrackData) => {
|
||||
|
||||
/** EC3SpecificBox */
|
||||
const dec3 = (trackData: IsobmffAudioTrackData) => {
|
||||
const frameInfo = parseEac3SyncFrame(trackData.info.firstPacket.data);
|
||||
assert(trackData.info.primingPacket);
|
||||
|
||||
const frameInfo = parseEac3SyncFrame(trackData.info.primingPacket.data);
|
||||
if (!frameInfo) {
|
||||
throw new Error(
|
||||
'Couldn\'t extract E-AC-3 frame info from the audio packet. '
|
||||
@@ -1426,7 +1441,7 @@ export const mfra = (trackDatas: IsobmffTrackData[]) => {
|
||||
};
|
||||
|
||||
/** Track Fragment Random Access Box: Provides pointers to sync samples within the file for random access. */
|
||||
export const tfra = (trackData: IsobmffTrackData, trackIndex: number) => {
|
||||
export const tfra = (trackData: IsobmffTrackData) => {
|
||||
const version = 1; // Using this version allows us to use 64-bit time and offset values
|
||||
|
||||
return fullBox('tfra', version, 0, [
|
||||
@@ -1436,7 +1451,7 @@ export const tfra = (trackData: IsobmffTrackData, trackIndex: number) => {
|
||||
trackData.finalizedChunks.map(chunk => [
|
||||
u64(intoTimescale(chunk.samples[0]!.timestamp, trackData.timescale)), // Time (in presentation time)
|
||||
u64(chunk.moofOffset!), // moof offset
|
||||
u32(trackIndex + 1), // traf number
|
||||
u32(chunk.trafIndex! + 1), // traf number
|
||||
u32(1), // trun number
|
||||
u32(1), // Sample number
|
||||
]),
|
||||
|
||||
@@ -352,6 +352,7 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
return this.metadataPromise ??= (async () => {
|
||||
let currentPos = 0;
|
||||
let lookForMfraBox = false;
|
||||
let foundMovieBoxes = false;
|
||||
|
||||
while (true) {
|
||||
let slice = this.reader.requestSliceRange(currentPos, MIN_BOX_HEADER_SIZE, MAX_BOX_HEADER_SIZE);
|
||||
@@ -388,6 +389,7 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
lookForMfraBox = this.isFragmented
|
||||
&& this.reader.fileSize !== null
|
||||
&& this.reader.fileSize > startPos + boxInfo.totalSize; // There's more after the moov box
|
||||
foundMovieBoxes = true;
|
||||
|
||||
break;
|
||||
} else if (boxInfo.name === 'moof') {
|
||||
@@ -399,65 +401,10 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
);
|
||||
}
|
||||
|
||||
const initDemuxer = (await this.input._initInput._getDemuxer()) as IsobmffDemuxer;
|
||||
if (initDemuxer.constructor !== IsobmffDemuxer) {
|
||||
throw new Error('Init input must match the input\'s format.');
|
||||
}
|
||||
|
||||
await initDemuxer.readMetadata();
|
||||
|
||||
this.movieTimescale = initDemuxer.movieTimescale;
|
||||
this.movieDurationInTimescale = initDemuxer.movieDurationInTimescale;
|
||||
this.metadataTags = initDemuxer.metadataTags;
|
||||
this.isFragmented = true;
|
||||
this.fragmentTrackDefaults = initDemuxer.fragmentTrackDefaults;
|
||||
this.psshBoxes = initDemuxer.psshBoxes;
|
||||
|
||||
// Create tracks from the init input's tracks
|
||||
for (const foreignTrack of initDemuxer.tracks) {
|
||||
const track: InternalTrack = {
|
||||
id: foreignTrack.id,
|
||||
demuxer: this,
|
||||
trackBacking: null,
|
||||
disposition: foreignTrack.disposition,
|
||||
timescale: foreignTrack.timescale,
|
||||
durationInMediaTimescale: foreignTrack.durationInMediaTimescale,
|
||||
durationInMovieTimescale: foreignTrack.durationInMovieTimescale,
|
||||
rotation: foreignTrack.rotation,
|
||||
internalCodecId: foreignTrack.internalCodecId,
|
||||
name: foreignTrack.name,
|
||||
languageCode: foreignTrack.languageCode,
|
||||
sampleTableByteOffset: null,
|
||||
sampleTable: null,
|
||||
fragmentLookupTable: [],
|
||||
currentFragmentState: null,
|
||||
fragmentPositionCache: [],
|
||||
editListPreviousSegmentDurations: foreignTrack.editListPreviousSegmentDurations,
|
||||
editListOffset: foreignTrack.editListOffset,
|
||||
encryptionInfo: foreignTrack.encryptionInfo,
|
||||
encryptionAuxInfo: null,
|
||||
frmaCodecString: null,
|
||||
info: foreignTrack.info,
|
||||
};
|
||||
|
||||
if (foreignTrack.trackBacking) {
|
||||
assert(track.info);
|
||||
|
||||
if (track.info.type === 'video' && track.info.width !== -1) {
|
||||
const videoTrack = track as InternalVideoTrack;
|
||||
track.trackBacking = new IsobmffVideoTrackBacking(videoTrack);
|
||||
this.tracks.push(track);
|
||||
} else if (track.info.type === 'audio' && track.info.numberOfChannels !== -1) {
|
||||
const audioTrack = track as InternalAudioTrack;
|
||||
track.trackBacking = new IsobmffAudioTrackBacking(audioTrack);
|
||||
this.tracks.push(track);
|
||||
}
|
||||
} else {
|
||||
// The track didn't have enough info to warrant a backing
|
||||
}
|
||||
}
|
||||
await this.copyMetadataFromInitInput(this.input._initInput);
|
||||
|
||||
lookForMfraBox = false; // No point in doing it for segment files
|
||||
foundMovieBoxes = true;
|
||||
|
||||
break;
|
||||
}
|
||||
@@ -465,6 +412,12 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
currentPos = startPos + boxInfo.totalSize;
|
||||
}
|
||||
|
||||
if (!foundMovieBoxes && this.input._initInput) {
|
||||
// A segment file is allowed to hold zero fragments, in which case there's no moof box to key off of.
|
||||
// It's still a perfectly valid segment, so let's take the tracks from the init input.
|
||||
await this.copyMetadataFromInitInput(this.input._initInput);
|
||||
}
|
||||
|
||||
if (lookForMfraBox) {
|
||||
assert(this.reader.fileSize !== null);
|
||||
|
||||
@@ -502,6 +455,66 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
})();
|
||||
}
|
||||
|
||||
private async copyMetadataFromInitInput(initInput: Input) {
|
||||
const initDemuxer = (await initInput._getDemuxer()) as IsobmffDemuxer;
|
||||
if (initDemuxer.constructor !== IsobmffDemuxer) {
|
||||
throw new Error('Init input must match the input\'s format.');
|
||||
}
|
||||
|
||||
await initDemuxer.readMetadata();
|
||||
|
||||
this.movieTimescale = initDemuxer.movieTimescale;
|
||||
this.movieDurationInTimescale = initDemuxer.movieDurationInTimescale;
|
||||
this.metadataTags = initDemuxer.metadataTags;
|
||||
this.isFragmented = true;
|
||||
this.fragmentTrackDefaults = initDemuxer.fragmentTrackDefaults;
|
||||
this.psshBoxes = initDemuxer.psshBoxes;
|
||||
|
||||
// Create tracks from the init input's tracks
|
||||
for (const foreignTrack of initDemuxer.tracks) {
|
||||
const track: InternalTrack = {
|
||||
id: foreignTrack.id,
|
||||
demuxer: this,
|
||||
trackBacking: null,
|
||||
disposition: foreignTrack.disposition,
|
||||
timescale: foreignTrack.timescale,
|
||||
durationInMediaTimescale: foreignTrack.durationInMediaTimescale,
|
||||
durationInMovieTimescale: foreignTrack.durationInMovieTimescale,
|
||||
rotation: foreignTrack.rotation,
|
||||
internalCodecId: foreignTrack.internalCodecId,
|
||||
name: foreignTrack.name,
|
||||
languageCode: foreignTrack.languageCode,
|
||||
sampleTableByteOffset: null,
|
||||
sampleTable: null,
|
||||
fragmentLookupTable: [],
|
||||
currentFragmentState: null,
|
||||
fragmentPositionCache: [],
|
||||
editListPreviousSegmentDurations: foreignTrack.editListPreviousSegmentDurations,
|
||||
editListOffset: foreignTrack.editListOffset,
|
||||
encryptionInfo: foreignTrack.encryptionInfo,
|
||||
encryptionAuxInfo: null,
|
||||
frmaCodecString: null,
|
||||
info: foreignTrack.info,
|
||||
};
|
||||
|
||||
if (foreignTrack.trackBacking) {
|
||||
assert(track.info);
|
||||
|
||||
if (track.info.type === 'video' && track.info.width !== -1) {
|
||||
const videoTrack = track as InternalVideoTrack;
|
||||
track.trackBacking = new IsobmffVideoTrackBacking(videoTrack);
|
||||
this.tracks.push(track);
|
||||
} else if (track.info.type === 'audio' && track.info.numberOfChannels !== -1) {
|
||||
const audioTrack = track as InternalAudioTrack;
|
||||
track.trackBacking = new IsobmffAudioTrackBacking(audioTrack);
|
||||
this.tracks.push(track);
|
||||
}
|
||||
} else {
|
||||
// The track didn't have enough info to warrant a backing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getSampleTableForTrack(internalTrack: InternalTrack) {
|
||||
if (internalTrack.sampleTable) {
|
||||
return internalTrack.sampleTable;
|
||||
|
||||
+164
-78
@@ -72,6 +72,9 @@ type Chunk = {
|
||||
offset: number | null;
|
||||
// In the case of a fragmented file, this indicates the position of the moof box pointing to the data in this chunk
|
||||
moofOffset: number | null;
|
||||
// In the case of a fragmented file, this indicates the index of the traf box in the moof box pointing to the data
|
||||
// in this chunk
|
||||
trafIndex: number | null;
|
||||
};
|
||||
|
||||
export type IsobmffTrackData = {
|
||||
@@ -128,7 +131,7 @@ export type IsobmffTrackData = {
|
||||
* ADTS-wrapped data.
|
||||
*/
|
||||
requiresAdtsStripping: boolean;
|
||||
firstPacket: EncodedPacket;
|
||||
primingPacket: EncodedPacket | null;
|
||||
};
|
||||
} | {
|
||||
track: OutputSubtitleTrack;
|
||||
@@ -168,6 +171,7 @@ export const intoTimescale = (timeInSeconds: number, timescale: number, round =
|
||||
|
||||
export class IsobmffMuxer extends Muxer {
|
||||
format: IsobmffOutputFormat;
|
||||
formatOptions: IsobmffOutputFormatOptions;
|
||||
private writer: Writer | null = null;
|
||||
private boxWriter: IsobmffBoxWriter | null = null;
|
||||
private initWriter: Writer | null = null;
|
||||
@@ -191,21 +195,23 @@ export class IsobmffMuxer extends Muxer {
|
||||
creationTime = Math.floor(Date.now() / 1000) + TIMESTAMP_OFFSET;
|
||||
private finalizedChunks: Chunk[] = [];
|
||||
|
||||
private wroteFragmentedHeader = false;
|
||||
private nextFragmentNumber = 1;
|
||||
// Only relevant for fragmented files, to make sure new fragments start with the highest timestamp seen so far
|
||||
private maxWrittenTimestamp = -Infinity;
|
||||
minWrittenTimestamp = Infinity;
|
||||
maxWrittenEndTimestamp = -Infinity;
|
||||
private minimumFragmentDuration: number;
|
||||
minimumFragmentDuration: number;
|
||||
private segmentHeaderSize: number | null = null;
|
||||
|
||||
constructor(output: Output, format: IsobmffOutputFormat) {
|
||||
super(output);
|
||||
|
||||
this.format = format;
|
||||
this.formatOptions = { ...format._options };
|
||||
this.isQuickTime = format instanceof MovOutputFormat;
|
||||
this.isCmaf = format instanceof CmafOutputFormat;
|
||||
this.minimumFragmentDuration = format._options.minimumFragmentDuration
|
||||
this.minimumFragmentDuration = this.formatOptions.minimumFragmentDuration
|
||||
?? (format instanceof CmafOutputFormat ? Infinity : 1);
|
||||
|
||||
this.auxWriter.start();
|
||||
@@ -216,15 +222,15 @@ export class IsobmffMuxer extends Muxer {
|
||||
|
||||
if (!this.isCmaf) {
|
||||
this.writer = await this.output._getRootWriter(target => (
|
||||
this.format._options.fastStart !== undefined
|
||||
? this.format._options.fastStart === 'fragmented'
|
||||
this.formatOptions.fastStart !== undefined
|
||||
? this.formatOptions.fastStart === 'fragmented'
|
||||
: target instanceof BufferTarget // Since if this is the case we'll use 'in-memory'
|
||||
));
|
||||
this.boxWriter = new IsobmffBoxWriter(this.writer);
|
||||
|
||||
// If the fastStart option isn't defined, enable in-memory fast start if the target is an ArrayBuffer, as
|
||||
// the memory usage remains identical
|
||||
this.fastStart = this.format._options.fastStart
|
||||
this.fastStart = this.formatOptions.fastStart
|
||||
?? (this.writer.target instanceof BufferTarget ? 'in-memory' : false);
|
||||
this.isFragmented = this.fastStart === 'fragmented';
|
||||
} else {
|
||||
@@ -256,7 +262,7 @@ export class IsobmffMuxer extends Muxer {
|
||||
const boxWriter = this.initBoxWriter ?? this.boxWriter;
|
||||
assert(boxWriter);
|
||||
|
||||
if (this.format._options.onFtyp) {
|
||||
if (this.formatOptions.onFtyp) {
|
||||
boxWriter.writer.startTrackingWrites();
|
||||
}
|
||||
|
||||
@@ -267,9 +273,9 @@ export class IsobmffMuxer extends Muxer {
|
||||
cmaf: this.isCmaf,
|
||||
}));
|
||||
|
||||
if (this.format._options.onFtyp) {
|
||||
if (this.formatOptions.onFtyp) {
|
||||
const { data, start } = boxWriter.writer.stopTrackingWrites();
|
||||
this.format._options.onFtyp(data, start);
|
||||
this.formatOptions.onFtyp(data, start);
|
||||
}
|
||||
|
||||
this.ftypSize = boxWriter.writer.getPos();
|
||||
@@ -299,7 +305,7 @@ export class IsobmffMuxer extends Muxer {
|
||||
assert(this.writer);
|
||||
assert(this.boxWriter);
|
||||
|
||||
if (this.format._options.onMdat) {
|
||||
if (this.formatOptions.onMdat) {
|
||||
this.writer.startTrackingWrites();
|
||||
}
|
||||
|
||||
@@ -309,6 +315,22 @@ export class IsobmffMuxer extends Muxer {
|
||||
|
||||
await this.writer?.flush();
|
||||
|
||||
for (const track of this.output.tracks) {
|
||||
if (track.isVideoTrack() && track.metadata.decoderConfig) {
|
||||
this.getVideoTrackData(
|
||||
track,
|
||||
track.metadata.primingPacket ?? null,
|
||||
{ decoderConfig: track.metadata.decoderConfig },
|
||||
);
|
||||
} else if (track.isAudioTrack() && track.metadata.decoderConfig) {
|
||||
this.getAudioTrackData(
|
||||
track,
|
||||
track.metadata.primingPacket ?? null,
|
||||
{ decoderConfig: track.metadata.decoderConfig },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
release();
|
||||
}
|
||||
|
||||
@@ -346,13 +368,13 @@ export class IsobmffMuxer extends Muxer {
|
||||
});
|
||||
}
|
||||
|
||||
private getVideoTrackData(track: OutputVideoTrack, packet: EncodedPacket, meta?: EncodedVideoChunkMetadata) {
|
||||
private getVideoTrackData(track: OutputVideoTrack, packet: EncodedPacket | null, meta?: EncodedVideoChunkMetadata) {
|
||||
const existingTrackData = this.trackDatas.find(x => x.track === track);
|
||||
if (existingTrackData) {
|
||||
return existingTrackData as IsobmffVideoTrackData;
|
||||
}
|
||||
|
||||
validateVideoChunkMetadata(meta);
|
||||
validateVideoChunkMetadata(meta, track.source._codec);
|
||||
|
||||
assert(meta);
|
||||
assert(meta.decoderConfig);
|
||||
@@ -367,6 +389,10 @@ export class IsobmffMuxer extends Muxer {
|
||||
// ISOBMFF can only hold AVC in the AVCC format, not in Annex B, but the missing description indicates
|
||||
// Annex B. This means we'll need to do some converterino.
|
||||
|
||||
if (!packet) {
|
||||
throw new Error('No AVC description provided; you must therefore provide a priming packet.');
|
||||
}
|
||||
|
||||
const decoderConfigurationRecord = extractAvcDecoderConfigurationRecord(packet.data);
|
||||
if (!decoderConfigurationRecord) {
|
||||
throw new Error(
|
||||
@@ -383,6 +409,10 @@ export class IsobmffMuxer extends Muxer {
|
||||
// ISOBMFF can only hold HEVC in the HEVC format, not in Annex B, but the missing description indicates
|
||||
// Annex B. This means we'll need to do some converterino.
|
||||
|
||||
if (!packet) {
|
||||
throw new Error('No HEVC description provided; you must therefore provide a priming packet.');
|
||||
}
|
||||
|
||||
const decoderConfigurationRecord = extractHevcDecoderConfigurationRecord(packet.data);
|
||||
if (!decoderConfigurationRecord) {
|
||||
throw new Error(
|
||||
@@ -454,13 +484,13 @@ export class IsobmffMuxer extends Muxer {
|
||||
return newTrackData;
|
||||
}
|
||||
|
||||
private getAudioTrackData(track: OutputAudioTrack, packet: EncodedPacket, meta?: EncodedAudioChunkMetadata) {
|
||||
private getAudioTrackData(track: OutputAudioTrack, packet: EncodedPacket | null, meta?: EncodedAudioChunkMetadata) {
|
||||
const existingTrackData = this.trackDatas.find(x => x.track === track);
|
||||
if (existingTrackData) {
|
||||
return existingTrackData as IsobmffAudioTrackData;
|
||||
}
|
||||
|
||||
validateAudioChunkMetadata(meta);
|
||||
validateAudioChunkMetadata(meta, track.source._codec);
|
||||
|
||||
assert(meta);
|
||||
assert(meta.decoderConfig);
|
||||
@@ -471,6 +501,11 @@ export class IsobmffMuxer extends Muxer {
|
||||
if (track.source._codec === 'aac' && !decoderConfig.description) {
|
||||
// ISOBMFF can only hold AAC in raw format, not ADTS, but the missing description indicates ADTS.
|
||||
// Parse the first packet to extract the AudioSpecificConfig.
|
||||
|
||||
if (!packet) {
|
||||
throw new Error('No AAC description provided; you must therefore provide a priming packet.');
|
||||
}
|
||||
|
||||
const adtsFrame = readAdtsFrameHeader(FileSlice.tempFromBytes(packet.data));
|
||||
if (!adtsFrame) {
|
||||
throw new Error(
|
||||
@@ -496,6 +531,12 @@ export class IsobmffMuxer extends Muxer {
|
||||
requiresAdtsStripping = true;
|
||||
}
|
||||
|
||||
if (track.source._codec === 'ac3' || track.source._codec === 'eac3') {
|
||||
if (!packet) {
|
||||
throw new Error('AC-3/E-AC-3 require a priming packet.');
|
||||
}
|
||||
}
|
||||
|
||||
const newTrackData: IsobmffAudioTrackData = {
|
||||
muxer: this,
|
||||
track,
|
||||
@@ -509,7 +550,7 @@ export class IsobmffMuxer extends Muxer {
|
||||
&& (PCM_AUDIO_CODECS as readonly string[]).includes(track.source._codec),
|
||||
expectedNextPcmPacketTimestamp: null,
|
||||
requiresAdtsStripping,
|
||||
firstPacket: packet,
|
||||
primingPacket: packet,
|
||||
},
|
||||
timescale: decoderConfig.sampleRate,
|
||||
samples: [],
|
||||
@@ -1091,6 +1132,7 @@ export class IsobmffMuxer extends Muxer {
|
||||
samples: [],
|
||||
offset: null,
|
||||
moofOffset: null,
|
||||
trafIndex: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1180,13 +1222,13 @@ export class IsobmffMuxer extends Muxer {
|
||||
private async finalizeFragment(flushWriter = !this.isCmaf) {
|
||||
assert(this.isFragmented);
|
||||
|
||||
const fragmentNumber = this.nextFragmentNumber++;
|
||||
if (!this.wroteFragmentedHeader) {
|
||||
this.wroteFragmentedHeader = true;
|
||||
|
||||
if (fragmentNumber === 1) {
|
||||
const boxWriter = this.initBoxWriter ?? this.boxWriter;
|
||||
assert(boxWriter);
|
||||
|
||||
if (this.format._options.onMoov) {
|
||||
if (this.formatOptions.onMoov) {
|
||||
boxWriter.writer.startTrackingWrites();
|
||||
}
|
||||
|
||||
@@ -1196,9 +1238,9 @@ export class IsobmffMuxer extends Muxer {
|
||||
const movieBox = moov(this);
|
||||
boxWriter.writeBox(movieBox);
|
||||
|
||||
if (this.format._options.onMoov) {
|
||||
if (this.formatOptions.onMoov) {
|
||||
const { data, start } = boxWriter.writer.stopTrackingWrites();
|
||||
this.format._options.onMoov(data, start);
|
||||
this.formatOptions.onMoov(data, start);
|
||||
}
|
||||
|
||||
if (this.isCmaf) {
|
||||
@@ -1225,6 +1267,18 @@ export class IsobmffMuxer extends Muxer {
|
||||
// Not all tracks need to be present in every fragment
|
||||
const tracksInFragment = this.trackDatas.filter(x => x.currentChunk);
|
||||
|
||||
if (tracksInFragment.length === 0) {
|
||||
// Zero tracks in this fragment and thus no fragment data
|
||||
|
||||
if (flushWriter) {
|
||||
await this.writer.flush();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const fragmentNumber = this.nextFragmentNumber++;
|
||||
|
||||
// Create an initial moof box and measure it; we need this to know where the following mdat box will begin
|
||||
const moofBox = moof(fragmentNumber, tracksInFragment);
|
||||
const moofOffset = this.writer.getPos();
|
||||
@@ -1232,9 +1286,12 @@ export class IsobmffMuxer extends Muxer {
|
||||
|
||||
let currentPos = mdatStartPos + MIN_BOX_HEADER_SIZE;
|
||||
let fragmentStartTimestamp = Infinity;
|
||||
for (const trackData of tracksInFragment) {
|
||||
for (let i = 0; i < tracksInFragment.length; i++) {
|
||||
const trackData = tracksInFragment[i]!;
|
||||
|
||||
trackData.currentChunk!.offset = currentPos;
|
||||
trackData.currentChunk!.moofOffset = moofOffset;
|
||||
trackData.currentChunk!.trafIndex = i;
|
||||
|
||||
for (const sample of trackData.currentChunk!.samples) {
|
||||
currentPos += sample.size;
|
||||
@@ -1254,21 +1311,21 @@ export class IsobmffMuxer extends Muxer {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.format._options.onMoof) {
|
||||
if (this.formatOptions.onMoof) {
|
||||
this.writer.startTrackingWrites();
|
||||
}
|
||||
|
||||
const newMoofBox = moof(fragmentNumber, tracksInFragment);
|
||||
this.boxWriter.writeBox(newMoofBox);
|
||||
|
||||
if (this.format._options.onMoof) {
|
||||
if (this.formatOptions.onMoof) {
|
||||
const { data, start } = this.writer.stopTrackingWrites();
|
||||
this.format._options.onMoof(data, start, fragmentStartTimestamp);
|
||||
this.formatOptions.onMoof(data, start, fragmentStartTimestamp);
|
||||
}
|
||||
|
||||
assert(this.writer.getPos() === mdatStartPos);
|
||||
|
||||
if (this.format._options.onMdat) {
|
||||
if (this.formatOptions.onMdat) {
|
||||
this.writer.startTrackingWrites();
|
||||
}
|
||||
|
||||
@@ -1286,9 +1343,9 @@ export class IsobmffMuxer extends Muxer {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.format._options.onMdat) {
|
||||
if (this.formatOptions.onMdat) {
|
||||
const { data, start } = this.writer.stopTrackingWrites();
|
||||
this.format._options.onMdat(data, start);
|
||||
this.formatOptions.onMdat(data, start);
|
||||
}
|
||||
|
||||
for (const trackData of tracksInFragment) {
|
||||
@@ -1303,38 +1360,9 @@ export class IsobmffMuxer extends Muxer {
|
||||
}
|
||||
|
||||
private async registerSampleFastStartReserve(trackData: IsobmffTrackData, sample: Sample) {
|
||||
assert(this.writer);
|
||||
assert(this.boxWriter);
|
||||
|
||||
if (this.allTracksAreKnown()) {
|
||||
if (!this.mdat) {
|
||||
this.ensureOneEnabledTrack();
|
||||
|
||||
// We finally know all tracks, let's reserve space for the moov box
|
||||
const moovBox = moov(this);
|
||||
const moovSize = this.boxWriter.measureBox(moovBox);
|
||||
|
||||
const reservedSize = moovSize
|
||||
+ this.computeSampleTableSizeUpperBound()
|
||||
+ 4096; // Just a little extra headroom
|
||||
|
||||
assert(this.ftypSize !== null);
|
||||
this.writer.seek(this.ftypSize + reservedSize);
|
||||
|
||||
if (this.format._options.onMdat) {
|
||||
this.writer.startTrackingWrites();
|
||||
}
|
||||
|
||||
this.mdat = mdat(true);
|
||||
this.boxWriter.writeBox(this.mdat);
|
||||
|
||||
// Now write everything that was queued
|
||||
for (const trackData of this.trackDatas) {
|
||||
for (const sample of trackData.sampleQueue) {
|
||||
await this.addSampleToTrack(trackData, sample);
|
||||
}
|
||||
trackData.sampleQueue.length = 0;
|
||||
}
|
||||
await this.createFastStartReserveMdat();
|
||||
}
|
||||
|
||||
await this.addSampleToTrack(trackData, sample);
|
||||
@@ -1344,6 +1372,39 @@ export class IsobmffMuxer extends Muxer {
|
||||
}
|
||||
}
|
||||
|
||||
private async createFastStartReserveMdat() {
|
||||
assert(this.writer);
|
||||
assert(this.boxWriter);
|
||||
|
||||
this.ensureOneEnabledTrack();
|
||||
|
||||
// We finally know all tracks, let's reserve space for the moov box
|
||||
const moovBox = moov(this);
|
||||
const moovSize = this.boxWriter.measureBox(moovBox);
|
||||
|
||||
const reservedSize = moovSize
|
||||
+ this.computeSampleTableSizeUpperBound()
|
||||
+ 4096; // Just a little extra headroom
|
||||
|
||||
assert(this.ftypSize !== null);
|
||||
this.writer.seek(this.ftypSize + reservedSize);
|
||||
|
||||
if (this.formatOptions.onMdat) {
|
||||
this.writer.startTrackingWrites();
|
||||
}
|
||||
|
||||
this.mdat = mdat(true);
|
||||
this.boxWriter.writeBox(this.mdat);
|
||||
|
||||
// Now write everything that was queued
|
||||
for (const trackData of this.trackDatas) {
|
||||
for (const sample of trackData.sampleQueue) {
|
||||
await this.addSampleToTrack(trackData, sample);
|
||||
}
|
||||
trackData.sampleQueue.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private computeSampleTableSizeUpperBound() {
|
||||
assert(this.fastStart === 'reserve');
|
||||
|
||||
@@ -1422,6 +1483,28 @@ export class IsobmffMuxer extends Muxer {
|
||||
}
|
||||
}
|
||||
|
||||
/** Internal function for external callers who want to full control fragment boundaries. */
|
||||
async forceFragmentFinalization() {
|
||||
assert(this.isFragmented);
|
||||
|
||||
const release = await this.mutex.acquire();
|
||||
|
||||
try {
|
||||
for (const trackData of this.trackDatas) {
|
||||
if (trackData.type === 'subtitle' && trackData.track.source._codec === 'webvtt') {
|
||||
await this.processWebVTTCues(trackData, Infinity);
|
||||
}
|
||||
|
||||
this.processTimestamps(trackData);
|
||||
}
|
||||
|
||||
await this.interleaveSamples(true);
|
||||
await this.finalizeFragment();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
/** Finalizes the file, making it ready for use. Must be called after all video and audio chunks have been added. */
|
||||
async finalize() {
|
||||
const release = await this.mutex.acquire();
|
||||
@@ -1429,6 +1512,10 @@ export class IsobmffMuxer extends Muxer {
|
||||
this.allTracksKnown.resolve();
|
||||
this.ensureOneEnabledTrack();
|
||||
|
||||
if (!this.mdat && this.fastStart === 'reserve') {
|
||||
await this.createFastStartReserveMdat();
|
||||
}
|
||||
|
||||
for (const trackData of this.trackDatas) {
|
||||
trackData.closed = true;
|
||||
|
||||
@@ -1446,15 +1533,14 @@ export class IsobmffMuxer extends Muxer {
|
||||
for (const trackData of this.trackDatas) {
|
||||
await this.finalizeCurrentChunk(trackData);
|
||||
|
||||
// Must hold because we will have processed at least one sample
|
||||
assert(trackData.startTimestampOffset !== null);
|
||||
|
||||
// Shift all of the samples by the start offset. We'll then write out an edit list that will shift them
|
||||
// back to their proper spot in the composition.
|
||||
for (let i = 0; i < trackData.samples.length; i++) {
|
||||
const sample = trackData.samples[i]!;
|
||||
sample.timestamp -= trackData.startTimestampOffset;
|
||||
sample.decodeTimestamp -= trackData.startTimestampOffset;
|
||||
if (trackData.startTimestampOffset !== null) {
|
||||
// Shift all of the samples by the start offset. We'll then write out an edit list that will shift
|
||||
// them back to their proper spot in the composition.
|
||||
for (let i = 0; i < trackData.samples.length; i++) {
|
||||
const sample = trackData.samples[i]!;
|
||||
sample.timestamp -= trackData.startTimestampOffset;
|
||||
sample.decodeTimestamp -= trackData.startTimestampOffset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1493,19 +1579,19 @@ export class IsobmffMuxer extends Muxer {
|
||||
if (mdatSize >= 2 ** 32) this.mdat.largeSize = true;
|
||||
}
|
||||
|
||||
if (this.format._options.onMoov) {
|
||||
if (this.formatOptions.onMoov) {
|
||||
this.writer.startTrackingWrites();
|
||||
}
|
||||
|
||||
const movieBox = moov(this);
|
||||
this.boxWriter.writeBox(movieBox);
|
||||
|
||||
if (this.format._options.onMoov) {
|
||||
if (this.formatOptions.onMoov) {
|
||||
const { data, start } = this.writer.stopTrackingWrites();
|
||||
this.format._options.onMoov(data, start);
|
||||
this.formatOptions.onMoov(data, start);
|
||||
}
|
||||
|
||||
if (this.format._options.onMdat) {
|
||||
if (this.formatOptions.onMdat) {
|
||||
this.writer.startTrackingWrites();
|
||||
}
|
||||
|
||||
@@ -1520,9 +1606,9 @@ export class IsobmffMuxer extends Muxer {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.format._options.onMdat) {
|
||||
if (this.formatOptions.onMdat) {
|
||||
const { data, start } = this.writer.stopTrackingWrites();
|
||||
this.format._options.onMdat(data, start);
|
||||
this.formatOptions.onMdat(data, start);
|
||||
}
|
||||
} else if (this.isFragmented) {
|
||||
if (this.isCmaf) {
|
||||
@@ -1556,9 +1642,9 @@ export class IsobmffMuxer extends Muxer {
|
||||
this.mdat.largeSize = mdatSize >= 2 ** 32; // Only use the large size if we need it
|
||||
this.boxWriter.patchBox(this.mdat);
|
||||
|
||||
if (this.format._options.onMdat) {
|
||||
if (this.formatOptions.onMdat) {
|
||||
const { data, start } = this.writer.stopTrackingWrites();
|
||||
this.format._options.onMdat(data, start);
|
||||
this.formatOptions.onMdat(data, start);
|
||||
}
|
||||
|
||||
const movieBox = moov(this);
|
||||
@@ -1567,7 +1653,7 @@ export class IsobmffMuxer extends Muxer {
|
||||
assert(this.ftypSize !== null);
|
||||
this.writer.seek(this.ftypSize);
|
||||
|
||||
if (this.format._options.onMoov) {
|
||||
if (this.formatOptions.onMoov) {
|
||||
this.writer.startTrackingWrites();
|
||||
}
|
||||
|
||||
@@ -1577,16 +1663,16 @@ export class IsobmffMuxer extends Muxer {
|
||||
const remainingSpace = this.boxWriter.offsets.get(this.mdat)! - this.writer.getPos();
|
||||
this.boxWriter.writeBox(free(remainingSpace));
|
||||
} else {
|
||||
if (this.format._options.onMoov) {
|
||||
if (this.formatOptions.onMoov) {
|
||||
this.writer.startTrackingWrites();
|
||||
}
|
||||
|
||||
this.boxWriter.writeBox(movieBox);
|
||||
}
|
||||
|
||||
if (this.format._options.onMoov) {
|
||||
if (this.formatOptions.onMoov) {
|
||||
const { data, start } = this.writer.stopTrackingWrites();
|
||||
this.format._options.onMoov(data, start);
|
||||
this.formatOptions.onMoov(data, start);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +97,8 @@ type MatroskaTrackData = {
|
||||
height: number;
|
||||
aspectRatio: Rational | null;
|
||||
decoderConfig: VideoDecoderConfig;
|
||||
alphaMode: boolean;
|
||||
/** Null until the first packet comes in, which is what determines if this track has alpha or not. */
|
||||
alphaMode: boolean | null;
|
||||
};
|
||||
} | {
|
||||
track: OutputAudioTrack;
|
||||
@@ -176,6 +177,22 @@ export class MatroskaMuxer extends Muxer {
|
||||
|
||||
await this.writer.flush();
|
||||
|
||||
for (const track of this.output.tracks) {
|
||||
if (track.isVideoTrack() && track.metadata.decoderConfig) {
|
||||
this.getVideoTrackData(
|
||||
track,
|
||||
track.metadata.primingPacket ?? null,
|
||||
{ decoderConfig: track.metadata.decoderConfig },
|
||||
);
|
||||
} else if (track.isAudioTrack() && track.metadata.decoderConfig) {
|
||||
this.getAudioTrackData(
|
||||
track,
|
||||
track.metadata.primingPacket ?? null,
|
||||
{ decoderConfig: track.metadata.decoderConfig },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
release();
|
||||
}
|
||||
|
||||
@@ -724,13 +741,13 @@ export class MatroskaMuxer extends Muxer {
|
||||
});
|
||||
}
|
||||
|
||||
private getVideoTrackData(track: OutputVideoTrack, packet: EncodedPacket, meta?: EncodedVideoChunkMetadata) {
|
||||
private getVideoTrackData(track: OutputVideoTrack, packet: EncodedPacket | null, meta?: EncodedVideoChunkMetadata) {
|
||||
const existingTrackData = this.trackDatas.find(x => x.track === track);
|
||||
if (existingTrackData) {
|
||||
return existingTrackData as MatroskaVideoTrackData;
|
||||
}
|
||||
|
||||
validateVideoChunkMetadata(meta);
|
||||
validateVideoChunkMetadata(meta, track.source._codec);
|
||||
|
||||
assert(meta);
|
||||
assert(meta.decoderConfig);
|
||||
@@ -755,7 +772,7 @@ export class MatroskaMuxer extends Muxer {
|
||||
height: meta.decoderConfig.codedHeight,
|
||||
aspectRatio,
|
||||
decoderConfig: meta.decoderConfig,
|
||||
alphaMode: !!packet.sideData.alpha, // The first packet determines if this track has alpha or not
|
||||
alphaMode: packet ? !!packet.sideData.alpha : null,
|
||||
},
|
||||
chunkQueue: [],
|
||||
lastWrittenMsTimestamp: null,
|
||||
@@ -791,13 +808,13 @@ export class MatroskaMuxer extends Muxer {
|
||||
return newTrackData;
|
||||
}
|
||||
|
||||
private getAudioTrackData(track: OutputAudioTrack, packet: EncodedPacket, meta?: EncodedAudioChunkMetadata) {
|
||||
private getAudioTrackData(track: OutputAudioTrack, packet: EncodedPacket | null, meta?: EncodedAudioChunkMetadata) {
|
||||
const existingTrackData = this.trackDatas.find(x => x.track === track);
|
||||
if (existingTrackData) {
|
||||
return existingTrackData as MatroskaAudioTrackData;
|
||||
}
|
||||
|
||||
validateAudioChunkMetadata(meta);
|
||||
validateAudioChunkMetadata(meta, track.source._codec);
|
||||
|
||||
assert(meta);
|
||||
assert(meta.decoderConfig);
|
||||
@@ -808,6 +825,11 @@ export class MatroskaMuxer extends Muxer {
|
||||
if (track.source._codec === 'aac' && !decoderConfig.description) {
|
||||
// Matroska stores raw AAC with AudioSpecificConfig in CodecPrivate, not ADTS-wrapped data.
|
||||
// Parse the first packet to extract the AudioSpecificConfig.
|
||||
|
||||
if (!packet) {
|
||||
throw new Error('No AAC description provided; you must therefore provide a priming packet.');
|
||||
}
|
||||
|
||||
const adtsFrame = readAdtsFrameHeader(FileSlice.tempFromBytes(packet.data));
|
||||
if (!adtsFrame) {
|
||||
throw new Error(
|
||||
@@ -896,6 +918,7 @@ export class MatroskaMuxer extends Muxer {
|
||||
|
||||
try {
|
||||
const trackData = this.getVideoTrackData(track, packet, meta);
|
||||
trackData.info.alphaMode ??= !!packet.sideData.alpha;
|
||||
|
||||
let packetData = packet.data;
|
||||
if (track.source._codec === 'prores') {
|
||||
|
||||
+3
-12
@@ -1989,21 +1989,12 @@ export class CanvasSink {
|
||||
}) as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
|
||||
assert(context);
|
||||
|
||||
context.resetTransform();
|
||||
|
||||
if (!canvasIsNew) {
|
||||
if (!this._alpha && isFirefox()) {
|
||||
context.fillStyle = 'black';
|
||||
context.fillRect(0, 0, width, height);
|
||||
} else {
|
||||
context.clearRect(0, 0, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
sample.drawWithFit(context, {
|
||||
sample._drawWithFitAndMipmapping(canvas, context, {
|
||||
fit: this._fit,
|
||||
rotation: this._rotation,
|
||||
crop: this._crop,
|
||||
targetIsFresh: canvasIsNew,
|
||||
fillBlack: !this._alpha && isFirefox(),
|
||||
});
|
||||
|
||||
const result = {
|
||||
|
||||
+18
-6
@@ -705,10 +705,14 @@ class VideoEncoderWrapper {
|
||||
);
|
||||
}
|
||||
|
||||
const support = await VideoEncoder.isConfigSupported(candidateConfig);
|
||||
if (support.supported) {
|
||||
selected = candidate;
|
||||
break;
|
||||
try {
|
||||
const support = await VideoEncoder.isConfigSupported(candidateConfig);
|
||||
if (support.supported) {
|
||||
selected = candidate;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Not supported
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2209,8 +2213,16 @@ class AudioEncoderWrapper {
|
||||
throw new Error('AudioEncoder is not supported by this browser.');
|
||||
}
|
||||
|
||||
const support = await AudioEncoder.isConfigSupported(encoderConfig);
|
||||
if (!support.supported) {
|
||||
let supported: boolean;
|
||||
|
||||
try {
|
||||
const support = await AudioEncoder.isConfigSupported(encoderConfig);
|
||||
supported = support.supported ?? false;
|
||||
} catch {
|
||||
supported = false;
|
||||
}
|
||||
|
||||
if (!supported) {
|
||||
throw new Error(
|
||||
`This specific encoder configuration (${encoderConfig.codec}, ${encoderConfig.bitrate} bps,`
|
||||
+ ` ${encoderConfig.numberOfChannels} channels, ${encoderConfig.sampleRate} Hz) is not`
|
||||
|
||||
@@ -53,6 +53,8 @@ export class Mp3Demuxer extends Demuxer {
|
||||
metadataPromise: Promise<void> | null = null;
|
||||
firstFrameHeader: Mp3FrameHeader | null = null;
|
||||
firstFrameHeaderPos: number | null = null;
|
||||
xingFrameHeader: Mp3FrameHeader | null = null;
|
||||
xingFrameHeaderPos: number | null = null;
|
||||
loadedSamples: Sample[] = []; // All samples from the start of the file to lastLoadedPos
|
||||
metadataTags: MetadataTags | null = null;
|
||||
xingData: {
|
||||
@@ -80,6 +82,13 @@ export class Mp3Demuxer extends Demuxer {
|
||||
await this.advanceReader();
|
||||
}
|
||||
|
||||
if (!this.firstFrameHeader && this.xingFrameHeader) {
|
||||
// The file consists of nothing but a Xing frame, so it holds no audio data - but that frame still
|
||||
// tells us everything about the track
|
||||
this.firstFrameHeader = this.xingFrameHeader;
|
||||
this.firstFrameHeaderPos = this.xingFrameHeaderPos;
|
||||
}
|
||||
|
||||
if (!this.firstFrameHeader) {
|
||||
throw new Error('No valid MP3 frame found.');
|
||||
}
|
||||
@@ -135,6 +144,11 @@ export class Mp3Demuxer extends Demuxer {
|
||||
if (isXing) {
|
||||
// There's no actual audio data in this frame, so let's skip it
|
||||
|
||||
if (!this.xingFrameHeader) {
|
||||
this.xingFrameHeader = header;
|
||||
this.xingFrameHeaderPos = result.startPos;
|
||||
}
|
||||
|
||||
if (!this.xingData) {
|
||||
let xingDataSlice = this.reader.requestSlice(result.startPos + xingOffset + 4, 12);
|
||||
if (xingDataSlice instanceof Promise) xingDataSlice = await xingDataSlice;
|
||||
|
||||
+105
-11
@@ -6,14 +6,14 @@
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { toDataView } from '../misc';
|
||||
import { assert, toDataView } from '../misc';
|
||||
import { metadataTagsAreEmpty } from '../metadata';
|
||||
import { Muxer } from '../muxer';
|
||||
import { Output, OutputAudioTrack } from '../output';
|
||||
import { Mp3OutputFormat } from '../output-format';
|
||||
import { EncodedPacket } from '../packet';
|
||||
import { Writer } from '../writer';
|
||||
import { getXingOffset, INFO, readMp3FrameHeader, XING } from '../../shared/mp3-misc';
|
||||
import { getXingOffset, INFO, readMp3FrameHeader, SAMPLING_RATES, XING } from '../../shared/mp3-misc';
|
||||
import { Mp3Writer, XingFrameData } from './mp3-writer';
|
||||
import { Id3V2Writer } from '../id3';
|
||||
|
||||
@@ -130,28 +130,122 @@ export class Mp3Muxer extends Muxer {
|
||||
}
|
||||
|
||||
async finalize() {
|
||||
if (!this.xingFrameData || this.xingFramePos === null) {
|
||||
return;
|
||||
const release = await this.mutex.acquire();
|
||||
|
||||
if (!this.xingFrameData && this.format._options.xingHeader === false) {
|
||||
// MP3 has no container-level header, so the Xing frame is the only thing we could have synthesized
|
||||
throw new Error(
|
||||
'Cannot finalize an empty MP3 file: not a single packet was added and the Xing header is disabled, so'
|
||||
+ ' there\'s no frame we could write.',
|
||||
);
|
||||
}
|
||||
|
||||
const release = await this.mutex.acquire();
|
||||
if (!this.xingFrameData) {
|
||||
// Not a single packet came in, so let's write a lone Xing frame; that way, the file is still a valid
|
||||
// (if empty) MP3. We derive its header from whatever the track told us up front.
|
||||
const track = this.output.tracks[0];
|
||||
assert(track?.isAudioTrack());
|
||||
|
||||
const primingPacket = track.metadata.primingPacket;
|
||||
if (primingPacket) {
|
||||
// The best case: an actual frame tells us exactly what the header should look like
|
||||
const view = toDataView(primingPacket.data);
|
||||
if (view.byteLength < 4) {
|
||||
throw new Error('Invalid MP3 header in priming packet.');
|
||||
}
|
||||
|
||||
const word = view.getUint32(0, false);
|
||||
const header = readMp3FrameHeader(word, null).header;
|
||||
if (!header) {
|
||||
throw new Error('Invalid MP3 header in priming packet.');
|
||||
}
|
||||
|
||||
this.xingFrameData = {
|
||||
mpegVersionId: header.mpegVersionId,
|
||||
layer: header.layer,
|
||||
frequencyIndex: header.frequencyIndex,
|
||||
sampleRate: header.sampleRate,
|
||||
channel: header.channel,
|
||||
modeExtension: header.modeExtension,
|
||||
copyright: header.copyright,
|
||||
original: header.original,
|
||||
emphasis: header.emphasis,
|
||||
|
||||
frameCount: null,
|
||||
fileSize: null,
|
||||
toc: null,
|
||||
};
|
||||
} else if (track.metadata.decoderConfig) {
|
||||
// All we know is the sample rate and channel count, so let's derive the rest
|
||||
const { sampleRate, numberOfChannels } = track.metadata.decoderConfig;
|
||||
|
||||
// MPEG Version 1 uses the sampling rates directly, Version 2 halves them, and Version 2.5 quarters them
|
||||
const mpegVersionIds = [3, 2, 0];
|
||||
let mpegVersionId: number | null = null;
|
||||
let frequencyIndex = -1;
|
||||
|
||||
for (let i = 0; i < mpegVersionIds.length; i++) {
|
||||
frequencyIndex = SAMPLING_RATES.indexOf(sampleRate << i);
|
||||
if (frequencyIndex !== -1) {
|
||||
mpegVersionId = mpegVersionIds[i]!;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (mpegVersionId === null) {
|
||||
throw new Error(`${sampleRate} Hz is not a valid MP3 sample rate.`);
|
||||
}
|
||||
|
||||
this.xingFrameData = {
|
||||
mpegVersionId,
|
||||
layer: 1, // Layer III
|
||||
frequencyIndex,
|
||||
sampleRate,
|
||||
channel: numberOfChannels === 1 ? 3 : 0, // 3 = single channel, 0 = stereo
|
||||
modeExtension: 0,
|
||||
copyright: 0,
|
||||
original: 0,
|
||||
emphasis: 0,
|
||||
|
||||
frameCount: null,
|
||||
fileSize: null,
|
||||
toc: null,
|
||||
};
|
||||
} else {
|
||||
throw new Error(
|
||||
'Cannot finalize an empty MP3 file: no packets were added and the track specified neither a'
|
||||
+ ' decoderConfig nor a primingPacket in its metadata, so there\'s no telling what the file'
|
||||
+ ' should look like.',
|
||||
);
|
||||
}
|
||||
|
||||
this.xingFramePos = this.writer.getPos();
|
||||
this.mp3Writer.writeXingFrame(this.xingFrameData);
|
||||
|
||||
this.frameCount++;
|
||||
}
|
||||
|
||||
assert(this.xingFramePos !== null);
|
||||
|
||||
const endPos = this.writer.getPos();
|
||||
const audioDataEndPos = endPos - this.xingFramePos;
|
||||
|
||||
this.writer.seek(this.xingFramePos);
|
||||
|
||||
const toc = new Uint8Array(100);
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const index = Math.floor(this.framePositions.length * (i / 100));
|
||||
if (this.framePositions.length > 0) {
|
||||
const toc = new Uint8Array(100);
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const index = Math.floor(this.framePositions.length * (i / 100));
|
||||
|
||||
const byteOffset = this.framePositions[index]! - this.xingFramePos;
|
||||
toc[i] = 256 * (byteOffset / audioDataEndPos);
|
||||
const byteOffset = this.framePositions[index]! - this.xingFramePos;
|
||||
toc[i] = 256 * (byteOffset / audioDataEndPos);
|
||||
}
|
||||
|
||||
this.xingFrameData.toc = toc;
|
||||
}
|
||||
|
||||
this.xingFrameData.frameCount = this.frameCount;
|
||||
this.xingFrameData.fileSize = audioDataEndPos;
|
||||
this.xingFrameData.toc = toc;
|
||||
|
||||
if (this.format._options.onXingFrame) {
|
||||
this.writer.startTrackingWrites();
|
||||
|
||||
@@ -116,6 +116,8 @@ export class Mp3Writer {
|
||||
const frameSize = computeMp3FrameSize(
|
||||
lowSamplingFrequency, data.layer, 1000 * kilobitRate, data.sampleRate, padding,
|
||||
);
|
||||
this.writer.seek(startPos + frameSize);
|
||||
|
||||
// Pad the frame out to its full size
|
||||
this.writer.write(new Uint8Array(startPos + frameSize - this.writer.getPos()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -713,6 +713,11 @@ export class MpegTsDemuxer extends Demuxer {
|
||||
}
|
||||
|
||||
for (const stream of this.elementaryStreams) {
|
||||
if (!stream.initialized) {
|
||||
// Stream was declared but no initialization data for it was found
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stream.info.type === 'video') {
|
||||
this.trackBackingEntries.push(
|
||||
new MpegTsVideoTrackBacking(stream as ElementaryVideoStream),
|
||||
|
||||
@@ -112,7 +112,7 @@ export class MpegTsMuxer extends Muxer {
|
||||
return existingTrackData;
|
||||
}
|
||||
|
||||
validateVideoChunkMetadata(meta);
|
||||
validateVideoChunkMetadata(meta, track.source._codec);
|
||||
assert(meta?.decoderConfig);
|
||||
|
||||
const codec = track.source._codec;
|
||||
@@ -157,7 +157,7 @@ export class MpegTsMuxer extends Muxer {
|
||||
return existingTrackData;
|
||||
}
|
||||
|
||||
validateAudioChunkMetadata(meta);
|
||||
validateAudioChunkMetadata(meta, track.source._codec);
|
||||
assert(meta?.decoderConfig);
|
||||
|
||||
const codec = track.source._codec;
|
||||
|
||||
@@ -81,6 +81,13 @@ export class OggMuxer extends Muxer {
|
||||
|
||||
this.writer = await this.output._getRootWriter(true); // Ogg is always monotonically written!
|
||||
|
||||
for (const track of this.output.tracks) {
|
||||
assert(track.isAudioTrack());
|
||||
if (track.metadata.decoderConfig) {
|
||||
this.getTrackData(track, { decoderConfig: track.metadata.decoderConfig });
|
||||
}
|
||||
}
|
||||
|
||||
release();
|
||||
}
|
||||
|
||||
@@ -110,7 +117,7 @@ export class OggMuxer extends Muxer {
|
||||
|
||||
assert(track.source._codec === 'vorbis' || track.source._codec === 'opus');
|
||||
|
||||
validateAudioChunkMetadata(meta);
|
||||
validateAudioChunkMetadata(meta, track.source._codec);
|
||||
|
||||
assert(meta);
|
||||
assert(meta.decoderConfig);
|
||||
|
||||
+15
-5
@@ -109,6 +109,11 @@ export abstract class OutputFormat {
|
||||
_codecUnsupportedHint(codec: MediaCodec) {
|
||||
return '';
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_isFragmentedIsobmff() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -261,7 +266,7 @@ export abstract class IsobmffOutputFormat extends OutputFormat {
|
||||
video: { min: 0, max },
|
||||
audio: { min: 0, max },
|
||||
subtitle: { min: 0, max },
|
||||
total: { min: 1, max },
|
||||
total: { min: 0, max },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -277,6 +282,11 @@ export abstract class IsobmffOutputFormat extends OutputFormat {
|
||||
_createMuxer(output: Output) {
|
||||
return new IsobmffMuxer(output, this);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
override _isFragmentedIsobmff(): boolean {
|
||||
return this._options.fastStart === 'fragmented';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -540,7 +550,7 @@ export class MkvOutputFormat extends OutputFormat {
|
||||
video: { min: 0, max },
|
||||
audio: { min: 0, max },
|
||||
subtitle: { min: 0, max },
|
||||
total: { min: 1, max },
|
||||
total: { min: 0, max },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -881,7 +891,7 @@ export class OggOutputFormat extends OutputFormat {
|
||||
video: { min: 0, max: 0 },
|
||||
audio: { min: 0, max },
|
||||
subtitle: { min: 0, max: 0 },
|
||||
total: { min: 1, max },
|
||||
total: { min: 0, max },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1128,7 +1138,7 @@ export class MpegTsOutputFormat extends OutputFormat {
|
||||
video: { min: 0, max: maxVideo },
|
||||
audio: { min: 0, max: maxAudio },
|
||||
subtitle: { min: 0, max: 0 },
|
||||
total: { min: 1, max: maxTotal },
|
||||
total: { min: 0, max: maxTotal },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1405,7 +1415,7 @@ export class HlsOutputFormat extends OutputFormat {
|
||||
video: { min: 0, max: supportsVideo ? Infinity : 0 },
|
||||
audio: { min: 0, max: supportsAudio ? Infinity : 0 },
|
||||
subtitle: { min: 0, max: 0 }, // Currently disabled
|
||||
total: { min: 1, max: Infinity },
|
||||
total: { min: 0, max: Infinity },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+51
-3
@@ -14,6 +14,8 @@ import { AudioSource, MediaSource, SubtitleSource, VideoSource } from './media-s
|
||||
import { PathedTarget, Target, TargetRequest } from './target';
|
||||
import { Writer } from './writer';
|
||||
import { Logging } from './logging';
|
||||
import { EncodedPacket } from './packet';
|
||||
import { validateAudioChunkMetadata, validateVideoChunkMetadata } from './codec';
|
||||
|
||||
/**
|
||||
* List of all track types.
|
||||
@@ -260,13 +262,36 @@ export type VideoTrackMetadata = BaseTrackMetadata & {
|
||||
* frame to this track.
|
||||
*/
|
||||
hasOnlyKeyPackets?: boolean;
|
||||
/**
|
||||
* The decoder config for this video track, provided ahead of time. This is provided automatically when media data
|
||||
* added to the track, but by specifying it here, you give the muxer additional information that it can make use of.
|
||||
* Zero-packet tracks become possible to write when this field is set.
|
||||
*/
|
||||
decoderConfig?: VideoDecoderConfig;
|
||||
/**
|
||||
* Can be provided in addition to {@link VideoTrackMetadata.decoderConfig} to provide additional track information
|
||||
* not included in the decoder config. This packet will not be added to the media data.
|
||||
*/
|
||||
primingPacket?: EncodedPacket;
|
||||
};
|
||||
/**
|
||||
* Additional metadata for audio tracks.
|
||||
* @group Output files
|
||||
* @public
|
||||
*/
|
||||
export type AudioTrackMetadata = BaseTrackMetadata & {};
|
||||
export type AudioTrackMetadata = BaseTrackMetadata & {
|
||||
/**
|
||||
* The decoder config for this audio track, provided ahead of time. This is provided automatically when media data
|
||||
* added to the track, but by specifying it here, you give the muxer additional information that it can make use of.
|
||||
* Zero-packet tracks become possible to write when this field is set.
|
||||
*/
|
||||
decoderConfig?: AudioDecoderConfig;
|
||||
/**
|
||||
* Can be provided in addition to {@link AudioTrackMetadata.decoderConfig} to provide additional track information
|
||||
* not included in the decoder config. This packet will not be added to the media data.
|
||||
*/
|
||||
primingPacket?: EncodedPacket;
|
||||
};
|
||||
/**
|
||||
* Additional metadata for subtitle tracks.
|
||||
* @group Output files
|
||||
@@ -617,6 +642,17 @@ export class Output<
|
||||
`Invalid video frame rate: ${metadata.frameRate}. Must be a positive number.`,
|
||||
);
|
||||
}
|
||||
if (metadata.decoderConfig !== undefined) {
|
||||
validateVideoChunkMetadata({ decoderConfig: metadata.decoderConfig }, source._codec);
|
||||
}
|
||||
if (metadata.primingPacket !== undefined) {
|
||||
if (!(metadata.primingPacket instanceof EncodedPacket)) {
|
||||
throw new TypeError('metadata.primingPacket, when provided, must be an EncodedPacket.');
|
||||
}
|
||||
if (metadata.decoderConfig === undefined) {
|
||||
throw new TypeError('metadata.primingPacket can only be provided alongside metadata.decoderConfig.');
|
||||
}
|
||||
}
|
||||
|
||||
const metadataCopy = { ...metadata };
|
||||
metadataCopy.group ??= this.defaultTrackGroup;
|
||||
@@ -632,6 +668,17 @@ export class Output<
|
||||
throw new TypeError('source must be an AudioSource.');
|
||||
}
|
||||
validateBaseTrackMetadata(metadata);
|
||||
if (metadata.decoderConfig !== undefined) {
|
||||
validateAudioChunkMetadata({ decoderConfig: metadata.decoderConfig }, source._codec);
|
||||
}
|
||||
if (metadata.primingPacket !== undefined) {
|
||||
if (!(metadata.primingPacket instanceof EncodedPacket)) {
|
||||
throw new TypeError('metadata.primingPacket, when provided, must be an EncodedPacket.');
|
||||
}
|
||||
if (metadata.decoderConfig === undefined) {
|
||||
throw new TypeError('metadata.primingPacket can only be provided alongside metadata.decoderConfig.');
|
||||
}
|
||||
}
|
||||
|
||||
const metadataCopy = { ...metadata };
|
||||
metadataCopy.group ??= this.defaultTrackGroup;
|
||||
@@ -830,7 +877,8 @@ export class Output<
|
||||
return this._startPromise = (async () => {
|
||||
this.state = 'started';
|
||||
|
||||
const release = await this._mutex.acquire();
|
||||
// We want to call muxer.start immediately, so we avoid using an await here
|
||||
const releasePromise = this._mutex.acquire();
|
||||
|
||||
try {
|
||||
await this._muxer.start();
|
||||
@@ -838,7 +886,7 @@ export class Output<
|
||||
const promises = this.tracks.map(track => track.source._start());
|
||||
await Promise.all(promises);
|
||||
} finally {
|
||||
release();
|
||||
(await releasePromise)();
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
+160
-65
@@ -534,6 +534,9 @@ export class VideoSample implements Disposable {
|
||||
if (init.duration !== undefined && (!Number.isFinite(init.duration) || init.duration < 0)) {
|
||||
throw new TypeError('init.duration, when provided, must be a non-negative number.');
|
||||
}
|
||||
if (init.visibleRect !== undefined) {
|
||||
validateRectangle(init.visibleRect, 'init.visibleRect');
|
||||
}
|
||||
|
||||
if (typeof VideoFrame !== 'undefined') {
|
||||
return new VideoSample(
|
||||
@@ -541,6 +544,13 @@ export class VideoSample implements Disposable {
|
||||
timestamp: Math.trunc(init.timestamp! * SECOND_TO_MICROSECOND_FACTOR),
|
||||
// Drag 0 to undefined
|
||||
duration: Math.trunc((init.duration ?? 0) * SECOND_TO_MICROSECOND_FACTOR) || undefined,
|
||||
// WebCodecs wants DOMRectInit
|
||||
visibleRect: init.visibleRect && {
|
||||
x: init.visibleRect.left,
|
||||
y: init.visibleRect.top,
|
||||
width: init.visibleRect.width,
|
||||
height: init.visibleRect.height,
|
||||
},
|
||||
}),
|
||||
init,
|
||||
);
|
||||
@@ -565,7 +575,9 @@ export class VideoSample implements Disposable {
|
||||
throw new TypeError('Could not determine dimensions.');
|
||||
}
|
||||
|
||||
const canvas = new OffscreenCanvas(width, height);
|
||||
const visibleRect = init.visibleRect ?? { left: 0, top: 0, width, height };
|
||||
|
||||
const canvas = new OffscreenCanvas(visibleRect.width, visibleRect.height);
|
||||
const context = canvas.getContext('2d', {
|
||||
alpha: isFirefox(), // Firefox has VideoFrame glitches with opaque canvases
|
||||
willReadFrequently: true,
|
||||
@@ -577,15 +589,15 @@ export class VideoSample implements Disposable {
|
||||
);
|
||||
}
|
||||
|
||||
// Draw it to a canvas
|
||||
context.drawImage(data, 0, 0);
|
||||
// Draw it to a canvas, cropped to the visible rect
|
||||
context.drawImage(data, -visibleRect.left, -visibleRect.top);
|
||||
this._data = canvas;
|
||||
this._layout = null;
|
||||
|
||||
this.format = 'RGBX';
|
||||
this.visibleRect = { left: 0, top: 0, width, height };
|
||||
this.squarePixelWidth = width;
|
||||
this.squarePixelHeight = height;
|
||||
this.visibleRect = { left: 0, top: 0, width: visibleRect.width, height: visibleRect.height };
|
||||
this.squarePixelWidth = visibleRect.width;
|
||||
this.squarePixelHeight = visibleRect.height;
|
||||
this.rotation = init.rotation ?? 0;
|
||||
this.timestamp = init.timestamp!;
|
||||
this.duration = init.duration ?? 0;
|
||||
@@ -1364,6 +1376,94 @@ export class VideoSample implements Disposable {
|
||||
return { sx, sy, sWidth, sHeight };
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the sample onto the target canvas with fit behavior, manually mipmapping on strong downscales for quality.
|
||||
* @internal
|
||||
*/
|
||||
_drawWithFitAndMipmapping(
|
||||
targetCanvas: HTMLCanvasElement | OffscreenCanvas,
|
||||
targetContext: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
|
||||
options: {
|
||||
fit: 'fill' | 'contain' | 'cover';
|
||||
rotation: Rotation;
|
||||
crop: CropRectangle | undefined;
|
||||
/** Freshly-created target canvases carry no stale pixels and don't need to be cleared. */
|
||||
targetIsFresh: boolean;
|
||||
/** Paints on a black background instead of transparency (for alpha discard or opaque emulation). */
|
||||
fillBlack: boolean;
|
||||
},
|
||||
) {
|
||||
const targetWidth = targetCanvas.width;
|
||||
const targetHeight = targetCanvas.height;
|
||||
|
||||
const [rotatedWidth, rotatedHeight] = options.rotation % 180 === 0
|
||||
? [this.squarePixelWidth, this.squarePixelHeight]
|
||||
: [this.squarePixelHeight, this.squarePixelWidth];
|
||||
const sourceWidth = options.crop ? options.crop.width : rotatedWidth;
|
||||
const sourceHeight = options.crop ? options.crop.height : rotatedHeight;
|
||||
|
||||
// Once we downscale by more than 2x, bilinear filtering starts skipping pixels and the result aliases. So in
|
||||
// that case, we do manual mipmapping: draw at a larger size first, then iteratively halve the image until we
|
||||
// arrive at the target size. Sucks that we have to do this honestly, but imageSmoothingQuality is very flaky -
|
||||
// some resources are just not mipmapped in the browser and thus always fall back to bilinear downsampling.
|
||||
let mipLevels = 0;
|
||||
if (2 * targetWidth < sourceWidth && 2 * targetHeight < sourceHeight) {
|
||||
mipLevels = Math.floor(Math.log2(
|
||||
Math.min(sourceWidth / targetWidth, sourceHeight / targetHeight),
|
||||
));
|
||||
}
|
||||
|
||||
const drawWidth = targetWidth * 2 ** mipLevels;
|
||||
const drawHeight = targetHeight * 2 ** mipLevels;
|
||||
|
||||
const { canvas, context, isNew } = mipLevels > 0
|
||||
? getTransformationCanvas(drawWidth, drawHeight)
|
||||
: { canvas: targetCanvas, context: targetContext, isNew: options.targetIsFresh };
|
||||
|
||||
context.imageSmoothingQuality = 'high';
|
||||
|
||||
if (options.fillBlack) {
|
||||
context.fillStyle = 'black';
|
||||
context.fillRect(0, 0, drawWidth, drawHeight);
|
||||
} else if (!isNew) {
|
||||
// Reused canvases carry stale pixels from a prior draw
|
||||
context.clearRect(0, 0, drawWidth, drawHeight);
|
||||
}
|
||||
|
||||
this.drawWithFit(context, {
|
||||
fit: options.fit,
|
||||
rotation: options.rotation,
|
||||
crop: options.crop,
|
||||
});
|
||||
|
||||
// Walk down the mip chain by repeatedly drawing the canvas onto itself at half size. The 'copy' composite
|
||||
// operation makes sure pixels get replaced instead of blended with what's already there.
|
||||
context.globalCompositeOperation = 'copy';
|
||||
for (let i = mipLevels; i > 1; i--) {
|
||||
const levelWidth = targetWidth * 2 ** i;
|
||||
const levelHeight = targetHeight * 2 ** i;
|
||||
|
||||
context.drawImage(canvas, 0, 0, levelWidth, levelHeight, 0, 0, levelWidth / 2, levelHeight / 2);
|
||||
}
|
||||
context.globalCompositeOperation = 'source-over';
|
||||
|
||||
if (mipLevels > 0) {
|
||||
// We'd love to skip this step and hand over the oversized mip canvas cropped with visibleRect, but sadly,
|
||||
// all browsers ignore a frame's visibleRect when encoding it and consume the full coded frame instead
|
||||
// (violating the WebCodecs spec, I think? See https://issues.chromium.org/issues/543284189). So, the result
|
||||
// must live on a canvas of exactly the target size, which is why the last halving step draws onto the
|
||||
// target canvas directly. 'copy' also conveniently disposes of any stale pixels there.
|
||||
targetContext.imageSmoothingQuality = 'high';
|
||||
targetContext.globalCompositeOperation = 'copy';
|
||||
targetContext.drawImage(
|
||||
canvas,
|
||||
0, 0, 2 * targetWidth, 2 * targetHeight,
|
||||
0, 0, targetWidth, targetHeight,
|
||||
);
|
||||
targetContext.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts this video sample to a
|
||||
* [`CanvasImageSource`](https://udn.realityripple.com/docs/Web/API/CanvasImageSource) for drawing to a canvas.
|
||||
@@ -1495,69 +1595,14 @@ export class VideoSample implements Disposable {
|
||||
|
||||
// We need to handle it ourselves, and we use canvases to do it
|
||||
|
||||
let canvas: HTMLCanvasElement | OffscreenCanvas | null = null;
|
||||
let canvasIsNew = false;
|
||||
const { canvas, context, isNew } = getTransformationCanvas(description.width, description.height);
|
||||
|
||||
for (const entry of transformationCanvasCache) {
|
||||
if (entry.canvas.width === description.width && entry.canvas.height === description.height) {
|
||||
canvas = entry.canvas;
|
||||
entry.age = transformationCanvasCacheNextAge++;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (canvas === null) {
|
||||
if (typeof OffscreenCanvas !== 'undefined') {
|
||||
canvas = new OffscreenCanvas(description.width, description.height);
|
||||
} else {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||
throw new Error(
|
||||
'Cannot transform VideoSamples in this environment. Either run in an environment with'
|
||||
+ ' OffscreenCanvas or HTMLCanvasElement, or supply a custom VideoSample transformer using'
|
||||
+ ' registerVideoSampleTransformer().',
|
||||
);
|
||||
}
|
||||
|
||||
canvas = document.createElement('canvas');
|
||||
canvas.width = description.width;
|
||||
canvas.height = description.height;
|
||||
}
|
||||
|
||||
canvasIsNew = true;
|
||||
|
||||
if (transformationCanvasCache.length >= TRANSFORMATION_CANVAS_CACHE_MAX_SIZE) {
|
||||
transformationCanvasCache.splice(arrayArgmin(transformationCanvasCache, x => x.age), 1);
|
||||
}
|
||||
|
||||
transformationCanvasCache.push({
|
||||
canvas,
|
||||
age: transformationCanvasCacheNextAge++,
|
||||
});
|
||||
}
|
||||
|
||||
const context = canvas.getContext('2d', {
|
||||
alpha: true,
|
||||
}) as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'The \'2d\' canvas context is required to transform VideoSamples. Register a custom transformer using'
|
||||
+ ' registerVideoSampleTransformer to work around this limitation.',
|
||||
);
|
||||
}
|
||||
|
||||
if (description.alpha === 'discard') {
|
||||
context.fillStyle = 'black';
|
||||
context.fillRect(0, 0, description.width, description.height);
|
||||
} else if (!canvasIsNew) {
|
||||
// Cached canvases carry stale pixels from a prior draw
|
||||
context.clearRect(0, 0, description.width, description.height);
|
||||
}
|
||||
|
||||
this.drawWithFit(context, {
|
||||
this._drawWithFitAndMipmapping(canvas, context, {
|
||||
fit: description.fit,
|
||||
rotation: description.rotation,
|
||||
crop: description.crop,
|
||||
targetIsFresh: isNew,
|
||||
fillBlack: description.alpha === 'discard',
|
||||
});
|
||||
|
||||
return new VideoSample(canvas, {
|
||||
@@ -1726,10 +1771,60 @@ export const registerVideoSampleTransformer = (
|
||||
const TRANSFORMATION_CANVAS_CACHE_MAX_SIZE = 3;
|
||||
const transformationCanvasCache: {
|
||||
canvas: HTMLCanvasElement | OffscreenCanvas;
|
||||
context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
|
||||
age: number;
|
||||
}[] = [];
|
||||
let transformationCanvasCacheNextAge = 0;
|
||||
|
||||
const getTransformationCanvas = (width: number, height: number) => {
|
||||
for (const entry of transformationCanvasCache) {
|
||||
if (entry.canvas.width === width && entry.canvas.height === height) {
|
||||
entry.age = transformationCanvasCacheNextAge++;
|
||||
return { canvas: entry.canvas, context: entry.context, isNew: false };
|
||||
}
|
||||
}
|
||||
|
||||
let canvas: HTMLCanvasElement | OffscreenCanvas;
|
||||
if (typeof OffscreenCanvas !== 'undefined') {
|
||||
canvas = new OffscreenCanvas(width, height);
|
||||
} else {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||
throw new Error(
|
||||
'Cannot transform VideoSamples in this environment. Either run in an environment with'
|
||||
+ ' OffscreenCanvas or HTMLCanvasElement, or supply a custom VideoSample transformer using'
|
||||
+ ' registerVideoSampleTransformer().',
|
||||
);
|
||||
}
|
||||
|
||||
canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
}
|
||||
|
||||
const context = canvas.getContext('2d', {
|
||||
alpha: true,
|
||||
willReadFrequently: false,
|
||||
}) as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'The \'2d\' canvas context is required to transform VideoSamples. Register a custom transformer using'
|
||||
+ ' registerVideoSampleTransformer to work around this limitation.',
|
||||
);
|
||||
}
|
||||
|
||||
if (transformationCanvasCache.length >= TRANSFORMATION_CANVAS_CACHE_MAX_SIZE) {
|
||||
transformationCanvasCache.splice(arrayArgmin(transformationCanvasCache, x => x.age), 1);
|
||||
}
|
||||
|
||||
transformationCanvasCache.push({
|
||||
canvas,
|
||||
context,
|
||||
age: transformationCanvasCacheNextAge++,
|
||||
});
|
||||
|
||||
return { canvas, context, isNew: true };
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the color space of a {@link VideoSample}. Corresponds to the WebCodecs API's VideoColorSpace.
|
||||
* @group Samples
|
||||
|
||||
+44
-8
@@ -240,6 +240,7 @@ class SegmentedInputInputTrackBacking implements InputTrackBacking {
|
||||
|
||||
hydrationPromise: Promise<void> | null = null;
|
||||
firstInputTrack: InputTrack | null = null;
|
||||
firstSegment: Segment | null = null;
|
||||
|
||||
constructor(segmentedInput: SegmentedInput, decl: SegmentedInputTrackDeclaration, number: number) {
|
||||
this.segmentedInput = segmentedInput;
|
||||
@@ -254,15 +255,29 @@ class SegmentedInputInputTrackBacking implements InputTrackBacking {
|
||||
throw new Error('Missing first segment, can\'t retrieve track.');
|
||||
}
|
||||
|
||||
const input = this.segmentedInput.getInputForSegment(this.segmentedInput.firstSegment);
|
||||
const inputTracks = await input.getTracks();
|
||||
let currentSegment: Segment | null = this.segmentedInput.firstSegment;
|
||||
let track: InputTrack | null = null;
|
||||
|
||||
// For playlists with sparse tracks (rare af!!), not every segment has every track, so we need to loop to
|
||||
// find the first segment that actually contains the track we want.
|
||||
while (currentSegment) {
|
||||
const input = this.segmentedInput.getInputForSegment(currentSegment);
|
||||
const inputTracks = await input.getTracks();
|
||||
track = inputTracks.find(x => x.type === this.decl.type && x.number === this.number) ?? null;
|
||||
|
||||
if (track) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentSegment = await this.segmentedInput.getNextSegment(currentSegment, {});
|
||||
}
|
||||
|
||||
const track = inputTracks.find(x => x.type === this.decl.type && x.number === this.number);
|
||||
if (!track) {
|
||||
throw new Error('No matching track found in underlying media data.');
|
||||
}
|
||||
|
||||
this.firstInputTrack = track;
|
||||
this.firstSegment = currentSegment;
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -380,15 +395,36 @@ class SegmentedInputInputTrackBacking implements InputTrackBacking {
|
||||
async getFirstPacket(options: PacketRetrievalOptions): Promise<EncodedPacket | null> {
|
||||
await this.hydrate();
|
||||
|
||||
assert(this.segmentedInput.firstSegment);
|
||||
assert(this.firstInputTrack);
|
||||
assert(this.firstSegment);
|
||||
|
||||
const packet = await this.firstInputTrack._backing.getFirstPacket(options);
|
||||
if (!packet) {
|
||||
return null;
|
||||
let currentTrack: InputTrack | null = this.firstInputTrack;
|
||||
let currentSegment: Segment | null = this.firstSegment;
|
||||
|
||||
// Loop until we found a segment with a packet (segments may contain zero packets in rare cases)
|
||||
while (true) {
|
||||
if (currentTrack) {
|
||||
const packet = await currentTrack._backing.getFirstPacket(options);
|
||||
if (packet) {
|
||||
return this.createAdjustedPacket(packet, currentSegment, currentTrack);
|
||||
}
|
||||
}
|
||||
|
||||
currentSegment = await this.segmentedInput.getNextSegment(currentSegment, {
|
||||
skipLiveWait: options.skipLiveWait,
|
||||
});
|
||||
if (!currentSegment) {
|
||||
break;
|
||||
}
|
||||
|
||||
const nextInput = this.segmentedInput.getInputForSegment(currentSegment);
|
||||
const nextTracks = await nextInput.getTracks();
|
||||
currentTrack = nextTracks.find(t => (
|
||||
t.type === this.firstInputTrack!.type && t.number === this.firstInputTrack!.number
|
||||
)) ?? null;
|
||||
}
|
||||
|
||||
return this.createAdjustedPacket(packet, this.segmentedInput.firstSegment, this.firstInputTrack);
|
||||
return null;
|
||||
}
|
||||
|
||||
getNextPacket(packet: EncodedPacket, options: PacketRetrievalOptions): Promise<EncodedPacket | null> {
|
||||
|
||||
+208
-71
@@ -712,7 +712,8 @@ export type UrlSourceOptions = {
|
||||
|
||||
/**
|
||||
* A source backed by a URL. This is useful for reading data from the network. Requests will be made using an optimized
|
||||
* reading and prefetching pattern to minimize request count and latency.
|
||||
* reading and prefetching pattern to minimize request count and latency. Works best with servers that support HTTP
|
||||
* range requests; otherwise, resources must be streamed and read sequentially.
|
||||
* @group Input sources
|
||||
* @public
|
||||
*/
|
||||
@@ -737,6 +738,12 @@ export class UrlSource extends PathedSource {
|
||||
* @internal
|
||||
*/
|
||||
_fileSizeDetermined = false;
|
||||
/**
|
||||
* When the server doesn't support range requests, we abandon the orchestrator and instead defer to an internal
|
||||
* ReadableStreamSource wrapping the response body, which pulls new data only when reads demand it.
|
||||
* @internal
|
||||
*/
|
||||
_sequentialBacking: ReadableStreamSource | null = null;
|
||||
|
||||
/**
|
||||
* Creates a new {@link UrlSource} backed by the resource at the specified URL.
|
||||
@@ -847,7 +854,9 @@ export class UrlSource extends PathedSource {
|
||||
return this._length !== null ? this._length : undefined;
|
||||
}
|
||||
|
||||
const baseSize = this._orchestrator.fileSize;
|
||||
const baseSize = this._sequentialBacking
|
||||
? this._sequentialBacking._endIndex
|
||||
: this._orchestrator.fileSize;
|
||||
if (baseSize === null) {
|
||||
return this._length !== null ? this._length : null;
|
||||
}
|
||||
@@ -867,12 +876,14 @@ export class UrlSource extends PathedSource {
|
||||
}
|
||||
|
||||
const offset = this._offset;
|
||||
const result = this._orchestrator.read(
|
||||
offset + start,
|
||||
offset + end,
|
||||
Math.max(offset + minReadPosition, offset),
|
||||
offset + Math.min(maxReadPosition, this._length ?? Infinity),
|
||||
);
|
||||
const result = this._sequentialBacking
|
||||
? this._sequentialBacking._read(offset + start, offset + end)
|
||||
: this._orchestrator.read(
|
||||
offset + start,
|
||||
offset + end,
|
||||
Math.max(offset + minReadPosition, offset),
|
||||
offset + Math.min(maxReadPosition, this._length ?? Infinity),
|
||||
);
|
||||
|
||||
const processResult = (result: ReadResult | null) => {
|
||||
if (!result) {
|
||||
@@ -937,13 +948,29 @@ export class UrlSource extends PathedSource {
|
||||
// Note: For range requests, this is _technically_ not correct, as the range response could contain
|
||||
// less data than was requested. In practice, it seems most servers don't do this though, and the
|
||||
// Content-Length header actually contains the length until the end of the file.
|
||||
this._orchestrator.supplyFileSize(worker.currentPos + Number(contentLength));
|
||||
// A non-206 response always spans the entire resource, no matter what range we asked for.
|
||||
const basePos = response.status === 206 ? worker.currentPos : 0;
|
||||
this._orchestrator.supplyFileSize(basePos + Number(contentLength));
|
||||
}
|
||||
}
|
||||
|
||||
this._fileSizeDetermined = true; // Yes, this is correct even if file size is still null
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error(
|
||||
'Missing HTTP response body stream. The used fetch function must provide the response body as a'
|
||||
+ ' ReadableStream.',
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status !== 206) {
|
||||
if (this._sequentialBacking) {
|
||||
// Another worker already discovered the missing range request support and initiated the
|
||||
// transition into sequential mode; this response is of no use anymore
|
||||
void response.body.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._usedForHls) {
|
||||
const url = new URL(
|
||||
this._url instanceof Request ? this._url.url : this._url,
|
||||
@@ -958,34 +985,19 @@ export class UrlSource extends PathedSource {
|
||||
if (!warnedOrigins.has(url.origin)) {
|
||||
Logging._warn(
|
||||
`HTTP server (origin ${url.origin}) did not respond to a range request with 206 Partial`
|
||||
+ ' Content, meaning the entire resource will now be downloaded. To enable efficient'
|
||||
+ ' media file streaming across a network, please make sure your server supports'
|
||||
+ ' range requests.',
|
||||
+ ' Content, meaning the resource will now be streamed sequentially, with old data'
|
||||
+ ' being evicted from the cache. Reads into evicted regions will throw. To enable'
|
||||
+ ' efficient media file streaming across a network, please make sure your server'
|
||||
+ ' supports range requests. Alternatively, set maxCacheSize to Infinity in the'
|
||||
+ ' UrlSource options to keep the entire resource in memory.',
|
||||
);
|
||||
warnedOrigins.add(url.origin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
worker.currentPos = 0;
|
||||
this._orchestrator.options.maxCacheSize = Infinity; // 🤷
|
||||
|
||||
if (this._orchestrator.fileSize !== null) {
|
||||
worker.targetPos = this._orchestrator.fileSize;
|
||||
} else {
|
||||
// The server is dumb, doesn't even surface the content length, but we'll work with it.
|
||||
worker.targetPos = Infinity;
|
||||
worker.strictTarget = false;
|
||||
}
|
||||
|
||||
this._orchestrator.consolidateEverythingIntoOneWorker(worker);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error(
|
||||
'Missing HTTP response body stream. The used fetch function must provide the response body as a'
|
||||
+ ' ReadableStream.',
|
||||
);
|
||||
this._transitionToSequentialMode(response.body);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
@@ -1054,9 +1066,165 @@ export class UrlSource extends PathedSource {
|
||||
// logic for that has vanished for now. Leaving a comment here if this becomes relevant again.
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
private _transitionToSequentialMode(body: ReadableStream<Uint8Array>) {
|
||||
// The server ignored our range request and is sending the entire resource from byte 0. Instead of downloading
|
||||
// and caching the whole thing, we hand the response over to an internal ReadableStreamSource, which pulls new
|
||||
// data only when reads demand it and evicts old data as usual. The response body is wrapped in a stream that
|
||||
// transparently resumes when the connection dies.
|
||||
|
||||
let currentReader = body.getReader();
|
||||
let streamPosition = 0;
|
||||
let skipRemaining = 0;
|
||||
|
||||
const wrappedStream = new ReadableStream<Uint8Array>({
|
||||
pull: async (controller) => {
|
||||
while (true) {
|
||||
let readResult: ReadableStreamReadResult<Uint8Array>;
|
||||
|
||||
try {
|
||||
readResult = await currentReader.read();
|
||||
} catch (error) {
|
||||
if (this._disposed) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const retryDelayInSeconds = this._getRetryDelay(1, error, this._url);
|
||||
if (retryDelayInSeconds === null) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
Logging._error('Error while reading response stream. Attempting to resume.', error);
|
||||
await wait(1000 * retryDelayInSeconds);
|
||||
|
||||
const newResponse = await retriedFetch(
|
||||
this._options.fetchFn ?? fetch,
|
||||
this._url,
|
||||
mergeRequestInit(this._requestInit, {
|
||||
headers: {
|
||||
// Who knows, maybe the server honors range requests this time
|
||||
Range: `bytes=${streamPosition}-`,
|
||||
},
|
||||
}),
|
||||
this._getRetryDelay,
|
||||
() => this._disposed,
|
||||
);
|
||||
|
||||
if (!newResponse.ok) {
|
||||
throw new Error(
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
`Error fetching ${String(this._url)}:`
|
||||
+ ` ${newResponse.status} ${newResponse.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!newResponse.body) {
|
||||
throw new Error(
|
||||
'Missing HTTP response body stream. The used fetch function must provide the'
|
||||
+ ' response body as a ReadableStream.',
|
||||
);
|
||||
}
|
||||
|
||||
currentReader = newResponse.body.getReader();
|
||||
// If the server still doesn't do ranges, the new response starts at byte 0 again and
|
||||
// we need to skip over everything we already delivered. Cursed!
|
||||
skipRemaining = newResponse.status === 206 ? 0 : streamPosition;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (readResult.done) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
let chunk = readResult.value;
|
||||
|
||||
if (skipRemaining > 0) {
|
||||
const skippedAmount = Math.min(skipRemaining, chunk.length);
|
||||
skipRemaining -= skippedAmount;
|
||||
chunk = chunk.subarray(skippedAmount);
|
||||
}
|
||||
|
||||
if (chunk.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
streamPosition += chunk.length;
|
||||
controller.enqueue(chunk);
|
||||
|
||||
return;
|
||||
}
|
||||
},
|
||||
cancel: () => currentReader.cancel(),
|
||||
});
|
||||
|
||||
const backing = new ReadableStreamSource(wrappedStream, {
|
||||
maxCacheSize: this._orchestrator.options.maxCacheSize,
|
||||
});
|
||||
backing._endIndex = this._orchestrator.fileSize; // Might still be null
|
||||
backing._cacheMissErrorMessage = 'Attempted to read data from an already-evicted part of the cache. Because the'
|
||||
+ ' HTTP server did not honor the range request, data can only be read sequentially, with old data being'
|
||||
+ ' evicted from the cache. To fix this issue, either ensure your server responds to range requests with'
|
||||
+ ' 206 Partial Content, or set maxCacheSize to Infinity in the UrlSource options. Note that the latter'
|
||||
+ ' will store the entire file in the cache if needed, no matter how large.';
|
||||
backing.on('read', ({ start, end }) => this._dispatchRead(start, end));
|
||||
|
||||
this._sequentialBacking = backing;
|
||||
|
||||
// Everything still pending in the orchestrator must now be served by the backing instead. Gather all
|
||||
// pending slices, then retire the orchestrator's workers and queued reads for good; _read will only
|
||||
// consult the backing from now on.
|
||||
const uniqueSlices = new Set<PendingSlice>();
|
||||
|
||||
for (const otherWorker of this._orchestrator.workers) {
|
||||
for (const slice of otherWorker.pendingSlices) {
|
||||
uniqueSlices.add(slice);
|
||||
}
|
||||
|
||||
otherWorker.aborted = true;
|
||||
otherWorker.pendingSlices.length = 0;
|
||||
}
|
||||
|
||||
for (const queuedRead of this._orchestrator.queuedReads) {
|
||||
for (const slice of queuedRead.pendingSlices) {
|
||||
uniqueSlices.add(slice);
|
||||
}
|
||||
}
|
||||
|
||||
this._orchestrator.workers.length = 0;
|
||||
this._orchestrator.queuedReads.length = 0;
|
||||
|
||||
for (const slice of uniqueSlices) {
|
||||
const result = backing._read(slice.start, slice.start + slice.bytes.length);
|
||||
|
||||
if (result instanceof Promise) {
|
||||
result.then((readResult) => {
|
||||
if (readResult) {
|
||||
// The backing's cache is empty at this point, so the read is guaranteed to produce
|
||||
// exactly the requested range
|
||||
assert(readResult.offset === slice.start);
|
||||
slice.resolve(readResult.bytes);
|
||||
} else {
|
||||
slice.resolve(null);
|
||||
}
|
||||
}, (error: unknown) => slice.reject(error));
|
||||
} else {
|
||||
// Can only happen synchronously when the slice lies beyond the known file size
|
||||
assert(result === null);
|
||||
slice.resolve(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_dispose() {
|
||||
this._orchestrator.dispose();
|
||||
|
||||
if (this._sequentialBacking) {
|
||||
this._sequentialBacking._disposed = true;
|
||||
this._sequentialBacking._dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1456,6 +1624,12 @@ export class ReadableStreamSource extends Source {
|
||||
_endIndex: number | null = null;
|
||||
/** @internal */
|
||||
_pulling = false;
|
||||
/**
|
||||
* Overridable for internal use.
|
||||
* @internal
|
||||
*/
|
||||
_cacheMissErrorMessage = 'Attempted to read data from an already-evicted part of the cache. With'
|
||||
+ ' ReadableStreamSource, you must access the data more sequentially or increase the size of its cache.';
|
||||
|
||||
/** Creates a new {@link ReadableStreamSource} backed by the specified `ReadableStream<Uint8Array>`. */
|
||||
constructor(stream: ReadableStream<Uint8Array>, options: ReadableStreamSourceOptions = {}) {
|
||||
@@ -1581,10 +1755,7 @@ export class ReadableStreamSource extends Source {
|
||||
|
||||
/** @internal */
|
||||
_throwDueToCacheMiss() {
|
||||
throw new Error(
|
||||
'Read is before the cached region. With ReadableStreamSource, you must access the data more'
|
||||
+ ' sequentially or increase the size of its cache.',
|
||||
);
|
||||
throw new Error(this._cacheMissErrorMessage);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
@@ -1644,8 +1815,8 @@ export class ReadableStreamSource extends Source {
|
||||
});
|
||||
|
||||
// Do cache eviction, based on the distance from the last-requested index. It's important that we do it like
|
||||
// this and not based on where the reader is at, because if the reader is fast, we'll unnecessarily evict
|
||||
// data that we still might need.
|
||||
// this and not based on how far we've pulled the stream, because if the stream supplies data faster than it
|
||||
// is being requested, we'd unnecessarily evict data that we still might need.
|
||||
while (this._cache.length > 0) {
|
||||
const firstEntry = this._cache[0]!;
|
||||
const distance = this._maxRequestedIndex - firstEntry.end;
|
||||
@@ -2167,40 +2338,6 @@ class ReadOrchestrator {
|
||||
});
|
||||
}
|
||||
|
||||
consolidateEverythingIntoOneWorker(worker: ReadWorker) {
|
||||
// Here we merge everything into one "megaworker" that spans the entire file. We assume the passed-in worker
|
||||
// is already configured to be a megaworker.
|
||||
|
||||
const uniqueSlices = new Set(worker.pendingSlices);
|
||||
|
||||
for (let i = 0; i < this.workers.length; i++) {
|
||||
const otherWorker = this.workers[i]!;
|
||||
if (otherWorker === worker) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const slice of otherWorker.pendingSlices) {
|
||||
uniqueSlices.add(slice);
|
||||
}
|
||||
|
||||
otherWorker.aborted = true;
|
||||
otherWorker.pendingSlices.length = 0;
|
||||
this.workers.splice(i, 1);
|
||||
i--;
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.queuedReads.length; i++) {
|
||||
const queuedRead = this.queuedReads[i]!;
|
||||
|
||||
for (const slice of queuedRead.pendingSlices) {
|
||||
uniqueSlices.add(slice);
|
||||
}
|
||||
}
|
||||
|
||||
worker.pendingSlices = [...uniqueSlices];
|
||||
this.queuedReads.length = 0;
|
||||
}
|
||||
|
||||
/** Called by a worker when it has read some data. */
|
||||
supplyWorkerData(worker: ReadWorker, bytes: Uint8Array) {
|
||||
assert(!worker.aborted);
|
||||
|
||||
+12
-2
@@ -8,7 +8,7 @@
|
||||
|
||||
import type { FileHandle } from 'node:fs/promises';
|
||||
import * as nodeAlias from './node';
|
||||
import { assert, EventEmitter, FilePath, MaybePromise } from './misc';
|
||||
import { assert, EventEmitter, FilePath, isWebKit, MaybePromise } from './misc';
|
||||
|
||||
const node = typeof nodeAlias !== 'undefined'
|
||||
? nodeAlias // Aliasing it prevents some bundler warnings
|
||||
@@ -542,9 +542,19 @@ export class StreamTarget extends Target {
|
||||
throw new Error('Internal error: Monotonicity violation.');
|
||||
}
|
||||
|
||||
const isPartialView = section.start !== 0 || section.end !== chunk.data.byteLength;
|
||||
|
||||
let data: Uint8Array<ArrayBuffer>;
|
||||
if (isPartialView && isWebKit()) {
|
||||
// https://bugs.webkit.org/show_bug.cgi?id=302733
|
||||
data = chunk.data.slice(section.start, section.end);
|
||||
} else {
|
||||
data = chunk.data.subarray(section.start, section.end);
|
||||
}
|
||||
|
||||
void this._streamWriter.write({
|
||||
type: 'write',
|
||||
data: chunk.data.subarray(section.start, section.end),
|
||||
data,
|
||||
position,
|
||||
}).catch((error) => {
|
||||
this._writeError ??= error;
|
||||
|
||||
+20
-2
@@ -48,7 +48,18 @@ export class WaveMuxer extends Muxer {
|
||||
this.writer = await this.output._getRootWriter(false);
|
||||
this.riffWriter = new RiffWriter(this.writer);
|
||||
|
||||
// No writing needed here - we'll write the header with the first sample
|
||||
// If the track already tells us everything we need, we can write the header right now. Otherwise, we'll write
|
||||
// it with the first sample.
|
||||
const track = this.output.tracks[0];
|
||||
assert(track?.isAudioTrack());
|
||||
|
||||
if (track.metadata.decoderConfig) {
|
||||
validateAudioChunkMetadata({ decoderConfig: track.metadata.decoderConfig }, track.source._codec);
|
||||
|
||||
this.writeHeader(track, track.metadata.decoderConfig);
|
||||
this.sampleRate = track.metadata.decoderConfig.sampleRate;
|
||||
this.headerWritten = true;
|
||||
}
|
||||
|
||||
release();
|
||||
}
|
||||
@@ -70,7 +81,7 @@ export class WaveMuxer extends Muxer {
|
||||
|
||||
try {
|
||||
if (!this.headerWritten) {
|
||||
validateAudioChunkMetadata(meta);
|
||||
validateAudioChunkMetadata(meta, track.source._codec);
|
||||
|
||||
assert(meta);
|
||||
assert(meta.decoderConfig);
|
||||
@@ -341,6 +352,13 @@ export class WaveMuxer extends Muxer {
|
||||
async finalize() {
|
||||
const release = await this.mutex.acquire();
|
||||
|
||||
if (!this.headerWritten) {
|
||||
throw new Error(
|
||||
'Cannot finalize an empty WAVE file: no packets were added and the track specified no decoderConfig in'
|
||||
+ ' its metadata, so there\'s no telling what the file should look like.',
|
||||
);
|
||||
}
|
||||
|
||||
const endPos = this.writer.getPos();
|
||||
|
||||
if (this.isRf64) {
|
||||
|
||||
@@ -759,3 +759,27 @@ test('Pre-signaled pause signal', async () => {
|
||||
await conversion.execute();
|
||||
expect(conversion.state).toBe('done');
|
||||
});
|
||||
|
||||
test('Resizing at various scale factors', async () => {
|
||||
// The source is 1080p. 720p downscales by less than 2x, 240p downscales by more than 2x (which kicks in the manual
|
||||
// mipmapping path), and 1440p upscales.
|
||||
for (const height of [720, 240, 1440]) {
|
||||
using input = new Input({
|
||||
source: new UrlSource('/video.mp4'),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const output = new Output({ format: new Mp4OutputFormat(), target: new BufferTarget() });
|
||||
const conversion = await Conversion.init({
|
||||
input,
|
||||
output,
|
||||
video: { height },
|
||||
trim: { end: 1 },
|
||||
});
|
||||
await conversion.execute();
|
||||
|
||||
using result = new Input({ source: new BufferSource(output.target.buffer!), formats: ALL_FORMATS });
|
||||
const videoTrack = await result.getPrimaryVideoTrack();
|
||||
expect(await videoTrack!.getDisplayHeight()).toBe(height);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -19,13 +19,18 @@ test('FLAC encoder, 24-bit', async () => {
|
||||
const durationSeconds = 2;
|
||||
const data = createF32SineWave(sampleRate, channels, durationSeconds);
|
||||
|
||||
using sample = await encodeAndDecodeFirstSample(new AudioSample({
|
||||
const result = await encodeAndDecodeFirstSample(new AudioSample({
|
||||
data,
|
||||
format: 'f32',
|
||||
numberOfChannels: channels,
|
||||
sampleRate,
|
||||
timestamp: 0,
|
||||
}));
|
||||
using sample = result.sample;
|
||||
|
||||
expect(result.size).toBeGreaterThan(90_000);
|
||||
|
||||
console.log(result.size);
|
||||
|
||||
expect(sample.format).toBe('s32');
|
||||
});
|
||||
@@ -38,15 +43,20 @@ test('FLAC encoder, 16-bit', async () => {
|
||||
const durationSeconds = 2;
|
||||
const data = createS16SineWave(sampleRate, channels, durationSeconds);
|
||||
|
||||
using sample = await encodeAndDecodeFirstSample(new AudioSample({
|
||||
const result = await encodeAndDecodeFirstSample(new AudioSample({
|
||||
data,
|
||||
format: 's16',
|
||||
numberOfChannels: channels,
|
||||
sampleRate,
|
||||
timestamp: 0,
|
||||
}));
|
||||
using sample = result.sample;
|
||||
|
||||
expect(sample.format).toBe('s16');
|
||||
expect(result.size).toBeLessThan(50_000); // Shit just uses less data
|
||||
|
||||
// Really, this should only have s16 but decoders can differ and be flaky here. Trust me, I have tested that the
|
||||
// encoder does in fact encode s16.
|
||||
expect(['s16', 's32'].includes(sample.format)).toBe(true);
|
||||
});
|
||||
|
||||
const createF32SineWave = (sampleRate: number, channels: number, durationSeconds: number) => {
|
||||
@@ -104,5 +114,5 @@ const encodeAndDecodeFirstSample = async (audioSample: AudioSample) => {
|
||||
const sample = await sink.getSample(0);
|
||||
assert(sample);
|
||||
|
||||
return sample;
|
||||
return { sample, size: output.target.buffer!.byteLength };
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ test('MPEG-TS output format', async () => {
|
||||
video: { min: 0, max: 16 },
|
||||
audio: { min: 0, max: 32 },
|
||||
subtitle: { min: 0, max: 0 },
|
||||
total: { min: 1, max: 48 },
|
||||
total: { min: 0, max: 48 },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,711 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { Input } from '../../src/input.js';
|
||||
import { ALL_FORMATS } from '../../src/input-format.js';
|
||||
import { EncodedAudioPacketSource, EncodedVideoPacketSource } from '../../src/media-source.js';
|
||||
import { AudioTrackMetadata, Output } from '../../src/output.js';
|
||||
import {
|
||||
AdtsOutputFormat,
|
||||
CmafOutputFormat,
|
||||
FlacOutputFormat,
|
||||
HlsOutputFormat,
|
||||
MkvOutputFormat,
|
||||
Mp3OutputFormat,
|
||||
Mp4OutputFormat,
|
||||
MpegTsOutputFormat,
|
||||
OggOutputFormat,
|
||||
WavOutputFormat,
|
||||
} from '../../src/output-format.js';
|
||||
import { EncodedPacket } from '../../src/packet.js';
|
||||
import { Bitstream } from '../../shared/bitstream.js';
|
||||
import { BufferSource } from '../../src/source.js';
|
||||
import { BufferTarget, PathedTarget } from '../../src/target.js';
|
||||
import { InputAudioTrack, InputVideoTrack } from '../../src/input-track.js';
|
||||
|
||||
type EmptyMediaVariant =
|
||||
| { type: 'mp4'; fastStart: false | 'in-memory' | 'reserve' | 'fragmented' }
|
||||
| { type: 'cmaf' }
|
||||
| { type: 'matroska' };
|
||||
|
||||
test('No tracks, MP4, fastStart: false', async () => {
|
||||
await testNoTracks({ type: 'mp4', fastStart: false });
|
||||
});
|
||||
|
||||
test('No tracks, MP4, fastStart: in-memory', async () => {
|
||||
await testNoTracks({ type: 'mp4', fastStart: 'in-memory' });
|
||||
});
|
||||
|
||||
test('No tracks, MP4, fastStart: reserve', async () => {
|
||||
await testNoTracks({ type: 'mp4', fastStart: 'reserve' });
|
||||
});
|
||||
|
||||
test('No tracks, MP4, fastStart: fragmented', async () => {
|
||||
await testNoTracks({ type: 'mp4', fastStart: 'fragmented' });
|
||||
});
|
||||
|
||||
test('No tracks, CMAF', async () => {
|
||||
await testNoTracks({ type: 'cmaf' });
|
||||
});
|
||||
|
||||
test('No tracks, Matroska', async () => {
|
||||
await testNoTracks({ type: 'matroska' });
|
||||
});
|
||||
|
||||
// These formats are inherently multi-track, so holding zero tracks is perfectly fine
|
||||
const testNoTracks = async (variant: EmptyMediaVariant) => {
|
||||
const initTarget = new BufferTarget();
|
||||
|
||||
const output = new Output({
|
||||
format: createFormat(variant),
|
||||
target: new BufferTarget(),
|
||||
initTarget: variant.type === 'cmaf' ? initTarget : undefined,
|
||||
});
|
||||
|
||||
await output.start();
|
||||
await output.finalize();
|
||||
|
||||
using initInput = variant.type === 'cmaf'
|
||||
? new Input({ source: new BufferSource(initTarget.buffer!), formats: ALL_FORMATS })
|
||||
: undefined;
|
||||
|
||||
using input = new Input({
|
||||
source: new BufferSource(output.target.buffer!),
|
||||
formats: ALL_FORMATS,
|
||||
initInput,
|
||||
});
|
||||
|
||||
expect(await input.getTracks()).toHaveLength(0);
|
||||
};
|
||||
|
||||
test('No tracks, MPEG-TS', async () => {
|
||||
const output = new Output({
|
||||
format: new MpegTsOutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
await output.start();
|
||||
await output.finalize();
|
||||
|
||||
using input = new Input({
|
||||
source: new BufferSource(output.target.buffer!),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
expect(await input.getTracks()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('No tracks, Ogg', async () => {
|
||||
const output = new Output({
|
||||
format: new OggOutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
await output.start();
|
||||
await output.finalize();
|
||||
|
||||
// An Ogg file is nothing but its logical bitstreams, so zero tracks means zero bytes
|
||||
expect(output.target.buffer!.byteLength).toBe(0);
|
||||
});
|
||||
|
||||
test('No tracks, HLS', async () => {
|
||||
const files = new Map<string, Uint8Array>();
|
||||
|
||||
const output = new Output({
|
||||
format: new HlsOutputFormat({ segmentFormat: new MpegTsOutputFormat() }),
|
||||
target: new PathedTarget('', (request) => {
|
||||
const target = new BufferTarget();
|
||||
target.on('finalized', () => files.set(request.path, new Uint8Array(target.buffer!)));
|
||||
return target;
|
||||
}),
|
||||
});
|
||||
|
||||
await output.start();
|
||||
await output.finalize();
|
||||
|
||||
// Without tracks there are no playlists either, so all we get is the master playlist (the root target)
|
||||
expect([...files.keys()]).toEqual(['']);
|
||||
});
|
||||
|
||||
test('No tracks, MP3', async () => {
|
||||
const output = new Output({
|
||||
format: new Mp3OutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
await expect(output.start()).rejects.toThrow('MP3 requires exactly 1 audio track');
|
||||
});
|
||||
|
||||
test('No tracks, WAVE', async () => {
|
||||
const output = new Output({
|
||||
format: new WavOutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
await expect(output.start()).rejects.toThrow('WAVE requires exactly 1 audio track');
|
||||
});
|
||||
|
||||
test('No tracks, ADTS', async () => {
|
||||
const output = new Output({
|
||||
format: new AdtsOutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
await expect(output.start()).rejects.toThrow('ADTS requires exactly 1 audio track');
|
||||
});
|
||||
|
||||
test('No tracks, FLAC', async () => {
|
||||
const output = new Output({
|
||||
format: new FlacOutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
await expect(output.start()).rejects.toThrow('FLAC requires exactly 1 audio track');
|
||||
});
|
||||
|
||||
test('Empty MP4, fastStart: false', async () => {
|
||||
await testEmptyMedia({ type: 'mp4', fastStart: false });
|
||||
});
|
||||
|
||||
test('Empty MP4, fastStart: in-memory', async () => {
|
||||
await testEmptyMedia({ type: 'mp4', fastStart: 'in-memory' });
|
||||
});
|
||||
|
||||
test('Empty MP4, fastStart: reserve', async () => {
|
||||
await testEmptyMedia({ type: 'mp4', fastStart: 'reserve' });
|
||||
});
|
||||
|
||||
test('Empty MP4, fastStart: fragmented', async () => {
|
||||
await testEmptyMedia({ type: 'mp4', fastStart: 'fragmented' });
|
||||
});
|
||||
|
||||
test('Empty CMAF', async () => {
|
||||
await testEmptyMedia({ type: 'cmaf' });
|
||||
});
|
||||
|
||||
test('Empty Matroska', async () => {
|
||||
await testEmptyMedia({ type: 'matroska' });
|
||||
});
|
||||
|
||||
const testEmptyMedia = async (variant: EmptyMediaVariant) => {
|
||||
const initTarget = new BufferTarget();
|
||||
|
||||
const output = new Output({
|
||||
format: createFormat(variant),
|
||||
target: new BufferTarget(),
|
||||
initTarget: variant.type === 'cmaf' ? initTarget : undefined,
|
||||
});
|
||||
|
||||
output.addVideoTrack(new EncodedVideoPacketSource('avc'), { maximumPacketCount: 100 });
|
||||
output.addAudioTrack(new EncodedAudioPacketSource('opus'), { maximumPacketCount: 100 });
|
||||
|
||||
await output.start();
|
||||
await output.finalize();
|
||||
|
||||
using initInput = variant.type === 'cmaf'
|
||||
? new Input({ source: new BufferSource(initTarget.buffer!), formats: ALL_FORMATS })
|
||||
: undefined;
|
||||
|
||||
using input = new Input({
|
||||
source: new BufferSource(output.target.buffer!),
|
||||
formats: ALL_FORMATS,
|
||||
initInput,
|
||||
});
|
||||
|
||||
expect(await input.getTracks()).toHaveLength(0);
|
||||
};
|
||||
|
||||
test('Empty MP4 with declared decoder config, fastStart: false', async () => {
|
||||
await testEmptyMediaWithDecoderConfig({ type: 'mp4', fastStart: false });
|
||||
});
|
||||
|
||||
test('Empty MP4 with declared decoder config, fastStart: in-memory', async () => {
|
||||
await testEmptyMediaWithDecoderConfig({ type: 'mp4', fastStart: 'in-memory' });
|
||||
});
|
||||
|
||||
test('Empty MP4 with declared decoder config, fastStart: reserve', async () => {
|
||||
await testEmptyMediaWithDecoderConfig({ type: 'mp4', fastStart: 'reserve' });
|
||||
});
|
||||
|
||||
test('Empty MP4 with declared decoder config, fastStart: fragmented', async () => {
|
||||
await testEmptyMediaWithDecoderConfig({ type: 'mp4', fastStart: 'fragmented' });
|
||||
});
|
||||
|
||||
test('Empty CMAF with declared decoder config', async () => {
|
||||
await testEmptyMediaWithDecoderConfig({ type: 'cmaf' });
|
||||
});
|
||||
|
||||
test('Empty Matroska with declared decoder config', async () => {
|
||||
await testEmptyMediaWithDecoderConfig({ type: 'matroska' });
|
||||
});
|
||||
|
||||
// VP9 and Opus need no description, so the decoder config alone is enough to fully define the tracks
|
||||
const testEmptyMediaWithDecoderConfig = async (variant: EmptyMediaVariant) => {
|
||||
const initTarget = new BufferTarget();
|
||||
|
||||
const output = new Output({
|
||||
format: createFormat(variant),
|
||||
target: new BufferTarget(),
|
||||
initTarget: variant.type === 'cmaf' ? initTarget : undefined,
|
||||
});
|
||||
|
||||
output.addVideoTrack(new EncodedVideoPacketSource('vp9'), {
|
||||
maximumPacketCount: 100,
|
||||
decoderConfig: {
|
||||
codec: 'vp09.00.10.08',
|
||||
codedWidth: 1280,
|
||||
codedHeight: 720,
|
||||
},
|
||||
});
|
||||
output.addAudioTrack(new EncodedAudioPacketSource('opus'), {
|
||||
maximumPacketCount: 100,
|
||||
decoderConfig: {
|
||||
codec: 'opus',
|
||||
sampleRate: 48000,
|
||||
numberOfChannels: 2,
|
||||
},
|
||||
});
|
||||
|
||||
await output.start();
|
||||
await output.finalize();
|
||||
|
||||
using initInput = variant.type === 'cmaf'
|
||||
? new Input({ source: new BufferSource(initTarget.buffer!), formats: ALL_FORMATS })
|
||||
: undefined;
|
||||
|
||||
using input = new Input({
|
||||
source: new BufferSource(output.target.buffer!),
|
||||
formats: ALL_FORMATS,
|
||||
initInput,
|
||||
});
|
||||
|
||||
const tracks = await input.getTracks();
|
||||
expect(tracks).toHaveLength(2);
|
||||
|
||||
const videoTrack = tracks[0] as InputVideoTrack;
|
||||
expect(videoTrack.isVideoTrack()).toBe(true);
|
||||
expect(await videoTrack.getCodec()).toBe('vp9');
|
||||
expect(await videoTrack.getCodedWidth()).toBe(1280);
|
||||
expect(await videoTrack.getCodedHeight()).toBe(720);
|
||||
expect((await videoTrack.computePacketStats()).packetCount).toBe(0);
|
||||
|
||||
const audioTrack = tracks[1] as InputAudioTrack;
|
||||
expect(audioTrack.isAudioTrack()).toBe(true);
|
||||
expect(await audioTrack.getCodec()).toBe('opus');
|
||||
expect(await audioTrack.getSampleRate()).toBe(48000);
|
||||
expect(await audioTrack.getNumberOfChannels()).toBe(2);
|
||||
expect((await audioTrack.computePacketStats()).packetCount).toBe(0);
|
||||
};
|
||||
|
||||
test('Empty Ogg', async () => {
|
||||
const output = new Output({
|
||||
format: new OggOutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
// Ogg is audio-only, so let's go for two audio tracks here
|
||||
output.addAudioTrack(new EncodedAudioPacketSource('opus'), { maximumPacketCount: 100 });
|
||||
output.addAudioTrack(new EncodedAudioPacketSource('opus'), { maximumPacketCount: 100 });
|
||||
|
||||
await output.start();
|
||||
await output.finalize();
|
||||
|
||||
// Ogg has no container-level header, so without any packets, nothing at all gets written
|
||||
expect(output.target.buffer!.byteLength).toBe(0);
|
||||
});
|
||||
|
||||
test('Empty Ogg with declared decoder config', async () => {
|
||||
const output = new Output({
|
||||
format: new OggOutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
// An OpusHead packet as specified in RFC 7845
|
||||
const description = new Uint8Array(19);
|
||||
const view = new DataView(description.buffer);
|
||||
description.set([0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64]); // 'OpusHead'
|
||||
view.setUint8(8, 1); // Version
|
||||
view.setUint8(9, 2); // Channel count
|
||||
view.setUint16(10, 312, true); // Pre-skip
|
||||
view.setUint32(12, 48000, true); // Sample rate
|
||||
view.setInt16(16, 0, true); // Output gain
|
||||
view.setUint8(18, 0); // Channel mapping family
|
||||
|
||||
const metadata: AudioTrackMetadata = {
|
||||
maximumPacketCount: 100,
|
||||
decoderConfig: {
|
||||
codec: 'opus',
|
||||
sampleRate: 48000,
|
||||
numberOfChannels: 2,
|
||||
description,
|
||||
},
|
||||
};
|
||||
|
||||
output.addAudioTrack(new EncodedAudioPacketSource('opus'), metadata);
|
||||
output.addAudioTrack(new EncodedAudioPacketSource('opus'), metadata);
|
||||
|
||||
await output.start();
|
||||
await output.finalize();
|
||||
|
||||
using input = new Input({
|
||||
source: new BufferSource(output.target.buffer!),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const tracks = await input.getTracks();
|
||||
expect(tracks).toHaveLength(2);
|
||||
|
||||
for (const track of tracks) {
|
||||
const audioTrack = track as InputAudioTrack;
|
||||
expect(audioTrack.isAudioTrack()).toBe(true);
|
||||
expect(await audioTrack.getCodec()).toBe('opus');
|
||||
expect(await audioTrack.getSampleRate()).toBe(48000);
|
||||
expect(await audioTrack.getNumberOfChannels()).toBe(2);
|
||||
expect((await audioTrack.computePacketStats()).packetCount).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('Empty WAVE', async () => {
|
||||
const output = new Output({
|
||||
format: new WavOutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
// WAVE holds a single audio track
|
||||
output.addAudioTrack(new EncodedAudioPacketSource('pcm-s16'), { maximumPacketCount: 100 });
|
||||
|
||||
await output.start();
|
||||
|
||||
// There's no information to go on, so the muxer can't make anything up
|
||||
await expect(output.finalize()).rejects.toThrow('Cannot finalize an empty WAVE file');
|
||||
});
|
||||
|
||||
test('Empty WAVE with declared decoder config', async () => {
|
||||
const output = new Output({
|
||||
format: new WavOutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
// Deliberately not the fallback values, so we can tell the declared config was actually used
|
||||
output.addAudioTrack(new EncodedAudioPacketSource('pcm-s16'), {
|
||||
maximumPacketCount: 100,
|
||||
decoderConfig: {
|
||||
codec: 'pcm-s16',
|
||||
sampleRate: 44100,
|
||||
numberOfChannels: 1,
|
||||
},
|
||||
});
|
||||
|
||||
await output.start();
|
||||
await output.finalize();
|
||||
|
||||
using input = new Input({
|
||||
source: new BufferSource(output.target.buffer!),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const tracks = await input.getTracks();
|
||||
expect(tracks).toHaveLength(1);
|
||||
|
||||
const audioTrack = tracks[0] as InputAudioTrack;
|
||||
expect(audioTrack.isAudioTrack()).toBe(true);
|
||||
expect(await audioTrack.getCodec()).toBe('pcm-s16');
|
||||
expect(await audioTrack.getSampleRate()).toBe(44100);
|
||||
expect(await audioTrack.getNumberOfChannels()).toBe(1);
|
||||
expect((await audioTrack.computePacketStats()).packetCount).toBe(0);
|
||||
});
|
||||
|
||||
test('Empty MP3', async () => {
|
||||
const output = new Output({
|
||||
format: new Mp3OutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
output.addAudioTrack(new EncodedAudioPacketSource('mp3'), { maximumPacketCount: 100 });
|
||||
|
||||
await output.start();
|
||||
|
||||
// There's no information to go on, so the muxer can't make anything up
|
||||
await expect(output.finalize()).rejects.toThrow('Cannot finalize an empty MP3 file');
|
||||
});
|
||||
|
||||
test('Empty MP3 with declared decoder config', async () => {
|
||||
const output = new Output({
|
||||
format: new Mp3OutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
// Deliberately not the fallback values, so we can tell the declared config was actually used
|
||||
output.addAudioTrack(new EncodedAudioPacketSource('mp3'), {
|
||||
maximumPacketCount: 100,
|
||||
decoderConfig: {
|
||||
codec: 'mp3',
|
||||
sampleRate: 44100,
|
||||
numberOfChannels: 1,
|
||||
},
|
||||
});
|
||||
|
||||
await output.start();
|
||||
await output.finalize();
|
||||
|
||||
using input = new Input({
|
||||
source: new BufferSource(output.target.buffer!),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const tracks = await input.getTracks();
|
||||
expect(tracks).toHaveLength(1);
|
||||
|
||||
const audioTrack = tracks[0] as InputAudioTrack;
|
||||
expect(audioTrack.isAudioTrack()).toBe(true);
|
||||
expect(await audioTrack.getCodec()).toBe('mp3');
|
||||
expect(await audioTrack.getSampleRate()).toBe(44100);
|
||||
expect(await audioTrack.getNumberOfChannels()).toBe(1);
|
||||
expect((await audioTrack.computePacketStats()).packetCount).toBe(0);
|
||||
});
|
||||
|
||||
test('Empty MP3 with priming packet', async () => {
|
||||
const output = new Output({
|
||||
format: new Mp3OutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
// An MPEG Version 1 Layer III frame header, 128 kbps, 32 kHz, single channel
|
||||
const frameHeader = new Uint8Array([0xff, 0xfb, 0x98, 0xc0]);
|
||||
|
||||
// The priming packet takes precedence over the declared config, so we can tell which one was used
|
||||
output.addAudioTrack(new EncodedAudioPacketSource('mp3'), {
|
||||
maximumPacketCount: 100,
|
||||
decoderConfig: {
|
||||
codec: 'mp3',
|
||||
sampleRate: 44100,
|
||||
numberOfChannels: 2,
|
||||
},
|
||||
primingPacket: new EncodedPacket(frameHeader, 'key', 0, 0),
|
||||
});
|
||||
|
||||
await output.start();
|
||||
await output.finalize();
|
||||
|
||||
using input = new Input({
|
||||
source: new BufferSource(output.target.buffer!),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const tracks = await input.getTracks();
|
||||
expect(tracks).toHaveLength(1);
|
||||
|
||||
const audioTrack = tracks[0] as InputAudioTrack;
|
||||
expect(audioTrack.isAudioTrack()).toBe(true);
|
||||
expect(await audioTrack.getCodec()).toBe('mp3');
|
||||
expect(await audioTrack.getSampleRate()).toBe(32000);
|
||||
expect(await audioTrack.getNumberOfChannels()).toBe(1);
|
||||
expect((await audioTrack.computePacketStats()).packetCount).toBe(0);
|
||||
});
|
||||
|
||||
test('Empty MP3 without a Xing header', async () => {
|
||||
const output = new Output({
|
||||
format: new Mp3OutputFormat({ xingHeader: false }),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
output.addAudioTrack(new EncodedAudioPacketSource('mp3'), {
|
||||
maximumPacketCount: 100,
|
||||
decoderConfig: {
|
||||
codec: 'mp3',
|
||||
sampleRate: 44100,
|
||||
numberOfChannels: 1,
|
||||
},
|
||||
});
|
||||
|
||||
await output.start();
|
||||
|
||||
// The Xing frame is the only frame we could have synthesized, so there'd be nothing to write at all
|
||||
await expect(output.finalize()).rejects.toThrow('Cannot finalize an empty MP3 file');
|
||||
});
|
||||
|
||||
test('Empty ADTS', async () => {
|
||||
const output = new Output({
|
||||
format: new AdtsOutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
output.addAudioTrack(new EncodedAudioPacketSource('aac'), { maximumPacketCount: 100 });
|
||||
|
||||
await output.start();
|
||||
|
||||
// ADTS is a bare sequence of frames, each carrying its own header, so there'd be nothing to write
|
||||
await expect(output.finalize()).rejects.toThrow('Cannot finalize an empty ADTS file');
|
||||
});
|
||||
|
||||
test('Empty ADTS with declared decoder config', async () => {
|
||||
const output = new Output({
|
||||
format: new AdtsOutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
output.addAudioTrack(new EncodedAudioPacketSource('aac'), {
|
||||
maximumPacketCount: 100,
|
||||
decoderConfig: {
|
||||
codec: 'mp4a.40.2',
|
||||
sampleRate: 44100,
|
||||
numberOfChannels: 2,
|
||||
description: new Uint8Array([0x12, 0x10]), // AudioSpecificConfig: AAC-LC, 44100 Hz, stereo
|
||||
},
|
||||
});
|
||||
|
||||
await output.start();
|
||||
|
||||
// The declared config doesn't help; ADTS has no place to put it
|
||||
await expect(output.finalize()).rejects.toThrow('Cannot finalize an empty ADTS file');
|
||||
});
|
||||
|
||||
test('Empty FLAC', async () => {
|
||||
const output = new Output({
|
||||
format: new FlacOutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
output.addAudioTrack(new EncodedAudioPacketSource('flac'), { maximumPacketCount: 100 });
|
||||
|
||||
await output.start();
|
||||
|
||||
// There's no information to go on, so the muxer can't make anything up
|
||||
await expect(output.finalize()).rejects.toThrow('Cannot finalize an empty FLAC file');
|
||||
});
|
||||
|
||||
test('Empty FLAC with declared decoder config', async () => {
|
||||
const output = new Output({
|
||||
format: new FlacOutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
// A STREAMINFO metadata block for a 44100 Hz mono, 16-bit stream
|
||||
const description = new Uint8Array(4 + 4 + 34);
|
||||
description.set([0x66, 0x4c, 0x61, 0x43]); // 'fLaC'
|
||||
description[4] = 0x80; // Last metadata block, type STREAMINFO
|
||||
description[7] = 34; // Block size
|
||||
const streamInfo = new Bitstream(description.subarray(8));
|
||||
streamInfo.writeBits(16, 4096); // Minimum block size
|
||||
streamInfo.writeBits(16, 4096); // Maximum block size
|
||||
streamInfo.writeBits(24, 0); // Minimum frame size
|
||||
streamInfo.writeBits(24, 0); // Maximum frame size
|
||||
streamInfo.writeBits(20, 44100); // Sample rate
|
||||
streamInfo.writeBits(3, 0); // Channels - 1
|
||||
streamInfo.writeBits(5, 15); // Bits per sample - 1
|
||||
|
||||
output.addAudioTrack(new EncodedAudioPacketSource('flac'), {
|
||||
maximumPacketCount: 100,
|
||||
decoderConfig: {
|
||||
codec: 'flac',
|
||||
sampleRate: 44100,
|
||||
numberOfChannels: 1,
|
||||
description,
|
||||
},
|
||||
});
|
||||
|
||||
await output.start();
|
||||
await output.finalize();
|
||||
|
||||
using input = new Input({
|
||||
source: new BufferSource(output.target.buffer!),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const tracks = await input.getTracks();
|
||||
expect(tracks).toHaveLength(1);
|
||||
|
||||
const audioTrack = tracks[0] as InputAudioTrack;
|
||||
expect(audioTrack.isAudioTrack()).toBe(true);
|
||||
expect(await audioTrack.getCodec()).toBe('flac');
|
||||
expect(await audioTrack.getSampleRate()).toBe(44100);
|
||||
expect(await audioTrack.getNumberOfChannels()).toBe(1);
|
||||
expect((await audioTrack.computePacketStats()).packetCount).toBe(0);
|
||||
});
|
||||
|
||||
test('Empty MPEG-TS', async () => {
|
||||
const output = new Output({
|
||||
format: new MpegTsOutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
output.addVideoTrack(new EncodedVideoPacketSource('avc'), { maximumPacketCount: 100 });
|
||||
output.addAudioTrack(new EncodedAudioPacketSource('aac'), { maximumPacketCount: 100 });
|
||||
|
||||
await output.start();
|
||||
await output.finalize();
|
||||
|
||||
using input = new Input({
|
||||
source: new BufferSource(output.target.buffer!),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
expect(await input.getTracks()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Empty MPEG-TS with declared decoder config', async () => {
|
||||
const output = new Output({
|
||||
format: new MpegTsOutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
// An MPEG-TS stream cannot be described without a packet - it's the first packet that defines the stream - so
|
||||
// declaring the config up front changes nothing
|
||||
output.addVideoTrack(new EncodedVideoPacketSource('avc'), {
|
||||
maximumPacketCount: 100,
|
||||
decoderConfig: {
|
||||
codec: 'avc1.42001f',
|
||||
codedWidth: 1280,
|
||||
codedHeight: 720,
|
||||
description: new Uint8Array([0x01, 0x42, 0x00, 0x1f, 0xff, 0xe1, 0x00, 0x00, 0x01, 0x00, 0x00]),
|
||||
},
|
||||
});
|
||||
|
||||
await output.start();
|
||||
await output.finalize();
|
||||
|
||||
using input = new Input({
|
||||
source: new BufferSource(output.target.buffer!),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
expect(await input.getTracks()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Empty HLS', async () => {
|
||||
const files = new Map<string, Uint8Array>();
|
||||
|
||||
const output = new Output({
|
||||
format: new HlsOutputFormat({ segmentFormat: new MpegTsOutputFormat() }),
|
||||
target: new PathedTarget('', (request) => {
|
||||
const target = new BufferTarget();
|
||||
target.on('finalized', () => files.set(request.path, new Uint8Array(target.buffer!)));
|
||||
return target;
|
||||
}),
|
||||
});
|
||||
|
||||
output.addVideoTrack(new EncodedVideoPacketSource('avc'), {
|
||||
maximumPacketCount: 100,
|
||||
decoderConfig: {
|
||||
codec: 'avc1.42001f',
|
||||
codedWidth: 1280,
|
||||
codedHeight: 720,
|
||||
description: new Uint8Array([0x01, 0x42, 0x00, 0x1f, 0xff, 0xe1, 0x00, 0x00, 0x01, 0x00, 0x00]),
|
||||
},
|
||||
});
|
||||
|
||||
await output.start();
|
||||
await output.finalize();
|
||||
|
||||
// HLS only ever writes segments for packets it has actually seen, so all we get is the master playlist (which is
|
||||
// the root target) and one empty media playlist
|
||||
expect([...files.keys()].sort()).toEqual(['', 'playlist-1.m3u8']);
|
||||
});
|
||||
|
||||
const createFormat = (variant: EmptyMediaVariant) => {
|
||||
if (variant.type === 'mp4') {
|
||||
return new Mp4OutputFormat({ fastStart: variant.fastStart });
|
||||
} else if (variant.type === 'cmaf') {
|
||||
return new CmafOutputFormat();
|
||||
} else {
|
||||
return new MkvOutputFormat();
|
||||
}
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { Output, OutputTrackGroup } from '../../src/output.js';
|
||||
import {
|
||||
CmafOutputFormat,
|
||||
HlsOutputFormat,
|
||||
HlsOutputFormatOptions,
|
||||
HlsOutputSegmentInfo,
|
||||
Mp4OutputFormat,
|
||||
MpegTsOutputFormat,
|
||||
@@ -21,7 +22,7 @@ import { AudioCodec, VideoCodec } from '../../src/codec.js';
|
||||
import { EncodedPacket, PacketType } from '../../src/packet.js';
|
||||
import { assert, promiseWithResolvers } from '../../src/misc.js';
|
||||
import { Input } from '../../src/input.js';
|
||||
import { BufferSource } from '../../src/source.js';
|
||||
import { BufferSource, CustomPathedSource } from '../../src/source.js';
|
||||
import { ALL_FORMATS } from '../../src/input-format.js';
|
||||
import { InputAudioTrack, InputVideoTrack } from '../../src/input-track.js';
|
||||
import { EncodedPacketSink } from '../../src/media-sink.js';
|
||||
@@ -2004,6 +2005,89 @@ test('Single-file mode', async () => {
|
||||
expect(onSegment).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Single-file mode with fragmented MP4 produces proper standalone segment file', async () => {
|
||||
let playlistText: string | null = null;
|
||||
let segmentBuffer: ArrayBuffer | null = null;
|
||||
const segmentPaths = new Set<string>();
|
||||
|
||||
const onSegment = vi.fn();
|
||||
|
||||
const output = new Output({
|
||||
format: new HlsOutputFormat({
|
||||
segmentFormat: new Mp4OutputFormat({
|
||||
fastStart: 'fragmented',
|
||||
minimumFragmentDuration: 0, // This is to be ignored
|
||||
}),
|
||||
singleFilePerPlaylist: true,
|
||||
onSegment,
|
||||
}),
|
||||
target: new PathedTarget('', (request) => {
|
||||
const target = new BufferTarget();
|
||||
|
||||
if (request.path.includes('playlist')) {
|
||||
target.on('finalized', () => {
|
||||
playlistText = new TextDecoder().decode(target.buffer!);
|
||||
});
|
||||
} else if (request.path.includes('segment')) {
|
||||
segmentPaths.add(request.path);
|
||||
|
||||
target.on('finalized', () => {
|
||||
segmentBuffer = target.buffer!;
|
||||
});
|
||||
}
|
||||
|
||||
return target;
|
||||
}),
|
||||
});
|
||||
|
||||
const source = videoSource();
|
||||
output.addVideoTrack(source);
|
||||
|
||||
await output.start();
|
||||
|
||||
await source.add(new EncodedPacket(avcPacketData, 'key', 0, 0), avcMetadata);
|
||||
await source.add(new EncodedPacket(avcPacketData, 'delta', 0.5, 0), avcMetadata);
|
||||
await source.add(new EncodedPacket(avcPacketData, 'delta', 1, 0), avcMetadata);
|
||||
await source.add(new EncodedPacket(avcPacketData, 'delta', 1.5, 0), avcMetadata);
|
||||
await source.add(new EncodedPacket(avcPacketData, 'key', 2, 0), avcMetadata);
|
||||
await source.add(new EncodedPacket(avcPacketData, 'delta', 2.5, 0), avcMetadata);
|
||||
await source.add(new EncodedPacket(avcPacketData, 'delta', 3, 0), avcMetadata);
|
||||
await source.add(new EncodedPacket(avcPacketData, 'delta', 3.5, 0), avcMetadata);
|
||||
|
||||
expect(onSegment).toHaveBeenCalledTimes(0);
|
||||
|
||||
await output.finalize();
|
||||
|
||||
// Only one segment file should have been created
|
||||
expect(segmentPaths.size).toBe(1);
|
||||
|
||||
expect(playlistText).not.toBeNull();
|
||||
expect(playlistText!.match(/#EXT-X-BYTERANGE/g)).toHaveLength(2);
|
||||
expect(playlistText).toContain('#EXT-X-VERSION:6');
|
||||
|
||||
expect(onSegment).toHaveBeenCalledTimes(1);
|
||||
|
||||
assert(segmentBuffer);
|
||||
|
||||
const str = new TextDecoder('ascii').decode(segmentBuffer);
|
||||
expect(str.includes('mfra')).toBe(true); // It's a proper standalone fMP4 file
|
||||
expect(str.split('moov')).toHaveLength(2); // Only one moov box
|
||||
|
||||
using input = new Input({
|
||||
source: new BufferSource(segmentBuffer),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
const track = (await input.getPrimaryVideoTrack())!;
|
||||
const timestamps: number[] = [];
|
||||
const sink = new EncodedPacketSink(track);
|
||||
|
||||
for await (const packet of sink.packets()) {
|
||||
timestamps.push(packet.timestamp);
|
||||
}
|
||||
|
||||
expect(timestamps).toEqual([0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5]);
|
||||
});
|
||||
|
||||
test('StreamTarget, write is called for each target', async () => {
|
||||
const writeCounts = new Map<string, number>();
|
||||
|
||||
@@ -2248,6 +2332,110 @@ test('CMAF segmentation, single file per playlist', async () => {
|
||||
expect(playlistText).toContain('#EXT-X-MAP:URI=');
|
||||
});
|
||||
|
||||
test('Sparse tracks in segments, MPEG-TS', async () => {
|
||||
await runSparseTracksInSegments({
|
||||
segmentFormat: new MpegTsOutputFormat(),
|
||||
});
|
||||
});
|
||||
|
||||
test('Sparse tracks in segments, CMAF', async () => {
|
||||
await runSparseTracksInSegments({
|
||||
segmentFormat: new CmafOutputFormat(),
|
||||
});
|
||||
});
|
||||
|
||||
test('Sparse tracks in segments, standard MP4', async () => {
|
||||
await runSparseTracksInSegments({
|
||||
segmentFormat: new Mp4OutputFormat(),
|
||||
});
|
||||
});
|
||||
|
||||
test('Sparse tracks in segments, fragmented MP4', async () => {
|
||||
await runSparseTracksInSegments({
|
||||
segmentFormat: new Mp4OutputFormat({ fastStart: 'fragmented' }),
|
||||
});
|
||||
});
|
||||
|
||||
test('Sparse tracks in segments, fragmented MP4 + single file', async () => {
|
||||
await runSparseTracksInSegments({
|
||||
segmentFormat: new Mp4OutputFormat({ fastStart: 'fragmented' }),
|
||||
singleFilePerPlaylist: true,
|
||||
});
|
||||
});
|
||||
|
||||
const runSparseTracksInSegments = async (hlsOptions: HlsOutputFormatOptions) => {
|
||||
const targets = new Map<string, BufferTarget>();
|
||||
|
||||
const output = new Output({
|
||||
format: new HlsOutputFormat(hlsOptions),
|
||||
target: new PathedTarget('master.m3u8', (request) => {
|
||||
const target = new BufferTarget();
|
||||
targets.set(request.path, target);
|
||||
|
||||
return target;
|
||||
}),
|
||||
});
|
||||
|
||||
const video = videoSource();
|
||||
const audio = audioSource();
|
||||
output.addVideoTrack(video);
|
||||
output.addAudioTrack(audio);
|
||||
|
||||
await output.start();
|
||||
|
||||
// We expect three segments: First one with just video, second with video and audio, last one with just audio.
|
||||
|
||||
await video.add(new EncodedPacket(avcPacketData, 'key', 0, 0), avcMetadata);
|
||||
await video.add(new EncodedPacket(avcPacketData, 'delta', 0.5, 0), avcMetadata);
|
||||
await video.add(new EncodedPacket(avcPacketData, 'delta', 1, 0), avcMetadata);
|
||||
await video.add(new EncodedPacket(avcPacketData, 'delta', 1.5, 0), avcMetadata);
|
||||
await video.add(new EncodedPacket(avcPacketData, 'key', 2, 0), avcMetadata);
|
||||
await video.add(new EncodedPacket(avcPacketData, 'delta', 2.5, 0), avcMetadata);
|
||||
await video.add(new EncodedPacket(avcPacketData, 'delta', 3, 0), avcMetadata);
|
||||
await video.add(new EncodedPacket(avcPacketData, 'delta', 3.5, 0), avcMetadata);
|
||||
|
||||
await audio.add(new EncodedPacket(aacPacketData, 'key', 2, 0), aacMetadata);
|
||||
await audio.add(new EncodedPacket(aacPacketData, 'key', 2.5, 0), aacMetadata);
|
||||
await audio.add(new EncodedPacket(aacPacketData, 'key', 3, 0), aacMetadata);
|
||||
await audio.add(new EncodedPacket(aacPacketData, 'key', 3.5, 0), aacMetadata);
|
||||
await audio.add(new EncodedPacket(aacPacketData, 'key', 4, 0), aacMetadata);
|
||||
await audio.add(new EncodedPacket(aacPacketData, 'key', 4.5, 0), aacMetadata);
|
||||
await audio.add(new EncodedPacket(aacPacketData, 'key', 5, 0), aacMetadata);
|
||||
await audio.add(new EncodedPacket(aacPacketData, 'key', 5.5, 0), aacMetadata);
|
||||
|
||||
await output.finalize();
|
||||
|
||||
// Read the entire output back using the HLS input
|
||||
using input = new Input({
|
||||
source: new CustomPathedSource('master.m3u8', ({ path }) => {
|
||||
const target = targets.get(path);
|
||||
assert(target);
|
||||
|
||||
return new BufferSource(target.buffer!);
|
||||
}),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const videoTrack = await input.getPrimaryVideoTrack() as InputVideoTrack;
|
||||
expect(videoTrack).toBeTruthy();
|
||||
const audioTrack = await input.getPrimaryAudioTrack() as InputAudioTrack;
|
||||
expect(audioTrack).toBeTruthy();
|
||||
|
||||
const videoSink = new EncodedPacketSink(videoTrack);
|
||||
const videoTimestamps: number[] = [];
|
||||
for await (const packet of videoSink.packets()) {
|
||||
videoTimestamps.push(packet.timestamp);
|
||||
}
|
||||
expect(videoTimestamps).toEqual([0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5]);
|
||||
|
||||
const audioSink = new EncodedPacketSink(audioTrack);
|
||||
const audioTimestamps: number[] = [];
|
||||
for await (const packet of audioSink.packets()) {
|
||||
audioTimestamps.push(packet.timestamp);
|
||||
}
|
||||
expect(audioTimestamps).toEqual([2, 2.5, 3, 3.5, 4, 4.5, 5, 5.5]);
|
||||
};
|
||||
|
||||
test('Live mode', async () => {
|
||||
const writtenTexts = new Map<string, string>();
|
||||
const writeCounts = new Map<string, number>();
|
||||
|
||||
@@ -12,7 +12,7 @@ import { assert } from '../../src/misc.js';
|
||||
|
||||
const SAMPLE_URL = 'https://pub-1ee78aacb848486482b20a72b55b3121.r2.dev/turbores-sample.mov';
|
||||
|
||||
test.concurrent('ProRes MOV file reading', async () => {
|
||||
test.concurrent('ProRes MOV file reading', { timeout: 20_000 }, async () => {
|
||||
using input = new Input({
|
||||
source: new UrlSource(SAMPLE_URL),
|
||||
formats: ALL_FORMATS,
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
ALL_FORMATS,
|
||||
EncodedPacket,
|
||||
EncodedPacketSink,
|
||||
FilePathSource,
|
||||
Input,
|
||||
Logging,
|
||||
LogLevel,
|
||||
UrlSource,
|
||||
} from '../../src/index.js';
|
||||
|
||||
const __dirname = new URL('.', import.meta.url).pathname;
|
||||
const videoFilePath = path.join(__dirname, '..', 'public/video.mp4');
|
||||
|
||||
test('UrlSource works against a server without range request support', async () => {
|
||||
const server = await startRangelessServer();
|
||||
const logs = captureLogs();
|
||||
|
||||
try {
|
||||
using input = new Input({
|
||||
source: new UrlSource(server.url),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const track = await input.getPrimaryVideoTrack();
|
||||
if (!track) throw new Error('No video track found');
|
||||
|
||||
const sink = new EncodedPacketSink(track);
|
||||
|
||||
const timestamps: number[] = [];
|
||||
for await (const packet of sink.packets()) {
|
||||
timestamps.push(packet.timestamp);
|
||||
}
|
||||
|
||||
expect(timestamps).toHaveLength(125);
|
||||
|
||||
// The default cache size exceeds the file size, so random access back to the start of the file still works
|
||||
const firstPacket = await sink.getFirstPacket();
|
||||
if (!firstPacket) throw new Error('No first packet found');
|
||||
|
||||
expect(firstPacket.timestamp).toBe(0);
|
||||
expect(firstPacket.data.byteLength).toBeGreaterThan(0);
|
||||
|
||||
expect(logs.warnings.filter(
|
||||
x => x.includes('did not respond to a range request with 206 Partial Content'),
|
||||
)).toHaveLength(1);
|
||||
} finally {
|
||||
logs.stop();
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('UrlSource throws when reading from an evicted region in sequential mode', async () => {
|
||||
const server = await startRangelessServer();
|
||||
const logs = captureLogs();
|
||||
|
||||
try {
|
||||
using input = new Input({
|
||||
// Much smaller than the file, so the start of the file will get evicted during full iteration
|
||||
source: new UrlSource(server.url, { maxCacheSize: 2 ** 20 /* 1 MiB */ }),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const track = await input.getPrimaryVideoTrack();
|
||||
if (!track) throw new Error('No video track found');
|
||||
|
||||
const sink = new EncodedPacketSink(track);
|
||||
|
||||
const timestamps: number[] = [];
|
||||
for await (const packet of sink.packets()) {
|
||||
timestamps.push(packet.timestamp);
|
||||
}
|
||||
|
||||
expect(timestamps).toHaveLength(125);
|
||||
|
||||
await expect(sink.getFirstPacket()).rejects.toThrow(/already-evicted part of the cache/);
|
||||
|
||||
expect(logs.warnings.filter(
|
||||
x => x.includes('did not respond to a range request with 206 Partial Content'),
|
||||
)).toHaveLength(1);
|
||||
} finally {
|
||||
logs.stop();
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('UrlSource resumes with correct data when the connection dies in sequential mode', async () => {
|
||||
// The server kills the connection partway through the first two responses. Since it doesn't support range
|
||||
// requests, each resume response starts back at byte 0 and the already-delivered prefix must be skipped over
|
||||
// exactly; any off-by-one would corrupt the packet data.
|
||||
const server = await startRangelessServer({ responseByteLimits: [700_000, 1_500_000] });
|
||||
const logs = captureLogs();
|
||||
|
||||
try {
|
||||
using referenceInput = new Input({
|
||||
source: new FilePathSource(videoFilePath),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const referenceTrack = await referenceInput.getPrimaryVideoTrack();
|
||||
if (!referenceTrack) throw new Error('No video track found');
|
||||
|
||||
const referencePackets: EncodedPacket[] = [];
|
||||
for await (const packet of new EncodedPacketSink(referenceTrack).packets()) {
|
||||
referencePackets.push(packet);
|
||||
}
|
||||
|
||||
using input = new Input({
|
||||
source: new UrlSource(server.url, { getRetryDelay: () => 0 }),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const track = await input.getPrimaryVideoTrack();
|
||||
if (!track) throw new Error('No video track found');
|
||||
|
||||
let packetIndex = 0;
|
||||
for await (const packet of new EncodedPacketSink(track).packets()) {
|
||||
const referencePacket = referencePackets[packetIndex]!;
|
||||
|
||||
expect(packet.timestamp).toBe(referencePacket.timestamp);
|
||||
expect(Buffer.from(packet.data).equals(Buffer.from(referencePacket.data))).toBe(true);
|
||||
|
||||
packetIndex++;
|
||||
}
|
||||
|
||||
expect(packetIndex).toBe(referencePackets.length);
|
||||
|
||||
// Initial request plus one resume per killed response
|
||||
expect(server.requestCount()).toBe(3);
|
||||
expect(logs.errors.filter(x => x.includes('Attempting to resume'))).toHaveLength(2);
|
||||
} finally {
|
||||
logs.stop();
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('UrlSource with maxCacheSize: Infinity allows random access against a rangeless server', async () => {
|
||||
const server = await startRangelessServer();
|
||||
const logs = captureLogs();
|
||||
|
||||
try {
|
||||
using input = new Input({
|
||||
// This is the escape hatch recommended by the eviction error message: with an infinite cache, nothing
|
||||
// is ever evicted and random access keeps working
|
||||
source: new UrlSource(server.url, { maxCacheSize: Infinity }),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const track = await input.getPrimaryVideoTrack();
|
||||
if (!track) throw new Error('No video track found');
|
||||
|
||||
const sink = new EncodedPacketSink(track);
|
||||
|
||||
const timestamps: number[] = [];
|
||||
for await (const packet of sink.packets()) {
|
||||
timestamps.push(packet.timestamp);
|
||||
}
|
||||
|
||||
expect(timestamps).toHaveLength(125);
|
||||
|
||||
const firstPacket = await sink.getFirstPacket();
|
||||
if (!firstPacket) throw new Error('No first packet found');
|
||||
|
||||
expect(firstPacket.timestamp).toBe(0);
|
||||
expect(firstPacket.data.byteLength).toBeGreaterThan(0);
|
||||
|
||||
expect(logs.warnings.filter(
|
||||
x => x.includes('did not respond to a range request with 206 Partial Content'),
|
||||
)).toHaveLength(1);
|
||||
} finally {
|
||||
logs.stop();
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('UrlSource in sequential mode downloads lazily and aborts the response on dispose', async () => {
|
||||
// The resource is padded to a size far beyond what kernel socket buffers can swallow. This matters: the server
|
||||
// can only observe the client's laziness through backpressure, and on some systems (like Linux with its TCP
|
||||
// buffer autotuning), the kernel happily buffers multiple megabytes of sent data that the client never read.
|
||||
const paddingSize = 2 ** 26; // 64 MiB
|
||||
const totalSize = fs.statSync(videoFilePath).size + paddingSize;
|
||||
|
||||
const server = await startRangelessServer({ trailingPaddingSize: paddingSize });
|
||||
const logs = captureLogs();
|
||||
|
||||
const input = new Input({
|
||||
source: new UrlSource(server.url),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
try {
|
||||
const track = await input.getPrimaryVideoTrack();
|
||||
if (!track) throw new Error('No video track found');
|
||||
|
||||
// If the client were downloading eagerly, the entire resource would easily arrive during this window.
|
||||
// Instead, the server is expected to stall, since data is only pulled down when reads demand it.
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
|
||||
expect(server.bytesSent()).toBeLessThan(totalSize / 2);
|
||||
|
||||
// The response is merely suspended, not dead: reading still works
|
||||
const sink = new EncodedPacketSink(track);
|
||||
const firstPacket = await sink.getFirstPacket();
|
||||
if (!firstPacket) throw new Error('No first packet found');
|
||||
|
||||
expect(firstPacket.timestamp).toBe(0);
|
||||
expect(firstPacket.data.byteLength).toBeGreaterThan(0);
|
||||
|
||||
input.dispose();
|
||||
|
||||
// Give the abort a moment to propagate to the server
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
|
||||
// Disposal terminated the response early, long before the entire resource was sent
|
||||
expect(server.abortedResponses()).toBe(1);
|
||||
expect(server.bytesSent()).toBeLessThan(totalSize / 2);
|
||||
} finally {
|
||||
input.dispose();
|
||||
logs.stop();
|
||||
server.close();
|
||||
}
|
||||
}, 10_000);
|
||||
|
||||
const startRangelessServer = async (
|
||||
options: { responseByteLimits?: number[]; trailingPaddingSize?: number } = {},
|
||||
) => {
|
||||
const fileSize = fs.statSync(videoFilePath).size;
|
||||
const paddingSize = options.trailingPaddingSize ?? 0;
|
||||
let requestCount = 0;
|
||||
let bytesSent = 0;
|
||||
let abortedResponses = 0;
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const byteLimit = options.responseByteLimits?.[requestCount] ?? Infinity;
|
||||
requestCount++;
|
||||
|
||||
res.on('error', () => {}); // The client may abort the connection at any time
|
||||
res.on('close', () => {
|
||||
if (!res.writableFinished) {
|
||||
abortedResponses++;
|
||||
}
|
||||
});
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'video/mp4',
|
||||
'Content-Length': fileSize + paddingSize,
|
||||
});
|
||||
|
||||
// Stream the file with backpressure and a small chunk size, so that the amount of sent bytes closely
|
||||
// tracks how much the client actually consumes
|
||||
const stream = fs.createReadStream(videoFilePath, { highWaterMark: 2 ** 14 });
|
||||
let bytesSentThisResponse = 0;
|
||||
|
||||
stream.on('data', (chunk) => {
|
||||
const canContinue = res.write(chunk);
|
||||
bytesSent += chunk.length;
|
||||
bytesSentThisResponse += chunk.length;
|
||||
|
||||
if (bytesSentThisResponse >= byteLimit) {
|
||||
stream.destroy();
|
||||
res.destroy(); // Kill the connection mid-response
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canContinue) {
|
||||
stream.pause();
|
||||
res.once('drain', () => stream.resume());
|
||||
}
|
||||
});
|
||||
stream.on('end', () => {
|
||||
if (paddingSize === 0) {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Append a giant 'free' box: valid ISOBMFF, but never read by the demuxer
|
||||
const header = Buffer.alloc(8);
|
||||
header.writeUInt32BE(paddingSize, 0);
|
||||
header.write('free', 4, 'latin1');
|
||||
|
||||
const zeroes = Buffer.alloc(2 ** 14);
|
||||
let paddingSent = 0;
|
||||
|
||||
const writeMore = () => {
|
||||
while (paddingSent < paddingSize) {
|
||||
const chunk = paddingSent === 0
|
||||
? header
|
||||
: zeroes.subarray(0, Math.min(paddingSize - paddingSent, zeroes.length));
|
||||
|
||||
paddingSent += chunk.length;
|
||||
bytesSent += chunk.length;
|
||||
|
||||
if (!res.write(chunk)) {
|
||||
res.once('drain', writeMore);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
res.end();
|
||||
};
|
||||
writeMore();
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>(resolve => server.listen(0, resolve));
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') throw new Error('Unexpected server address');
|
||||
|
||||
return {
|
||||
url: `http://localhost:${address.port}/video.mp4`,
|
||||
requestCount: () => requestCount,
|
||||
bytesSent: () => bytesSent,
|
||||
abortedResponses: () => abortedResponses,
|
||||
close: () => {
|
||||
server.closeAllConnections();
|
||||
server.close();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** Collects logged warnings and errors, keeping the console clean while doing so. */
|
||||
const captureLogs = () => {
|
||||
const warnings: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const unsubscribeWarn = Logging.on('warn', args => warnings.push(args.map(String).join(' ')));
|
||||
const unsubscribeError = Logging.on('error', args => errors.push(args.map(String).join(' ')));
|
||||
|
||||
const previousLogLevel = Logging.level;
|
||||
Logging.level = LogLevel.Silent;
|
||||
|
||||
return {
|
||||
warnings,
|
||||
errors,
|
||||
stop: () => {
|
||||
unsubscribeWarn();
|
||||
unsubscribeError();
|
||||
Logging.level = previousLogLevel;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -42,6 +42,14 @@ export default defineConfig({
|
||||
provider: 'webdriverio',
|
||||
instances: [{
|
||||
browser: 'chrome',
|
||||
capabilities: {
|
||||
// macOS periodically purges files (but not folders) from the default os.tmpdir() cache,
|
||||
// leaving a gutted install that bricks the chromedriver setup - so keep the cache
|
||||
// somewhere persistent
|
||||
'wdio:chromedriverOptions': {
|
||||
cacheDir: path.resolve(__dirname, 'node_modules/.cache/webdriver'),
|
||||
},
|
||||
},
|
||||
}],
|
||||
headless: false, // A bunch of features need the head
|
||||
screenshotFailures: false,
|
||||
|
||||
Reference in New Issue
Block a user