mediabunny
Version:
Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.
221 lines (220 loc) • 8.17 kB
JavaScript
/*!
* Copyright (c) 2025-present, Vanilagy and contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { mergeObjectsDeeply, retriedFetch } from './misc.js';
/**
* The source base class, representing a resource from which bytes can be read.
* @public
*/
export class Source {
constructor() {
/** @internal */
this._sizePromise = null;
/** Called each time data is requested from the source. */
this.onread = null;
}
/**
* Resolves with the total size of the file in bytes. This function is memoized, meaning only the first call
* will retrieve the size.
*/
getSize() {
return this._sizePromise ??= this._retrieveSize();
}
}
/**
* A source backed by an ArrayBuffer or ArrayBufferView, with the entire file held in memory.
* @public
*/
export class BufferSource extends Source {
constructor(buffer) {
if (!(buffer instanceof ArrayBuffer) && !(buffer instanceof Uint8Array)) {
throw new TypeError('buffer must be an ArrayBuffer or Uint8Array.');
}
super();
this._bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
}
/** @internal */
async _read(start, end) {
return this._bytes.subarray(start, end);
}
/** @internal */
async _retrieveSize() {
return this._bytes.byteLength;
}
}
/**
* A general-purpose, callback-driven source that can get its data from anywhere.
* @public
*/
export class StreamSource extends Source {
constructor(options) {
if (!options || typeof options !== 'object') {
throw new TypeError('options must be an object.');
}
if (typeof options.read !== 'function') {
throw new TypeError('options.read must be a function.');
}
if (typeof options.getSize !== 'function') {
throw new TypeError('options.getSize must be a function.');
}
super();
this._options = options;
}
/** @internal */
async _read(start, end) {
return this._options.read(start, end);
}
/** @internal */
async _retrieveSize() {
return this._options.getSize();
}
}
/**
* A source backed by a Blob. Since Files are also Blobs, this is the source to use when reading files off the disk.
* @public
*/
export class BlobSource extends Source {
constructor(blob) {
if (!(blob instanceof Blob)) {
throw new TypeError('blob must be a Blob.');
}
super();
this._blob = blob;
}
/** @internal */
async _read(start, end) {
const slice = this._blob.slice(start, end);
const buffer = await slice.arrayBuffer();
return new Uint8Array(buffer);
}
/** @internal */
async _retrieveSize() {
return this._blob.size;
}
}
/**
* A source backed by a URL. This is useful for reading data from the network. Be careful using this source however,
* as it typically comes with increased latency.
* @beta
*/
export class UrlSource extends Source {
constructor(url, options = {}) {
if (typeof url !== 'string' && !(url instanceof URL)) {
throw new TypeError('url must be a string or URL.');
}
if (!options || typeof options !== 'object') {
throw new TypeError('options must be an object.');
}
if (options.requestInit !== undefined && (!options.requestInit || typeof options.requestInit !== 'object')) {
throw new TypeError('options.requestInit, when provided, must be an object.');
}
if (options.getRetryDelay !== undefined && typeof options.getRetryDelay !== 'function') {
throw new TypeError('options.getRetryDelay, when provided, must be a function.');
}
super();
/** @internal */
this._fullData = null;
/** @internal */
this._nextUrlVersion = null;
this._url = url instanceof URL ? url : new URL(url);
this._options = options;
}
/** @internal */
async _makeRequest(range) {
const headers = {};
if (range) {
headers['Range'] = `bytes=${range.start}-${range.end - 1}`;
}
if (this._nextUrlVersion !== null) {
this._url.searchParams.set('mediabunny_version', this._nextUrlVersion.toString());
this._nextUrlVersion++;
}
const response = await retriedFetch(this._url, mergeObjectsDeeply(this._options.requestInit ?? {}, {
method: 'GET',
headers,
}), this._options.getRetryDelay ?? (() => null));
if (!response.ok) {
throw new Error(`Error fetching ${this._url}: ${response.status} ${response.statusText}`);
}
const buffer = await response.arrayBuffer();
if (response.status === 206
&& range
&& buffer.byteLength !== range.end - range.start
&& this._nextUrlVersion === null) {
// We did a range request but it resolved with the wrong range; in Chromium, this can be due to a caching
// bug (https://issues.chromium.org/issues/436025873). Let's circumvent the cache for the rest of the
// session by appending a version to the URL.
this._nextUrlVersion = 1;
return this._makeRequest(range);
}
if (response.status === 200) {
// The server didn't return 206 Partial Content, so it's not a range response
this._fullData = buffer;
}
return {
response: buffer,
statusCode: response.status,
};
}
/** @internal */
async _read(start, end) {
if (this._fullData) {
return new Uint8Array(this._fullData, start, end - start);
}
const { response, statusCode } = await this._makeRequest({ start, end });
// If server doesn't support range requests, it will return 200 instead of 206. In that case, let's manually
// slice the response.
if (statusCode === 200) {
const fullData = new Uint8Array(response);
return fullData.subarray(start, end);
}
return new Uint8Array(response);
}
/** @internal */
async _retrieveSize() {
if (this._fullData) {
return this._fullData.byteLength;
}
// First, try a HEAD request to get the size
try {
const headResponse = await retriedFetch(this._url, mergeObjectsDeeply(this._options.requestInit ?? {}, {
method: 'HEAD',
}), this._options.getRetryDelay ?? (() => null));
if (headResponse.ok) {
const contentLength = headResponse.headers.get('Content-Length');
if (contentLength) {
return parseInt(contentLength);
}
}
}
catch {
// We tried
}
// Try a range request to get the Content-Range header
const rangeResponse = await retriedFetch(this._url, mergeObjectsDeeply(this._options.requestInit ?? {}, {
method: 'GET',
headers: { Range: 'bytes=0-0' },
}), this._options.getRetryDelay ?? (() => null));
if (rangeResponse.status === 206) {
const contentRange = rangeResponse.headers.get('Content-Range');
if (contentRange) {
const match = contentRange.match(/bytes \d+-\d+\/(\d+)/);
if (match && match[1]) {
return parseInt(match[1]);
}
}
}
else if (rangeResponse.status === 200) {
// The server just returned the whole thing
this._fullData = await rangeResponse.arrayBuffer();
return this._fullData.byteLength;
}
// If the range request didn't provide the size, make a full GET request
const { response } = await this._makeRequest();
return response.byteLength;
}
}