mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-03 13:53:53 +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
|
::: 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.
|
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).
|
||||||
|
|
||||||
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).
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
`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.
|
`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. */
|
/** 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) {
|
if (headers instanceof Headers) {
|
||||||
const result: Record<string, string> = {};
|
const result: Record<string, string> = {};
|
||||||
headers.forEach((value, key) => {
|
headers.forEach((value, key) => {
|
||||||
|
|||||||
+113
-24
@@ -17,6 +17,7 @@ import {
|
|||||||
isWebKit,
|
isWebKit,
|
||||||
MaybePromise,
|
MaybePromise,
|
||||||
mergeRequestInit,
|
mergeRequestInit,
|
||||||
|
normalizeHeaders,
|
||||||
polyfillSymbolDispose,
|
polyfillSymbolDispose,
|
||||||
promiseWithResolvers,
|
promiseWithResolvers,
|
||||||
retriedFetch,
|
retriedFetch,
|
||||||
@@ -677,9 +678,6 @@ export type UrlSourceOptions = {
|
|||||||
*
|
*
|
||||||
* The `signal` field is not available, as Mediabunny controls request cancellation internally. If you want to
|
* The `signal` field is not available, as Mediabunny controls request cancellation internally. If you want to
|
||||||
* cancel ongoing requests, use {@link Input.dispose}.
|
* 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'>;
|
requestInit?: Omit<RequestInit, 'signal'>;
|
||||||
|
|
||||||
@@ -720,6 +718,12 @@ export class UrlSource extends PathedSource {
|
|||||||
/** @internal */
|
/** @internal */
|
||||||
_options: UrlSourceOptions;
|
_options: UrlSourceOptions;
|
||||||
/** @internal */
|
/** @internal */
|
||||||
|
_requestInit: RequestInit;
|
||||||
|
/** @internal */
|
||||||
|
_offset = 0;
|
||||||
|
/** @internal */
|
||||||
|
_length: number | null = null;
|
||||||
|
/** @internal */
|
||||||
_orchestrator: ReadOrchestrator;
|
_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
|
* 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.
|
* 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
|
* When passing a `Request` instance, note that its `signal` will be overridden by Mediabunny; if you want to cancel
|
||||||
* Mediabunny. If you want to cancel ongoing requests, use {@link Input.dispose}.
|
* ongoing requests, use {@link Input.dispose}.
|
||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
url: string | URL | Request,
|
url: string | URL | Request,
|
||||||
@@ -780,6 +784,42 @@ export class UrlSource extends PathedSource {
|
|||||||
this._options = options;
|
this._options = options;
|
||||||
this._getRetryDelay = options.getRetryDelay ?? DEFAULT_RETRY_DELAY;
|
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
|
// Most files in the real-world have a single sequential access pattern, but having two in parallel can
|
||||||
// also happen
|
// also happen
|
||||||
const DEFAULT_PARALLELISM = 2;
|
const DEFAULT_PARALLELISM = 2;
|
||||||
@@ -794,9 +834,16 @@ export class UrlSource extends PathedSource {
|
|||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
_getFileSize(): number | null | undefined {
|
_getFileSize(): number | null | undefined {
|
||||||
return this._fileSizeDetermined
|
if (!this._fileSizeDetermined) {
|
||||||
? this._orchestrator.fileSize
|
return this._length !== null ? this._length : undefined;
|
||||||
: 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 */
|
/** @internal */
|
||||||
@@ -806,7 +853,32 @@ export class UrlSource extends PathedSource {
|
|||||||
minReadPosition: number,
|
minReadPosition: number,
|
||||||
maxReadPosition: number,
|
maxReadPosition: number,
|
||||||
): MaybePromise<ReadResult | null> {
|
): 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 */
|
/** @internal */
|
||||||
@@ -817,7 +889,7 @@ export class UrlSource extends PathedSource {
|
|||||||
const response = await retriedFetch(
|
const response = await retriedFetch(
|
||||||
this._options.fetchFn ?? fetch,
|
this._options.fetchFn ?? fetch,
|
||||||
this._url,
|
this._url,
|
||||||
mergeRequestInit(this._options.requestInit ?? {}, {
|
mergeRequestInit(this._requestInit, {
|
||||||
headers: {
|
headers: {
|
||||||
// Always sending a range request is a good way to probe if the server supports them
|
// Always sending a range request is a good way to probe if the server supports them
|
||||||
Range: `bytes=${worker.currentPos}-`,
|
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}.
|
* Options for {@link FilePathSource}.
|
||||||
* @group Input sources
|
* @group Input sources
|
||||||
@@ -2425,22 +2517,19 @@ export class RangedSource extends Source {
|
|||||||
this._offset + maxReadPosition,
|
this._offset + maxReadPosition,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const processResult = (result: ReadResult | null) => {
|
||||||
|
if (!result) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.offset -= this._offset;
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
if (result instanceof Promise) {
|
if (result instanceof Promise) {
|
||||||
return result.then((result) => {
|
return result.then(processResult);
|
||||||
if (!result) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
result.offset -= this._offset;
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
if (!result) {
|
return processResult(result);
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
result.offset -= this._offset;
|
|
||||||
return 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