Skip to content

Commit a0e4aac

Browse files
committed
Add I-Frame playlists for Angel One HLS + Widevine DRM shaka packager test video
1 parent 38e3750 commit a0e4aac

23 files changed

Lines changed: 1209 additions & 0 deletions

README.md

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

1313
Playlist: https://pbs.github.io/test-streams/test-streams.mux.dev/x36xhzz/x36xhzz.m3u8
1414

15+
### Shaka — Angel One (Widevine, with I-frame playlists added)
16+
17+
A multivariant fMP4/CMAF HLS playlist of *Angel One* sourced from the Shaka demo assets, encrypted with **Widevine** (`SAMPLE-AES-CTR`) with a short clear lead. `EXT-X-I-FRAME-STREAM-INF` entries plus I-frame-only child playlists were synthesized for trick-play (scrubbing/seek thumbnails).
18+
19+
Because the content is CENC-encrypted, the I-frame playlists carry the original `EXT-X-KEY`, `EXT-X-MAP`, and `EXT-X-DISCONTINUITY` tags so trick-play frames decrypt with the same keys. CENC preserves the fMP4 box structure, so the IDR byteranges were derived from the container without the content key.
20+
21+
Only the patched manifests are hosted here; all media segments (`.mp4`) and the in-manifest Widevine `pssh` (`data:` URI) are still served from the Shaka origin via absolute URLs. To play, point your DRM-capable player at the Widevine license server `https://cwip-shaka-proxy.appspot.com/no_auth`.
22+
23+
Playlist: https://pbs.github.io/test-streams/storage.googleapis.com/shaka-demo-assets/angel-one-widevine-hls/hls.m3u8
24+
1525
### PBS — Test Pattern
1626

