Merge branch 'main' into cursors

This commit is contained in:
Vanilagy
2025-11-26 12:06:05 +01:00
19 changed files with 428 additions and 540 deletions
+4 -1
View File
@@ -14,6 +14,9 @@ export default withMermaid({
title: 'Mediabunny',
description: DESCRIPTION,
cleanUrls: true,
sitemap: {
hostname: 'https://mediabunny.dev',
},
head: [
['link', { rel: 'icon', type: 'image/png', href: '/mediabunny-logo.png' }],
['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
nav: [
{ 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: 'Examples', link: '/examples', activeMatch: '/examples' },
{ text: 'Sponsors', link: '/#sponsors', activeMatch: '/#sponsors' },
+6 -6
View File
@@ -9,37 +9,37 @@ hero:
features:
- title: Metadata extraction
details: Extract various metadata from an input media file.
link: /examples/metadata-extraction
link: /examples/metadata-extraction/
target: _self
icon:
src: /mingcute--file-info-line.svg
- title: Thumbnail generation
details: Generate multiple small thumbnails for a video track.
link: /examples/thumbnail-generation
link: /examples/thumbnail-generation/
target: _self
icon:
src: /mingcute--photo-album-line.svg
- title: Media player (advanced)
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
icon:
src: /mingcute--video-line.svg
- title: File compression
details: Convert an input file to a highly-compressed MP4 file.
link: /examples/file-compression
link: /examples/file-compression/
target: _self
icon:
src: /mingcute--file-zip-line.svg
- title: Procedural video generation
details: Generate a video file as fast as the hardware allows.
link: /examples/procedural-generation
link: /examples/procedural-generation/
target: _self
icon:
src: /mingcute--magic-3-line.svg
- title: Live recording & streaming
details: Record a video from live sources and stream it to a video element.
link: /examples/live-recording
link: /examples/live-recording/
target: _self
icon:
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');
```
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.24.7",
"version": "1.25.2",
"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.24.7",
"version": "1.25.2",
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
+40 -1
View File
@@ -22,6 +22,7 @@ import {
VideoCodec,
} from './codec';
import { customAudioEncoders, customVideoEncoders } from './custom-coder';
import { isFirefox } from './misc';
import { EncodedPacket } from './packet';
/**
@@ -529,7 +530,45 @@ export const canEncodeVideo = async (
});
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 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
+1 -1
View File
@@ -467,7 +467,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 = /* #__PURE__ */ 2 * MAX_VAR_INT_SIZE; // 8-byte ID and 8-byte size
export const MAX_HEADER_SIZE = 2 * MAX_VAR_INT_SIZE; // 8-byte ID and 8-byte size
export const readVarIntSize = (slice: FileSlice) => {
const firstByte = readU8(slice);
+1 -1
View File
@@ -228,7 +228,7 @@ const METADATA_ELEMENTS = [
{ id: EBMLId.Tracks, flag: 'tracksSeen' },
{ id: EBMLId.Cues, flag: 'cuesSeen' },
] as const;
const MAX_RESYNC_LENGTH = /* #__PURE__ */ 10 * 2 ** 20; // 10 MiB
const MAX_RESYNC_LENGTH = 10 * 2 ** 20; // 10 MiB
export class MatroskaDemuxer extends Demuxer {
reader: Reader;
+2 -2
View File
@@ -65,8 +65,8 @@ import { EncodedPacket } from '../packet';
import { parseOpusIdentificationHeader } from '../codec-data';
import { AttachedFile } from '../metadata';
const MIN_CLUSTER_TIMESTAMP_MS = /* #__PURE__ */ -(2 ** 15);
const MAX_CLUSTER_TIMESTAMP_MS = /* #__PURE__ */ 2 ** 15 - 1;
const MIN_CLUSTER_TIMESTAMP_MS = -(2 ** 15);
const MAX_CLUSTER_TIMESTAMP_MS = 2 ** 15 - 1;
const APP_NAME = 'Mediabunny';
const SEGMENT_SIZE_BYTES = 6;
const CLUSTER_SIZE_BYTES = 5;
+17 -8
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);
}
};
@@ -515,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 = /* #__PURE__ */ 1e6 * (1 + Number.EPSILON);
export const SECOND_TO_MICROSECOND_FACTOR = 1e6 * (1 + Number.EPSILON);
/**
* Sets all keys K of T to be required.
@@ -584,6 +584,7 @@ export const retriedFetch = async (
url: string | URL | Request,
requestInit: RequestInit,
getRetryDelay: (previousAttempts: number, error: unknown, url: string | URL | Request) => number | null,
shouldStop: () => boolean,
) => {
let attempts = 0;
@@ -591,6 +592,10 @@ export const retriedFetch = async (
try {
return await fetchFn(url, requestInit);
} catch (error) {
if (shouldStop()) {
throw error;
}
attempts++;
const retryDelayInSeconds = getRetryDelay(attempts, error, url);
@@ -607,6 +612,10 @@ export const retriedFetch = async (
if (retryDelayInSeconds > 0) {
await new Promise(resolve => setTimeout(resolve, 1000 * retryDelayInSeconds));
}
if (shouldStop()) {
throw error;
}
}
}
};
+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 = /* #__PURE__ */ MAX_PAGE_HEADER_SIZE + 255 * 255;
export const MAX_PAGE_SIZE = MAX_PAGE_HEADER_SIZE + 255 * 255;
export type Page = {
headerStartPos: number;
+3 -1
View File
@@ -1250,7 +1250,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
});
}
}
+45 -41
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) {
@@ -269,7 +271,7 @@ export class BlobSource extends Source {
}
}
const URL_SOURCE_MIN_LOAD_AMOUNT = /* #__PURE__ */ 0.5 * 2 ** 20; // 0.5 MiB
const URL_SOURCE_MIN_LOAD_AMOUNT = 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
@@ -297,6 +299,10 @@ const DEFAULT_RETRY_DELAY
= typeof navigator !== 'undefined' && typeof navigator.onLine === 'boolean' ? navigator.onLine : true;
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;
}
}
@@ -425,6 +431,7 @@ export class UrlSource extends Source {
signal: abortController.signal,
}),
this._getRetryDelay,
() => this._disposed,
);
if (!response.ok) {
@@ -436,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.
@@ -492,6 +499,7 @@ export class UrlSource extends Source {
signal: abortController.signal,
}),
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) {
throw new Error(
'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 {
readResult = await reader.read();
} catch (error) {
if (this._disposed) {
// No need to try to retry
throw error;
}
const retryDelayInSeconds = this._getRetryDelay(1, error, this._url);
if (retryDelayInSeconds !== null) {
console.error('Error while reading response stream. Attempting to resume.', error);
@@ -557,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);
@@ -586,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.',
);
}
}
@@ -639,7 +643,7 @@ export class FilePathSource extends Source {
_fileHandle: FileHandle | null = null;
/** 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') {
throw new TypeError('filePath must be a string.');
}
+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;
};
+5 -5
View File
@@ -89,8 +89,8 @@ export abstract class Writer {
}
}
const ARRAY_BUFFER_INITIAL_SIZE = /* #__PURE__ */ 2 ** 16;
const ARRAY_BUFFER_MAX_SIZE = /* #__PURE__ */ 2 ** 32;
const ARRAY_BUFFER_INITIAL_SIZE = 2 ** 16;
const ARRAY_BUFFER_MAX_SIZE = 2 ** 32;
export class BufferTargetWriter extends Writer {
private pos = 0;
@@ -184,13 +184,13 @@ export class BufferTargetWriter extends Writer {
}
}
const DEFAULT_CHUNK_SIZE = /* #__PURE__ */ 2 ** 24;
const DEFAULT_CHUNK_SIZE = 2 ** 24;
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);