From 92786101fa115beba59d8f31cacd4d57f9cb46a9 Mon Sep 17 00:00:00 2001 From: Nawid3333 <102778966+Nawid3333@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:41:36 +0100 Subject: [PATCH 1/2] HLS: hls.js no longer plays interstitials; FastStream plays the stream itself, in order An interstitial is an EXT-X-DATERANGE of CLASS "com.apple.hls.interstitial" whose X-ASSET-URI is another stream (an ad) played at that point. hls.js plays one in a player of its own built from HLSPlayer's config - FastStream's loader and level control included - and FastStream drives none of it. Measured on the hls-ts fixture (9 s) with a 9 s ad, new e2e spec hls-interstitials.e2e.mjs, before and after: - a break replacing part of the stream (no X-RESUME-OFFSET, as live ad insertion has it): the video skipped from 0 to 9.9 s within a second and ended at 13 s; plain hls.js on the same stream played it to 4 s, then the ad; - a break inserted before the stream (X-RESUME-OFFSET=0, CUE="PRE"): no ad played, but hls.js fetched the ad's playlist; - a break inserted into it: no ad, hls.js's schedule stuck at 0 (already the stream itself, in order). With enableInterstitialPlayback false, all three play the stream's 9 s in order and never ask for the ad (hls.js's InterstitialsController still reads the schedule; playbackDisabled stops asset lists, asset players and seeks). That is what the HLS spec has a player without interstitials do. With the switch back on, the spec fails twice (the ad playlist fetched; a 13 s timeline), the inserted-into case passing as measured. Verified locally: the spec with and without the change, lint, typecheck, 3378 unit tests. Co-Authored-By: Claude Opus 5.5 --- chrome/player/players/hls/HLSPlayer.mjs | 8 ++ tests/e2e/specs/hls-interstitials.e2e.mjs | 137 ++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 tests/e2e/specs/hls-interstitials.e2e.mjs diff --git a/chrome/player/players/hls/HLSPlayer.mjs b/chrome/player/players/hls/HLSPlayer.mjs index a76fd1bb..2d1eeb47 100644 --- a/chrome/player/players/hls/HLSPlayer.mjs +++ b/chrome/player/players/hls/HLSPlayer.mjs @@ -109,6 +109,14 @@ export default class HLSPlayer extends EventEmitter { enableWebVTT: false, enableIMSC1: false, enableCEA708Captions: false, + // Interstitials (EXT-X-DATERANGE CLASS="com.apple.hls.interstitial", ad breaks): hls.js + // plays each in a player of its own made from this config, FastStream's loader and level + // control included, and FastStream drives none of it. Measured (hls-interstitials.e2e.mjs): + // a break that replaces part of the stream made the video skip the stream's first seconds + // and end inside the break; one inserted into it played no ad, though hls.js fetched the + // ad's playlist and kept a schedule that never moved. Off, hls.js plays the stream itself, + // in order, as the HLS spec has a player without interstitials do. + enableInterstitialPlayback: false, stretchShortVideoTrack: false, maxAudioFramesDrift: 1, forceKeyFrameOnDiscontinuity: true, diff --git a/tests/e2e/specs/hls-interstitials.e2e.mjs b/tests/e2e/specs/hls-interstitials.e2e.mjs new file mode 100644 index 00000000..40fcb352 --- /dev/null +++ b/tests/e2e/specs/hls-interstitials.e2e.mjs @@ -0,0 +1,137 @@ +// An HLS stream with an interstitial: an EXT-X-DATERANGE of CLASS "com.apple.hls.interstitial" +// whose X-ASSET-URI is another stream, the ad, played at that point of the timeline. +// +// hls.js plays one in a player of its own, built from the same config, so it loaded through +// FastStream's loader and level control, which drive none of it. Measured before +// enableInterstitialPlayback went off (HLSPlayer), on the hls-ts fixture's 9 s with a 9 s ad: +// a break that replaces part of the stream (no X-RESUME-OFFSET, as live ad insertion has it) +// made the video skip from 0 to 9.9 s within a second and end at 13; plain hls.js played the +// stream to 4 s, then the ad. A break inserted into the stream (X-RESUME-OFFSET=0) played no +// ad in FastStream, though hls.js fetched the ad's playlist and kept a schedule that never +// moved. Now hls.js plays the stream itself, in order, as the HLS spec has a player without +// interstitials do, and never asks for the ad. + +import fs from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; + +import {browser, expect} from '@wdio/globals'; + +const PORT = 41898; +const ORIGIN = `http://127.0.0.1:${PORT}`; +const SEGMENTS = path.resolve(import.meta.dirname, '../fixtures/hls-ts'); +// The hls-ts fixture's segments (fixtures/hls-ts/index.m3u8): 9 s. +const DURATIONS = [2, 2, 2, 2, 1]; +const START = '2026-01-01T00:00:00.000Z'; + +let server; +let requests = []; + +/** + * A VOD playlist of the hls-ts fixture's segments, under a path of its own. + * @param {string} prefix - main or ad. + * @param {string[]} [tags] - Lines after EXT-X-PROGRAM-DATE-TIME. + * @return {string} + */ +function playlist(prefix, tags = []) { + const lines = ['#EXTM3U', '#EXT-X-VERSION:3', '#EXT-X-TARGETDURATION:2', '#EXT-X-PLAYLIST-TYPE:VOD', + '#EXT-X-MEDIA-SEQUENCE:0', `#EXT-X-PROGRAM-DATE-TIME:${START}`, ...tags]; + DURATIONS.forEach((d, i) => lines.push(`#EXTINF:${d.toFixed(6)},`, `${ORIGIN}/${prefix}/seg-${String(i).padStart(3, '0')}.ts`)); + lines.push('#EXT-X-ENDLIST'); + return lines.join('\n') + '\n'; +} + +/** + * The interstitial: the ad stream, 9 s, at a time of the stream. + * @param {string} start - Its START-DATE. + * @param {string} [attributes] - More attributes. + * @return {string} + */ +const adBreak = (start, attributes = '') => '#EXT-X-DATERANGE:ID="ad",CLASS="com.apple.hls.interstitial",' + + `START-DATE="${start}",DURATION=9,X-ASSET-URI="${ORIGIN}/ad.m3u8"${attributes}`; + +const PLAYLISTS = { + '/preroll.m3u8': () => playlist('main', [adBreak(START, ',X-RESUME-OFFSET=0,CUE="PRE"')]), + '/midroll.m3u8': () => playlist('main', [adBreak('2026-01-01T00:00:04.000Z', ',X-RESUME-OFFSET=0')]), + '/midroll-replacing.m3u8': () => playlist('main', [adBreak('2026-01-01T00:00:04.000Z')]), + '/ad.m3u8': () => playlist('ad'), +}; + +/** + * Plays a stream to its end, noting the time every second. + * @param {string} name - Its path. + * @return {Promise<{times: number[], ended: boolean, duration: number, error: ?string}>} + */ +async function playThrough(name) { + requests = []; + await browser.url(`/player/index.html?t=${Date.now()}#${ORIGIN}${name}`); + const times = []; + let last = null; + const end = Date.now() + 30000; + while (Date.now() < end) { + last = await browser.execute(() => { + const client = window.fastStream; + const video = client?.player?.getVideo(); + if (!video) return null; + if (video.paused && !video.ended) video.play().catch(() => {}); + return { + time: video.currentTime, ended: video.ended, duration: video.duration, + error: client.interfaceController?.statusManager?.statusMessages?.get('error')?.message || null, + }; + }); + if (last) times.push(last.time); + if (last?.ended || last?.error) break; + await browser.pause(1000); + } + return {times, ended: !!last?.ended, duration: last?.duration ?? NaN, error: last?.error ?? null}; +} + +describe('An HLS stream with an interstitial (an ad break)', function() { + before(async function() { + server = http.createServer((req, res) => { + const url = new URL(req.url, ORIGIN); + requests.push(url.pathname); + const headers = {'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin'}; + if (PLAYLISTS[url.pathname]) { + res.writeHead(200, {...headers, 'Content-Type': 'application/vnd.apple.mpegurl'}); + res.end(PLAYLISTS[url.pathname]()); + return; + } + const m = /^\/(main|ad)\/(seg-\d+\.ts)$/.exec(url.pathname); + const file = m && path.join(SEGMENTS, m[2]); + if (!file || !fs.existsSync(file)) { + res.writeHead(404, headers); + res.end(); + return; + } + res.writeHead(200, {...headers, 'Content-Type': 'video/mp2t'}); + res.end(fs.readFileSync(file)); + }); + await new Promise((resolve, reject) => { + server.on('error', reject); + server.listen(PORT, '127.0.0.1', resolve); + }); + }); + + after(async function() { + await new Promise((resolve) => server.close(resolve)); + }); + + for (const [name, what] of [ + ['/preroll.m3u8', 'inserted before the stream'], + ['/midroll.m3u8', 'inserted into the stream'], + ['/midroll-replacing.m3u8', 'replacing part of the stream'], + ]) { + it(`plays the stream itself, in order, past a break ${what}, and never asks for the ad`, async function() { + const {times, ended, duration, error} = await playThrough(name); + expect(error).toBe(null); + expect(ended).toBe(true); + // The stream's 9 s, not a timeline with the ad's 9 in it. + expect(duration).toBeCloseTo(9, 0); + // A second a step, never a jump. + const steps = times.slice(1).map((t, i) => Math.round((t - times[i]) * 10) / 10); + expect(steps.filter((s) => s < 0 || s > 2.5)).toEqual([]); + expect(requests.filter((p) => p.startsWith('/ad'))).toEqual([]); + }); + } +}); From 30a2af2a1e59c0d8c2c535d0d1d98a5b03f28a34 Mon Sep 17 00:00:00 2001 From: Nawid3333 <102778966+Nawid3333@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:53:58 +0100 Subject: [PATCH 2/2] hls-interstitials.e2e: a jump is the video running ahead of the page's clock; the comments name the pre-roll for the ad playlist The spec failed a step of more than 2.5 s between two samples a second apart, so one slow round trip on a busy runner could fail a correct build. Each sample now carries the page's clock (performance.now) beside currentTime: a step fails when the video went back, or further than the clock went plus a second. With interstitials back on and the duration check left out, the replacing break still fails on it (from 0, 8.9 s ahead). The ad's playlist was fetched for the break inserted before the stream (CUE="PRE"), not the one inserted into it, which loaded nothing; the comments in HLSPlayer and the spec said otherwise (glm's review). Co-Authored-By: Claude Opus 5.5 --- chrome/player/players/hls/HLSPlayer.mjs | 6 ++-- tests/e2e/specs/hls-interstitials.e2e.mjs | 34 +++++++++++++---------- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/chrome/player/players/hls/HLSPlayer.mjs b/chrome/player/players/hls/HLSPlayer.mjs index 2d1eeb47..208881db 100644 --- a/chrome/player/players/hls/HLSPlayer.mjs +++ b/chrome/player/players/hls/HLSPlayer.mjs @@ -113,9 +113,9 @@ export default class HLSPlayer extends EventEmitter { // plays each in a player of its own made from this config, FastStream's loader and level // control included, and FastStream drives none of it. Measured (hls-interstitials.e2e.mjs): // a break that replaces part of the stream made the video skip the stream's first seconds - // and end inside the break; one inserted into it played no ad, though hls.js fetched the - // ad's playlist and kept a schedule that never moved. Off, hls.js plays the stream itself, - // in order, as the HLS spec has a player without interstitials do. + // and end inside the break; one inserted before it played no ad, though hls.js fetched the + // ad's playlist. Off, hls.js plays the stream itself, in order, as the HLS spec has a + // player without interstitials do. enableInterstitialPlayback: false, stretchShortVideoTrack: false, maxAudioFramesDrift: 1, diff --git a/tests/e2e/specs/hls-interstitials.e2e.mjs b/tests/e2e/specs/hls-interstitials.e2e.mjs index 40fcb352..be6aa870 100644 --- a/tests/e2e/specs/hls-interstitials.e2e.mjs +++ b/tests/e2e/specs/hls-interstitials.e2e.mjs @@ -6,9 +6,10 @@ // enableInterstitialPlayback went off (HLSPlayer), on the hls-ts fixture's 9 s with a 9 s ad: // a break that replaces part of the stream (no X-RESUME-OFFSET, as live ad insertion has it) // made the video skip from 0 to 9.9 s within a second and end at 13; plain hls.js played the -// stream to 4 s, then the ad. A break inserted into the stream (X-RESUME-OFFSET=0) played no -// ad in FastStream, though hls.js fetched the ad's playlist and kept a schedule that never -// moved. Now hls.js plays the stream itself, in order, as the HLS spec has a player without +// stream to 4 s, then the ad. A break inserted before the stream (X-RESUME-OFFSET=0, CUE="PRE") +// played no ad in FastStream, though hls.js fetched the ad's playlist. One inserted into the +// stream already played the stream itself (hls.js's schedule never moved); its test keeps it +// that way. Now hls.js plays the stream itself, in order, as the HLS spec has a player without // interstitials do, and never asks for the ad. import fs from 'node:fs'; @@ -58,16 +59,18 @@ const PLAYLISTS = { }; /** - * Plays a stream to its end, noting the time every second. + * Plays a stream to its end, noting about every second where the video is and when the page + * saw it there. * @param {string} name - Its path. - * @return {Promise<{times: number[], ended: boolean, duration: number, error: ?string}>} + * @return {Promise<{samples: Array<{time: number, at: number}>, ended: boolean, duration: number, + * error: ?string}>} at: the page's clock, in seconds. */ async function playThrough(name) { requests = []; await browser.url(`/player/index.html?t=${Date.now()}#${ORIGIN}${name}`); - const times = []; + const samples = []; let last = null; - const end = Date.now() + 30000; + const end = Date.now() + 45000; while (Date.now() < end) { last = await browser.execute(() => { const client = window.fastStream; @@ -75,15 +78,15 @@ async function playThrough(name) { if (!video) return null; if (video.paused && !video.ended) video.play().catch(() => {}); return { - time: video.currentTime, ended: video.ended, duration: video.duration, + time: video.currentTime, at: performance.now() / 1000, ended: video.ended, duration: video.duration, error: client.interfaceController?.statusManager?.statusMessages?.get('error')?.message || null, }; }); - if (last) times.push(last.time); + if (last) samples.push({time: last.time, at: last.at}); if (last?.ended || last?.error) break; await browser.pause(1000); } - return {times, ended: !!last?.ended, duration: last?.duration ?? NaN, error: last?.error ?? null}; + return {samples, ended: !!last?.ended, duration: last?.duration ?? NaN, error: last?.error ?? null}; } describe('An HLS stream with an interstitial (an ad break)', function() { @@ -123,14 +126,17 @@ describe('An HLS stream with an interstitial (an ad break)', function() { ['/midroll-replacing.m3u8', 'replacing part of the stream'], ]) { it(`plays the stream itself, in order, past a break ${what}, and never asks for the ad`, async function() { - const {times, ended, duration, error} = await playThrough(name); + const {samples, ended, duration, error} = await playThrough(name); expect(error).toBe(null); expect(ended).toBe(true); // The stream's 9 s, not a timeline with the ad's 9 in it. expect(duration).toBeCloseTo(9, 0); - // A second a step, never a jump. - const steps = times.slice(1).map((t, i) => Math.round((t - times[i]) * 10) / 10); - expect(steps.filter((s) => s < 0 || s > 2.5)).toEqual([]); + // Never back, and never further on than the page's clock went (a jump), with a second + // to spare for a frame that lands between two samples. + const jumps = samples.slice(1).map((s, i) => ({ + from: samples[i].time, to: s.time, ahead: (s.time - samples[i].time) - (s.at - samples[i].at), + })).filter((step) => step.to < step.from - 0.05 || step.ahead > 1); + expect(jumps).toEqual([]); expect(requests.filter((p) => p.startsWith('/ad'))).toEqual([]); }); }