Compare commits

...
26 Commits
Author SHA1 Message Date
Vanilagy 89d48d1bf9 Bump patch 2025-11-13 08:52:48 +01:00
Vanilagy 6684984e7e Fix faulty timestamp rounding to nearest integer (fixes #207) 2025-11-12 21:35:18 +01:00
Vanilagy 0f030dc2a6 Add Ponder 2025-11-12 19:28:43 +01:00
Vanilagy 02b08e036b Fix Matroska files where no audio packets are key packets (fixes #192) 2025-11-12 16:07:17 +01:00
Vanilagy 327696666b Expand doc blocks 2025-11-12 14:40:47 +01:00
Vanilagy 07b2e70863 Force software decoding in Chromium for interlaced AVC 2025-11-12 14:29:56 +01:00
Vanilagy 3cb6ed82ce Fix globals not being treeshakable by some bundlers (fixes #172) 2025-11-12 13:11:18 +01:00
Vanilagy 12216ae29e Remove reordering Matroska blocks by references (fixes #178) 2025-11-12 10:49:47 +01:00
Vanilagy 198b3d2eae Merge branch 'main' of https://github.com/Vanilagy/mediabunny 2025-11-12 10:05:41 +01:00
Vanilagy e33e9f13fe Fix missing VideoSample.close() calls in CanvasSink conversion path 2025-11-12 10:05:41 +01:00
Vanilagy fa1f44cf92 Bump patch 2025-11-11 22:26:46 +01:00
Vanilagy 966ac133e9 Merge branch 'main' of https://github.com/Vanilagy/mediabunny 2025-11-11 18:03:28 +01:00
Vanilagy 23f814679e Modify audio encoder to fill gaps between timestamps with silence to ensure AV sync (fixes #176) 2025-11-11 18:03:28 +01:00
Jonny BurgerandGitHub f3a2d57156 Don't overlook potential packets in FLAC bitstream (#219)
* Don't overlook potential packets in FLAC bitstream

* Update way of going back
2025-11-11 17:07:49 +01:00
Vanilagy 9d477dfb14 Allow muxing of avc3 into ISOBMFF (see #216) 2025-11-11 17:06:10 +01:00
Vanilagy 5b45c51d40 Support AVC3 in ISOBMFF (fixes #216) 2025-11-11 16:55:54 +01:00
Vanilagy 71dd2c565b Merge branch 'main' of https://github.com/Vanilagy/metamuxer 2025-11-11 11:05:17 +01:00
Vanilagy 3db4aa8b91 Fixed doubly stacked rotation correction when converting rotated video with a custom process function (fixes #218) 2025-11-11 11:05:15 +01:00
Vanilagy 8b39eb0d43 Revert Matroska default duration flooring 2025-11-10 21:14:43 +01:00
Vanilagy 6022781e8c Bump patch 2025-11-10 20:53:28 +01:00
Vanilagy c0da28fb6c Floor Matroska default packet duration (fixes #170) 2025-11-10 16:14:25 +01:00
Vanilagy fd8c74d4db Fix invalid encoder AAC description on WebKit (fixes #206) 2025-11-10 14:44:35 +01:00
David P.andGitHub 6fb3103c01 Merge pull request #215 from hexcowboy/issue-#184
Use `ArrayBuffer` instead of `ReadableStreamReader`on WebKit browsers
2025-11-10 11:36:13 +01:00
Vanilagy d969266662 Simplify isWebKit, get rid of isSafari 2025-11-10 11:30:26 +01:00
hex e0dda582b1 fix webkit video conversion bug 2025-11-07 16:58:20 -07:00
Vanilagy 5aabf6350a Clarify docs 2025-11-06 16:31:02 +01:00
30 changed files with 576 additions and 224 deletions
+4
View File
@@ -39,6 +39,10 @@ Mediabunny is a JavaScript library for reading, writing, and converting media fi
<a href="https://pqina.nl/pintura/" target="_blank" rel="sponsored">
<img src="./docs/public/sponsors/pintura-labs.png" width="50" height="50" alt="Pintura Labs">
</a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://ponder.ai/" target="_blank" rel="sponsored">
<img src="./docs/public/sponsors/ponder.png" width="50" height="50" alt="Ponder">
</a>
</div>
### Bronze sponsors
+12 -3
View File
@@ -48,6 +48,7 @@
target
});
let ctx = null;
const conversion = await Mediabunny.Conversion.init({
input: new Mediabunny.Input({
formats: Mediabunny.ALL_FORMATS,
@@ -56,6 +57,8 @@
output,
audio: (_, n) => ({
discard: n > 1,
//codec: 'opus',
//codec: 'opus',
/*
process: (sample) => {
return sample;
@@ -97,6 +100,10 @@
},
*/
video: () => ({
width: 720,
frameRate: 30,
bitrate: Mediabunny.QUALITY_VERY_LOW,
//discard: true,
/*
process: (sample) => {
if (!ctx) {
@@ -108,9 +115,11 @@
ctx = canvas.getContext('2d');
}
console.log(ctx.canvas.width, ctx.canvas.height);
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
sample.draw(ctx, 0, 0);
ctx.drawImage(watermark, 32, 32);
sample.drawWithFit(ctx, { fit: 'fill' });
//ctx.drawImage(watermark, 32, 32);
return ctx.canvas;
},
@@ -171,7 +180,7 @@
},
trim: {
start: 0,
end: 10
end: 20
},
});
console.log(conversion);
+53 -1
View File
@@ -14,7 +14,58 @@
source: new Mediabunny.BlobSource(file),
});
const videoTrack = await input.getPrimaryVideoTrack();
const audioTrack = await input.getPrimaryAudioTrack();
const sink = new Mediabunny.EncodedPacketSink(audioTrack);
for await (const packet of sink.packets()) {
console.log(packet.timestamp, packet.duration, packet.type)
}
/*
const audioTrack = await input.getPrimaryAudioTrack();
const sink = new Mediabunny.AudioSampleSink(audioTrack);
let lastEnd = 0;
for await (const sample of sink.samples()) {
if (sample.timestamp - lastEnd > 0) {
console.warn(sample.timestamp - lastEnd)
console.log(sample.timestamp, sample.duration, "diffie", sample.timestamp - lastEnd);
}
lastEnd = sample.timestamp + sample.duration;
sample.close();
}
*/
/*
const sink = new Mediabunny.EncodedPacketSink(videoTrack);
for await (const packet of sink.packets()) {
console.log(packet.timestamp, packet.duration, packet.timestamp + packet.duration);
if (packet.timestamp > 10) break;
}
*/
/*
const sink = new Mediabunny.VideoSampleSink(videoTrack);
for await (const sample of sink.samples()) {
console.log(sample.timestamp, sample.duration);
sample.close();
}
*/
/*
const sink = new Mediabunny.EncodedPacketSink(videoTrack);
for await (const packet of sink.packets()) {
console.log(packet.timestamp, packet.timestamp + packet.duration);
}
*/
/*
const sink = new Mediabunny.VideoSampleSink(videoTrack);
console.log(await videoTrack.getDecoderConfig())
@@ -30,6 +81,7 @@
for await (const sample of sink.samples()) {
console.log(sample);
}
*/
/*
let total = 0;
+1
View File
@@ -98,6 +98,7 @@ const sponsors = {
],
silver: [
{ image: '/sponsors/pintura-labs.png', name: 'Pintura Labs', url: 'https://pqina.nl/pintura/' },
{ image: '/sponsors/ponder.png', name: 'Ponder', url: 'https://ponder.ai/' },
],
bronze: [
{ image: '/sponsors/rve.png', name: 'React Video Editor', url: 'https://www.reactvideoeditor.com/' },
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+6 -6
View File
@@ -1,12 +1,12 @@
{
"name": "mediabunny",
"version": "1.24.4",
"version": "1.24.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mediabunny",
"version": "1.24.4",
"version": "1.24.7",
"license": "MPL-2.0",
"workspaces": [
"packages/*"
@@ -7749,9 +7749,9 @@
}
},
"node_modules/mediabunny": {
"version": "1.24.3",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.24.3.tgz",
"integrity": "sha512-uQu5lXg6c3z2YUKLimAsem1qYPdGfGziQ5CKaAMJAs0C0NNw5T/Zkn0foPML0gWqQJ5ZeC0wTgCxpiBQY8m1Tw==",
"version": "1.24.6",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.24.6.tgz",
"integrity": "sha512-GODsNvKeig2Zhj2NCABBskVgJS1IMltP0uX/I0ltFksPCHpshreZhJEMwc903YbgdsW3RT7e6uvLX6UtlmeIkQ==",
"license": "MPL-2.0",
"peer": true,
"workspaces": [
@@ -12242,7 +12242,7 @@
},
"packages/mp3-encoder": {
"name": "@mediabunny/mp3-encoder",
"version": "1.24.4",
"version": "1.24.7",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mediabunny",
"author": "Vanilagy",
"version": "1.24.4",
"version": "1.24.7",
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
"type": "module",
"workspaces": [
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/mp3-encoder",
"author": "Vanilagy",
"version": "1.24.4",
"version": "1.24.7",
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
+215 -48
View File
@@ -229,7 +229,7 @@ const extractNalUnitTypeForAvc = (data: Uint8Array) => {
};
/** Builds an AvcDecoderConfigurationRecord from an AVC packet in Annex B format. */
export const extractAvcDecoderConfigurationRecord = (packetData: Uint8Array) => {
export const extractAvcDecoderConfigurationRecord = (packetData: Uint8Array): AvcDecoderConfigurationRecord | null => {
try {
const nalUnits = findNalUnitsInAnnexB(packetData);
@@ -247,60 +247,27 @@ export const extractAvcDecoderConfigurationRecord = (packetData: Uint8Array) =>
// Let's get the first SPS for profile and level information
const spsData = spsUnits[0]!;
const bitstream = new Bitstream(removeEmulationPreventionBytes(spsData));
const spsInfo = parseAvcSps(spsData);
assert(spsInfo !== null);
bitstream.skipBits(1); // forbidden_zero_bit
bitstream.skipBits(2); // nal_ref_idc
const nal_unit_type = bitstream.readBits(5);
const hasExtendedData = spsInfo.profileIdc === 100
|| spsInfo.profileIdc === 110
|| spsInfo.profileIdc === 122
|| spsInfo.profileIdc === 144;
if (nal_unit_type !== 7) { // SPS NAL unit type is 7
console.error('Invalid SPS NAL unit type');
return null;
}
const profile_idc = bitstream.readAlignedByte();
const constraint_flags = bitstream.readAlignedByte();
const level_idc = bitstream.readAlignedByte();
const record: AvcDecoderConfigurationRecord = {
return {
configurationVersion: 1,
avcProfileIndication: profile_idc,
profileCompatibility: constraint_flags,
avcLevelIndication: level_idc,
avcProfileIndication: spsInfo.profileIdc,
profileCompatibility: spsInfo.constraintFlags,
avcLevelIndication: spsInfo.levelIdc,
lengthSizeMinusOne: 3, // Typically 4 bytes for length field
sequenceParameterSets: spsUnits,
pictureParameterSets: ppsUnits,
chromaFormat: null,
bitDepthLumaMinus8: null,
bitDepthChromaMinus8: null,
sequenceParameterSetExt: null,
chromaFormat: hasExtendedData ? spsInfo.chromaFormatIdc : null,
bitDepthLumaMinus8: hasExtendedData ? spsInfo.bitDepthLumaMinus8 : null,
bitDepthChromaMinus8: hasExtendedData ? spsInfo.bitDepthChromaMinus8 : null,
sequenceParameterSetExt: hasExtendedData ? spsExtUnits : null,
};
if (
profile_idc === 100
|| profile_idc === 110
|| profile_idc === 122
|| profile_idc === 144
) {
readExpGolomb(bitstream); // seq_parameter_set_id
const chroma_format_idc = readExpGolomb(bitstream);
if (chroma_format_idc === 3) {
bitstream.skipBits(1); // separate_colour_plane_flag
}
const bit_depth_luma_minus8 = readExpGolomb(bitstream);
const bit_depth_chroma_minus8 = readExpGolomb(bitstream);
record.chromaFormat = chroma_format_idc;
record.bitDepthLumaMinus8 = bit_depth_luma_minus8;
record.bitDepthChromaMinus8 = bit_depth_chroma_minus8;
record.sequenceParameterSetExt = spsExtUnits;
}
return record;
} catch (error) {
console.error('Error building AVC Decoder Configuration Record:', error);
return null;
@@ -377,6 +344,206 @@ export const serializeAvcDecoderConfigurationRecord = (record: AvcDecoderConfigu
return new Uint8Array(bytes);
};
/** Deserializes an AvcDecoderConfigurationRecord from the format specified in Section 5.3.3.1 of ISO 14496-15. */
export const deserializeAvcDecoderConfigurationRecord = (data: Uint8Array): AvcDecoderConfigurationRecord | null => {
try {
const view = toDataView(data);
let offset = 0;
// Read header
const configurationVersion = view.getUint8(offset++);
const avcProfileIndication = view.getUint8(offset++);
const profileCompatibility = view.getUint8(offset++);
const avcLevelIndication = view.getUint8(offset++);
const lengthSizeMinusOne = view.getUint8(offset++) & 0x03;
const numOfSequenceParameterSets = view.getUint8(offset++) & 0x1F;
// Read SPS
const sequenceParameterSets: Uint8Array[] = [];
for (let i = 0; i < numOfSequenceParameterSets; i++) {
const length = view.getUint16(offset, false);
offset += 2;
sequenceParameterSets.push(data.subarray(offset, offset + length));
offset += length;
}
const numOfPictureParameterSets = view.getUint8(offset++);
// Read PPS
const pictureParameterSets: Uint8Array[] = [];
for (let i = 0; i < numOfPictureParameterSets; i++) {
const length = view.getUint16(offset, false);
offset += 2;
pictureParameterSets.push(data.subarray(offset, offset + length));
offset += length;
}
const record: AvcDecoderConfigurationRecord = {
configurationVersion,
avcProfileIndication,
profileCompatibility,
avcLevelIndication,
lengthSizeMinusOne,
sequenceParameterSets,
pictureParameterSets,
chromaFormat: null,
bitDepthLumaMinus8: null,
bitDepthChromaMinus8: null,
sequenceParameterSetExt: null,
};
// Check if there are extended profile fields
if (
(
avcProfileIndication === 100
|| avcProfileIndication === 110
|| avcProfileIndication === 122
|| avcProfileIndication === 144
)
&& offset + 4 <= data.length
) {
const chromaFormat = view.getUint8(offset++) & 0x03;
const bitDepthLumaMinus8 = view.getUint8(offset++) & 0x07;
const bitDepthChromaMinus8 = view.getUint8(offset++) & 0x07;
const numOfSequenceParameterSetExt = view.getUint8(offset++);
record.chromaFormat = chromaFormat;
record.bitDepthLumaMinus8 = bitDepthLumaMinus8;
record.bitDepthChromaMinus8 = bitDepthChromaMinus8;
// Read SPS Ext
const sequenceParameterSetExt: Uint8Array[] = [];
for (let i = 0; i < numOfSequenceParameterSetExt; i++) {
const length = view.getUint16(offset, false);
offset += 2;
sequenceParameterSetExt.push(data.subarray(offset, offset + length));
offset += length;
}
record.sequenceParameterSetExt = sequenceParameterSetExt;
}
return record;
} catch (error) {
console.error('Error deserializing AVC Decoder Configuration Record:', error);
return null;
}
};
export type AvcSpsInfo = {
profileIdc: number;
constraintFlags: number;
levelIdc: number;
frameMbsOnlyFlag: number;
chromaFormatIdc: number | null;
bitDepthLumaMinus8: number | null;
bitDepthChromaMinus8: number | null;
};
/** Parses an AVC SPS (Sequence Parameter Set) to extract basic information. */
export const parseAvcSps = (sps: Uint8Array): AvcSpsInfo | null => {
try {
const bitstream = new Bitstream(removeEmulationPreventionBytes(sps));
bitstream.skipBits(1); // forbidden_zero_bit
bitstream.skipBits(2); // nal_ref_idc
const nalUnitType = bitstream.readBits(5);
if (nalUnitType !== 7) { // SPS NAL unit type is 7
return null;
}
const profileIdc = bitstream.readAlignedByte();
const constraintFlags = bitstream.readAlignedByte();
const levelIdc = bitstream.readAlignedByte();
readExpGolomb(bitstream); // seq_parameter_set_id
let chromaFormatIdc: number | null = null;
let bitDepthLumaMinus8: number | null = null;
let bitDepthChromaMinus8: number | null = null;
// Handle high profile chroma_format_idc
if (
profileIdc === 100
|| profileIdc === 110
|| profileIdc === 122
|| profileIdc === 244
|| profileIdc === 44
|| profileIdc === 83
|| profileIdc === 86
|| profileIdc === 118
|| profileIdc === 128
) {
chromaFormatIdc = readExpGolomb(bitstream);
if (chromaFormatIdc === 3) {
bitstream.skipBits(1); // separate_colour_plane_flag
}
bitDepthLumaMinus8 = readExpGolomb(bitstream);
bitDepthChromaMinus8 = readExpGolomb(bitstream);
bitstream.skipBits(1); // qpprime_y_zero_transform_bypass_flag
const seqScalingMatrixPresentFlag = bitstream.readBits(1);
if (seqScalingMatrixPresentFlag) {
for (let i = 0; i < (chromaFormatIdc !== 3 ? 8 : 12); i++) {
const seqScalingListPresentFlag = bitstream.readBits(1);
if (seqScalingListPresentFlag) {
const sizeOfScalingList = i < 6 ? 16 : 64;
let lastScale = 8;
let nextScale = 8;
for (let j = 0; j < sizeOfScalingList; j++) {
if (nextScale !== 0) {
const deltaScale = readSignedExpGolomb(bitstream);
nextScale = (lastScale + deltaScale + 256) % 256;
}
lastScale = nextScale === 0 ? lastScale : nextScale;
}
}
}
}
}
readExpGolomb(bitstream); // log2_max_frame_num_minus4
const picOrderCntType = readExpGolomb(bitstream);
if (picOrderCntType === 0) {
readExpGolomb(bitstream); // log2_max_pic_order_cnt_lsb_minus4
} else if (picOrderCntType === 1) {
bitstream.skipBits(1); // delta_pic_order_always_zero_flag
readSignedExpGolomb(bitstream); // offset_for_non_ref_pic
readSignedExpGolomb(bitstream); // offset_for_top_to_bottom_field
const numRefFramesInPicOrderCntCycle = readExpGolomb(bitstream);
for (let i = 0; i < numRefFramesInPicOrderCntCycle; i++) {
readSignedExpGolomb(bitstream); // offset_for_ref_frame[i]
}
}
readExpGolomb(bitstream); // max_num_ref_frames
bitstream.skipBits(1); // gaps_in_frame_num_value_allowed_flag
readExpGolomb(bitstream); // pic_width_in_mbs_minus1
readExpGolomb(bitstream); // pic_height_in_map_units_minus1
const frameMbsOnlyFlag = bitstream.readBits(1);
return {
profileIdc,
constraintFlags,
levelIdc,
frameMbsOnlyFlag,
chromaFormatIdc,
bitDepthLumaMinus8,
bitDepthChromaMinus8,
};
} catch (error) {
console.error('Error parsing AVC SPS:', error);
return null;
}
};
// Data specified in ISO 14496-15
export type HevcDecoderConfigurationRecord = {
configurationVersion: number;
+53 -2
View File
@@ -337,6 +337,7 @@ export const extractVideoCodecString = (trackInfo: {
codec: VideoCodec | null;
codecDescription: Uint8Array | null;
colorSpace: VideoColorSpaceInit | null;
avcType: 1 | 3 | null;
avcCodecInfo: AvcDecoderConfigurationRecord | null;
hevcCodecInfo: HevcDecoderConfigurationRecord | null;
vp9CodecInfo: Vp9CodecInfo | null;
@@ -345,6 +346,8 @@ export const extractVideoCodecString = (trackInfo: {
const { codec, codecDescription, colorSpace, avcCodecInfo, hevcCodecInfo, vp9CodecInfo, av1CodecInfo } = trackInfo;
if (codec === 'avc') {
assert(trackInfo.avcType !== null);
if (avcCodecInfo) {
const bytes = new Uint8Array([
avcCodecInfo.avcProfileIndication,
@@ -352,14 +355,14 @@ export const extractVideoCodecString = (trackInfo: {
avcCodecInfo.avcLevelIndication,
]);
return `avc1.${bytesToHexString(bytes)}`;
return `avc${trackInfo.avcType}.${bytesToHexString(bytes)}`;
}
if (!codecDescription || codecDescription.byteLength < 4) {
throw new TypeError('AVC decoder description is not provided or is not at least 4 bytes long.');
}
return `avc1.${bytesToHexString(codecDescription.subarray(1, 4))}`;
return `avc${trackInfo.avcType}.${bytesToHexString(codecDescription.subarray(1, 4))}`;
} else if (codec === 'hevc') {
let generalProfileSpace: number;
let generalProfileIdc: number;
@@ -621,6 +624,54 @@ export const parseAacAudioSpecificConfig = (bytes: Uint8Array | null): AacAudioS
};
};
export const buildAacAudioSpecificConfig = (config: {
objectType: number;
sampleRate: number;
numberOfChannels: number;
}) => {
let frequencyIndex = aacFrequencyTable.indexOf(config.sampleRate);
let customSampleRate: number | null = null;
if (frequencyIndex === -1) {
frequencyIndex = 15;
customSampleRate = config.sampleRate;
}
const channelConfiguration = aacChannelMap.indexOf(config.numberOfChannels);
if (channelConfiguration === -1) {
throw new TypeError(`Unsupported number of channels: ${config.numberOfChannels}`);
}
let bitCount = 5 + 4 + 4;
if (config.objectType >= 32) {
bitCount += 6;
}
if (frequencyIndex === 15) {
bitCount += 24;
}
const byteCount = Math.ceil(bitCount / 8);
const bytes = new Uint8Array(byteCount);
const bitstream = new Bitstream(bytes);
if (config.objectType < 32) {
bitstream.writeBits(5, config.objectType);
} else {
bitstream.writeBits(5, 31);
bitstream.writeBits(6, config.objectType - 32);
}
bitstream.writeBits(4, frequencyIndex);
if (frequencyIndex === 15) {
bitstream.writeBits(24, customSampleRate!);
}
bitstream.writeBits(4, channelConfiguration);
return bytes;
};
export const OPUS_SAMPLE_RATE = 48_000;
const PCM_CODEC_REGEX = /^pcm-([usf])(\d+)+(be)?$/;
+8 -4
View File
@@ -182,7 +182,8 @@ export type ConversionVideoOptions = {
* corrections.
*
* Must return a {@link VideoSample} or a `CanvasImageSource`, an array of them, or `null` for dropping the frame.
* When non-timestamped data is returned, the timestamp and duration from the source sample will be used.
* When non-timestamped data is returned, the timestamp and duration from the source sample will be used. Rotation
* metadata of the returned sample will be ignored.
*
* This function can also be used to manually resize frames. When doing so, you should signal the post-process
* dimensions using the `processedWidth` and `processedHeight` fields, which enables the encoder to better know what
@@ -873,7 +874,10 @@ export class Conversion {
|| trackOptions.process !== undefined;
let needsRerender = width !== originalWidth
|| height !== originalHeight
|| (totalRotation !== 0 && !outputSupportsRotation)
// TODO This is suboptimal: Forcing a rerender when both rotation and process are set is not
// performance-optimal, but right now there's no other way because we can't change the track rotation
// metadata after the output has already started. Should be possible with API changes in v2, though!
|| (totalRotation !== 0 && (!outputSupportsRotation || trackOptions.process !== undefined))
|| !!crop;
const alpha = trackOptions.alpha ?? 'discard';
@@ -1039,6 +1043,7 @@ export class Conversion {
duration: 1 / frameRate,
});
await this._registerVideoSample(track, trackOptions, source, sample);
sample.close();
}
};
@@ -1075,12 +1080,11 @@ export class Conversion {
duration: frameRate !== undefined ? 1 / frameRate : duration,
});
await this._registerVideoSample(track, trackOptions, source, sample);
sample.close();
if (frameRate !== undefined) {
lastCanvas = canvas;
lastCanvasTimestamp = adjustedSampleTimestamp;
} else {
sample.close();
}
}
+15 -8
View File
@@ -115,9 +115,15 @@ export type VideoEncodingAdditionalOptions = {
* format that supports transparency (such as WebM or Matroska).
*/
alpha?: 'discard' | 'keep';
/** Configures the bitrate mode. */
/** Configures the bitrate mode; defaults to `'variable'`. */
bitrateMode?: 'constant' | 'variable';
/** The latency mode used by the encoder; controls the performance-quality tradeoff. */
/**
* The latency mode used by the encoder; controls the performance-quality tradeoff.
*
* - `'quality'` (default): The encoder prioritizes quality over latency, and no frames can be dropped.
* - `'realtime'`: The encoder prioritizes low latency over quality, and may drop frames if the encoder becomes
* overloaded to keep up with real-time requirements.
*/
latencyMode?: 'quality' | 'realtime';
/**
* The full codec string as specified in the WebCodecs Codec Registry. This string must match the codec
@@ -125,7 +131,8 @@ export type VideoEncodingAdditionalOptions = {
*/
fullCodecString?: string;
/**
* A hint that configures the hardware acceleration method of this codec. This is best left on `'no-preference'`.
* A hint that configures the hardware acceleration method of this codec. This is best left on `'no-preference'`,
* the default.
*/
hardwareAcceleration?: 'no-preference' | 'prefer-hardware' | 'prefer-software';
/**
@@ -403,31 +410,31 @@ export class Quality {
* @group Encoding
* @public
*/
export const QUALITY_VERY_LOW = new Quality(0.3);
export const QUALITY_VERY_LOW = /* #__PURE__ */ new Quality(0.3);
/**
* Represents a low media quality.
* @group Encoding
* @public
*/
export const QUALITY_LOW = new Quality(0.6);
export const QUALITY_LOW = /* #__PURE__ */ new Quality(0.6);
/**
* Represents a medium media quality.
* @group Encoding
* @public
*/
export const QUALITY_MEDIUM = new Quality(1);
export const QUALITY_MEDIUM = /* #__PURE__ */ new Quality(1);
/**
* Represents a high media quality.
* @group Encoding
* @public
*/
export const QUALITY_HIGH = new Quality(2);
export const QUALITY_HIGH = /* #__PURE__ */ new Quality(2);
/**
* Represents a very high media quality.
* @group Encoding
* @public
*/
export const QUALITY_VERY_HIGH = new Quality(4);
export const QUALITY_VERY_HIGH = /* #__PURE__ */ new Quality(4);
/**
* Checks if the browser is able to encode the given codec.
+6 -4
View File
@@ -328,11 +328,13 @@ export class FlacDemuxer extends Demuxer {
const nextByte = readU8(slice);
if (nextByte === 0xff) {
const positionBeforeReading = slice.filePos;
const byteAfterNextByte = readU8(slice);
const expected = this.blockingBit === 1 ? 0b1111_1001 : 0b1111_1000;
if (byteAfterNextByte !== expected) {
slice.skip(-1);
slice.filePos = positionBeforeReading;
continue;
}
@@ -345,7 +347,7 @@ export class FlacDemuxer extends Demuxer {
});
if (!nextFrameHeader) {
slice.skip(-1);
slice.filePos = positionBeforeReading;
continue;
}
@@ -355,14 +357,14 @@ export class FlacDemuxer extends Demuxer {
if (this.blockingBit === 0) {
// Case A: If the stream is fixed block size, this is the frame number, which increments by 1
if (nextFrameHeader.num - frameHeader.num !== 1) {
slice.skip(-1);
slice.filePos = positionBeforeReading;
continue;
}
} else {
// Case B: If the stream is variable block size, this is the sample number, which increments by
// amount of samples in a frame.
if (nextFrameHeader.num - frameHeader.num !== frameHeader.blockSize) {
slice.skip(-1);
slice.filePos = positionBeforeReading;
continue;
}
}
+1 -1
View File
@@ -28,7 +28,7 @@ import {
readCodedNumber,
} from './flac-misc';
const FLAC_HEADER = new Uint8Array([0x66, 0x4c, 0x61, 0x43]); // 'fLaC'
const FLAC_HEADER = /* #__PURE__ */ new Uint8Array([0x66, 0x4c, 0x61, 0x43]); // 'fLaC'
const STREAMINFO_SIZE = 38;
const STREAMINFO_BLOCK_SIZE = 34;
+9 -9
View File
@@ -481,56 +481,56 @@ export class AdtsInputFormat extends InputFormat {
* @group Input formats
* @public
*/
export const MP4 = new Mp4InputFormat();
export const MP4 = /* #__PURE__ */ new Mp4InputFormat();
/**
* QuickTime File Format input format singleton.
* @group Input formats
* @public
*/
export const QTFF = new QuickTimeInputFormat();
export const QTFF = /* #__PURE__ */ new QuickTimeInputFormat();
/**
* Matroska input format singleton.
* @group Input formats
* @public
*/
export const MATROSKA = new MatroskaInputFormat();
export const MATROSKA = /* #__PURE__ */ new MatroskaInputFormat();
/**
* WebM input format singleton.
* @group Input formats
* @public
*/
export const WEBM = new WebMInputFormat();
export const WEBM = /* #__PURE__ */ new WebMInputFormat();
/**
* MP3 input format singleton.
* @group Input formats
* @public
*/
export const MP3 = new Mp3InputFormat();
export const MP3 = /* #__PURE__ */ new Mp3InputFormat();
/**
* WAVE input format singleton.
* @group Input formats
* @public
*/
export const WAVE = new WaveInputFormat();
export const WAVE = /* #__PURE__ */ new WaveInputFormat();
/**
* Ogg input format singleton.
* @group Input formats
* @public
*/
export const OGG = new OggInputFormat();
export const OGG = /* #__PURE__ */ new OggInputFormat();
/**
* ADTS input format singleton.
* @group Input formats
* @public
*/
export const ADTS = new AdtsInputFormat();
export const ADTS = /* #__PURE__ */ new AdtsInputFormat();
/**
* FLAC input format singleton.
* @group Input formats
* @public
*/
export const FLAC = new FlacInputFormat();
export const FLAC = /* #__PURE__ */ new FlacInputFormat();
/**
* List of all input format singletons. If you don't need to support all input formats, you should specify the
+12 -10
View File
@@ -135,8 +135,8 @@ export class IsobmffBoxWriter {
}
}
const bytes = new Uint8Array(8);
const view = new DataView(bytes.buffer);
const bytes = /* #__PURE__ */ new Uint8Array(8);
const view = /* #__PURE__ */ new DataView(bytes.buffer);
const u8 = (value: number) => {
return [(value % 0x100 + 0x100) % 0x100];
@@ -243,7 +243,7 @@ const rotationMatrix = (rotationInDegrees: number): TransformationMatrix => {
0, 0, 1,
];
};
const IDENTITY_MATRIX = rotationMatrix(0);
const IDENTITY_MATRIX = /* #__PURE__ */ rotationMatrix(0);
const matrixToBytes = (matrix: TransformationMatrix) => {
return [
@@ -577,7 +577,7 @@ export const stsd = (trackData: IsobmffTrackData) => {
if (trackData.type === 'video') {
sampleDescription = videoSampleDescription(
VIDEO_CODEC_TO_BOX_NAME[trackData.track.source._codec],
videoCodecToBoxName(trackData.track.source._codec, trackData.info.decoderConfig.codec),
trackData,
);
} else if (trackData.type === 'audio') {
@@ -1572,12 +1572,14 @@ const dataStringBoxLong = (value: string) => {
]);
};
const VIDEO_CODEC_TO_BOX_NAME: Record<VideoCodec, string> = {
avc: 'avc1',
hevc: 'hvc1',
vp8: 'vp08',
vp9: 'vp09',
av1: 'av01',
const videoCodecToBoxName = (codec: VideoCodec, fullCodecString: string) => {
switch (codec) {
case 'avc': return fullCodecString.startsWith('avc3') ? 'avc3' : 'avc1';
case 'hevc': return 'hvc1';
case 'vp8': return 'vp08';
case 'vp9': return 'vp09';
case 'av1': return 'av01';
}
};
const VIDEO_CODEC_TO_CONFIGURATION_BOX: Record<VideoCodec, (trackData: IsobmffVideoTrackData) => Box | null> = {
+6 -3
View File
@@ -51,13 +51,13 @@ import {
MATRIX_COEFFICIENTS_MAP_INVERSE,
normalizeRotation,
roundToMultiple,
roundToPrecision,
Rotation,
textDecoder,
TransformationMatrix,
TRANSFER_CHARACTERISTICS_MAP_INVERSE,
UNDETERMINED_LANGUAGE,
toDataView,
roundIfAlmostInteger,
} from '../misc';
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
import { buildIsobmffMimeType } from './isobmff-misc';
@@ -126,6 +126,7 @@ type InternalTrack = {
codec: VideoCodec | null;
codecDescription: Uint8Array | null;
colorSpace: VideoColorSpaceInit | null;
avcType: 1 | 3 | null;
avcCodecInfo: AvcDecoderConfigurationRecord | null;
hevcCodecInfo: HevcDecoderConfigurationRecord | null;
vp9CodecInfo: Vp9CodecInfo | null;
@@ -836,6 +837,7 @@ export class IsobmffDemuxer extends Demuxer {
codec: null,
codecDescription: null,
colorSpace: null,
avcType: null,
avcCodecInfo: null,
hevcCodecInfo: null,
vp9CodecInfo: null,
@@ -890,8 +892,9 @@ export class IsobmffDemuxer extends Demuxer {
const lowercaseBoxName = sampleBoxInfo.name.toLowerCase();
if (track.info.type === 'video') {
if (lowercaseBoxName === 'avc1') {
if (lowercaseBoxName === 'avc1' || lowercaseBoxName === 'avc3') {
track.info.codec = 'avc';
track.info.avcType = lowercaseBoxName === 'avc1' ? 1 : 3;
} else if (lowercaseBoxName === 'hvc1' || lowercaseBoxName === 'hev1') {
track.info.codec = 'hevc';
} else if (lowercaseBoxName === 'vp08') {
@@ -2385,7 +2388,7 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
// Do a little rounding to catch cases where the result is very close to an integer. If it is, it's likely
// that the number was originally an integer divided by the timescale. For stability, it's best
// to return the integer in this case.
return roundToPrecision(timestamp * this.internalTrack.timescale, 14) + this.internalTrack.editListOffset;
return roundIfAlmostInteger(timestamp * this.internalTrack.timescale) + this.internalTrack.editListOffset;
}
async getPacket(timestamp: number, options: PacketRetrievalOptions) {
+1 -1
View File
@@ -463,7 +463,7 @@ export class EBMLWriter {
export const MAX_VAR_INT_SIZE = 8;
export const MIN_HEADER_SIZE = 2; // 1-byte ID and 1-byte size
export const MAX_HEADER_SIZE = 2 * MAX_VAR_INT_SIZE; // 8-byte ID and 8-byte size
export const MAX_HEADER_SIZE = /* #__PURE__ */ 2 * MAX_VAR_INT_SIZE; // 8-byte ID and 8-byte size
export const readVarIntSize = (slice: FileSlice) => {
const firstByte = readU8(slice);
+18 -69
View File
@@ -43,7 +43,7 @@ import {
MATRIX_COEFFICIENTS_MAP_INVERSE,
normalizeRotation,
Rotation,
roundToPrecision,
roundIfAlmostInteger,
TRANSFER_CHARACTERISTICS_MAP_INVERSE,
UNDETERMINED_LANGUAGE,
} from '../misc';
@@ -61,7 +61,6 @@ import {
readElementHeader,
readElementId,
readFloat,
readSignedInt,
readUnsignedInt,
readVarInt,
resync,
@@ -137,7 +136,6 @@ type ClusterBlock = {
timestamp: number;
duration: number;
isKeyFrame: boolean;
referencedTimestamps: number[];
data: Uint8Array;
lacing: BlockLacing;
decoded: boolean;
@@ -230,7 +228,7 @@ const METADATA_ELEMENTS = [
{ id: EBMLId.Tracks, flag: 'tracksSeen' },
{ id: EBMLId.Cues, flag: 'cuesSeen' },
] as const;
const MAX_RESYNC_LENGTH = 10 * 2 ** 20; // 10 MiB
const MAX_RESYNC_LENGTH = /* #__PURE__ */ 10 * 2 ** 20; // 10 MiB
export class MatroskaDemuxer extends Demuxer {
reader: Reader;
@@ -649,21 +647,15 @@ export class MatroskaDemuxer extends Demuxer {
// This must hold, as track datas only get created if a block for that track is encountered
assert(trackData.blocks.length > 0);
let blockReferencesExist = false;
let hasLacedBlocks = false;
for (let i = 0; i < trackData.blocks.length; i++) {
const block = trackData.blocks[i]!;
block.timestamp += cluster.timestamp;
blockReferencesExist ||= block.referencedTimestamps.length > 0;
hasLacedBlocks ||= block.lacing !== BlockLacing.None;
}
if (blockReferencesExist) {
trackData.blocks = sortBlocksByReferences(trackData.blocks);
}
trackData.presentationTimestamps = trackData.blocks
.map((block, i) => ({ timestamp: block.timestamp, blockIndex: i }))
.sort((a, b) => a.timestamp - b.timestamp);
@@ -844,13 +836,13 @@ export class MatroskaDemuxer extends Demuxer {
blocks.splice(blockIndex, 1); // Remove the original block
const blockDuration = originalBlock.duration || frameCount * (track.defaultDuration ?? 0);
// Now, let's insert each frame as its own block
for (let i = 0; i < frameCount; i++) {
const frameSize = frameSizes[i]!;
const frameData = readBytes(slice, frameSize);
const blockDuration = originalBlock.duration || (frameCount * (track.defaultDuration ?? 0));
// Distribute timestamps evenly across the block duration
const frameTimestamp = originalBlock.timestamp + (blockDuration * i / frameCount);
const frameDuration = blockDuration / frameCount;
@@ -859,7 +851,6 @@ export class MatroskaDemuxer extends Demuxer {
timestamp: frameTimestamp,
duration: frameDuration,
isKeyFrame: originalBlock.isKeyFrame,
referencedTimestamps: originalBlock.referencedTimestamps,
data: frameData,
lacing: BlockLacing.None,
decoded: true,
@@ -1383,9 +1374,17 @@ export class MatroskaDemuxer extends Demuxer {
const relativeTimestamp = readI16Be(slice);
const flags = readU8(slice);
const isKeyFrame = !!(flags & 0x80);
const lacing = (flags >> 1) & 0x3 as BlockLacing; // If the block is laced, we'll expand it later
let isKeyFrame = !!(flags & 0x80);
if (trackData.track.info?.type === 'audio' && trackData.track.info.codec) {
// Some files don't mark their audio packets as key packets (I'm looking at you, Firefox). But, we
// can fix this in most cases: if we recognize the codec of the track, then we know every packet is
// necessarily a key packet, no matter what the container says.
// https://github.com/Vanilagy/mediabunny/issues/192
isKeyFrame = true;
}
const blockData = readBytes(slice, size - (slice.filePos - dataStartPos));
const hasDecodingInstructions = trackData.track.decodingInstructions.length > 0;
@@ -1393,7 +1392,6 @@ export class MatroskaDemuxer extends Demuxer {
timestamp: relativeTimestamp, // We'll add the cluster's timestamp to this later
duration: 0, // Will set later
isKeyFrame,
referencedTimestamps: [],
data: blockData,
lacing,
decoded: !hasDecodingInstructions,
@@ -1406,13 +1404,7 @@ export class MatroskaDemuxer extends Demuxer {
this.readContiguousElements(slice.slice(dataStartPos, size));
if (this.currentBlock) {
for (let i = 0; i < this.currentBlock.referencedTimestamps.length; i++) {
this.currentBlock.referencedTimestamps[i]! += this.currentBlock.timestamp;
}
this.currentBlock = null;
}
this.currentBlock = null;
}; break;
case EBMLId.Block: {
@@ -1436,7 +1428,6 @@ export class MatroskaDemuxer extends Demuxer {
timestamp: relativeTimestamp, // We'll add the cluster's timestamp to this later
duration: 0, // Will set later
isKeyFrame: true,
referencedTimestamps: [],
data: blockData,
lacing,
decoded: !hasDecodingInstructions,
@@ -1487,11 +1478,8 @@ export class MatroskaDemuxer extends Demuxer {
if (!this.currentBlock) break;
this.currentBlock.isKeyFrame = false;
const relativeTimestamp = readSignedInt(slice, size);
// We'll offset this by the block's timestamp later
this.currentBlock.referencedTimestamps.push(relativeTimestamp);
// We ignore the actual value here, we just use the reference as an indicator for "not a key frame".
// This is in line with FFmpeg's behavior.
}; break;
case EBMLId.Tag: {
@@ -1886,7 +1874,7 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
// Do a little rounding to catch cases where the result is very close to an integer. If it is, it's likely
// that the number was originally an integer divided by the timescale. For stability, it's best
// to return the integer in this case.
return roundToPrecision(timestamp * this.internalTrack.segment.timestampFactor, 14);
return roundIfAlmostInteger(timestamp * this.internalTrack.segment.timestampFactor);
}
async getPacket(timestamp: number, options: PacketRetrievalOptions) {
@@ -2319,6 +2307,7 @@ class MatroskaVideoTrackBacking extends MatroskaTrackBacking implements InputVid
codec: this.internalTrack.info.codec,
codecDescription: this.internalTrack.info.codecDescription,
colorSpace: this.internalTrack.info.colorSpace,
avcType: 1, // We don't know better (or do we?) so just assume 'avc1'
avcCodecInfo: this.internalTrack.info.codec === 'avc' && firstPacket
? extractAvcDecoderConfigurationRecord(firstPacket.data)
: null,
@@ -2379,43 +2368,3 @@ class MatroskaAudioTrackBacking extends MatroskaTrackBacking implements InputAud
};
}
}
/** Sorts blocks such that referenced blocks come before the blocks that reference them. */
const sortBlocksByReferences = (blocks: ClusterBlock[]) => {
const timestampToBlock = new Map<number, ClusterBlock>();
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i]!;
timestampToBlock.set(block.timestamp, block);
}
const processedBlocks = new Set<ClusterBlock>();
const result: ClusterBlock[] = [];
const processBlock = (block: ClusterBlock) => {
if (processedBlocks.has(block)) {
return;
}
// Marking the block as processed here already; prevents this algorithm from dying on cycles
processedBlocks.add(block);
for (let j = 0; j < block.referencedTimestamps.length; j++) {
const timestamp = block.referencedTimestamps[j]!;
const otherBlock = timestampToBlock.get(timestamp);
if (!otherBlock) {
continue;
}
processBlock(otherBlock);
}
result.push(block);
};
for (let i = 0; i < blocks.length; i++) {
processBlock(blocks[i]!);
}
return result;
};
+2 -2
View File
@@ -65,8 +65,8 @@ import { EncodedPacket } from '../packet';
import { parseOpusIdentificationHeader } from '../codec-data';
import { AttachedFile } from '../tags';
const MIN_CLUSTER_TIMESTAMP_MS = -(2 ** 15);
const MAX_CLUSTER_TIMESTAMP_MS = 2 ** 15 - 1;
const MIN_CLUSTER_TIMESTAMP_MS = /* #__PURE__ */ -(2 ** 15);
const MAX_CLUSTER_TIMESTAMP_MS = /* #__PURE__ */ 2 ** 15 - 1;
const APP_NAME = 'Mediabunny';
const SEGMENT_SIZE_BYTES = 6;
const CLUSTER_SIZE_BYTES = 5;
+25 -5
View File
@@ -8,10 +8,12 @@
import { parsePcmCodec, PCM_AUDIO_CODECS, PcmAudioCodec, VideoCodec, AudioCodec } from './codec';
import {
deserializeAvcDecoderConfigurationRecord,
determineVideoPacketType,
extractHevcNalUnits,
extractNalUnitTypeForHevc,
HevcNalUnitType,
parseAvcSps,
} from './codec-data';
import { CustomVideoDecoder, customVideoDecoders, CustomAudioDecoder, customAudioDecoders } from './custom-coder';
import { InputDisposedError } from './input';
@@ -24,15 +26,17 @@ import {
getInt24,
getUint24,
insertSorted,
isChromium,
isFirefox,
isNumber,
isSafari,
isWebKit,
last,
mapAsyncGenerator,
promiseWithResolvers,
Rotation,
toAsyncIterator,
toDataView,
toUint8Array,
validateAnyIterable,
} from './misc';
import { EncodedPacket } from './packet';
@@ -872,6 +876,22 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
}
};
if (codec === 'avc' && this.decoderConfig.description && isChromium()) {
// Chromium has/had a bug with playing interlaced AVC (https://issues.chromium.org/issues/456919096)
// which can be worked around by requesting that software decoding be used. So, here we peek into the
// AVC description, if present, and switch to software decoding if we find interlaced content.
const record = deserializeAvcDecoderConfigurationRecord(toUint8Array(this.decoderConfig.description));
if (record && record.sequenceParameterSets.length > 0) {
const sps = parseAvcSps(record.sequenceParameterSets[0]!);
if (sps && sps.frameMbsOnlyFlag === 0) {
this.decoderConfig = {
...this.decoderConfig,
hardwareAcceleration: 'prefer-software',
};
}
}
}
this.decoder = new VideoDecoder({
output: (frame) => {
try {
@@ -882,7 +902,7 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
},
error: onError,
});
this.decoder.configure(decoderConfig);
this.decoder.configure(this.decoderConfig);
}
}
@@ -918,7 +938,7 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
} else {
assert(this.decoder);
if (!isSafari()) {
if (!isWebKit()) {
insertSorted(this.inputTimestamps, packet.timestamp, x => x);
}
@@ -1049,7 +1069,7 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
/** Handler for the WebCodecs VideoDecoder for ironing out browser differences. */
sampleHandler(sample: VideoSample) {
if (isSafari()) {
if (isWebKit()) {
// For correct B-frame handling, we don't just hand over the frames directly but instead add them to
// a queue, because we want to ensure frames are emitted in presentation order. We flush the queue
// each time we receive a frame with a timestamp larger than the highest we've seen so far, as we
@@ -1137,7 +1157,7 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
this.alphaRaslSkipped = false;
}
if (isSafari()) {
if (isWebKit()) {
for (const sample of this.sampleQueue) {
this.finalizeAndEmitSample(sample);
}
+61
View File
@@ -9,6 +9,8 @@
import {
AUDIO_CODECS,
AudioCodec,
buildAacAudioSpecificConfig,
parseAacAudioSpecificConfig,
parsePcmCodec,
PCM_AUDIO_CODECS,
PcmAudioCodec,
@@ -24,9 +26,11 @@ import {
CallSerializer,
clamp,
isFirefox,
last,
promiseWithResolvers,
setInt24,
setUint24,
toUint8Array,
} from './misc';
import { Muxer } from './muxer';
import { SubtitleParser } from './subtitles';
@@ -1290,6 +1294,8 @@ class AudioEncoderWrapper {
private customEncoderCallSerializer = new CallSerializer();
private customEncoderQueueSize = 0;
private lastEndSampleIndex: number | null = null;
/**
* Encoders typically throw their errors "out of band", meaning asynchronously in some other execution context.
* However, we want to surface these errors to the user within the normal control flow, so they don't go uncaught.
@@ -1338,6 +1344,35 @@ class AudioEncoderWrapper {
}
assert(this.encoderInitialized);
// Handle padding of gaps with silence to avoid audio drift over time, like in
// https://github.com/Vanilagy/mediabunny/issues/176
// TODO An open question is how encoders deal with the first AudioData having a non-zero timestamp, and with
// AudioDatas that have an overlapping timestamp range.
{
const startSampleIndex = Math.round(
audioSample.timestamp * audioSample.sampleRate,
);
const endSampleIndex = Math.round(
(audioSample.timestamp + audioSample.duration) * audioSample.sampleRate,
);
if (this.lastEndSampleIndex !== null && startSampleIndex > this.lastEndSampleIndex) {
const sampleCount = startSampleIndex - this.lastEndSampleIndex;
const fillSample = new AudioSample({
data: new Float32Array(sampleCount * audioSample.numberOfChannels),
format: 'f32-planar',
sampleRate: audioSample.sampleRate,
numberOfChannels: audioSample.numberOfChannels,
numberOfFrames: sampleCount,
timestamp: this.lastEndSampleIndex / audioSample.sampleRate,
});
await this.add(fillSample, true); // Recursive call
}
this.lastEndSampleIndex = endSampleIndex;
}
if (this.customEncoder) {
this.customEncoderQueueSize++;
@@ -1515,6 +1550,32 @@ class AudioEncoderWrapper {
this.encoder = new AudioEncoder({
output: (chunk, meta) => {
// WebKit emits an invalid description for AAC (https://bugs.webkit.org/show_bug.cgi?id=302253),
// which we try to detect here. If detected, we'll provide our own description instead, derived
// from the codec string and audio parameters.
if (this.encodingConfig.codec === 'aac' && meta?.decoderConfig) {
let needsDescriptionOverwrite = false;
if (!meta.decoderConfig.description || meta.decoderConfig.description.byteLength < 2) {
needsDescriptionOverwrite = true;
} else {
const audioSpecificConfig = parseAacAudioSpecificConfig(
toUint8Array(meta.decoderConfig.description),
);
needsDescriptionOverwrite = audioSpecificConfig.objectType === 0;
}
if (needsDescriptionOverwrite) {
const objectType = Number(last(encoderConfig.codec.split('.')));
meta.decoderConfig.description = buildAacAudioSpecificConfig({
objectType,
numberOfChannels: meta.decoderConfig.numberOfChannels,
sampleRate: meta.decoderConfig.sampleRate,
});
}
}
const packet = EncodedPacket.fromEncodedChunk(chunk);
this.encodingConfig.onEncodedPacket?.(packet, meta);
+29 -23
View File
@@ -174,8 +174,8 @@ export const toDataView = (source: AllowSharedBufferSource) => {
}
};
export const textDecoder = new TextDecoder();
export const textEncoder = new TextEncoder();
export const textDecoder = /* #__PURE__ */ new TextDecoder();
export const textEncoder = /* #__PURE__ */ new TextEncoder();
export const isIso88591Compatible = (text: string) => {
for (let i = 0; i < text.length; i++) {
@@ -201,7 +201,7 @@ export const COLOR_PRIMARIES_MAP = {
bt2020: 9, // ITU-R BT.202
smpte432: 12, // SMPTE EG 432-1
};
export const COLOR_PRIMARIES_MAP_INVERSE = invertObject(COLOR_PRIMARIES_MAP);
export const COLOR_PRIMARIES_MAP_INVERSE = /* #__PURE__ */ invertObject(COLOR_PRIMARIES_MAP);
export const TRANSFER_CHARACTERISTICS_MAP = {
'bt709': 1, // ITU-R BT.709
@@ -211,7 +211,7 @@ export const TRANSFER_CHARACTERISTICS_MAP = {
'pq': 16, // Rec. ITU-R BT.2100-2 perceptual quantization (PQ) system
'hlg': 18, // Rec. ITU-R BT.2100-2 hybrid loggamma (HLG) system
};
export const TRANSFER_CHARACTERISTICS_MAP_INVERSE = invertObject(TRANSFER_CHARACTERISTICS_MAP);
export const TRANSFER_CHARACTERISTICS_MAP_INVERSE = /* #__PURE__ */ invertObject(TRANSFER_CHARACTERISTICS_MAP);
export const MATRIX_COEFFICIENTS_MAP = {
'rgb': 0, // Identity
@@ -220,7 +220,7 @@ export const MATRIX_COEFFICIENTS_MAP = {
'smpte170m': 6, // SMPTE 170M
'bt2020-ncl': 9, // ITU-R BT.2020-2 (non-constant luminance)
};
export const MATRIX_COEFFICIENTS_MAP_INVERSE = invertObject(MATRIX_COEFFICIENTS_MAP);
export const MATRIX_COEFFICIENTS_MAP_INVERSE = /* #__PURE__ */ invertObject(MATRIX_COEFFICIENTS_MAP);
export type RequiredNonNull<T> = {
[K in keyof T]-?: NonNullable<T[K]>;
@@ -486,9 +486,14 @@ export const clamp = (value: number, min: number, max: number) => {
export const UNDETERMINED_LANGUAGE = 'und';
export const roundToPrecision = (value: number, digits: number) => {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
export const roundIfAlmostInteger = (value: number) => {
const rounded = Math.round(value);
if (Math.abs(value / rounded - 1) < 10 * Number.EPSILON) {
return rounded;
} else {
return value;
}
};
export const roundToMultiple = (value: number, multiple: number) => {
@@ -510,7 +515,7 @@ export const isIso639Dash2LanguageCode = (x: string) => {
};
// Since the result will be truncated, add a bit of eps to compensate for floating point errors
export const SECOND_TO_MICROSECOND_FACTOR = 1e6 * (1 + Number.EPSILON);
export const SECOND_TO_MICROSECOND_FACTOR = /* #__PURE__ */ 1e6 * (1 + Number.EPSILON);
/**
* Sets all keys K of T to be required.
@@ -658,22 +663,14 @@ export class CallSerializer {
}
}
let isSafariCache: boolean | null = null;
export const isSafari = () => {
if (isSafariCache !== null) {
return isSafariCache;
let isWebKitCache: boolean | null = null;
export const isWebKit = () => {
if (isWebKitCache !== null) {
return isWebKitCache;
}
const result = !!(
typeof navigator !== 'undefined'
&& navigator.vendor?.match(/apple/i)
&& !navigator.userAgent?.match(/crios/i)
&& !navigator.userAgent?.match(/fxios/i)
&& !navigator.userAgent?.match(/Opera|OPT\//)
);
isSafariCache = result;
return result;
// This even returns true for WebKit-wrapping browsers such as Chrome on iOS
return isWebKitCache = !!(typeof navigator !== 'undefined' && navigator.vendor?.match(/apple/i));
};
let isFirefoxCache: boolean | null = null;
@@ -685,6 +682,15 @@ export const isFirefox = () => {
return isFirefoxCache = typeof navigator !== 'undefined' && navigator.userAgent?.includes('Firefox');
};
let isChromiumCache: boolean | null = null;
export const isChromium = () => {
if (isChromiumCache !== null) {
return isChromiumCache;
}
return isChromiumCache = !!(typeof navigator !== 'undefined' && navigator.vendor?.includes('Google Inc'));
};
/**
* T or a promise that resolves to T.
* @group Miscellaneous
+11 -11
View File
@@ -46,21 +46,21 @@ export abstract class Muxer {
private trackTimestampInfo = new WeakMap<OutputTrack, {
maxTimestamp: number;
maxTimestampBeforeLastKeyFrame: number;
maxTimestampBeforeLastKeyPacket: number;
}>();
protected validateAndNormalizeTimestamp(track: OutputTrack, timestampInSeconds: number, isKeyFrame: boolean) {
protected validateAndNormalizeTimestamp(track: OutputTrack, timestampInSeconds: number, isKeyPacket: boolean) {
timestampInSeconds += track.source._timestampOffset;
let timestampInfo = this.trackTimestampInfo.get(track);
if (!timestampInfo) {
if (!isKeyFrame) {
throw new Error('First frame must be a key frame.');
if (!isKeyPacket) {
throw new Error('First packet must be a key packet.');
}
timestampInfo = {
maxTimestamp: timestampInSeconds,
maxTimestampBeforeLastKeyFrame: timestampInSeconds,
maxTimestampBeforeLastKeyPacket: timestampInSeconds,
};
this.trackTimestampInfo.set(track, timestampInfo);
}
@@ -69,15 +69,15 @@ export abstract class Muxer {
throw new Error(`Timestamps must be non-negative (got ${timestampInSeconds}s).`);
}
if (isKeyFrame) {
timestampInfo.maxTimestampBeforeLastKeyFrame = timestampInfo.maxTimestamp;
if (isKeyPacket) {
timestampInfo.maxTimestampBeforeLastKeyPacket = timestampInfo.maxTimestamp;
}
if (timestampInSeconds < timestampInfo.maxTimestampBeforeLastKeyFrame) {
if (timestampInSeconds < timestampInfo.maxTimestampBeforeLastKeyPacket) {
throw new Error(
`Timestamps cannot be smaller than the highest timestamp of the previous GOP (a GOP begins with a key`
+ ` frame and ends right before the next key frame). Got ${timestampInSeconds}s, but highest timestamp`
+ ` is ${timestampInfo.maxTimestampBeforeLastKeyFrame}s.`,
`Timestamps cannot be smaller than the largest timestamp of the previous GOP (a GOP begins with a key`
+ ` packet and ends right before the next key packet). Got ${timestampInSeconds}s, but largest`
+ ` timestamp is ${timestampInfo.maxTimestampBeforeLastKeyPacket}s.`,
);
}
+3 -3
View File
@@ -19,7 +19,7 @@ import {
binarySearchLessOrEqual,
findLast,
last,
roundToPrecision,
roundIfAlmostInteger,
toDataView,
UNDETERMINED_LANGUAGE,
} from '../misc';
@@ -577,7 +577,7 @@ class OggAudioTrackBacking implements InputAudioTrackBacking {
return this.getPacketSequential(timestamp, options);
}
const timestampInSamples = roundToPrecision(timestamp * this.internalSampleRate, 14);
const timestampInSamples = roundIfAlmostInteger(timestamp * this.internalSampleRate);
if (timestampInSamples === 0) {
// Fast path for timestamp 0 - avoids binary search when playing back from the start
return this.getFirstPacket(options);
@@ -910,7 +910,7 @@ class OggAudioTrackBacking implements InputAudioTrackBacking {
const release = await this.sequentialScanMutex.acquire(); // Requires exclusivity because we write to a cache
try {
const timestampInSamples = roundToPrecision(timestamp * this.internalSampleRate, 14);
const timestampInSamples = roundIfAlmostInteger(timestamp * this.internalSampleRate);
timestamp = timestampInSamples / this.internalSampleRate;
const index = binarySearchLessOrEqual(
+1 -1
View File
@@ -11,7 +11,7 @@ import { OGGS } from './ogg-misc';
export const MIN_PAGE_HEADER_SIZE = 27;
export const MAX_PAGE_HEADER_SIZE = 27 + 255;
export const MAX_PAGE_SIZE = MAX_PAGE_HEADER_SIZE + 255 * 255;
export const MAX_PAGE_SIZE = /* #__PURE__ */ MAX_PAGE_HEADER_SIZE + 255 * 255;
export type Page = {
headerStartPos: number;
+5 -2
View File
@@ -8,7 +8,7 @@
import { SECOND_TO_MICROSECOND_FACTOR } from './misc';
export const PLACEHOLDER_DATA = new Uint8Array(0);
export const PLACEHOLDER_DATA = /* #__PURE__ */ new Uint8Array(0);
/**
* The type of a packet. Key packets can be decoded without previous packets, while delta packets depend on previous
@@ -126,7 +126,10 @@ export class EncodedPacket {
}
}
/** If this packet is a metadata-only packet. Metadata-only packets don't contain their packet data. */
/**
* If this packet is a metadata-only packet. Metadata-only packets don't contain their packet data. They are the
* result of retrieving packets with {@link PacketRetrievalOptions.metadataOnly} set to `true`.
*/
get isMetadataOnly() {
return this.data === PLACEHOLDER_DATA;
}
+4 -1
View File
@@ -552,7 +552,6 @@ export class VideoSample implements Disposable {
dHeight,
);
// Restore the previous transformation state
context.restore();
}
@@ -644,6 +643,8 @@ export class VideoSample implements Disposable {
dy = (canvasHeight - newHeight) / 2;
}
context.save();
const aspectRatioChange = rotation % 180 === 0 ? 1 : newWidth / newHeight;
context.translate(canvasWidth / 2, canvasHeight / 2);
context.rotate(rotation * Math.PI / 180);
@@ -655,6 +656,8 @@ export class VideoSample implements Disposable {
// Important that we don't use .draw() here since that would take rotation into account, but we wanna handle it
// ourselves here
context.drawImage(this.toCanvasImageSource(), sx, sy, sWidth, sHeight, dx, dy, newWidth, newHeight);
context.restore();
}
/** @internal */
+10 -2
View File
@@ -12,6 +12,7 @@ import {
binarySearchLessOrEqual,
closedIntervalsOverlap,
isNumber,
isWebKit,
MaybePromise,
mergeRequestInit,
promiseWithResolvers,
@@ -209,7 +210,14 @@ export class BlobSource extends Source {
private async _runWorker(worker: ReadWorker) {
let reader = this._readers.get(worker);
if (reader === undefined) {
if ('stream' in this._blob) {
// https://github.com/Vanilagy/mediabunny/issues/184
// WebKit has critical bugs with blob.stream():
// - WebKitBlobResource error 1 when streaming large files
// - Memory buildup and reload loops on iOS (network process crashes)
// - ReadableStream stalls under backpressure (especially video)
// Affects Safari and all iOS browsers (Chrome, Firefox, etc.).
// Use arrayBuffer() fallback for WebKit browsers.
if ('stream' in this._blob && !isWebKit()) {
// Get a reader of the blob starting at the required offset, and then keep it around
const slice = this._blob.slice(worker.currentPos);
reader = slice.stream().getReader();
@@ -261,7 +269,7 @@ export class BlobSource extends Source {
}
}
const URL_SOURCE_MIN_LOAD_AMOUNT = 0.5 * 2 ** 20; // 0.5 MiB
const URL_SOURCE_MIN_LOAD_AMOUNT = /* #__PURE__ */ 0.5 * 2 ** 20; // 0.5 MiB
const DEFAULT_RETRY_DELAY
= ((previousAttempts, error, src) => {
// Check if this could be a CORS error. If so, we cannot recover from it and
+3 -3
View File
@@ -89,8 +89,8 @@ export abstract class Writer {
}
}
const ARRAY_BUFFER_INITIAL_SIZE = 2 ** 16;
const ARRAY_BUFFER_MAX_SIZE = 2 ** 32;
const ARRAY_BUFFER_INITIAL_SIZE = /* #__PURE__ */ 2 ** 16;
const ARRAY_BUFFER_MAX_SIZE = /* #__PURE__ */ 2 ** 32;
export class BufferTargetWriter extends Writer {
private pos = 0;
@@ -184,7 +184,7 @@ export class BufferTargetWriter extends Writer {
}
}
const DEFAULT_CHUNK_SIZE = 2 ** 24;
const DEFAULT_CHUNK_SIZE = /* #__PURE__ */ 2 ** 24;
const MAX_CHUNKS_AT_ONCE = 2;
interface Chunk {