From 05f8ecd90831488ef5d30c883fa4441859cc91cc Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:05:46 +0200 Subject: [PATCH] Respect Range header for UrlSource (#387) --- docs/guide/reading-media-files.md | 4 +- src/misc.ts | 2 +- src/source.ts | 129 +++++++++++++++++---- test/browser/url-source-short-file.test.ts | 19 --- test/browser/url-source.test.ts | 72 ++++++++++++ 5 files changed, 183 insertions(+), 43 deletions(-) delete mode 100644 test/browser/url-source-short-file.test.ts create mode 100644 test/browser/url-source.test.ts diff --git a/docs/guide/reading-media-files.md b/docs/guide/reading-media-files.md index ef2a7b6..61c7393 100644 --- a/docs/guide/reading-media-files.md +++ b/docs/guide/reading-media-files.md @@ -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. diff --git a/src/misc.ts b/src/misc.ts index 167e69e..cfc3008 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -524,7 +524,7 @@ export const mergeRequestInit = (init1: RequestInit, init2: RequestInit): Reques }; /** Normalizes HeadersInit to a Record format. */ -const normalizeHeaders = (headers: HeadersInit): Record => { +export const normalizeHeaders = (headers: HeadersInit): Record => { if (headers instanceof Headers) { const result: Record = {}; headers.forEach((value, key) => { diff --git a/src/source.ts b/src/source.ts index eb5991d..1fb266a 100644 --- a/src/source.ts +++ b/src/source.ts @@ -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; @@ -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 { - 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); } } diff --git a/test/browser/url-source-short-file.test.ts b/test/browser/url-source-short-file.test.ts deleted file mode 100644 index d610223..0000000 --- a/test/browser/url-source-short-file.test.ts +++ /dev/null @@ -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); -}); diff --git a/test/browser/url-source.test.ts b/test/browser/url-source.test.ts new file mode 100644 index 0000000..befa870 --- /dev/null +++ b/test/browser/url-source.test.ts @@ -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); +};