Skip to content

Commit 69cc5a7

Browse files
committed
Add FairPlay support with I-frame playlists and update related streams
1 parent a0e4aac commit 69cc5a7

6 files changed

Lines changed: 555 additions & 0 deletions

File tree

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@ Only the patched manifests are hosted here; all media segments (`.mp4`) and the
2222

2323
Playlist: https://pbs.github.io/test-streams/storage.googleapis.com/shaka-demo-assets/angel-one-widevine-hls/hls.m3u8
2424

25+
### EZDRM — FairPlay (with I-frame playlists added)
26+
27+
A single-file fMP4/CMAF HLS playlist sourced from EZDRM's FairPlay demo, encrypted with **FairPlay** (`SAMPLE-AES`, `cbcs`). `EXT-X-I-FRAME-STREAM-INF` entries plus an I-frame-only child playlist were synthesized for trick-play (scrubbing/seek thumbnails).
28+
29+
The whole variant lives in one `video.mp4` addressed entirely by `EXT-X-BYTERANGE`, so the I-frame playlist also references `video.mp4` by byterange — each I-frame byterange is an absolute offset into the file covering the fragment header (with the `cbcs` `senc`/`saiz`/`saio` boxes) through the IDR sample. The I-frame playlist carries the original `EXT-X-KEY` (with its `skd://` URI) and `EXT-X-MAP` so trick-play frames decrypt with the same key. `SAMPLE-AES` preserves the fMP4 box structure, so the IDR byteranges were derived from the container without the content key.
30+
31+
Only the patched manifests are hosted here; the media (`video.mp4`, `audio.mp4`) and the EZDRM FairPlay license server are still served from the EZDRM origin via absolute URLs. To play, point a FairPlay-capable player at the EZDRM license server.
32+
33+
Playlist: https://pbs.github.io/test-streams/na-fps.ezdrm.com/demo/ezdrm/master.m3u8
34+
2535
### PBS — Test Pattern
2636

