Compare commits

..
25 Commits
Author SHA1 Message Date
Vanilagy 49af8d5435 Bump patch 2025-12-09 14:25:01 +01:00
Vanilagy a09cb4aebf Add sponsor 2025-12-09 14:24:48 +01:00
Vanilagy 0500beaf34 Fix user agent detection in workers (fixes #223, #242) 2025-12-08 18:46:58 +01:00
Vanilagy f424f6e840 Count AVC SEI recovery points as keyframes (except on older Chromium versions) 2025-12-07 19:24:08 +01:00
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
Vanilagy cc3d48451e Bump patch 2025-11-25 17:26:12 +01:00
Vanilagy 34b3ef0d44 Fix URLs that get redirected 2025-11-24 17:41:41 +01:00
Vanilagy 896242a5a5 Enable sitemap generation for docs 2025-11-24 17:34:07 +01:00
Vanilagy ef7a40944e Stronger encodability checks for Firefox (fixes #222) 2025-11-24 17:18:46 +01:00
Vanilagy f62ecb0eb6 Fix wrong type being used 2025-11-24 16:53:53 +01:00
Vanilagy da159bb063 Stop retrying requests when the source is disposed, log warning in possible CORS detection case (see #158) 2025-11-24 16:28:10 +01:00
27 changed files with 771 additions and 600 deletions
+8 -8
View File
@@ -5,6 +5,7 @@ on:
types: [published] types: [published]
permissions: permissions:
id-token: write # Required for OIDC
contents: read contents: read
jobs: jobs:
@@ -14,7 +15,7 @@ jobs:
permissions: write-all permissions: write-all
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -27,12 +28,15 @@ jobs:
git push origin release git push origin release
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: 'npm' cache: 'npm'
registry-url: https://registry.npmjs.org registry-url: https://registry.npmjs.org
- name: Update npm
run: npm install -g npm@latest
- name: Get package.json version - name: Get package.json version
id: package-json-version id: package-json-version
run: echo "version=v$(cat package.json | jq '.version' --raw-output)" >> $GITHUB_OUTPUT 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 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 - name: Publish Mediabunny to npm
run: npm publish --provenance --access public run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish workspace packages to npm - name: Publish workspace packages to npm
run: npm publish --provenance --access public --workspaces run: npm publish --access public --workspaces
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+16 -1
View File
@@ -14,7 +14,22 @@
source: new Mediabunny.BlobSource(file), 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; return;
+4 -1
View File
@@ -1,6 +1,7 @@
<button>Go</button> <button>Go</button>
<script src="../dist/bundles/mediabunny.cjs"></script> <script src="../dist/bundles/mediabunny.cjs"></script>
<script src="../packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.js"></script>
<script type="module"> <script type="module">
function download(blob, filename) { function download(blob, filename) {
@@ -12,6 +13,8 @@
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
MediabunnyMp3Encoder.registerMp3Encoder();
const button = document.querySelector('button'); const button = document.querySelector('button');
button.addEventListener('click', async () => { button.addEventListener('click', async () => {
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
@@ -34,7 +37,7 @@
} }
if (audioTrack) { if (audioTrack) {
const source = new Mediabunny.MediaStreamAudioTrackSource(audioTrack, { const source = new Mediabunny.MediaStreamAudioTrackSource(audioTrack, {
codec: 'aac', codec: 'mp3',
bitrate: Mediabunny.QUALITY_MEDIUM bitrate: Mediabunny.QUALITY_MEDIUM
}); });
+4 -1
View File
@@ -14,6 +14,9 @@ export default withMermaid({
title: 'Mediabunny', title: 'Mediabunny',
description: DESCRIPTION, description: DESCRIPTION,
cleanUrls: true, cleanUrls: true,
sitemap: {
hostname: 'https://mediabunny.dev',
},
head: [ head: [
['link', { rel: 'icon', type: 'image/png', href: '/mediabunny-logo.png' }], ['link', { rel: 'icon', type: 'image/png', href: '/mediabunny-logo.png' }],
['link', { rel: 'icon', type: 'image/svg+xml', href: '/mediabunny-logo.svg' }], ['link', { rel: 'icon', type: 'image/svg+xml', href: '/mediabunny-logo.svg' }],
@@ -34,7 +37,7 @@ export default withMermaid({
// https://vitepress.dev/reference/default-theme-config // https://vitepress.dev/reference/default-theme-config
nav: [ nav: [
{ text: 'Guide', link: '/guide/introduction', activeMatch: '/guide' }, { text: 'Guide', link: '/guide/introduction', activeMatch: '/guide' },
{ text: 'API', link: '/api', activeMatch: '/api' }, { text: 'API', link: '/api/', activeMatch: '/api' },
{ text: 'LLMs', link: '/llms', activeMatch: '/llms' }, { text: 'LLMs', link: '/llms', activeMatch: '/llms' },
{ text: 'Examples', link: '/examples', activeMatch: '/examples' }, { text: 'Examples', link: '/examples', activeMatch: '/examples' },
{ text: 'Sponsors', link: '/#sponsors', activeMatch: '/#sponsors' }, { text: 'Sponsors', link: '/#sponsors', activeMatch: '/#sponsors' },
+6 -6
View File
@@ -9,37 +9,37 @@ hero:
features: features:
- title: Metadata extraction - title: Metadata extraction
details: Extract various metadata from an input media file. details: Extract various metadata from an input media file.
link: /examples/metadata-extraction link: /examples/metadata-extraction/
target: _self target: _self
icon: icon:
src: /mingcute--file-info-line.svg src: /mingcute--file-info-line.svg
- title: Thumbnail generation - title: Thumbnail generation
details: Generate multiple small thumbnails for a video track. details: Generate multiple small thumbnails for a video track.
link: /examples/thumbnail-generation link: /examples/thumbnail-generation/
target: _self target: _self
icon: icon:
src: /mingcute--photo-album-line.svg src: /mingcute--photo-album-line.svg
- title: Media player (advanced) - title: Media player (advanced)
details: "A full video & audio media player, implemented from scratch with Mediabunny, with microsecond playback accuracy." details: "A full video & audio media player, implemented from scratch with Mediabunny, with microsecond playback accuracy."
link: /examples/media-player link: /examples/media-player/
target: _self target: _self
icon: icon:
src: /mingcute--video-line.svg src: /mingcute--video-line.svg
- title: File compression - title: File compression
details: Convert an input file to a highly-compressed MP4 file. details: Convert an input file to a highly-compressed MP4 file.
link: /examples/file-compression link: /examples/file-compression/
target: _self target: _self
icon: icon:
src: /mingcute--file-zip-line.svg src: /mingcute--file-zip-line.svg
- title: Procedural video generation - title: Procedural video generation
details: Generate a video file as fast as the hardware allows. details: Generate a video file as fast as the hardware allows.
link: /examples/procedural-generation link: /examples/procedural-generation/
target: _self target: _self
icon: icon:
src: /mingcute--magic-3-line.svg src: /mingcute--magic-3-line.svg
- title: Live recording & streaming - title: Live recording & streaming
details: Record a video from live sources and stream it to a video element. details: Record a video from live sources and stream it to a video element.
link: /examples/live-recording link: /examples/live-recording/
target: _self target: _self
icon: icon:
src: /mingcute--microphone-line.svg src: /mingcute--microphone-line.svg
+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'); 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
View File
@@ -114,6 +114,7 @@ const sponsors = {
{ image: 'https://avatars.githubusercontent.com/u/2698271', name: 'Matthew Gardner', url: 'https://github.com/spheric' }, { image: 'https://avatars.githubusercontent.com/u/2698271', name: 'Matthew Gardner', url: 'https://github.com/spheric' },
{ image: 'https://avatars.githubusercontent.com/u/5475819', name: 'AJ Funk', url: 'https://github.com/AJFunk' }, { image: 'https://avatars.githubusercontent.com/u/5475819', name: 'AJ Funk', url: 'https://github.com/AJFunk' },
{ image: 'https://avatars.githubusercontent.com/u/30229596', name: 'Pablo Bonilla', url: 'https://github.com/devPablo' }, { image: 'https://avatars.githubusercontent.com/u/30229596', name: 'Pablo Bonilla', url: 'https://github.com/devPablo' },
{ image: 'https://avatars.githubusercontent.com/u/56988069', name: 'SyhabouthAlex', url: 'https://github.com/SyhabouthAlex' },
{ image: 'https://avatars.githubusercontent.com/u/38181164', name: 'wcw', url: 'https://github.com/asd55667' }, { image: 'https://avatars.githubusercontent.com/u/38181164', name: 'wcw', url: 'https://github.com/asd55667' },
{ image: 'https://avatars.githubusercontent.com/u/1836701', name: 'Bean Deng', url: 'https://github.com/HADB' }, { image: 'https://avatars.githubusercontent.com/u/1836701', name: 'Bean Deng', url: 'https://github.com/HADB' },
{ image: 'https://avatars.githubusercontent.com/u/63088713', name: 'taf2000', url: 'https://github.com/taf2000' }, { image: 'https://avatars.githubusercontent.com/u/63088713', name: 'taf2000', url: 'https://github.com/taf2000' },
+1 -1
View File
@@ -21,7 +21,7 @@ const context = canvas.getContext('2d', { alpha: false, desynchronized: true })!
const frameRate = 30; const frameRate = 30;
const chunks: Uint8Array[] = []; const chunks: Uint8Array<ArrayBuffer>[] = [];
let recording = false; let recording = false;
let output: Output; let output: Output;
let videoSource: CanvasSource; let videoSource: CanvasSource;
@@ -97,7 +97,7 @@ const extractMetadata = (resource: File | string) => {
'Lyrics': tags.lyrics, 'Lyrics': tags.lyrics,
'Comment': tags.comment, 'Comment': tags.comment,
'Images': tags.images?.map((image) => { '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(); const element = new Image();
element.src = URL.createObjectURL(blob); 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", "name": "mediabunny",
"author": "Vanilagy", "author": "Vanilagy",
"version": "1.25.1", "version": "1.25.8",
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.", "description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
"type": "module", "type": "module",
"workspaces": [ "workspaces": [
@@ -70,7 +70,7 @@
"@eslint/js": "^9.22.0", "@eslint/js": "^9.22.0",
"@fontsource-variable/rubik": "^5.2.6", "@fontsource-variable/rubik": "^5.2.6",
"@fontsource/dm-mono": "^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", "@stylistic/eslint-plugin": "^4.2.0",
"@tailwindcss/vite": "^4.1.7", "@tailwindcss/vite": "^4.1.7",
"@types/markdown-it-footnote": "^3.0.4", "@types/markdown-it-footnote": "^3.0.4",
@@ -84,8 +84,8 @@
"mermaid": "^11.6.0", "mermaid": "^11.6.0",
"tailwindcss": "^4.1.7", "tailwindcss": "^4.1.7",
"tsx": "^4.19.4", "tsx": "^4.19.4",
"typescript": "^5.8.2", "typescript": "^5.9.3",
"typescript-eslint": "^8.26.1", "typescript-eslint": "^8.48.0",
"vite": "^6.3.5", "vite": "^6.3.5",
"vitepress": "^1.6.3", "vitepress": "^1.6.3",
"vitepress-plugin-llms": "^1.5.1", "vitepress-plugin-llms": "^1.5.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@mediabunny/mp3-encoder", "name": "@mediabunny/mp3-encoder",
"author": "Vanilagy", "author": "Vanilagy",
"version": "1.25.1", "version": "1.25.8",
"description": "MP3 encoder extension for Mediabunny, based on LAME.", "description": "MP3 encoder extension for Mediabunny, based on LAME.",
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs", "main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
"module": "./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 buffer = new Uint8Array(2 ** 16);
private currentBufferOffset = 0; private currentBufferOffset = 0;
private currentTimestamp = 0; private currentTimestamp: number | null = null;
private chunkMetadata: EncodedAudioChunkMetadata = {}; private chunkMetadata: EncodedAudioChunkMetadata = {};
static override supports(codec: AudioCodec, config: AudioDecoderConfig): boolean { static override supports(codec: AudioCodec, config: AudioDecoderConfig): boolean {
@@ -79,6 +79,11 @@ class Mp3Encoder extends CustomAudioEncoder {
} }
async encode(audioSample: AudioSample) { 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({ const sizePerChannel = audioSample.allocationSize({
format: 's16-planar', format: 's16-planar',
planeIndex: 0, planeIndex: 0,
@@ -123,6 +128,8 @@ class Mp3Encoder extends CustomAudioEncoder {
* these chunks and extract the MP3 frames only when they're complete. * these chunks and extract the MP3 frames only when they're complete.
*/ */
private digestOutput(bytes: Uint8Array) { private digestOutput(bytes: Uint8Array) {
assert(this.currentTimestamp !== null);
const requiredBufferSize = this.currentBufferOffset + bytes.length; const requiredBufferSize = this.currentBufferOffset + bytes.length;
if (requiredBufferSize > this.buffer.length) { if (requiredBufferSize > this.buffer.length) {
// Grow the buffer to the required size // Grow the buffer to the required size
+129 -4
View File
@@ -22,6 +22,8 @@ import {
textEncoder, textEncoder,
toDataView, toDataView,
toUint8Array, toUint8Array,
getChromiumVersion,
isChromium,
} from './misc'; } from './misc';
import { PacketType } from './packet'; import { PacketType } from './packet';
import { MetadataTags } from './metadata'; import { MetadataTags } from './metadata';
@@ -34,6 +36,7 @@ import { MetadataTags } from './metadata';
export enum AvcNalUnitType { export enum AvcNalUnitType {
IDR = 5, IDR = 5,
SEI = 6,
SPS = 7, SPS = 7,
PPS = 8, PPS = 8,
SPS_EXT = 13, SPS_EXT = 13,
@@ -590,9 +593,7 @@ export const extractNalUnitTypeForHevc = (data: Uint8Array) => {
}; };
/** Builds a HevcDecoderConfigurationRecord from an HEVC packet in Annex B format. */ /** Builds a HevcDecoderConfigurationRecord from an HEVC packet in Annex B format. */
export const extractHevcDecoderConfigurationRecord = ( export const extractHevcDecoderConfigurationRecord = (packetData: Uint8Array) => {
packetData: Uint8Array,
) => {
try { try {
const nalUnits = findNalUnitsInAnnexB(packetData); const nalUnits = findNalUnitsInAnnexB(packetData);
@@ -1456,6 +1457,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); const highBitdepth = bitstream.readBits(1);
let bitDepth = 8; let bitDepth = 8;
@@ -1653,7 +1717,68 @@ export const determineVideoPacketType = (
switch (codec) { switch (codec) {
case 'avc': { case 'avc': {
const nalUnits = extractAvcNalUnits(packetData, decoderConfig); const nalUnits = extractAvcNalUnits(packetData, decoderConfig);
const isKeyframe = nalUnits.some(x => extractNalUnitTypeForAvc(x) === AvcNalUnitType.IDR); let isKeyframe = nalUnits.some(x => extractNalUnitTypeForAvc(x) === AvcNalUnitType.IDR);
if (!isKeyframe && (!isChromium() || getChromiumVersion()! >= 144)) {
// In addition to IDR, Recovery Point SEI also counts as a valid H.264 keyframe by current consensus.
// See https://github.com/w3c/webcodecs/issues/650 for the relevant discussion. WebKit and Firefox have
// always supported them, but Chromium hasn't, therefore the (admittedly dirty) version check.
for (const nalUnit of nalUnits) {
const type = extractNalUnitTypeForAvc(nalUnit);
if (type !== AvcNalUnitType.SEI) {
continue;
}
const bytes = removeEmulationPreventionBytes(nalUnit);
let pos = 1; // Skip NALU header
// sei_rbsp()
do {
// sei_message()
let payloadType = 0;
while (true) {
const nextByte = bytes[pos++];
if (nextByte === undefined) break;
payloadType += nextByte;
if (nextByte < 255) {
break;
}
}
let payloadSize = 0;
while (true) {
const nextByte = bytes[pos++];
if (nextByte === undefined) break;
payloadSize += nextByte;
if (nextByte < 255) {
break;
}
}
// sei_payload()
const PAYLOAD_TYPE_RECOVERY_POINT = 6;
if (payloadType === PAYLOAD_TYPE_RECOVERY_POINT) {
const bitstream = new Bitstream(bytes);
bitstream.pos = 8 * pos;
const recoveryFrameCount = readExpGolomb(bitstream);
const exactMatchFlag = bitstream.readBits(1);
if (recoveryFrameCount === 0 && exactMatchFlag === 1) {
// https://github.com/w3c/webcodecs/pull/910
// "recovery_frame_cnt == 0 and exact_match_flag=1 in the SEI recovery payload"
isKeyframe = true;
break;
}
}
pos += payloadSize;
} while (pos < bytes.length - 1);
}
}
return isKeyframe ? 'key' : 'delta'; return isKeyframe ? 'key' : 'delta';
}; };
+15 -5
View File
@@ -82,10 +82,16 @@ export type ConversionOptions = {
/** Options to trim the input file. */ /** Options to trim the input file. */
trim?: { 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 start. Must be less than `end`.
/** The time in the input file in seconds at which the output file should end. Must be greater than `start`. */ * Defaults to 0 when omitted.
end: number; */
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, targetSampleRate,
startTime: this._startTimestamp, startTime: this._startTimestamp,
endTime: this._endTimestamp, 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); const sink = new AudioSampleSink(track);
@@ -1521,6 +1530,7 @@ export class Conversion {
} }
await resampler.add(sample); await resampler.add(sample);
sample.close();
} }
await resampler.finalize(); await resampler.finalize();
+40 -1
View File
@@ -22,6 +22,7 @@ import {
VideoCodec, VideoCodec,
} from './codec'; } from './codec';
import { customAudioEncoders, customVideoEncoders } from './custom-coder'; import { customAudioEncoders, customVideoEncoders } from './custom-coder';
import { isFirefox } from './misc';
import { EncodedPacket } from './packet'; import { EncodedPacket } from './packet';
/** /**
@@ -529,7 +530,45 @@ export const canEncodeVideo = async (
}); });
const support = await VideoEncoder.isConfigSupported(encoderConfig); const support = await VideoEncoder.isConfigSupported(encoderConfig);
return support.supported === true; if (!support.supported) {
return false;
}
if (isFirefox()) {
// isConfigSupported on Firefox appears to unreliably indicate if encoding will actually succeed. Therefore, we
// just try encoding a frame to see if it actually works.
// https://github.com/Vanilagy/mediabunny/issues/222
// eslint-disable-next-line @typescript-eslint/no-misused-promises, no-async-promise-executor
return new Promise<boolean>(async (resolve) => {
try {
const encoder = new VideoEncoder({
output: () => {},
error: () => resolve(false),
});
encoder.configure(encoderConfig);
const frameData = new Uint8Array(width * height * 4);
const frame = new VideoFrame(frameData, {
format: 'RGBA',
codedWidth: width,
codedHeight: height,
timestamp: 0,
});
encoder.encode(frame);
frame.close();
await encoder.flush();
resolve(true);
} catch {
resolve(false);
}
});
} else {
return true;
}
}; };
/** /**
+1 -1
View File
@@ -850,7 +850,7 @@ export const dOps = (trackData: IsobmffAudioTrackData) => {
let inputSampleRate = trackData.info.sampleRate; let inputSampleRate = trackData.info.sampleRate;
let outputGain = 0; let outputGain = 0;
let channelMappingFamily = 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 // Read preskip and from codec private data from the encoder
// https://www.rfc-editor.org/rfc/rfc7845#section-5 // 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 timestampInTimescale = this.mapTimestampIntoTimescale(timestamp);
const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack); const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);
const sampleIndex = getSampleIndexForTimestamp(sampleTable, timestampInTimescale); const sampleIndex = getKeyframeSampleIndexForTimestamp(sampleTable, timestampInTimescale);
const keyFrameSampleIndex = sampleIndex === -1 const regularPacket = await this.fetchPacketForSampleIndex(sampleIndex, options);
? -1
: getRelevantKeyframeIndexForSample(sampleTable, sampleIndex);
const regularPacket = await this.fetchPacketForSampleIndex(keyFrameSampleIndex, options);
if (!sampleTableIsEmpty(sampleTable) || !this.internalTrack.demuxer.isFragmented) { if (!sampleTableIsEmpty(sampleTable) || !this.internalTrack.demuxer.isFragmented) {
// Prefer the non-fragmented packet // Prefer the non-fragmented packet
@@ -2913,7 +2910,45 @@ const getSampleIndexForTimestamp = (sampleTable: SampleTable, timescaleUnits: nu
const entry = sampleTable.sampleTimingEntries[index]!; const entry = sampleTable.sampleTimingEntries[index]!;
return entry.startIndex 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) => { const getNextKeyframeIndexForSample = (sampleTable: SampleTable, sampleIndex: number) => {
if (!sampleTable.keySampleIndices) { if (!sampleTable.keySampleIndices) {
return sampleIndex + 1; return sampleIndex + 1;
+9 -7
View File
@@ -201,9 +201,10 @@ export class EncodedPacketSink {
} }
const packet = await this._track._backing.getKeyPacket(timestamp, options); const packet = await this._track._backing.getKeyPacket(timestamp, options);
if (!packet || packet.type === 'delta') { if (!packet) {
return packet; return packet;
} }
assert(packet.type === 'key');
const determinedType = await this._track.determinePacketType(packet); const determinedType = await this._track.determinePacketType(packet);
if (determinedType === 'delta') { if (determinedType === 'delta') {
@@ -235,9 +236,10 @@ export class EncodedPacketSink {
} }
const nextPacket = await this._track._backing.getNextKeyPacket(packet, options); const nextPacket = await this._track._backing.getNextKeyPacket(packet, options);
if (!nextPacket || nextPacket.type === 'delta') { if (!nextPacket) {
return nextPacket; return nextPacket;
} }
assert(nextPacket.type === 'key');
const determinedType = await this._track.determinePacketType(nextPacket); const determinedType = await this._track.determinePacketType(nextPacket);
if (determinedType === 'delta') { if (determinedType === 'delta') {
@@ -474,9 +476,6 @@ export abstract class BaseMediaSampleSink<
const packetSink = this._createPacketSink(); const packetSink = this._createPacketSink();
const keyPacket = await packetSink.getKeyPacket(startTimestamp, { verifyKeyPackets: true }) const keyPacket = await packetSink.getKeyPacket(startTimestamp, { verifyKeyPackets: true })
?? await packetSink.getFirstPacket(); ?? await packetSink.getFirstPacket();
if (!keyPacket) {
return;
}
let currentPacket: EncodedPacket | null = keyPacket; 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 await packets.next(); // Skip the start packet as we already have it
while (currentPacket && !ended && !this._track.input._disposed) { while (currentPacket && !ended && !this._track.input._disposed) {
@@ -2127,11 +2126,14 @@ export class AudioBufferSink {
/** @internal */ /** @internal */
_audioSampleToWrappedArrayBuffer(sample: AudioSample): WrappedAudioBuffer { _audioSampleToWrappedArrayBuffer(sample: AudioSample): WrappedAudioBuffer {
return { const result: WrappedAudioBuffer = {
buffer: sample.toAudioBuffer(), buffer: sample.toAudioBuffer(),
timestamp: sample.timestamp, timestamp: sample.timestamp,
duration: sample.duration, duration: sample.duration,
}; };
sample.close();
return result;
} }
/** /**
+19 -13
View File
@@ -1356,21 +1356,27 @@ class AudioEncoderWrapper {
(audioSample.timestamp + audioSample.duration) * audioSample.sampleRate, (audioSample.timestamp + audioSample.duration) * audioSample.sampleRate,
); );
if (this.lastEndSampleIndex !== null && startSampleIndex > this.lastEndSampleIndex) { if (this.lastEndSampleIndex === null) {
const sampleCount = startSampleIndex - this.lastEndSampleIndex; this.lastEndSampleIndex = endSampleIndex;
const fillSample = new AudioSample({ } else {
data: new Float32Array(sampleCount * audioSample.numberOfChannels), const sampleDiff = startSampleIndex - this.lastEndSampleIndex;
format: 'f32-planar',
sampleRate: audioSample.sampleRate,
numberOfChannels: audioSample.numberOfChannels,
numberOfFrames: sampleCount,
timestamp: this.lastEndSampleIndex / audioSample.sampleRate,
});
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) { if (this.customEncoder) {
+47 -9
View File
@@ -157,20 +157,20 @@ export const writeBits = (bytes: Uint8Array, start: number, end: number, value:
export const toUint8Array = (source: AllowSharedBufferSource): Uint8Array => { export const toUint8Array = (source: AllowSharedBufferSource): Uint8Array => {
if (source.constructor === Uint8Array) { // We want a true Uint8Array, not something that extends it like Buffer if (source.constructor === Uint8Array) { // We want a true Uint8Array, not something that extends it like Buffer
return source; return source;
} else if (source instanceof ArrayBuffer) { } else if (ArrayBuffer.isView(source)) {
return new Uint8Array(source);
} else {
return new Uint8Array(source.buffer, source.byteOffset, source.byteLength); 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) { if (source.constructor === DataView) {
return source; return source;
} else if (source instanceof ArrayBuffer) { } else if (ArrayBuffer.isView(source)) {
return new DataView(source);
} else {
return new DataView(source.buffer, source.byteOffset, source.byteLength); return new DataView(source.buffer, source.byteOffset, source.byteLength);
} else {
return new DataView(source);
} }
}; };
@@ -584,6 +584,7 @@ export const retriedFetch = async (
url: string | URL | Request, url: string | URL | Request,
requestInit: RequestInit, requestInit: RequestInit,
getRetryDelay: (previousAttempts: number, error: unknown, url: string | URL | Request) => number | null, getRetryDelay: (previousAttempts: number, error: unknown, url: string | URL | Request) => number | null,
shouldStop: () => boolean,
) => { ) => {
let attempts = 0; let attempts = 0;
@@ -591,6 +592,10 @@ export const retriedFetch = async (
try { try {
return await fetchFn(url, requestInit); return await fetchFn(url, requestInit);
} catch (error) { } catch (error) {
if (shouldStop()) {
throw error;
}
attempts++; attempts++;
const retryDelayInSeconds = getRetryDelay(attempts, error, url); const retryDelayInSeconds = getRetryDelay(attempts, error, url);
@@ -607,6 +612,10 @@ export const retriedFetch = async (
if (retryDelayInSeconds > 0) { if (retryDelayInSeconds > 0) {
await new Promise(resolve => setTimeout(resolve, 1000 * retryDelayInSeconds)); await new Promise(resolve => setTimeout(resolve, 1000 * retryDelayInSeconds));
} }
if (shouldStop()) {
throw error;
}
} }
} }
}; };
@@ -670,7 +679,15 @@ export const isWebKit = () => {
} }
// This even returns true for WebKit-wrapping browsers such as Chrome on iOS // This even returns true for WebKit-wrapping browsers such as Chrome on iOS
return isWebKitCache = !!(typeof navigator !== 'undefined' && navigator.vendor?.match(/apple/i)); return isWebKitCache = !!(
typeof navigator !== 'undefined'
&& (
navigator.vendor?.match(/apple/i)
// Or, in workers:
|| (/AppleWebKit/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent))
|| /\b(iPad|iPhone|iPod)\b/.test(navigator.userAgent)
)
);
}; };
let isFirefoxCache: boolean | null = null; let isFirefoxCache: boolean | null = null;
@@ -688,7 +705,28 @@ export const isChromium = () => {
return isChromiumCache; return isChromiumCache;
} }
return isChromiumCache = !!(typeof navigator !== 'undefined' && navigator.vendor?.includes('Google Inc')); return isChromiumCache = !!(
typeof navigator !== 'undefined'
&& (navigator.vendor?.includes('Google Inc') || /Chrome/.test(navigator.userAgent))
);
};
let chromiumVersionCache: number | null = null;
export const getChromiumVersion = () => {
if (chromiumVersionCache !== null) {
return chromiumVersionCache;
}
if (typeof navigator === 'undefined') {
return null;
}
const match = /\bChrome\/(\d+)/.exec(navigator.userAgent);
if (!match) {
return null;
}
return chromiumVersionCache = Number(match[1]!);
}; };
/** /**
+65 -4
View File
@@ -21,6 +21,54 @@ import {
polyfillSymbolDispose(); 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. * Metadata used for VideoSample initialization.
* @group Samples * @group Samples
@@ -133,7 +181,11 @@ export class VideoSample implements Disposable {
data: VideoFrame | CanvasImageSource | AllowSharedBufferSource, data: VideoFrame | CanvasImageSource | AllowSharedBufferSource,
init?: VideoSampleInit, 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') { if (!init || typeof init !== 'object') {
throw new TypeError('init must be an object.'); throw new TypeError('init must be an object.');
} }
@@ -265,6 +317,8 @@ export class VideoSample implements Disposable {
} else { } else {
throw new TypeError('Invalid data type: Must be a BufferSource or CanvasImageSource.'); 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. */ /** Clones this video sample. */
@@ -313,6 +367,8 @@ export class VideoSample implements Disposable {
return; return;
} }
finalizationRegistry?.unregister(this);
if (isVideoFrame(this._data)) { if (isVideoFrame(this._data)) {
this._data.close(); this._data.close();
} else { } else {
@@ -955,6 +1011,8 @@ export class AudioSample implements Disposable {
this._data = dataBuffer; 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. */ /** 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 { } else {
// Branch for Uint8Array data (non-AudioData)
const uint8Data = this._data; const uint8Data = this._data;
const srcView = new DataView(uint8Data.buffer, uint8Data.byteOffset, uint8Data.byteLength); const srcView = toDataView(uint8Data);
const srcFormat = this.format; const srcFormat = this.format;
const readFn = getReadFunction(srcFormat); const readFn = getReadFunction(srcFormat);
@@ -1188,6 +1245,8 @@ export class AudioSample implements Disposable {
return; return;
} }
finalizationRegistry?.unregister(this);
if (isAudioData(this._data)) { if (isAudioData(this._data)) {
this._data.close(); this._data.close();
} else { } else {
@@ -1250,7 +1309,9 @@ export class AudioSample implements Disposable {
numberOfFrames: this.numberOfFrames, numberOfFrames: this.numberOfFrames,
numberOfChannels: this.numberOfChannels, numberOfChannels: this.numberOfChannels,
timestamp: this.microsecondTimestamp, 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
}); });
} }
} }
+44 -40
View File
@@ -102,10 +102,17 @@ export class BufferSource extends Source {
/** @internal */ /** @internal */
_onreadCalled = false; _onreadCalled = false;
/** Creates a new {@link BufferSource} backed the specified `ArrayBuffer` or `ArrayBufferView`. */ /**
constructor(buffer: ArrayBuffer | ArrayBufferView) { * Creates a new {@link BufferSource} backed by the specified `ArrayBuffer`, `SharedArrayBuffer`,
if (!(buffer instanceof ArrayBuffer) && !ArrayBuffer.isView(buffer)) { * or `ArrayBufferView`.
throw new TypeError('buffer must be an ArrayBuffer 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(); super();
@@ -234,12 +241,7 @@ export class BlobSource extends Source {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) { if (done) {
this._orchestrator.forgetWorker(worker); this._orchestrator.forgetWorker(worker);
throw new Error('Blob reader stopped unexpectedly before all requested data was read.');
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;
} }
if (worker.aborted) { if (worker.aborted) {
@@ -297,6 +299,10 @@ const DEFAULT_RETRY_DELAY
= typeof navigator !== 'undefined' && typeof navigator.onLine === 'boolean' ? navigator.onLine : true; = typeof navigator !== 'undefined' && typeof navigator.onLine === 'boolean' ? navigator.onLine : true;
if (isOnline && originOfSrc !== null && originOfSrc !== window.location.origin) { if (isOnline && originOfSrc !== null && originOfSrc !== window.location.origin) {
console.warn(
`Request will not be retried because a CORS error was suspected due to different origins. You can`
+ ` modify this behavior by providing your own function for the 'getRetryDelay' option.`,
);
return null; return null;
} }
} }
@@ -425,6 +431,7 @@ export class UrlSource extends Source {
signal: abortController.signal, signal: abortController.signal,
}), }),
this._getRetryDelay, this._getRetryDelay,
() => this._disposed,
); );
if (!response.ok) { if (!response.ok) {
@@ -436,7 +443,7 @@ export class UrlSource extends Source {
let fileSize: number; let fileSize: number;
if (response.status === 206) { 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)); worker = this._orchestrator.createWorker(0, Math.min(fileSize, URL_SOURCE_MIN_LOAD_AMOUNT));
} else { } else {
// Server probably returned a 200. // Server probably returned a 200.
@@ -492,6 +499,7 @@ export class UrlSource extends Source {
signal: abortController.signal, signal: abortController.signal,
}), }),
this._getRetryDelay, this._getRetryDelay,
() => this._disposed,
); );
} }
@@ -509,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) { if (!response.body) {
throw new Error( throw new Error(
'Missing HTTP response body stream. The used fetch function must provide the response body as a' 'Missing HTTP response body stream. The used fetch function must provide the response body as a'
@@ -539,6 +539,11 @@ export class UrlSource extends Source {
try { try {
readResult = await reader.read(); readResult = await reader.read();
} catch (error) { } catch (error) {
if (this._disposed) {
// No need to try to retry
throw error;
}
const retryDelayInSeconds = this._getRetryDelay(1, error, this._url); const retryDelayInSeconds = this._getRetryDelay(1, error, this._url);
if (retryDelayInSeconds !== null) { if (retryDelayInSeconds !== null) {
console.error('Error while reading response stream. Attempting to resume.', error); console.error('Error while reading response stream. Attempting to resume.', error);
@@ -557,16 +562,17 @@ export class UrlSource extends Source {
const { done, value } = readResult; const { done, value } = readResult;
if (done) { if (done) {
this._orchestrator.forgetWorker(worker); if (worker.currentPos >= worker.targetPos) {
// All data was delivered, we're good
if (worker.currentPos < worker.targetPos) { this._orchestrator.forgetWorker(worker);
throw new Error( worker.running = false;
'Response stream reader stopped unexpectedly before all requested data was read.', return;
);
} }
worker.running = false; // The response stopped early, before the target. This can happen if server decides to cap range
return; // 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); this.onread?.(worker.currentPos, worker.currentPos + value.length);
@@ -586,25 +592,23 @@ export class UrlSource extends Source {
} }
/** @internal */ /** @internal */
private _getPartialLengthFromRangeResponse(response: Response) { private _getTotalLengthFromRangeResponse(response: Response) {
const contentRange = response.headers.get('Content-Range'); const contentRange = response.headers.get('Content-Range');
if (contentRange) { if (contentRange) {
const match = /\/(\d+)/.exec(contentRange); const match = /\/(\d+)/.exec(contentRange);
if (match) { if (match) {
return Number(match[1]); 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 { } else {
const contentLength = response.headers.get('Content-Length'); throw new Error(
if (contentLength) { 'Partial HTTP response (status 206) must surface either Content-Range or'
return Number(contentLength); + ' Content-Length header.',
} else { );
throw new Error(
'Partial HTTP response (status 206) must surface either Content-Range or'
+ ' Content-Length header.',
);
}
} }
} }
@@ -639,7 +643,7 @@ export class FilePathSource extends Source {
_fileHandle: FileHandle | null = null; _fileHandle: FileHandle | null = null;
/** Creates a new {@link FilePathSource} backed by the file at the specified file path. */ /** Creates a new {@link FilePathSource} backed by the file at the specified file path. */
constructor(filePath: string, options: BlobSourceOptions = {}) { constructor(filePath: string, options: FilePathSourceOptions = {}) {
if (typeof filePath !== 'string') { if (typeof filePath !== 'string') {
throw new TypeError('filePath must be a string.'); throw new TypeError('filePath must be a string.');
} }
+1 -1
View File
@@ -62,7 +62,7 @@ export type StreamTargetChunk = {
/** The operation type. */ /** The operation type. */
type: 'write'; // This ensures automatic compatibility with FileSystemWritableFileStream type: 'write'; // This ensures automatic compatibility with FileSystemWritableFileStream
/** The data to write. */ /** The data to write. */
data: Uint8Array; data: Uint8Array<ArrayBuffer>;
/** The byte offset in the output file at which to write the data. */ /** The byte offset in the output file at which to write the data. */
position: number; position: number;
}; };
+2 -2
View File
@@ -190,7 +190,7 @@ const MAX_CHUNKS_AT_ONCE = 2;
interface Chunk { interface Chunk {
start: number; start: number;
written: ChunkSection[]; written: ChunkSection[];
data: Uint8Array; data: Uint8Array<ArrayBuffer>;
shouldFlush: boolean; shouldFlush: boolean;
} }
@@ -281,7 +281,7 @@ export class StreamTargetWriter extends Writer {
const chunks: { const chunks: {
start: number; start: number;
size: number; size: number;
data?: Uint8Array; data?: Uint8Array<ArrayBuffer>;
}[] = []; }[] = [];
const sorted = [...this.sections].sort((a, b) => a.start - b.start); 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 sink = new AudioSampleSink(track);
const sample = await sink.getSample(1); using sample = await sink.getSample(1);
assert(sample); assert(sample);
expect(sample.timestamp).toBe(0.9287981859410431); 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); expect(await videoTrack.canBeTransparent()).toBe(true);
const sink = new VideoSampleSink(videoTrack); 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.format).toContain('A'); // Probably RGBA
expect(sample.hasAlpha).toBe(true); 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 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'); 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.format).not.toContain('A'); // There was no alpha key frame for this one
expect(secondSample.hasAlpha).toBe(false); expect(secondSample.hasAlpha).toBe(false);
}); });
@@ -164,7 +164,7 @@ test('Can encode transparent video', async () => {
const sink = new VideoSampleSink(videoTrack); const sink = new VideoSampleSink(videoTrack);
const firstSample = (await sink.getSample(0))!; using firstSample = (await sink.getSample(0))!;
expect(firstSample.format).toContain('A'); expect(firstSample.format).toContain('A');
probeContext.clearRect(0, 0, probeCanvas.width, probeCanvas.height); probeContext.clearRect(0, 0, probeCanvas.width, probeCanvas.height);
@@ -201,7 +201,7 @@ test('Can encode video with alternating transparency', async () => {
await output.start(); await output.start();
for (let i = 0; i < 64; i++) { 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', format: i % 2 ? 'RGBX' : 'RGBA',
codedWidth: 640, codedWidth: 640,
codedHeight: 480, codedHeight: 480,
@@ -235,7 +235,7 @@ test('Can encode video with alternating transparency', async () => {
const sampleSink = new VideoSampleSink(videoTrack); const sampleSink = new VideoSampleSink(videoTrack);
i = 0; i = 0;
for await (const sample of sampleSink.samples()) { for await (using sample of sampleSink.samples()) {
if (i % 2) { if (i % 2) {
expect(sample.format).not.toContain('A'); expect(sample.format).not.toContain('A');
} else { } else {
@@ -299,7 +299,7 @@ test('Can transmux transparent video, discards alpha by default', async () => {
expect(await videoTrack.canBeTransparent()).toBe(false); expect(await videoTrack.canBeTransparent()).toBe(false);
const sink = new VideoSampleSink(videoTrack); 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); expect(sample.hasAlpha).toBe(false);
}); });
@@ -331,7 +331,7 @@ test('Can transmux transparent video, can keep alpha', async () => {
expect(await videoTrack.canBeTransparent()).toBe(true); expect(await videoTrack.canBeTransparent()).toBe(true);
const sink = new VideoSampleSink(videoTrack); 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.format).toContain('A');
expect(sample.hasAlpha).toBe(true); expect(sample.hasAlpha).toBe(true);
}); });
@@ -370,7 +370,7 @@ test('Can reencode transparent video, keeping alpha', async () => {
expect(videoTrack.displayWidth).toBe(320); expect(videoTrack.displayWidth).toBe(320);
const sink = new VideoSampleSink(videoTrack); 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.format).toContain('A');
expect(sample.hasAlpha).toBe(true); expect(sample.hasAlpha).toBe(true);
}); });