1727
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: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
#!/usr/bin/env node
2+
// Snapshot a remote DRM (Widevine / SAMPLE-AES-CTR) 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 segment for the IDR
5+
// byte offset. Segments are NOT copied — the rewritten playlists point at
6+
// absolute URLs back to the origin server.
7+
//
8+
// This is the DRM/fMP4 sibling of build-iframe-stream.mjs (which targets clear
9+
// MPEG-TS). The differences that matter here:
10+
// * fMP4: each variant has an #EXT-X-MAP init segment that ffprobe needs
11+
// prepended to a media segment before it can be demuxed, and the I-frame
12+
// playlists must re-emit that #EXT-X-MAP.
13+
// * DRM: #EXT-X-KEY (and the #EXT-X-DISCONTINUITY at the clear-lead -> encrypted
14+
// boundary) must be carried into the I-frame playlists so the trick-play
15+
// frames decrypt with the same keys. CENC encrypts sample payloads in place
16+
// and preserves the moof/mdat box structure, so ffprobe can still report IDR
17+
// byte offsets/sizes from the container without the content key.
18+
//
19+
// Usage: node scripts/build-iframe-stream-drm.mjs
20+
21+
import { writeFile, mkdir } from 'node:fs/promises';
22+
import { dirname, resolve } from 'node:path';
23+
import { fileURLToPath } from 'node:url';
24+
import { execFile } from 'node:child_process';
25+
import { promisify } from 'node:util';
26+
import { tmpdir } from 'node:os';
27+
28+
const execFileP = promisify(execFile);
29+
30+
const MASTER_URL =
31+
'https://storage.googleapis.com/shaka-demo-assets/angel-one-widevine-hls/hls.m3u8';
32+
const CONCURRENCY = 8;
33+
34+
// Output path mirrors the origin URL: streams/<host>/<url-dirname>/
35+
const __dirname = dirname(fileURLToPath(import.meta.url));
36+
const ROOT = resolve(__dirname, '..');
37+
const _masterUrl = new URL(MASTER_URL);
38+
const OUT_DIR = resolve(
39+
ROOT,
40+
'streams',
41+
_masterUrl.hostname,
42+
dirname(_masterUrl.pathname).replace(/^\/+/, ''),
43+
);
44+
const MASTER_FILENAME = _masterUrl.pathname.split('/').pop();
45+
const TMP = tmpdir();
46+
47+
async function fetchText(url) {
48+
const r = await fetch(url);
49+
if (!r.ok) throw new Error(`${url} -> ${r.status}`);
50+
return r.text();
51+
}
52+
53+
async function fetchBuffer(url) {
54+
const r = await fetch(url);
55+
if (!r.ok) throw new Error(`${url} -> ${r.status}`);
56+
return Buffer.from(await r.arrayBuffer());
57+
}
58+
59+
// Parse a master playlist. Returns { lines, variants } where each variant is
60+
// { attrs, uri, lineIndex } for an #EXT-X-STREAM-INF (video) entry.
61+
function parseMaster(text) {
62+
const lines = text.split(/\r?\n/);
63+
const variants = [];
64+
for (let i = 0; i < lines.length; i++) {
65+
if (lines[i].startsWith('#EXT-X-STREAM-INF:')) {
66+
variants.push({ attrs: lines[i].slice('#EXT-X-STREAM-INF:'.length), uri: lines[i + 1] });
67+
}
68+
}
69+
return { lines, variants };
70+
}
71+
72+
function parseAttrs(s) {
73+
const out = {};
74+
const re = /([A-Z0-9-]+)=("([^"]*)"|([^,]+))/g;
75+
let m;
76+
while ((m = re.exec(s))) out[m[1]] = m[3] ?? m[4];
77+
return out;
78+
}
79+
80+
// Parse a media playlist into an ordered list of events that preserves the
81+
// per-segment tag context we care about for trick-play:
82+
// { type: 'map', uri }
83+
// { type: 'key', line } (verbatim #EXT-X-KEY line)
84+
// { type: 'disc' } (#EXT-X-DISCONTINUITY)
85+
// { type: 'seg', duration, uri }
86+
function parseMedia(text) {
87+
const lines = text.split(/\r?\n/);
88+
const events = [];
89+
let pendingDur = null;
90+
let mapUri = null;
91+
for (const line of lines) {
92+
if (line.startsWith('#EXT-X-MAP:')) {
93+
const m = line.match(/URI="([^"]+)"/);
94+
mapUri = m ? m[1] : null;
95+
events.push({ type: 'map', uri: mapUri });
96+
} else if (line.startsWith('#EXT-X-KEY:')) {
97+
events.push({ type: 'key', line });
98+
} else if (line.startsWith('#EXT-X-DISCONTINUITY')) {
99+
events.push({ type: 'disc' });
100+
} else if (line.startsWith('#EXTINF:')) {
101+
pendingDur = parseFloat(line.slice('#EXTINF:'.length));
102+
} else if (!line.startsWith('#') && line.trim() && pendingDur !== null) {
103+
events.push({ type: 'seg', duration: pendingDur, uri: line.trim() });
104+
pendingDur = null;
105+
}
106+
}
107+
return { events, mapUri };
108+
}
109+
110+
function abs(uri, base) {
111+
if (/^(https?|data):/i.test(uri)) return uri;
112+
return new URL(uri, base).href;
113+
}
114+
115+
// Rewrite a child playlist so every segment reference, #EXT-X-MAP URI, and any
116+
// other URI="" attr is absolute back to origin. data: URIs (e.g. the #EXT-X-KEY
117+
// pssh payload) are left untouched.
118+
function rewriteChildAbsolute(text, baseUrl) {
119+
return text
120+
.split(/\r?\n/)
121+
.map((line) => {
122+
if (!line) return line;
123+
if (line.startsWith('#')) {
124+
return line.replace(/URI="([^"]+)"/, (full, uri) => `URI="${abs(uri, baseUrl)}"`);
125+
}
126+
return abs(line, baseUrl);
127+
})
128+
.join('\n');
129+
}
130+
131+
// ffprobe a segment that has already been concatenated behind its init segment.
132+
// Returns the first video keyframe's { pos, size } (file-relative) and initSize
133+
// is subtracted by the caller. We only need the first IDR because every segment
134+
// in this asset is exactly one GOP (one keyframe), but we still scan for the
135+
// first K-flagged packet defensively.
136+
async function probeFirstKeyframe(filePath) {
137+
const { stdout } = await execFileP(
138+
'ffprobe',
139+
[
140+
'-v', 'error',
141+
'-select_streams', 'v:0',
142+
'-show_packets',
143+
'-show_entries', 'packet=size,pos,flags',
144+
'-of', 'json',
145+
'-i', filePath,
146+
],
147+
{ maxBuffer: 64 * 1024 * 1024 },
148+
);
149+
const packets = JSON.parse(stdout).packets || [];
150+
for (const p of packets) {
151+
if (typeof p.flags === 'string' && p.flags.startsWith('K')) {
152+
return { pos: parseInt(p.pos, 10), size: parseInt(p.size, 10) };
153+
}
154+
}
155+
return null;
156+
}
157+
158+
async function mapLimit(items, limit, fn, onProgress) {
159+
const results = new Array(items.length);
160+
let next = 0;
161+
let done = 0;
162+
await Promise.all(
163+
Array.from({ length: Math.min(limit, items.length) }, async () => {
164+
while (next < items.length) {
165+
const i = next++;
166+
results[i] = await fn(items[i], i);
167+
onProgress?.(++done, items.length);
168+
}
169+
}),
170+
);
171+
return results;
172+
}
173+
174+
async function processVariant(variant, masterUrl, idx) {
175+
const attrs = parseAttrs(variant.attrs);
176+
const variantUrl = new URL(variant.uri, masterUrl);
177+
const tag = `[${attrs.RESOLUTION || variant.uri}]`;
178+
console.log(`${tag} fetching ${variantUrl.href}`);
179+
const childText = await fetchText(variantUrl);
180+
const { events, mapUri } = parseMedia(childText);
181+
182+
if (!mapUri) throw new Error(`${tag} no #EXT-X-MAP — not fMP4?`);
183+
184+
// Download the init segment once; it must be prepended to each media segment
185+
// for ffprobe to demux the fMP4 fragment.
186+
const initBuf = await fetchBuffer(new URL(mapUri, variantUrl).href);
187+
const initSize = initBuf.length;
188+
const initPath = resolve(TMP, `iframe-drm-init-${idx}.mp4`);
189+
await writeFile(initPath, initBuf);
190+
191+
const segEvents = events.filter((e) => e.type === 'seg');
192+
const probed = await mapLimit(
193+
segEvents,
194+
CONCURRENCY,
195+
async (seg, i) => {
196+
const segBuf = await fetchBuffer(new URL(seg.uri, variantUrl).href);
197+
const catPath = resolve(TMP, `iframe-drm-${idx}-${i}.mp4`);
198+
await writeFile(catPath, Buffer.concat([initBuf, segBuf]));
199+
const kf = await probeFirstKeyframe(catPath);
200+
// I-frame byterange covers [start of fragment .. end of keyframe sample].
201+
// The keyframe is the first mdat sample, so this includes styp+sidx+moof
202+
// (with the CENC senc/saiz/saio + trun) plus the IDR bytes — everything a
203+
// player needs to decrypt and decode the frame.
204+
const length = kf ? kf.pos - initSize + kf.size : segBuf.length;
205+
return { length, offset: 0 };
206+
},
207+
(done, total) => process.stdout.write(`${tag} probed ${done}/${total}\r`),
208+
);
209+
process.stdout.write('\n');
210+
211+
// ---- Build the I-frame playlist, carrying MAP / KEY / DISCONTINUITY ----
212+
const out = [
213+
'#EXTM3U',
214+
'#EXT-X-VERSION:6',
215+
'#EXT-X-I-FRAMES-ONLY',
216+
'#EXT-X-PLAYLIST-TYPE:VOD',
217+
];
218+
let maxDur = 0;
219+
let segCursor = 0;
220+
const body = [];
221+
let totalBytes = 0;
222+
let totalDuration = 0;
223+
for (const ev of events) {
224+
if (ev.type === 'map') {
225+
body.push(`#EXT-X-MAP:URI="${abs(ev.uri, variantUrl)}"`);
226+
} else if (ev.type === 'key') {
227+
// data: pssh URI needs no rewrite; rewriteChildAbsolute leaves it as-is.
228+
body.push(rewriteChildAbsolute(ev.line, variantUrl));
229+
} else if (ev.type === 'disc') {
230+
body.push('#EXT-X-DISCONTINUITY');
231+
} else if (ev.type === 'seg') {
232+
const { length, offset } = probed[segCursor++];
233+
totalBytes += length;
234+
totalDuration += ev.duration;
235+
maxDur = Math.max(maxDur, ev.duration);
236+
body.push(`#EXTINF:${ev.duration.toFixed(3)},`);
237+
body.push(`#EXT-X-BYTERANGE:${length}@${offset}`);
238+
body.push(abs(ev.uri, variantUrl));
239+
}
240+
}
241+
out.push(`#EXT-X-TARGETDURATION:${Math.ceil(maxDur)}`);
242+
const iframePlaylist = [...out, ...body, '#EXT-X-ENDLIST', ''].join('\n');
243+
const iframeBandwidth = Math.round((totalBytes * 8) / totalDuration);
244+
245+
// ---- Write the rewritten child playlist + the I-frame playlist ----
246+
const relChild = variant.uri;
247+
const relIframe = relChild.replace(/\.m3u8$/, '.iframe.m3u8');
248+
const outChild = resolve(OUT_DIR, relChild);
249+
const outIframe = resolve(OUT_DIR, relIframe);
250+
await mkdir(dirname(outChild), { recursive: true });
251+
await writeFile(outChild, rewriteChildAbsolute(childText, variantUrl));
252+
await writeFile(outIframe, iframePlaylist);
253+
254+
const codecs = (attrs.CODECS || '')
255+
.split(',')
256+
.filter((c) => /^(avc|hev|hvc)/i.test(c.trim()))
257+
.map((c) => c.trim())
258+
.join(',');
259+
260+
console.log(
261+
`${tag} ${probed.length} I-frames, ${(totalBytes / 1024).toFixed(1)} KiB over ` +
262+
`${totalDuration.toFixed(1)}s -> ${iframeBandwidth} bps`,
263+
);
264+
265+
return { bandwidth: iframeBandwidth, codecs, resolution: attrs.RESOLUTION, uri: relIframe };
266+
}
267+
268+
async function main() {
269+
console.log(`Master: ${MASTER_URL}`);
270+
console.log(`Output: ${OUT_DIR}`);
271+
await mkdir(OUT_DIR, { recursive: true });
272+
273+
const masterText = await fetchText(MASTER_URL);
274+
const masterUrl = new URL(MASTER_URL);
275+
const { variants } = parseMaster(masterText);
276+
277+
// Mirror every audio/subtitle media playlist locally too, with segment refs
278+
// rewritten to absolute origin URLs, so the hosted master is fully resolvable.
279+
const mediaUris = [...masterText.matchAll(/#EXT-X-MEDIA:[^\n]*URI="([^"]+)"/g)].map((m) => m[1]);
280+
for (const uri of mediaUris) {
281+
const u = new URL(uri, masterUrl);
282+
const text = await fetchText(u);
283+
const outPath = resolve(OUT_DIR, uri);
284+
await mkdir(dirname(outPath), { recursive: true });
285+
await writeFile(outPath, rewriteChildAbsolute(text, u));
286+
console.log(`[media] ${uri}`);
287+
}
288+
289+
const iframeInfs = [];
290+
for (let i = 0; i < variants.length; i++) {
291+
iframeInfs.push(await processVariant(variants[i], masterUrl, i));
292+
}
293+
294+
// Append I-frame stream-inf lines to the original master, in variant order.
295+
// Child STREAM-INF / MEDIA URIs stay relative — they resolve to the local copies.
296+
let out = masterText;
297+
if (!out.endsWith('\n')) out += '\n';
298+
for (const inf of iframeInfs) {
299+
const a = [
300+
`BANDWIDTH=${inf.bandwidth}`,
301+
inf.codecs ? `CODECS="${inf.codecs}"` : null,
302+
inf.resolution ? `RESOLUTION=${inf.resolution}` : null,
303+
`URI="${inf.uri}"`,
304+
]
305+
.filter(Boolean)
306+
.join(',');
307+
out += `#EXT-X-I-FRAME-STREAM-INF:${a}\n`;
308+
}
309+
310+
const masterOut = resolve(OUT_DIR, MASTER_FILENAME);
311+
await writeFile(masterOut, out);
312+
console.log(`\nWrote ${masterOut}`);
313+
}
314+
315+
main().catch((e) => {
316+
console.error(e);
317+
process.exit(1);
318+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
#EXTM3U
2+
## Generated with https://github.com/google/shaka-packager version v2.3.0-5bf8ad5-release
3+
4+
#EXT-X-MEDIA:TYPE=AUDIO,URI="playlist_a-eng-0384k-aac-6c.mp4.m3u8",GROUP-ID="default-audio-group",LANGUAGE="en",NAME="stream_6",DEFAULT=YES,AUTOSELECT=YES,CHANNELS="6"
5+
#EXT-X-MEDIA:TYPE=AUDIO,URI="playlist_a-fra-0128k-aac-2c.mp4.m3u8",GROUP-ID="default-audio-group",LANGUAGE="fr",NAME="stream_7",AUTOSELECT=YES,CHANNELS="2"
6+
#EXT-X-MEDIA:TYPE=AUDIO,URI="playlist_a-deu-0128k-aac-2c.mp4.m3u8",GROUP-ID="default-audio-group",LANGUAGE="de",NAME="stream_4",AUTOSELECT=YES,CHANNELS="2"
7+
#EXT-X-MEDIA:TYPE=AUDIO,URI="playlist_a-ita-0128k-aac-2c.mp4.m3u8",GROUP-ID="default-audio-group",LANGUAGE="it",NAME="stream_8",AUTOSELECT=YES,CHANNELS="2"
8+
#EXT-X-MEDIA:TYPE=AUDIO,URI="playlist_a-spa-0128k-aac-2c.mp4.m3u8",GROUP-ID="default-audio-group",LANGUAGE="es",NAME="stream_9",AUTOSELECT=YES,CHANNELS="2"
9+
#EXT-X-MEDIA:TYPE=AUDIO,URI="playlist_a-eng-0128k-aac-2c.mp4.m3u8",GROUP-ID="default-audio-group",LANGUAGE="en",NAME="stream_5",CHANNELS="2"
10+
11+
#EXT-X-MEDIA:TYPE=SUBTITLES,URI="playlist_s-en.webvtt.m3u8",GROUP-ID="default-text-group",LANGUAGE="en",NAME="stream_0",DEFAULT=YES,AUTOSELECT=YES
12+
#EXT-X-MEDIA:TYPE=SUBTITLES,URI="playlist_s-el.webvtt.m3u8",GROUP-ID="default-text-group",LANGUAGE="el",NAME="stream_1",AUTOSELECT=YES
13+
#EXT-X-MEDIA:TYPE=SUBTITLES,URI="playlist_s-fr.webvtt.m3u8",GROUP-ID="default-text-group",LANGUAGE="fr",NAME="stream_2",AUTOSELECT=YES
14+
#EXT-X-MEDIA:TYPE=SUBTITLES,URI="playlist_s-pt-BR.webvtt.m3u8",GROUP-ID="default-text-group",LANGUAGE="pt-BR",NAME="stream_3",AUTOSELECT=YES
15+
16+
#EXT-X-STREAM-INF:BANDWIDTH=831086,AVERAGE-BANDWIDTH=487727,CODECS="avc1.42c01e,mp4a.40.2",RESOLUTION=192x144,AUDIO="default-audio-group",SUBTITLES="default-text-group"
17+
playlist_v-0144p-0100k-libx264.mp4.m3u8
18+
#EXT-X-STREAM-INF:BANDWIDTH=8065760,AVERAGE-BANDWIDTH=1830288,CODECS="avc1.4d401f,mp4a.40.2",RESOLUTION=768x576,AUDIO="default-audio-group",SUBTITLES="default-text-group"
19+
playlist_v-0576p-1400k-libx264.mp4.m3u8
20+
#EXT-X-STREAM-INF:BANDWIDTH=6099164,AVERAGE-BANDWIDTH=1416550,CODECS="avc1.4d401f,mp4a.40.2",RESOLUTION=640x480,AUDIO="default-audio-group",SUBTITLES="default-text-group"
21+
playlist_v-0480p-1000k-libx264.mp4.m3u8
22+
#EXT-X-STREAM-INF:BANDWIDTH=2193558,AVERAGE-BANDWIDTH=789576,CODECS="avc1.4d401f,mp4a.40.2",RESOLUTION=320x240,AUDIO="default-audio-group",SUBTITLES="default-text-group"
23+
playlist_v-0240p-0400k-libx264.mp4.m3u8
24+
#EXT-X-STREAM-INF:BANDWIDTH=4008262,AVERAGE-BANDWIDTH=1152321,CODECS="avc1.4d401f,mp4a.40.2",RESOLUTION=480x360,AUDIO="default-audio-group",SUBTITLES="default-text-group"
25+
playlist_v-0360p-0750k-libx264.mp4.m3u8
26+
#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=11811,CODECS="avc1.42c01e",RESOLUTION=192x144,URI="playlist_v-0144p-0100k-libx264.mp4.iframe.m3u8"
27+
#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=74502,CODECS="avc1.4d401f",RESOLUTION=768x576,URI="playlist_v-0576p-1400k-libx264.mp4.iframe.m3u8"
28+
#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=56609,CODECS="avc1.4d401f",RESOLUTION=640x480,URI="playlist_v-0480p-1000k-libx264.mp4.iframe.m3u8"
29+
#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=27273,CODECS="avc1.4d401f",RESOLUTION=320x240,URI="playlist_v-0240p-0400k-libx264.mp4.iframe.m3u8"
30+
#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=44466,CODECS="avc1.4d401f",RESOLUTION=480x360,URI="playlist_v-0360p-0750k-libx264.mp4.iframe.m3u8"

0 commit comments

Comments
 (0)