2737
PBS-branded SMPTE-style color-bars test stream packaged as 4K multicodec HLS, captions, burned in ABR variant id overlay (e.g. 720 HEVC or 2160 AV1), and I-frame playlists for trick-play.
Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
#!/usr/bin/env node
2+
// Snapshot a remote FairPlay (SAMPLE-AES) single-file fMP4/CMAF HLS master +
3+
// child playlists into streams/, and SYNTHESIZE #EXT-X-I-FRAME-STREAM-INF +
4+
// matching I-frame-only child playlists by ffprobing every fragment for the IDR
5+
// byte offset. Media is NOT copied — the rewritten playlists point at absolute
6+
// URLs back to the origin server.
7+
//
8+
// This is the FairPlay sibling of build-iframe-stream-drm.mjs (Widevine /
9+
// SAMPLE-AES-CTR). Two things make this asset different:
10+
// * SINGLE-FILE BYTERANGE fMP4: the whole variant lives in one video.mp4 and
11+
// every "segment" is an #EXT-X-BYTERANGE into it, including the #EXT-X-MAP
12+
// init range. There are no per-segment files, so the I-frame playlist also
13+
// references video.mp4 by byterange, and each I-frame byterange offset is
14+
// ABSOLUTE into video.mp4 (not 0 like the multi-file Widevine case).
15+
// * FAIRPLAY: #EXT-X-KEY uses METHOD=SAMPLE-AES with an skd:// URI and
16+
// KEYFORMAT="com.apple.streamingkeydelivery". The key line is carried into
17+
// the I-frame playlist verbatim; the skd:// URI must NOT be URL-resolved
18+
// against the origin. SAMPLE-AES (cbcs) encrypts NAL payloads in place and
19+
// preserves the moof/mdat box structure, so ffprobe still reports IDR byte
20+
// offsets/sizes from the container without the content key.
21+
//
22+
// To play, point a FairPlay-capable player at the EZDRM license server.
23+
//
24+
// Usage: node scripts/build-iframe-stream-fairplay.mjs
25+
26+
import { writeFile, mkdir } from 'node:fs/promises';
27+
import { dirname, resolve } from 'node:path';
28+
import { fileURLToPath } from 'node:url';
29+
import { execFile } from 'node:child_process';
30+
import { promisify } from 'node:util';
31+
import { tmpdir } from 'node:os';
32+
33+
const execFileP = promisify(execFile);
34+
35+
const MASTER_URL = 'https://na-fps.ezdrm.com/demo/ezdrm/master.m3u8';
36+
const CONCURRENCY = 8;
37+
38+
// Output path mirrors the origin URL: streams/<host>/<url-dirname>/
39+
const __dirname = dirname(fileURLToPath(import.meta.url));
40+
const ROOT = resolve(__dirname, '..');
41+
const _masterUrl = new URL(MASTER_URL);
42+
const OUT_DIR = resolve(
43+
ROOT,
44+
'streams',
45+
_masterUrl.hostname,
46+
dirname(_masterUrl.pathname).replace(/^\/+/, ''),
47+
);
48+
const MASTER_FILENAME = _masterUrl.pathname.split('/').pop();
49+
const TMP = tmpdir();
50+
51+
async function fetchText(url) {
52+
const r = await fetch(url);
53+
if (!r.ok) throw new Error(`${url} -> ${r.status}`);
54+
return r.text();
55+
}
56+
57+
async function fetchBuffer(url) {
58+
const r = await fetch(url);
59+
if (!r.ok) throw new Error(`${url} -> ${r.status}`);
60+
return Buffer.from(await r.arrayBuffer());
61+
}
62+
63+
// Parse a master playlist. Returns { lines, variants } where each variant is
64+
// { attrs, uri } for an #EXT-X-STREAM-INF (video) entry.
65+
function parseMaster(text) {
66+
const lines = text.split(/\r?\n/);
67+
const variants = [];
68+
for (let i = 0; i < lines.length; i++) {
69+
if (lines[i].startsWith('#EXT-X-STREAM-INF:')) {
70+
variants.push({ attrs: lines[i].slice('#EXT-X-STREAM-INF:'.length), uri: lines[i + 1] });
71+
}
72+
}
73+
return { lines, variants };
74+
}
75+
76+
function parseAttrs(s) {
77+
const out = {};
78+
const re = /([A-Z0-9-]+)=("([^"]*)"|([^,]+))/g;
79+
let m;
80+
while ((m = re.exec(s))) out[m[1]] = m[3] ?? m[4];
81+
return out;
82+
}
83+
84+
// Parse `<length>[@<offset>]`. If offset is absent the caller supplies the
85+
// running offset (byte after the previous sub-range of the same resource).
86+
function parseByterange(s) {
87+
const [len, off] = s.split('@');
88+
return { length: parseInt(len, 10), offset: off !== undefined ? parseInt(off, 10) : null };
89+
}
90+
91+
// Parse a single-file byterange media playlist into an ordered list of events,
92+
// resolving every byterange to an absolute { length, offset } into the media
93+
// file. Because the #EXT-X-MAP init range and every segment share one resource
94+
// (video.mp4), a missing @offset continues from the previous range's end.
95+
// { type: 'map', uri, range }
96+
// { type: 'key', line } (verbatim #EXT-X-KEY line)
97+
// { type: 'disc' } (#EXT-X-DISCONTINUITY)
98+
// { type: 'seg', duration, uri, range }
99+
function parseMedia(text) {
100+
const lines = text.split(/\r?\n/);
101+
const events = [];
102+
let pendingDur = null;
103+
let pendingRange = null;
104+
let mapUri = null;
105+
let lastEnd = 0; // running end offset for implicit byterange continuation
106+
const resolveRange = (r) => {
107+
const offset = r.offset !== null ? r.offset : lastEnd;
108+
lastEnd = offset + r.length;
109+
return { length: r.length, offset };
110+
};
111+
for (const line of lines) {
112+
if (line.startsWith('#EXT-X-MAP:')) {
113+
const u = line.match(/URI="([^"]+)"/);
114+
const br = line.match(/BYTERANGE="([^"]+)"/);
115+
mapUri = u ? u[1] : null;
116+
const range = br ? resolveRange(parseByterange(br[1])) : null;
117+
events.push({ type: 'map', uri: mapUri, range });
118+
} else if (line.startsWith('#EXT-X-KEY:')) {
119+
events.push({ type: 'key', line });
120+
} else if (line.startsWith('#EXT-X-DISCONTINUITY')) {
121+
events.push({ type: 'disc' });
122+
} else if (line.startsWith('#EXTINF:')) {
123+
pendingDur = parseFloat(line.slice('#EXTINF:'.length));
124+
} else if (line.startsWith('#EXT-X-BYTERANGE:')) {
125+
pendingRange = parseByterange(line.slice('#EXT-X-BYTERANGE:'.length));
126+
} else if (!line.startsWith('#') && line.trim() && pendingDur !== null) {
127+
const range = pendingRange ? resolveRange(pendingRange) : null;
128+
events.push({ type: 'seg', duration: pendingDur, uri: line.trim(), range });
129+
pendingDur = null;
130+
pendingRange = null;
131+
}
132+
}
133+
return { events, mapUri };
134+
}
135+
136+
// A URI is left untouched if it already carries a scheme (https:, data:, and
137+
// crucially skd: for the FairPlay key). Only schemeless relative refs (e.g.
138+
// video.mp4, stream_0.m3u8) are resolved against the playlist's origin URL.
139+
function abs(uri, base) {
140+
if (/^[a-z][a-z0-9+.-]*:/i.test(uri)) return uri;
141+
return new URL(uri, base).href;
142+
}
143+
144+
// Rewrite a child playlist so every segment reference and URI="" attr is
145+
// absolute back to origin. Scheme-bearing URIs (the skd:// key) are left as-is.
146+
function rewriteChildAbsolute(text, baseUrl) {
147+
return text
148+
.split(/\r?\n/)
149+
.map((line) => {
150+
if (!line) return line;
151+
if (line.startsWith('#')) {
152+
return line.replace(/URI="([^"]+)"/, (full, uri) => `URI="${abs(uri, baseUrl)}"`);
153+
}
154+
return abs(line, baseUrl);
155+
})
156+
.join('\n');
157+
}
158+
159+
// ffprobe a fragment (init segment concatenated in front of a media byterange)
160+
// for the first video keyframe's { pos, size } (file-relative). The caller
161+
// subtracts initSize to get the offset within the original fragment.
162+
async function probeFirstKeyframe(filePath) {
163+
const { stdout } = await execFileP(
164+
'ffprobe',
165+
[
166+
'-v', 'error',
167+
'-select_streams', 'v:0',
168+
'-show_packets',
169+
'-show_entries', 'packet=size,pos,flags',
170+
'-of', 'json',
171+
'-i', filePath,
172+
],
173+
{ maxBuffer: 64 * 1024 * 1024 },
174+
);
175+
const packets = JSON.parse(stdout).packets || [];
176+
for (const p of packets) {
177+
if (typeof p.flags === 'string' && p.flags.startsWith('K')) {
178+
return { pos: parseInt(p.pos, 10), size: parseInt(p.size, 10) };
179+
}
180+
}
181+
return null;
182+
}
183+
184+
async function mapLimit(items, limit, fn, onProgress) {
185+
const results = new Array(items.length);
186+
let next = 0;
187+
let done = 0;
188+
await Promise.all(
189+
Array.from({ length: Math.min(limit, items.length) }, async () => {
190+
while (next < items.length) {
191+
const i = next++;
192+
results[i] = await fn(items[i], i);
193+
onProgress?.(++done, items.length);
194+
}
195+
}),
196+
);
197+
return results;
198+
}
199+
200+
async function processVariant(variant, masterUrl, idx) {
201+
const attrs = parseAttrs(variant.attrs);
202+
const variantUrl = new URL(variant.uri, masterUrl);
203+
const tag = `[${attrs.RESOLUTION || variant.uri}]`;
204+
console.log(`${tag} fetching ${variantUrl.href}`);
205+
const childText = await fetchText(variantUrl);
206+
const { events, mapUri } = parseMedia(childText);
207+
208+
if (!mapUri) throw new Error(`${tag} no #EXT-X-MAP — not fMP4?`);
209+
const mapEvent = events.find((e) => e.type === 'map');
210+
if (!mapEvent?.range) throw new Error(`${tag} #EXT-X-MAP has no BYTERANGE`);
211+
212+
// This asset is single-file byterange fMP4: every segment references the same
213+
// media file as the init MAP. Download it once and slice ranges locally.
214+
const mediaUrl = new URL(mapUri, variantUrl).href;
215+
const mediaBuf = await fetchBuffer(mediaUrl);
216+
const initBuf = mediaBuf.subarray(
217+
mapEvent.range.offset,
218+
mapEvent.range.offset + mapEvent.range.length,
219+
);
220+
const initSize = initBuf.length;
221+
222+
const segEvents = events.filter((e) => e.type === 'seg');
223+
const probed = await mapLimit(
224+
segEvents,
225+
CONCURRENCY,
226+
async (seg, i) => {
227+
const segBuf = mediaBuf.subarray(seg.range.offset, seg.range.offset + seg.range.length);
228+
const catPath = resolve(TMP, `iframe-fps-${idx}-${i}.mp4`);
229+
await writeFile(catPath, Buffer.concat([initBuf, segBuf]));
230+
const kf = await probeFirstKeyframe(catPath);
231+
// I-frame byterange covers [start of fragment .. end of keyframe sample],
232+
// i.e. styp/moof (with the cbcs senc/saiz/saio + trun) plus the IDR bytes
233+
// — everything a player needs to decrypt and decode the frame. The offset
234+
// is ABSOLUTE into video.mp4 (the fragment's own offset).
235+
const length = kf ? kf.pos - initSize + kf.size : seg.range.length;
236+
return { length, offset: seg.range.offset };
237+
},
238+
(done, total) => process.stdout.write(`${tag} probed ${done}/${total}\r`),
239+
);
240+
process.stdout.write('\n');
241+
242+
// ---- Build the I-frame playlist, carrying MAP / KEY / DISCONTINUITY ----
243+
const out = [
244+
'#EXTM3U',
245+
'#EXT-X-VERSION:6',
246+
'#EXT-X-I-FRAMES-ONLY',
247+
'#EXT-X-PLAYLIST-TYPE:VOD',
248+
];
249+
let maxDur = 0;
250+
let segCursor = 0;
251+
const body = [];
252+
let totalBytes = 0;
253+
let totalDuration = 0;
254+
for (const ev of events) {
255+
if (ev.type === 'map') {
256+
body.push(
257+
`#EXT-X-MAP:URI="${abs(ev.uri, variantUrl)}",BYTERANGE="${ev.range.length}@${ev.range.offset}"`,
258+
);
259+
} else if (ev.type === 'key') {
260+
// skd:// key URI needs no rewrite; rewriteChildAbsolute leaves it as-is.
261+
body.push(rewriteChildAbsolute(ev.line, variantUrl));
262+
} else if (ev.type === 'disc') {
263+
body.push('#EXT-X-DISCONTINUITY');
264+
} else if (ev.type === 'seg') {
265+
const { length, offset } = probed[segCursor++];
266+
totalBytes += length;
267+
totalDuration += ev.duration;
268+
maxDur = Math.max(maxDur, ev.duration);
269+
body.push(`#EXTINF:${ev.duration.toFixed(3)},`);
270+
body.push(`#EXT-X-BYTERANGE:${length}@${offset}`);
271+
body.push(abs(ev.uri, variantUrl));
272+
}
273+
}
274+
out.push(`#EXT-X-TARGETDURATION:${Math.ceil(maxDur)}`);
275+
const iframePlaylist = [...out, ...body, '#EXT-X-ENDLIST', ''].join('\n');
276+
const iframeBandwidth = Math.round((totalBytes * 8) / totalDuration);
277+
278+
// ---- Write the rewritten child playlist + the I-frame playlist ----
279+
const relChild = variant.uri;
280+
const relIframe = relChild.replace(/\.m3u8$/, '.iframe.m3u8');
281+
const outChild = resolve(OUT_DIR, relChild);
282+
const outIframe = resolve(OUT_DIR, relIframe);
283+
await mkdir(dirname(outChild), { recursive: true });
284+
await writeFile(outChild, rewriteChildAbsolute(childText, variantUrl));
285+
await writeFile(outIframe, iframePlaylist);
286+
287+
const codecs = (attrs.CODECS || '')
288+
.split(',')
289+
.filter((c) => /^(avc|hev|hvc)/i.test(c.trim()))
290+
.map((c) => c.trim())
291+
.join(',');
292+
293+
console.log(
294+
`${tag} ${probed.length} I-frames, ${(totalBytes / 1024).toFixed(1)} KiB over ` +
295+
`${totalDuration.toFixed(1)}s -> ${iframeBandwidth} bps`,
296+
);
297+
298+
return { bandwidth: iframeBandwidth, codecs, resolution: attrs.RESOLUTION, uri: relIframe };
299+
}
300+
301+
async function main() {
302+
console.log(`Master: ${MASTER_URL}`);
303+
console.log(`Output: ${OUT_DIR}`);
304+
await mkdir(OUT_DIR, { recursive: true });
305+
306+
const masterText = await fetchText(MASTER_URL);
307+
const masterUrl = new URL(MASTER_URL);
308+
const { variants } = parseMaster(masterText);
309+
310+
// Mirror every audio/subtitle media playlist locally too, with segment refs
311+
// rewritten to absolute origin URLs, so the hosted master is fully resolvable.
312+
const mediaUris = [...masterText.matchAll(/#EXT-X-MEDIA:[^\n]*URI="([^"]+)"/g)].map((m) => m[1]);
313+
for (const uri of mediaUris) {
314+
const u = new URL(uri, masterUrl);
315+
const text = await fetchText(u);
316+
const outPath = resolve(OUT_DIR, uri);
317+
await mkdir(dirname(outPath), { recursive: true });
318+
await writeFile(outPath, rewriteChildAbsolute(text, u));
319+
console.log(`[media] ${uri}`);
320+
}
321+
322+
const iframeInfs = [];
323+
for (let i = 0; i < variants.length; i++) {
324+
iframeInfs.push(await processVariant(variants[i], masterUrl, i));
325+
}
326+
327+
// Append I-frame stream-inf lines to the original master, in variant order.
328+
// Child STREAM-INF / MEDIA URIs stay relative — they resolve to the local copies.
329+
let out = masterText;
330+
if (!out.endsWith('\n')) out += '\n';
331+
for (const inf of iframeInfs) {
332+
const a = [
333+
`BANDWIDTH=${inf.bandwidth}`,
334+
inf.codecs ? `CODECS="${inf.codecs}"` : null,
335+
inf.resolution ? `RESOLUTION=${inf.resolution}` : null,
336+
`URI="${inf.uri}"`,
337+
]
338+
.filter(Boolean)
339+
.join(',');
340+
out += `#EXT-X-I-FRAME-STREAM-INF:${a}\n`;
341+
}
342+
343+
const masterOut = resolve(OUT_DIR, MASTER_FILENAME);
344+
await writeFile(masterOut, out);
345+
console.log(`\nWrote ${masterOut}`);
346+
}
347+
348+
main().catch((e) => {
349+
console.error(e);
350+
process.exit(1);
351+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
#EXTM3U
2+
## Generated with https://github.com/google/shaka-packager version v2.4.3-dd9870075f-release
3+
4+
#EXT-X-MEDIA:TYPE=AUDIO,URI="stream_0.m3u8",GROUP-ID="default-audio-group",NAME="stream_0",AUTOSELECT=YES,CHANNELS="2"
5+
6+
#EXT-X-STREAM-INF:BANDWIDTH=5221194,AVERAGE-BANDWIDTH=2229378,CODECS="avc1.64001f,mp4a.40.2",RESOLUTION=1280x720,FRAME-RATE=24.000,VIDEO-RANGE=SDR,AUDIO="default-audio-group"
7+
stream_1.m3u8
8+
#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=126903,CODECS="avc1.64001f",RESOLUTION=1280x720,URI="stream_1.iframe.m3u8"

0 commit comments

Comments
 (0)