fix(spf): propagate byteRange when building segment load tasks (#904)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-03-11 18:06:01 -07:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent ff8fb3fd36
commit 801be291c3
3 changed files with 139 additions and 5 deletions
+4 -4
View File
@@ -1,6 +1,4 @@
export type SourceId = 'hls-1' | 'hls-2' | 'hls-3' | 'hls-4' | 'hls-5' | 'mp4-1';
export const SOURCES: Record<SourceId, { label: string; url: string; type: 'hls' | 'mp4'; subType?: 'mp4' | 'ts' }> = {
export const SOURCES = {
'hls-1': {
label: 'HLS - Big Buck Bunny',
url: 'https://stream.mux.com/VcmKA6aqzIzlg3MayLJDnbF55kX00mds028Z65QxvBYaA.m3u8',
@@ -36,7 +34,9 @@ export const SOURCES: Record<SourceId, { label: string; url: string; type: 'hls'
url: 'https://stream.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/highest.mp4',
type: 'mp4',
},
};
} as const;
export type SourceId = keyof typeof SOURCES;
export const SOURCE_IDS = Object.keys(SOURCES) as SourceId[];
export const MP4_SOURCE_IDS = SOURCE_IDS.filter((id) => SOURCES[id].type === 'mp4');
@@ -162,7 +162,12 @@ export function createSegmentLoaderActor(
// Case 2: Init
if (actorCtx.initTrackId !== track.id) {
tasks.push({ type: 'append-init', meta: { trackId: track.id }, url: track.initialization.url });
tasks.push({
type: 'append-init',
meta: { trackId: track.id },
url: track.initialization.url,
...(track.initialization.byteRange !== undefined && { byteRange: track.initialization.byteRange }),
});
}
// Case 3: Segments
@@ -190,6 +195,7 @@ export function createSegmentLoaderActor(
trackBandwidth: track.bandwidth,
},
url: segment.url,
...(segment.byteRange !== undefined && { byteRange: segment.byteRange }),
});
}
}
@@ -902,6 +902,134 @@ describe('loadSegments forward buffer flushing', () => {
});
});
// ---------------------------------------------------------------------------
// Byte-range segment fetching (fMP4 / CMAF range-request streams)
// ---------------------------------------------------------------------------
describe('loadSegments byte-range segment fetching', () => {
it('sends Range headers for byte-range init and media segments', async () => {
const segments: Segment[] = [
{
id: 's0',
url: 'http://example.com/video.mp4',
startTime: 0,
duration: 6,
byteRange: { start: 1000, end: 2999 },
},
{
id: 's1',
url: 'http://example.com/video.mp4',
startTime: 6,
duration: 6,
byteRange: { start: 3000, end: 4999 },
},
];
const rangeHeaders: string[] = [];
globalThis.fetch = vi.fn().mockImplementation((input: RequestInfo | URL) => {
const range = (input as Request).headers?.get('Range');
if (range) rangeHeaders.push(range);
return Promise.resolve(new Response(new ArrayBuffer(100)));
});
const { loadSegments } = await import('../load-segments');
const { createState: cs } = await import('../../../core/state/create-state');
const track = {
type: 'video' as const,
id: 'track-1',
url: 'http://example.com/video.m3u8',
mimeType: 'video/mp4',
codecs: ['avc1.42E01E'],
bandwidth: 1_000_000,
initialization: { url: 'http://example.com/video.mp4', byteRange: { start: 0, end: 999 } },
segments,
startTime: 0,
duration: 12,
};
const state = cs<SegmentLoadingState>({
preload: 'auto',
selectedVideoTrackId: 'track-1',
currentTime: 0,
presentation: {
id: 'p1',
url: 'http://example.com/playlist.m3u8',
startTime: 0,
duration: 12,
selectionSets: [{ id: 'ss1', type: 'video', switchingSets: [{ id: 'sw1', type: 'video', tracks: [track] }] }],
},
});
const { sourceBuffer, actor } = makeSourceBufferWithActor();
const owners = cs<SegmentLoadingOwners>({ videoBuffer: sourceBuffer, videoBufferActor: actor });
const cleanup = loadSegments({ state, owners }, { type: 'video' });
await vi.waitFor(
() => {
expect(owners.current.videoBufferActor?.snapshot.context.segments).toHaveLength(2);
},
{ timeout: 3000 }
);
expect(rangeHeaders).toContain('bytes=0-999'); // init segment
expect(rangeHeaders).toContain('bytes=1000-2999'); // s0
expect(rangeHeaders).toContain('bytes=3000-4999'); // s1
cleanup();
});
it('does not send Range header for non-byte-range segments', async () => {
const segments = [makeSegment('s0', 0, 10)];
const rangeHeaders: string[] = [];
globalThis.fetch = vi.fn().mockImplementation((input: RequestInfo | URL) => {
const range = (input as Request).headers?.get('Range');
if (range) rangeHeaders.push(range);
return Promise.resolve(new Response(new ArrayBuffer(100)));
});
const { loadSegments } = await import('../load-segments');
const { createState: cs } = await import('../../../core/state/create-state');
const state = cs<SegmentLoadingState>({
preload: 'auto',
selectedVideoTrackId: 'track-1',
currentTime: 0,
presentation: {
id: 'p1',
url: 'http://example.com/playlist.m3u8',
startTime: 0,
duration: 10,
selectionSets: [
{
id: 'ss1',
type: 'video',
switchingSets: [{ id: 'sw1', type: 'video', tracks: [makeResolvedVideoTrack(segments)] }],
},
],
},
});
const { sourceBuffer, actor } = makeSourceBufferWithActor();
const owners = cs<SegmentLoadingOwners>({ videoBuffer: sourceBuffer, videoBufferActor: actor });
const cleanup = loadSegments({ state, owners }, { type: 'video' });
await vi.waitFor(
() => {
expect(owners.current.videoBufferActor?.snapshot.context.segments).toHaveLength(1);
},
{ timeout: 3000 }
);
expect(rangeHeaders).toHaveLength(0);
cleanup();
});
});
// ---------------------------------------------------------------------------
// Streaming bandwidth tracking
// ---------------------------------------------------------------------------