mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-27 02:43:48 +02:00
Respect Range header for UrlSource (#387)
This commit is contained in:
@@ -644,9 +644,7 @@ const source = new UrlSource('https://example.com/bigbuckbunny.mp4', {
|
||||
```
|
||||
|
||||
::: info
|
||||
All `requestInit` fields are respected except for `signal` and `headers.Range`, which are overridden by Mediabunny. The same applies to the `signal` and `headers.Range` values of a `Request` passed as the first constructor argument.
|
||||
|
||||
To cancel ongoing requests, [dispose of the input](#disposing-inputs). To load a specific range of the resource, create the `UrlSource` first and then call [`.slice()`](../api/Source#slice).
|
||||
All `requestInit` fields are respected except for `signal`, which is overridden by Mediabunny. The same applies to the `signal` value of a `Request` passed as the first constructor argument. To cancel ongoing requests, [dispose of the input](#disposing-inputs).
|
||||
:::
|
||||
|
||||
`getRetryDelay` can be used to control the retry logic used should a request fail. When a request fails, `getRetryDelay` should return the time to wait in seconds before the request will be retried. Returning `null` prevents further retries.
|
||||
|
||||
+1
-1
@@ -524,7 +524,7 @@ export const mergeRequestInit = (init1: RequestInit, init2: RequestInit): Reques
|
||||
};
|
||||
|
||||
/** Normalizes HeadersInit to a Record<string, string> format. */
|
||||
const normalizeHeaders = (headers: HeadersInit): Record<string, string> => {
|
||||
export const normalizeHeaders = (headers: HeadersInit): Record<string, string> => {
|
||||
if (headers instanceof Headers) {
|
||||
const result: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
|
||||
+113
-24
@@ -17,6 +17,7 @@ import {
|
||||
isWebKit,
|
||||
MaybePromise,
|
||||
mergeRequestInit,
|
||||
normalizeHeaders,
|
||||
polyfillSymbolDispose,
|
||||
promiseWithResolvers,
|
||||
retriedFetch,
|
||||
@@ -677,9 +678,6 @@ export type UrlSourceOptions = {
|
||||
*
|
||||
* The `signal` field is not available, as Mediabunny controls request cancellation internally. If you want to
|
||||
* cancel ongoing requests, use {@link Input.dispose}.
|
||||
*
|
||||
* The `headers.Range` field will also be overridden by Mediabunny. For loading a ranged remote resource, create the
|
||||
* source, then call {@link Source.slice}.
|
||||
*/
|
||||
requestInit?: Omit<RequestInit, 'signal'>;
|
||||
|
||||
@@ -720,6 +718,12 @@ export class UrlSource extends PathedSource {
|
||||
/** @internal */
|
||||
_options: UrlSourceOptions;
|
||||
/** @internal */
|
||||
_requestInit: RequestInit;
|
||||
/** @internal */
|
||||
_offset = 0;
|
||||
/** @internal */
|
||||
_length: number | null = null;
|
||||
/** @internal */
|
||||
_orchestrator: ReadOrchestrator;
|
||||
/**
|
||||
* Note that this value being true does NOT mean the file size can't change anymore; it just signals that we have at
|
||||
@@ -731,8 +735,8 @@ export class UrlSource extends PathedSource {
|
||||
/**
|
||||
* Creates a new {@link UrlSource} backed by the resource at the specified URL.
|
||||
*
|
||||
* When passing a `Request` instance, note that the `signal` and `headers.Range` options will be overridden by
|
||||
* Mediabunny. If you want to cancel ongoing requests, use {@link Input.dispose}.
|
||||
* When passing a `Request` instance, note that its `signal` will be overridden by Mediabunny; if you want to cancel
|
||||
* ongoing requests, use {@link Input.dispose}.
|
||||
*/
|
||||
constructor(
|
||||
url: string | URL | Request,
|
||||
@@ -780,6 +784,42 @@ export class UrlSource extends PathedSource {
|
||||
this._options = options;
|
||||
this._getRetryDelay = options.getRetryDelay ?? DEFAULT_RETRY_DELAY;
|
||||
|
||||
// A user-supplied Range header is interpreted as a byte offset (and optional length) into the resource. We
|
||||
// pull it out of the request and remember it for subsequent requests.
|
||||
this._requestInit = { ...options.requestInit };
|
||||
|
||||
let rangeHeaderValue: string | null = null;
|
||||
|
||||
if (options.requestInit?.headers) {
|
||||
const headers = { ...normalizeHeaders(options.requestInit.headers) };
|
||||
const rangeKey = Object.keys(headers).find(key => key.toLowerCase() === 'range');
|
||||
if (rangeKey !== undefined) {
|
||||
rangeHeaderValue = headers[rangeKey]!;
|
||||
delete headers[rangeKey];
|
||||
this._requestInit.headers = headers;
|
||||
}
|
||||
}
|
||||
|
||||
if (url instanceof Request) {
|
||||
const requestRange = url.headers.get('Range');
|
||||
if (requestRange !== null) {
|
||||
rangeHeaderValue ??= requestRange;
|
||||
|
||||
// Clone the request so we don't mutate the user's object, then strip the Range header
|
||||
const strippedRequest = new Request(url);
|
||||
strippedRequest.headers.delete('Range');
|
||||
this._url = strippedRequest;
|
||||
}
|
||||
}
|
||||
|
||||
if (rangeHeaderValue !== null) {
|
||||
const parsed = parseByteRangeHeader(rangeHeaderValue);
|
||||
if (parsed) {
|
||||
this._offset = parsed.offset;
|
||||
this._length = parsed.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Most files in the real-world have a single sequential access pattern, but having two in parallel can
|
||||
// also happen
|
||||
const DEFAULT_PARALLELISM = 2;
|
||||
@@ -794,9 +834,16 @@ export class UrlSource extends PathedSource {
|
||||
|
||||
/** @internal */
|
||||
_getFileSize(): number | null | undefined {
|
||||
return this._fileSizeDetermined
|
||||
? this._orchestrator.fileSize
|
||||
: undefined;
|
||||
if (!this._fileSizeDetermined) {
|
||||
return this._length !== null ? this._length : undefined;
|
||||
}
|
||||
|
||||
const baseSize = this._orchestrator.fileSize;
|
||||
if (baseSize === null) {
|
||||
return this._length !== null ? this._length : null;
|
||||
}
|
||||
|
||||
return clamp(baseSize - this._offset, 0, this._length ?? Infinity);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
@@ -806,7 +853,32 @@ export class UrlSource extends PathedSource {
|
||||
minReadPosition: number,
|
||||
maxReadPosition: number,
|
||||
): MaybePromise<ReadResult | null> {
|
||||
return this._orchestrator.read(start, end, minReadPosition, maxReadPosition);
|
||||
if (this._length !== null && end > this._length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const offset = this._offset;
|
||||
const result = this._orchestrator.read(
|
||||
offset + start,
|
||||
offset + end,
|
||||
Math.max(offset + minReadPosition, offset),
|
||||
offset + Math.min(maxReadPosition, this._length ?? Infinity),
|
||||
);
|
||||
|
||||
const processResult = (result: ReadResult | null) => {
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
result.offset -= this._offset;
|
||||
return result;
|
||||
};
|
||||
|
||||
if (result instanceof Promise) {
|
||||
return result.then(processResult);
|
||||
} else {
|
||||
return processResult(result);
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
@@ -817,7 +889,7 @@ export class UrlSource extends PathedSource {
|
||||
const response = await retriedFetch(
|
||||
this._options.fetchFn ?? fetch,
|
||||
this._url,
|
||||
mergeRequestInit(this._options.requestInit ?? {}, {
|
||||
mergeRequestInit(this._requestInit, {
|
||||
headers: {
|
||||
// Always sending a range request is a good way to probe if the server supports them
|
||||
Range: `bytes=${worker.currentPos}-`,
|
||||
@@ -975,6 +1047,26 @@ export class UrlSource extends PathedSource {
|
||||
}
|
||||
}
|
||||
|
||||
const BYTE_RANGE_REGEX = /^bytes=(\d+)-(\d*)$/;
|
||||
const parseByteRangeHeader = (value: string) => {
|
||||
const match = BYTE_RANGE_REGEX.exec(value.trim());
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const offset = Number(match[1]);
|
||||
const end = match[2] === '' ? null : Number(match[2]);
|
||||
|
||||
if (end !== null && end < offset) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
offset,
|
||||
length: end !== null ? end - offset + 1 : null,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Options for {@link FilePathSource}.
|
||||
* @group Input sources
|
||||
@@ -2425,22 +2517,19 @@ export class RangedSource extends Source {
|
||||
this._offset + maxReadPosition,
|
||||
);
|
||||
|
||||
const processResult = (result: ReadResult | null) => {
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
result.offset -= this._offset;
|
||||
return result;
|
||||
};
|
||||
|
||||
if (result instanceof Promise) {
|
||||
return result.then((result) => {
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
result.offset -= this._offset;
|
||||
return result;
|
||||
});
|
||||
return result.then(processResult);
|
||||
} else {
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
result.offset -= this._offset;
|
||||
return result;
|
||||
return processResult(result);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { UrlSource } from '../../src/source.js';
|
||||
import { ALL_FORMATS } from '../../src/input-format.js';
|
||||
import { Input } from '../../src/input.js';
|
||||
|
||||
test('Should be able to load a very small video file via URL (<512 kB)', async () => {
|
||||
const source = new UrlSource('/frames.webm');
|
||||
using input = new Input({
|
||||
source,
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
const primaryVideoTrack = await input.getPrimaryVideoTrack();
|
||||
if (!primaryVideoTrack) {
|
||||
throw new Error('No video track found');
|
||||
};
|
||||
|
||||
const duration = await primaryVideoTrack.computeDuration();
|
||||
expect(duration).toBeCloseTo(3.33333);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { UrlSource } from '../../src/source.js';
|
||||
import { ALL_FORMATS } from '../../src/input-format.js';
|
||||
import { Input } from '../../src/input.js';
|
||||
import { Reader, readBytes } from '../../src/reader.js';
|
||||
|
||||
test('Should be able to load a very small video file via URL (<512 kB)', async () => {
|
||||
const source = new UrlSource('/frames.webm');
|
||||
using input = new Input({
|
||||
source,
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
const primaryVideoTrack = await input.getPrimaryVideoTrack();
|
||||
if (!primaryVideoTrack) {
|
||||
throw new Error('No video track found');
|
||||
};
|
||||
|
||||
const duration = await primaryVideoTrack.computeDuration();
|
||||
expect(duration).toBeCloseTo(3.33333);
|
||||
});
|
||||
|
||||
test('requestInit with Range', async () => {
|
||||
const url = makeRampedUrl();
|
||||
|
||||
const source = new UrlSource(url, {
|
||||
requestInit: {
|
||||
headers: { Range: 'bytes=10-' },
|
||||
},
|
||||
});
|
||||
const reader = new Reader(source);
|
||||
|
||||
const slice = await reader.requestSlice(0, 5);
|
||||
expect(slice).not.toBeNull();
|
||||
|
||||
expect([...readBytes(slice!, 5)]).toEqual([10, 11, 12, 13, 14]);
|
||||
|
||||
expect(reader.fileSize).toBe(256 - 10);
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
test('Request with Range', async () => {
|
||||
const url = makeRampedUrl();
|
||||
|
||||
const request = new Request(url, {
|
||||
headers: { Range: 'bytes=20-29' },
|
||||
});
|
||||
|
||||
const source = new UrlSource(request);
|
||||
const reader = new Reader(source);
|
||||
|
||||
const slice = await reader.requestSlice(2, 4);
|
||||
expect(slice).not.toBeNull();
|
||||
|
||||
expect([...readBytes(slice!, 4)]).toEqual([22, 23, 24, 25]);
|
||||
|
||||
expect(reader.fileSize).toBe(10);
|
||||
|
||||
expect(await reader.requestSlice(8, 4)).toBeNull();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
const makeRampedUrl = () => {
|
||||
const data = new Uint8Array(256);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
data[i] = i;
|
||||
}
|
||||
|
||||
const blob = new Blob([data.buffer]);
|
||||
return URL.createObjectURL(blob);
|
||||
};
|
||||
Reference in New Issue
Block a user