Respect Range header for UrlSource (#387)

This commit is contained in:
Vanilagy
2026-06-02 11:05:46 +02:00
parent e0ced9901e
commit 05f8ecd908
5 changed files with 183 additions and 43 deletions
+1 -3
View File
@@ -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
View File
@@ -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) => {
+109 -20
View File
@@ -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,
);
if (result instanceof Promise) {
return result.then((result) => {
if (!result) {
return null;
}
result.offset -= this._offset;
return result;
});
} else {
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);
}
}
@@ -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);
});
+72
View File
@@ -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);
};