Compare commits

...
15 Commits
Author SHA1 Message Date
Vanilagy d949d1eadb Bump patch 2025-12-05 23:01:23 +01:00
Vanilagy 11970675c6 Slightly adjust validated key packet retrieval logic 2025-12-05 23:00:40 +01:00
Vanilagy d6c223b0f6 Fix keyframe sample index lookup logic for ISOBMFF in rare B-frame scenarios 2025-12-05 22:14:23 +01:00
Vanilagy 5b703a1c78 Fix MP3 encoder emitting incorrect timestamps, fixed overaggressive audio sample gap filling (fixes #233) 2025-12-05 12:41:36 +01:00
Vanilagy 3dd7a9cb58 Fix unclosed samples in tests 2025-12-04 17:04:24 +01:00
Vanilagy 7b3f5dad2a Bump patch 2025-12-04 16:57:55 +01:00
Vanilagy dceb67a065 SURELY THIS TIME 2025-12-04 16:54:04 +01:00
Vanilagy dd39b8b2ca We try again again 2025-12-04 16:48:23 +01:00
Vanilagy 71963cbd92 Try again 2025-12-04 16:43:45 +01:00
Vanilagy 816bb020fb Fix incorrect conversion trim option typings, change release CI to "trusted publishing" 2025-12-04 16:40:41 +01:00
Vanilagy e5aeff89e3 Wtf 2025-12-04 10:03:09 +01:00
Vanilagy 3620b04caf Fix incomplete AV1 sequence header OBU parsing, fix missing saample close() calls in audio resampling path, add error logging when open samples are GC'd, bump patch 2025-12-04 09:58:12 +01:00
Vanilagy c0c353dbe8 Bump patch 2025-11-26 23:31:29 +01:00
Vanilagy 75096edb23 Update TypeScript, fix generic ArrayBufferView type errors, allow SharedArrayBuffer as input for BufferSource 2025-11-26 12:04:23 +01:00
Vanilagy f33dd4f2cd UrlSource: Deal gracefully with servers that cap unbounded range requests (fixes #231) 2025-11-26 11:26:09 +01:00
23 changed files with 603 additions and 588 deletions
+8 -8
View File
@@ -5,6 +5,7 @@ on:
types: [published]
permissions:
id-token: write # Required for OIDC
contents: read
jobs:
@@ -14,7 +15,7 @@ jobs:
permissions: write-all
steps:
- name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -27,12 +28,15 @@ jobs:
git push origin release
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
registry-url: https://registry.npmjs.org
- name: Update npm
run: npm install -g npm@latest
- name: Get package.json version
id: package-json-version
run: echo "version=v$(cat package.json | jq '.version' --raw-output)" >> $GITHUB_OUTPUT
@@ -58,11 +62,7 @@ jobs:
run: gh release upload ${{ github.event.release.tag_name }} dist/bundles/mediabunny.cjs dist/bundles/mediabunny.min.cjs dist/bundles/mediabunny.mjs dist/bundles/mediabunny.min.mjs dist/mediabunny.d.ts packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.js packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.min.js packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.mjs packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.min.mjs packages/mp3-encoder/dist/mediabunny-mp3-encoder.d.ts
- name: Publish Mediabunny to npm
run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --access public
- name: Publish workspace packages to npm
run: npm publish --provenance --access public --workspaces
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --access public --workspaces
+16 -1
View File
@@ -14,7 +14,22 @@
source: new Mediabunny.BlobSource(file),
});
console.log(await input.getTracks());
const videoTrack = await input.getPrimaryVideoTrack();
const sink = new Mediabunny.EncodedPacketSink(videoTrack);
for await (const packet of sink.packets()) {
console.log(packet);
}
/*
const videoTrack = await input.getPrimaryVideoTrack();
const sink = new Mediabunny.EncodedPacketSink(videoTrack);
for await (const packet of sink.packets()) {
console.log(packet);
}
*/
/*
return;
+4 -1
View File
@@ -1,6 +1,7 @@
<button>Go</button>
<script src="../dist/bundles/mediabunny.cjs"></script>
<script src="../packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.js"></script>
<script type="module">
function download(blob, filename) {
@@ -12,6 +13,8 @@
URL.revokeObjectURL(url);
}
MediabunnyMp3Encoder.registerMp3Encoder();
const button = document.querySelector('button');
button.addEventListener('click', async () => {
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
@@ -34,7 +37,7 @@
}
if (audioTrack) {
const source = new Mediabunny.MediaStreamAudioTrackSource(audioTrack, {
codec: 'aac',
codec: 'mp3',
bitrate: Mediabunny.QUALITY_MEDIUM
});
+9 -1
View File
@@ -433,4 +433,12 @@ await textSource.add('00:00:00.000 --> 00:00:02.000\nHello there!\n\n');
await textSource.add('00:00:02.500 --> 00:00:04.000\nChunky chunks.\n\n');
```
The chunks have certain constraints: A cue must be fully contained within a chunk and cannot be split across multiple smaller chunks (although a chunk can contain multiple cues). Also, the WebVTT preamble must be added first and all at once.
The chunks have certain constraints: A cue must be fully contained within a chunk and cannot be split across multiple smaller chunks (although a chunk can contain multiple cues). Also, the WebVTT preamble must be added first and all at once.
::: info
For QuickTime to display WebVTT subtitles, it typically expects alignment information to be specified:
```
00:00:00.000 --> 00:00:02.000 align:center
This is your last chance.
```
:::
+1 -1
View File
@@ -21,7 +21,7 @@ const context = canvas.getContext('2d', { alpha: false, desynchronized: true })!
const frameRate = 30;
const chunks: Uint8Array[] = [];
const chunks: Uint8Array<ArrayBuffer>[] = [];
let recording = false;
let output: Output;
let videoSource: CanvasSource;
@@ -97,7 +97,7 @@ const extractMetadata = (resource: File | string) => {
'Lyrics': tags.lyrics,
'Comment': tags.comment,
'Images': tags.images?.map((image) => {
const blob = new Blob([image.data], { type: image.mimeType });
const blob = new Blob([image.data.slice()], { type: image.mimeType });
const element = new Image();
element.src = URL.createObjectURL(blob);
+285 -462
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "mediabunny",
"author": "Vanilagy",
"version": "1.25.2",
"version": "1.25.7",
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
"type": "module",
"workspaces": [
@@ -70,7 +70,7 @@
"@eslint/js": "^9.22.0",
"@fontsource-variable/rubik": "^5.2.6",
"@fontsource/dm-mono": "^5.2.6",
"@microsoft/api-extractor": "^7.52.1",
"@microsoft/api-extractor": "^7.55.1",
"@stylistic/eslint-plugin": "^4.2.0",
"@tailwindcss/vite": "^4.1.7",
"@types/markdown-it-footnote": "^3.0.4",
@@ -84,8 +84,8 @@
"mermaid": "^11.6.0",
"tailwindcss": "^4.1.7",
"tsx": "^4.19.4",
"typescript": "^5.8.2",
"typescript-eslint": "^8.26.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^6.3.5",
"vitepress": "^1.6.3",
"vitepress-plugin-llms": "^1.5.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/mp3-encoder",
"author": "Vanilagy",
"version": "1.25.2",
"version": "1.25.7",
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
+8 -1
View File
@@ -22,7 +22,7 @@ class Mp3Encoder extends CustomAudioEncoder {
private buffer = new Uint8Array(2 ** 16);
private currentBufferOffset = 0;
private currentTimestamp = 0;
private currentTimestamp: number | null = null;
private chunkMetadata: EncodedAudioChunkMetadata = {};
static override supports(codec: AudioCodec, config: AudioDecoderConfig): boolean {
@@ -79,6 +79,11 @@ class Mp3Encoder extends CustomAudioEncoder {
}
async encode(audioSample: AudioSample) {
if (this.currentTimestamp === null) {
// The first sample's timestamp determines where we start
this.currentTimestamp = audioSample.timestamp;
}
const sizePerChannel = audioSample.allocationSize({
format: 's16-planar',
planeIndex: 0,
@@ -123,6 +128,8 @@ class Mp3Encoder extends CustomAudioEncoder {
* these chunks and extract the MP3 frames only when they're complete.
*/
private digestOutput(bytes: Uint8Array) {
assert(this.currentTimestamp !== null);
const requiredBufferSize = this.currentBufferOffset + bytes.length;
if (requiredBufferSize > this.buffer.length) {
// Grow the buffer to the required size
+64 -3
View File
@@ -590,9 +590,7 @@ export const extractNalUnitTypeForHevc = (data: Uint8Array) => {
};
/** Builds a HevcDecoderConfigurationRecord from an HEVC packet in Annex B format. */
export const extractHevcDecoderConfigurationRecord = (
packetData: Uint8Array,
) => {
export const extractHevcDecoderConfigurationRecord = (packetData: Uint8Array) => {
try {
const nalUnits = findNalUnitsInAnnexB(packetData);
@@ -1456,6 +1454,69 @@ export const extractAv1CodecInfoFromPacket = (
}
}
// Frame size
const frameWidthBitsMinus1 = bitstream.readBits(4);
const frameHeightBitsMinus1 = bitstream.readBits(4);
const n1 = frameWidthBitsMinus1 + 1;
bitstream.skipBits(n1); // max_frame_width_minus_1
const n2 = frameHeightBitsMinus1 + 1;
bitstream.skipBits(n2); // max_frame_height_minus_1
// Frame IDs
let frameIdNumbersPresentFlag = 0;
if (reducedStillPictureHeader) {
frameIdNumbersPresentFlag = 0;
} else {
frameIdNumbersPresentFlag = bitstream.readBits(1);
}
if (frameIdNumbersPresentFlag) {
bitstream.skipBits(4); // delta_frame_id_length_minus_2
bitstream.skipBits(3); // additional_frame_id_length_minus_1
}
bitstream.skipBits(1); // use_128x128_superblock
bitstream.skipBits(1); // enable_filter_intra
bitstream.skipBits(1); // enable_intra_edge_filter
if (!reducedStillPictureHeader) {
bitstream.skipBits(1); // enable_interintra_compound
bitstream.skipBits(1); // enable_masked_compound
bitstream.skipBits(1); // enable_warped_motion
bitstream.skipBits(1); // enable_dual_filter
const enableOrderHint = bitstream.readBits(1);
if (enableOrderHint) {
bitstream.skipBits(1); // enable_jnt_comp
bitstream.skipBits(1); // enable_ref_frame_mvs
}
const seqChooseScreenContentTools = bitstream.readBits(1);
let seqForceScreenContentTools = 0;
if (seqChooseScreenContentTools) {
seqForceScreenContentTools = 2; // SELECT_SCREEN_CONTENT_TOOLS
} else {
seqForceScreenContentTools = bitstream.readBits(1);
}
if (seqForceScreenContentTools > 0) {
const seqChooseIntegerMv = bitstream.readBits(1);
if (!seqChooseIntegerMv) {
bitstream.skipBits(1); // seq_force_integer_mv
}
}
if (enableOrderHint) {
bitstream.skipBits(3); // order_hint_bits_minus_1
}
}
bitstream.skipBits(1); // enable_superres
bitstream.skipBits(1); // enable_cdef
bitstream.skipBits(1); // enable_restoration
// color_config()
const highBitdepth = bitstream.readBits(1);
let bitDepth = 8;
+15 -5
View File
@@ -82,10 +82,16 @@ export type ConversionOptions = {
/** Options to trim the input file. */
trim?: {
/** The time in the input file in seconds at which the output file should start. Must be less than `end`. */
start: number;
/** The time in the input file in seconds at which the output file should end. Must be greater than `start`. */
end: number;
/**
* The time in the input file in seconds at which the output file should start. Must be less than `end`.
* Defaults to 0 when omitted.
*/
start?: number;
/**
* The time in the input file in seconds at which the output file should end. Must be greater than `start`.
* Defaults to the duration of the input when omitted.
*/
end?: number;
};
/**
@@ -1509,7 +1515,10 @@ export class Conversion {
targetSampleRate,
startTime: this._startTimestamp,
endTime: this._endTimestamp,
onSample: sample => this._registerAudioSample(track, trackOptions, source, sample),
onSample: async (sample) => {
await this._registerAudioSample(track, trackOptions, source, sample);
sample.close();
},
});
const sink = new AudioSampleSink(track);
@@ -1521,6 +1530,7 @@ export class Conversion {
}
await resampler.add(sample);
sample.close();
}
await resampler.finalize();
+1 -1
View File
@@ -850,7 +850,7 @@ export const dOps = (trackData: IsobmffAudioTrackData) => {
let inputSampleRate = trackData.info.sampleRate;
let outputGain = 0;
let channelMappingFamily = 0;
let channelMappingTable = new Uint8Array(0);
let channelMappingTable: Uint8Array<ArrayBufferLike> = new Uint8Array(0);
// Read preskip and from codec private data from the encoder
// https://www.rfc-editor.org/rfc/rfc7845#section-5
+41 -15
View File
@@ -2489,11 +2489,8 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
const timestampInTimescale = this.mapTimestampIntoTimescale(timestamp);
const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);
const sampleIndex = getSampleIndexForTimestamp(sampleTable, timestampInTimescale);
const keyFrameSampleIndex = sampleIndex === -1
? -1
: getRelevantKeyframeIndexForSample(sampleTable, sampleIndex);
const regularPacket = await this.fetchPacketForSampleIndex(keyFrameSampleIndex, options);
const sampleIndex = getKeyframeSampleIndexForTimestamp(sampleTable, timestampInTimescale);
const regularPacket = await this.fetchPacketForSampleIndex(sampleIndex, options);
if (!sampleTableIsEmpty(sampleTable) || !this.internalTrack.demuxer.isFragmented) {
// Prefer the non-fragmented packet
@@ -2913,7 +2910,45 @@ const getSampleIndexForTimestamp = (sampleTable: SampleTable, timescaleUnits: nu
const entry = sampleTable.sampleTimingEntries[index]!;
return entry.startIndex
+ Math.min(Math.floor((timescaleUnits - entry.startDecodeTimestamp) / entry.delta), entry.count - 1);
+ Math.min(
Math.floor((timescaleUnits - entry.startDecodeTimestamp) / entry.delta),
entry.count - 1,
);
}
};
const getKeyframeSampleIndexForTimestamp = (sampleTable: SampleTable, timescaleUnits: number) => {
if (!sampleTable.keySampleIndices) {
// Every sample is a keyframe
return getSampleIndexForTimestamp(sampleTable, timescaleUnits);
}
if (sampleTable.presentationTimestamps) {
const index = binarySearchLessOrEqual(
sampleTable.presentationTimestamps,
timescaleUnits,
x => x.presentationTimestamp,
);
if (index === -1) {
return -1;
}
// Walk the samples in presentation order until we find one that's a keyframe
for (let i = index; i >= 0; i--) {
const sampleIndex = sampleTable.presentationTimestamps[i]!.sampleIndex;
const isKeyFrame = binarySearchExact(sampleTable.keySampleIndices, sampleIndex, x => x) !== -1;
if (isKeyFrame) {
return sampleIndex;
}
}
return -1;
} else {
const sampleIndex = getSampleIndexForTimestamp(sampleTable, timescaleUnits);
const index = binarySearchLessOrEqual(sampleTable.keySampleIndices, sampleIndex, x => x);
return sampleTable.keySampleIndices[index] ?? -1;
}
};
@@ -3002,15 +3037,6 @@ const getSampleInfo = (sampleTable: SampleTable, sampleIndex: number): SampleInf
};
};
const getRelevantKeyframeIndexForSample = (sampleTable: SampleTable, sampleIndex: number) => {
if (!sampleTable.keySampleIndices) {
return sampleIndex;
}
const index = binarySearchLessOrEqual(sampleTable.keySampleIndices, sampleIndex, x => x);
return sampleTable.keySampleIndices[index] ?? -1;
};
const getNextKeyframeIndexForSample = (sampleTable: SampleTable, sampleIndex: number) => {
if (!sampleTable.keySampleIndices) {
return sampleIndex + 1;
+9 -7
View File
@@ -201,9 +201,10 @@ export class EncodedPacketSink {
}
const packet = await this._track._backing.getKeyPacket(timestamp, options);
if (!packet || packet.type === 'delta') {
if (!packet) {
return packet;
}
assert(packet.type === 'key');
const determinedType = await this._track.determinePacketType(packet);
if (determinedType === 'delta') {
@@ -235,9 +236,10 @@ export class EncodedPacketSink {
}
const nextPacket = await this._track._backing.getNextKeyPacket(packet, options);
if (!nextPacket || nextPacket.type === 'delta') {
if (!nextPacket) {
return nextPacket;
}
assert(nextPacket.type === 'key');
const determinedType = await this._track.determinePacketType(nextPacket);
if (determinedType === 'delta') {
@@ -474,9 +476,6 @@ export abstract class BaseMediaSampleSink<
const packetSink = this._createPacketSink();
const keyPacket = await packetSink.getKeyPacket(startTimestamp, { verifyKeyPackets: true })
?? await packetSink.getFirstPacket();
if (!keyPacket) {
return;
}
let currentPacket: EncodedPacket | null = keyPacket;
@@ -498,7 +497,7 @@ export abstract class BaseMediaSampleSink<
}
}
const packets = packetSink.packets(keyPacket, endPacket);
const packets = packetSink.packets(keyPacket ?? undefined, endPacket);
await packets.next(); // Skip the start packet as we already have it
while (currentPacket && !ended && !this._track.input._disposed) {
@@ -2127,11 +2126,14 @@ export class AudioBufferSink {
/** @internal */
_audioSampleToWrappedArrayBuffer(sample: AudioSample): WrappedAudioBuffer {
return {
const result: WrappedAudioBuffer = {
buffer: sample.toAudioBuffer(),
timestamp: sample.timestamp,
duration: sample.duration,
};
sample.close();
return result;
}
/**
+19 -13
View File
@@ -1356,21 +1356,27 @@ class AudioEncoderWrapper {
(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,
});
if (this.lastEndSampleIndex === null) {
this.lastEndSampleIndex = endSampleIndex;
} else {
const sampleDiff = startSampleIndex - this.lastEndSampleIndex;
await this.add(fillSample, true); // Recursive call
if (sampleDiff >= 64) {
// The gap is big enough, let's add a correction sample
const fillSample = new AudioSample({
data: new Float32Array(sampleDiff * audioSample.numberOfChannels),
format: 'f32-planar',
sampleRate: audioSample.sampleRate,
numberOfChannels: audioSample.numberOfChannels,
numberOfFrames: sampleDiff,
timestamp: this.lastEndSampleIndex / audioSample.sampleRate,
});
await this.add(fillSample, true); // Recursive call
}
this.lastEndSampleIndex += audioSample.numberOfFrames;
}
this.lastEndSampleIndex = endSampleIndex;
}
if (this.customEncoder) {
+7 -7
View File
@@ -157,20 +157,20 @@ export const writeBits = (bytes: Uint8Array, start: number, end: number, value:
export const toUint8Array = (source: AllowSharedBufferSource): Uint8Array => {
if (source.constructor === Uint8Array) { // We want a true Uint8Array, not something that extends it like Buffer
return source;
} else if (source instanceof ArrayBuffer) {
return new Uint8Array(source);
} else {
} else if (ArrayBuffer.isView(source)) {
return new Uint8Array(source.buffer, source.byteOffset, source.byteLength);
} else {
return new Uint8Array(source);
}
};
export const toDataView = (source: AllowSharedBufferSource) => {
export const toDataView = (source: AllowSharedBufferSource): DataView => {
if (source.constructor === DataView) {
return source;
} else if (source instanceof ArrayBuffer) {
return new DataView(source);
} else {
} else if (ArrayBuffer.isView(source)) {
return new DataView(source.buffer, source.byteOffset, source.byteLength);
} else {
return new DataView(source);
}
};
+65 -4
View File
@@ -21,6 +21,54 @@ import {
polyfillSymbolDispose();
type FinalizationRegistryValue = {
type: 'video';
data: VideoFrame | OffscreenCanvas | Uint8Array;
} | {
type: 'audio';
data: AudioData | Uint8Array;
};
// Let's manually handle logging the garbage collection errors that are typically logged by the browser. This way, they
// also kick for audio samples (which is normally not the case), making sure any incorrect code is quickly caught.
let lastVideoGcErrorLog = -Infinity;
let lastAudioGcErrorLog = -Infinity;
let finalizationRegistry: FinalizationRegistry<FinalizationRegistryValue> | null = null;
if (typeof FinalizationRegistry !== 'undefined') {
finalizationRegistry = new FinalizationRegistry<FinalizationRegistryValue>((value) => {
const now = Date.now();
if (value.type === 'video') {
if (now - lastVideoGcErrorLog >= 1000) {
// This error is annoying but oh so important
console.error(
`A VideoSample was garbage collected without first being closed. For proper resource management,`
+ ` make sure to call close() on all your VideoSamples as soon as you're done using them.`,
);
lastVideoGcErrorLog = now;
}
if (typeof VideoFrame !== 'undefined' && value.data instanceof VideoFrame) {
value.data.close(); // Prevent the browser error since we're logging our own
}
} else {
if (now - lastAudioGcErrorLog >= 1000) {
console.error(
`An AudioSample was garbage collected without first being closed. For proper resource management,`
+ ` make sure to call close() on all your AudioSamples as soon as you're done using them.`,
);
lastAudioGcErrorLog = now;
}
if (typeof AudioData !== 'undefined' && value.data instanceof AudioData) {
value.data.close();
}
}
});
}
/**
* Metadata used for VideoSample initialization.
* @group Samples
@@ -133,7 +181,11 @@ export class VideoSample implements Disposable {
data: VideoFrame | CanvasImageSource | AllowSharedBufferSource,
init?: VideoSampleInit,
) {
if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
if (
data instanceof ArrayBuffer
|| (typeof SharedArrayBuffer !== 'undefined' && data instanceof SharedArrayBuffer)
|| ArrayBuffer.isView(data)
) {
if (!init || typeof init !== 'object') {
throw new TypeError('init must be an object.');
}
@@ -265,6 +317,8 @@ export class VideoSample implements Disposable {
} else {
throw new TypeError('Invalid data type: Must be a BufferSource or CanvasImageSource.');
}
finalizationRegistry?.register(this, { type: 'video', data: this._data }, this);
}
/** Clones this video sample. */
@@ -313,6 +367,8 @@ export class VideoSample implements Disposable {
return;
}
finalizationRegistry?.unregister(this);
if (isVideoFrame(this._data)) {
this._data.close();
} else {
@@ -955,6 +1011,8 @@ export class AudioSample implements Disposable {
this._data = dataBuffer;
}
finalizationRegistry?.register(this, { type: 'audio', data: this._data }, this);
}
/** Returns the number of bytes required to hold the audio sample's data as specified by the given options. */
@@ -1116,9 +1174,8 @@ export class AudioSample implements Disposable {
}
}
} else {
// Branch for Uint8Array data (non-AudioData)
const uint8Data = this._data;
const srcView = new DataView(uint8Data.buffer, uint8Data.byteOffset, uint8Data.byteLength);
const srcView = toDataView(uint8Data);
const srcFormat = this.format;
const readFn = getReadFunction(srcFormat);
@@ -1188,6 +1245,8 @@ export class AudioSample implements Disposable {
return;
}
finalizationRegistry?.unregister(this);
if (isAudioData(this._data)) {
this._data.close();
} else {
@@ -1250,7 +1309,9 @@ export class AudioSample implements Disposable {
numberOfFrames: this.numberOfFrames,
numberOfChannels: this.numberOfChannels,
timestamp: this.microsecondTimestamp,
data: this._data,
data: this._data.buffer instanceof ArrayBuffer
? this._data.buffer
: this._data.slice(), // In the case of SharedArrayBuffer, convert to ArrayBuffer
});
}
}
+32 -39
View File
@@ -102,10 +102,17 @@ export class BufferSource extends Source {
/** @internal */
_onreadCalled = false;
/** Creates a new {@link BufferSource} backed the specified `ArrayBuffer` or `ArrayBufferView`. */
constructor(buffer: ArrayBuffer | ArrayBufferView) {
if (!(buffer instanceof ArrayBuffer) && !ArrayBuffer.isView(buffer)) {
throw new TypeError('buffer must be an ArrayBuffer or ArrayBufferView.');
/**
* Creates a new {@link BufferSource} backed by the specified `ArrayBuffer`, `SharedArrayBuffer`,
* or `ArrayBufferView`.
*/
constructor(buffer: AllowSharedBufferSource) {
if (
!(buffer instanceof ArrayBuffer)
&& !(typeof SharedArrayBuffer !== 'undefined' && buffer instanceof SharedArrayBuffer)
&& !ArrayBuffer.isView(buffer)
) {
throw new TypeError('buffer must be an ArrayBuffer, SharedArrayBuffer, or ArrayBufferView.');
}
super();
@@ -234,12 +241,7 @@ export class BlobSource extends Source {
const { done, value } = await reader.read();
if (done) {
this._orchestrator.forgetWorker(worker);
if (worker.currentPos < worker.targetPos) { // I think this `if` should always hit?
throw new Error('Blob reader stopped unexpectedly before all requested data was read.');
}
break;
throw new Error('Blob reader stopped unexpectedly before all requested data was read.');
}
if (worker.aborted) {
@@ -441,7 +443,7 @@ export class UrlSource extends Source {
let fileSize: number;
if (response.status === 206) {
fileSize = this._getPartialLengthFromRangeResponse(response);
fileSize = this._getTotalLengthFromRangeResponse(response);
worker = this._orchestrator.createWorker(0, Math.min(fileSize, URL_SOURCE_MIN_LOAD_AMOUNT));
} else {
// Server probably returned a 200.
@@ -515,14 +517,6 @@ export class UrlSource extends Source {
);
}
const length = this._getPartialLengthFromRangeResponse(response);
const required = worker.targetPos - worker.currentPos;
if (length < required) {
throw new Error(
`HTTP response unexpectedly too short: Needed at least ${required} bytes, got only ${length}.`,
);
}
if (!response.body) {
throw new Error(
'Missing HTTP response body stream. The used fetch function must provide the response body as a'
@@ -568,16 +562,17 @@ export class UrlSource extends Source {
const { done, value } = readResult;
if (done) {
this._orchestrator.forgetWorker(worker);
if (worker.currentPos < worker.targetPos) {
throw new Error(
'Response stream reader stopped unexpectedly before all requested data was read.',
);
if (worker.currentPos >= worker.targetPos) {
// All data was delivered, we're good
this._orchestrator.forgetWorker(worker);
worker.running = false;
return;
}
worker.running = false;
return;
// The response stopped early, before the target. This can happen if server decides to cap range
// requests arbitrarily, even if the request had an uncapped end. In this case, let's fetch the rest
// of the data using a new request.
break;
}
this.onread?.(worker.currentPos, worker.currentPos + value.length);
@@ -597,25 +592,23 @@ export class UrlSource extends Source {
}
/** @internal */
private _getPartialLengthFromRangeResponse(response: Response) {
private _getTotalLengthFromRangeResponse(response: Response) {
const contentRange = response.headers.get('Content-Range');
if (contentRange) {
const match = /\/(\d+)/.exec(contentRange);
if (match) {
return Number(match[1]);
} else {
throw new Error(`Invalid Content-Range header: ${contentRange}`);
}
}
const contentLength = response.headers.get('Content-Length');
if (contentLength) {
return Number(contentLength);
} else {
const contentLength = response.headers.get('Content-Length');
if (contentLength) {
return Number(contentLength);
} else {
throw new Error(
'Partial HTTP response (status 206) must surface either Content-Range or'
+ ' Content-Length header.',
);
}
throw new Error(
'Partial HTTP response (status 206) must surface either Content-Range or'
+ ' Content-Length header.',
);
}
}
+1 -1
View File
@@ -62,7 +62,7 @@ export type StreamTargetChunk = {
/** The operation type. */
type: 'write'; // This ensures automatic compatibility with FileSystemWritableFileStream
/** The data to write. */
data: Uint8Array;
data: Uint8Array<ArrayBuffer>;
/** The byte offset in the output file at which to write the data. */
position: number;
};
+2 -2
View File
@@ -190,7 +190,7 @@ const MAX_CHUNKS_AT_ONCE = 2;
interface Chunk {
start: number;
written: ChunkSection[];
data: Uint8Array;
data: Uint8Array<ArrayBuffer>;
shouldFlush: boolean;
}
@@ -281,7 +281,7 @@ export class StreamTargetWriter extends Writer {
const chunks: {
start: number;
size: number;
data?: Uint8Array;
data?: Uint8Array<ArrayBuffer>;
}[] = [];
const sorted = [...this.sections].sort((a, b) => a.start - b.start);
+1 -1
View File
@@ -19,7 +19,7 @@ test('can decode samples from a FLAC file', async () => {
const sink = new AudioSampleSink(track);
const sample = await sink.getSample(1);
using sample = await sink.getSample(1);
assert(sample);
expect(sample.timestamp).toBe(0.9287981859410431);
});
+9 -9
View File
@@ -21,7 +21,7 @@ test('Can decode transparent video', async () => {
expect(await videoTrack.canBeTransparent()).toBe(true);
const sink = new VideoSampleSink(videoTrack);
const sample = (await sink.getSample(0.5))!;
using sample = (await sink.getSample(0.5))!;
expect(sample.format).toContain('A'); // Probably RGBA
expect(sample.hasAlpha).toBe(true);
@@ -47,10 +47,10 @@ test('Can decode faulty transparent video and behaves gracefully', async () => {
const sink = new VideoSampleSink(videoTrack);
const startSample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!;
using startSample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!;
expect(startSample.format).toContain('A');
const secondSample = (await sink.getSample(secondKeyPacket.timestamp))!;
using secondSample = (await sink.getSample(secondKeyPacket.timestamp))!;
expect(secondSample.format).not.toContain('A'); // There was no alpha key frame for this one
expect(secondSample.hasAlpha).toBe(false);
});
@@ -164,7 +164,7 @@ test('Can encode transparent video', async () => {
const sink = new VideoSampleSink(videoTrack);
const firstSample = (await sink.getSample(0))!;
using firstSample = (await sink.getSample(0))!;
expect(firstSample.format).toContain('A');
probeContext.clearRect(0, 0, probeCanvas.width, probeCanvas.height);
@@ -201,7 +201,7 @@ test('Can encode video with alternating transparency', async () => {
await output.start();
for (let i = 0; i < 64; i++) {
const sample = new VideoSample(new Uint8Array(640 * 480 * 4), {
using sample = new VideoSample(new Uint8Array(640 * 480 * 4), {
format: i % 2 ? 'RGBX' : 'RGBA',
codedWidth: 640,
codedHeight: 480,
@@ -235,7 +235,7 @@ test('Can encode video with alternating transparency', async () => {
const sampleSink = new VideoSampleSink(videoTrack);
i = 0;
for await (const sample of sampleSink.samples()) {
for await (using sample of sampleSink.samples()) {
if (i % 2) {
expect(sample.format).not.toContain('A');
} else {
@@ -299,7 +299,7 @@ test('Can transmux transparent video, discards alpha by default', async () => {
expect(await videoTrack.canBeTransparent()).toBe(false);
const sink = new VideoSampleSink(videoTrack);
const sample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!;
using sample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!;
expect(sample.hasAlpha).toBe(false);
});
@@ -331,7 +331,7 @@ test('Can transmux transparent video, can keep alpha', async () => {
expect(await videoTrack.canBeTransparent()).toBe(true);
const sink = new VideoSampleSink(videoTrack);
const sample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!;
using sample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!;
expect(sample.format).toContain('A');
expect(sample.hasAlpha).toBe(true);
});
@@ -370,7 +370,7 @@ test('Can reencode transparent video, keeping alpha', async () => {
expect(videoTrack.displayWidth).toBe(320);
const sink = new VideoSampleSink(videoTrack);
const sample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!;
using sample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!;
expect(sample.format).toContain('A');
expect(sample.hasAlpha).toBe(true);
});