From 621d5d32cbd71870164bb25031e19c06bef76433 Mon Sep 17 00:00:00 2001 From: Ben Roberts Date: Thu, 30 Apr 2026 12:07:24 +0200 Subject: [PATCH 1/9] work in progress --- src/controller/base-stream-controller.ts | 3 + src/remux/mp4-remuxer.ts | 1 + src/remux/passthrough-remuxer.ts | 2 + src/utils/mp4-tools.ts | 54 +++++ tests/index.js | 1 + tests/unit/utils/mp4-tools.ts | 264 +++++++++++++++++++++++ 6 files changed, 325 insertions(+) create mode 100644 tests/unit/utils/mp4-tools.ts diff --git a/src/controller/base-stream-controller.ts b/src/controller/base-stream-controller.ts index 52896f65b50..7932325a707 100644 --- a/src/controller/base-stream-controller.ts +++ b/src/controller/base-stream-controller.ts @@ -646,6 +646,7 @@ export default class BaseStreamController return data; }) .then((data: FragLoadedData) => { + console.log('$$$ _loadInitSegment - loaded init segment, checking if decryption is needed', data); const { hls } = this; const { frag, payload } = data; const decryptData = frag.decryptdata; @@ -689,9 +690,11 @@ export default class BaseStreamController }, }); data.payload = decryptedData; + console.log('$$$ _loadInitSegment - calling this.completeInitSegmentLoad with decrypted data', data); return this.completeInitSegmentLoad(data); }); } + console.log('$$$ _loadInitSegment - calling this.completeInitSegmentLoad with non-decrypted data', data); return this.completeInitSegmentLoad(data); }) .catch((reason) => { diff --git a/src/remux/mp4-remuxer.ts b/src/remux/mp4-remuxer.ts index b26f8aab8d3..2dc13c02b7b 100644 --- a/src/remux/mp4-remuxer.ts +++ b/src/remux/mp4-remuxer.ts @@ -133,6 +133,7 @@ export default class MP4Remuxer extends Logger implements Remuxer { } resetInitSegment() { + console.log('$$$ MP4Remuxer - resetInitSegment called with audioCodec'); this.log('ISGenerated flag reset'); this.ISGenerated = false; this.videoTrackConfig = undefined; diff --git a/src/remux/passthrough-remuxer.ts b/src/remux/passthrough-remuxer.ts index 488b504254c..f9cb2b58887 100644 --- a/src/remux/passthrough-remuxer.ts +++ b/src/remux/passthrough-remuxer.ts @@ -73,8 +73,10 @@ class PassThroughRemuxer extends Logger implements Remuxer { videoCodec: string | undefined, decryptdata: DecryptData | null, ) { + console.log('$$$ PassThroughRemuxer - resetInitSegment called with audioCodec', audioCodec, 'videoCodec', videoCodec, 'decryptdata', JSON.stringify(decryptdata)); this.audioCodec = audioCodec; this.videoCodec = videoCodec; + // if (decryptdata) decryptdata.keyFormat = "com.microsoft.playready.recommendation"; this.generateInitSegment(initSegment, decryptdata); this.emitInitSegment = true; } diff --git a/src/utils/mp4-tools.ts b/src/utils/mp4-tools.ts index 4d6d836db2f..c78deefb6ef 100644 --- a/src/utils/mp4-tools.ts +++ b/src/utils/mp4-tools.ts @@ -589,6 +589,60 @@ export function patchEncyptionData( }); } } +/** + * Takes a clear init segment and returns a new one where every avc1 sample entry is wrapped + * as encv (and mp4a as enca), each with a sinf box containing frma (original codec), schm (cenc), + * and schi/tenc. The tenc needs default_isProtected=1 and default_Per_Sample_IV_Size=8 from the + * start (step 3 is baked into this). + */ +export function fakeEncryption(clearInitSegment: Uint8Array): Uint8Array { + console.log('[eme] Generating fake encrypted init segment', clearInitSegment); + const result = clearInitSegment.slice(); + const traks = findBox(result, ['moov', 'trak']); + traks.forEach((trak) => { + const stsd = findBox(trak, [ + 'mdia', + 'minf', + 'stbl', + 'stsd', + ])[0] as BoxDataOrUndefined; + + if (!stsd) return; + const sampleEntries = stsd.subarray(8); + const avc1Entries = findBox(sampleEntries, ['avc1']); + avc1Entries.forEach((avc1) => { + const avc1Index = avc1.byteOffset - result.byteOffset; + const encv = new Uint8Array(avc1.length + 78); + encv.set(avc1, 78); + const encvIndex = avc1Index - 28; + writeUint32(encv, 0, encv.length + 8); + encv.set([0x65, 0x6e, 0x63, 0x76], 4); // 'encv' + const sinf = new Uint8Array(32); + writeUint32(sinf, 0, sinf.length + 8); + sinf.set([0x73, 0x69, 0x6e, 0x66], 4); // 'sinf' + const frma = new Uint8Array(16); + writeUint32(frma, 0, frma.length + 8); + frma.set([0x66, 0x72, 0x6d, 0x61], 4); // 'frma' + frma.set(avc1.subarray(4, 8), 8); // original codec fourCC + const schm = new Uint8Array(16); + writeUint32(schm, 0, schm.length + 8); + schm.set([0x73, 0x63, 0x68, 0x6d], 4); // 'schm' + schm.set([0x63, 0x65, 0x6e, 0x63], 8); // 'cenc' + const tenc = new Uint8Array(24); + writeUint32(tenc, 0, tenc.length + 8); + tenc.set([0x73, 0x63, 0x68, 0x69], 4); // 'schi' + tenc.set([0x74, 0x65, 0x6e, 0x63], 8); // 'tenc' + tenc[16] = 1; // default_isProtected + tenc[17] = 8; // default_Per_Sample_IV_Size + sinf.set(frma, 8); + sinf.set(schm, 8 + frma.length); + sinf.set(tenc, 8 + frma.length + schm.length); + encv.set(sinf, 78 + avc1.length); + stsd.set(encv, avc1Index - 28); + }); + }); + return result; +} export function parseKeyIdsFromTenc( initSegment: Uint8Array, diff --git a/tests/index.js b/tests/index.js index 105d2aa9aa7..4e67b38c26a 100644 --- a/tests/index.js +++ b/tests/index.js @@ -37,6 +37,7 @@ import './unit/loader/level'; import './unit/loader/m3u8-parser'; import './unit/loader/playlist-loader'; import './unit/remux/mp4-remuxer'; +import './unit/utils/mp4-tools'; import './unit/utils/attr-list'; import './unit/utils/binary-search'; import './unit/utils/buffer-helper'; diff --git a/tests/unit/utils/mp4-tools.ts b/tests/unit/utils/mp4-tools.ts new file mode 100644 index 00000000000..501b84f6d91 --- /dev/null +++ b/tests/unit/utils/mp4-tools.ts @@ -0,0 +1,264 @@ +import { expect } from 'chai'; +import MP4 from '../../../src/remux/mp4-generator'; +import { + bin2str, + fakeEncryption, + findBox, + parseInitSegment, +} from '../../../src/utils/mp4-tools'; +import type { DemuxedAVC1 } from '../../../src/types/demuxer'; +import type { DemuxedAudioTrack } from '../../../src/types/demuxer'; + +// Minimal H.264 High profile SPS and PPS — enough for MP4 generator to produce a valid avc1 box +const MINIMAL_SPS = new Uint8Array([0x67, 0x64, 0x00, 0x1e, 0xac, 0xd9]); +const MINIMAL_PPS = new Uint8Array([0x68, 0xce, 0x38, 0x80]); + +function makeVideoTrack(): DemuxedAVC1 { + return { + id: 1, + pid: 1, + type: 'video', + segmentCodec: 'avc', + inputTimeScale: 90000, + timescale: 90000, + duration: 0, + width: 320, + height: 240, + pixelRatio: [1, 1], + sps: [MINIMAL_SPS], + pps: [MINIMAL_PPS], + samples: [], + dropped: 0, + sequenceNumber: 0, + }; +} + +function makeAudioTrack(): DemuxedAudioTrack { + return { + id: 2, + pid: 2, + type: 'audio', + segmentCodec: 'aac', + inputTimeScale: 48000, + timescale: 48000, + duration: 0, + channelCount: 2, + samplerate: 48000, + config: [0x11, 0x90], // AAC-LC, 48 kHz, 2ch + samples: [], + dropped: 0, + sequenceNumber: 0, + }; +} + +function makeClearVideoInitSegment(): Uint8Array { + MP4.init(); + return MP4.initSegment([makeVideoTrack()]) as Uint8Array; +} + +function makeClearAudioVideoInitSegment(): Uint8Array { + MP4.init(); + return MP4.initSegment([ + makeVideoTrack(), + makeAudioTrack(), + ]) as Uint8Array; +} + +describe('fakeEncryption', function () { + describe('video-only init segment', function () { + let result: Uint8Array; + + beforeEach(function () { + result = fakeEncryption(makeClearVideoInitSegment()); + }); + + it('replaces avc1 sample entry with encv', function () { + const stsd = findBox(result, [ + 'moov', + 'trak', + 'mdia', + 'minf', + 'stbl', + 'stsd', + ])[0]; + // sampleEntries starts at stsd+8 (skipping stsd version/flags) + debugger; + const sampleEntries = stsd.subarray(8); + const fourCC = bin2str(sampleEntries.subarray(4, 8)); + expect(fourCC).to.equal('encv'); + }); + + it('encv contains a sinf box', function () { + const stsd = findBox(result, [ + 'moov', + 'trak', + 'mdia', + 'minf', + 'stbl', + 'stsd', + ])[0]; + const encv = findBox(stsd.subarray(8), ['encv'])[0]; + // encv children start after 78 bytes of video sample entry header + const sinfs = findBox(encv.subarray(78), ['sinf']); + expect(sinfs).to.have.length(1); + }); + + it('sinf/frma contains the original avc1 codec', function () { + const stsd = findBox(result, [ + 'moov', + 'trak', + 'mdia', + 'minf', + 'stbl', + 'stsd', + ])[0]; + const encv = findBox(stsd.subarray(8), ['encv'])[0]; + const sinf = findBox(encv.subarray(78), ['sinf'])[0]; + const frma = findBox(sinf, ['frma'])[0]; + expect(bin2str(frma)).to.equal('avc1'); + }); + + it('sinf/schm declares cenc scheme', function () { + const stsd = findBox(result, [ + 'moov', + 'trak', + 'mdia', + 'minf', + 'stbl', + 'stsd', + ])[0]; + const encv = findBox(stsd.subarray(8), ['encv'])[0]; + const sinf = findBox(encv.subarray(78), ['sinf'])[0]; + const schm = findBox(sinf, ['schm'])[0]; + // scheme_type is at bytes 4–7 of schm content (after version/flags) + expect(bin2str(schm.subarray(4, 8))).to.equal('cenc'); + }); + + it('tenc sets default_isProtected = 1', function () { + const stsd = findBox(result, [ + 'moov', + 'trak', + 'mdia', + 'minf', + 'stbl', + 'stsd', + ])[0]; + const encv = findBox(stsd.subarray(8), ['encv'])[0]; + const sinf = findBox(encv.subarray(78), ['sinf'])[0]; + const tenc = findBox(sinf, ['schi', 'tenc'])[0]; + // tenc layout: [0–3] version/flags, [4–5] reserved, [6] isProtected, [7] IV size, [8–23] KID + expect(tenc[6]).to.equal(1); + }); + + it('tenc sets default_Per_Sample_IV_Size = 8', function () { + const stsd = findBox(result, [ + 'moov', + 'trak', + 'mdia', + 'minf', + 'stbl', + 'stsd', + ])[0]; + const encv = findBox(stsd.subarray(8), ['encv'])[0]; + const sinf = findBox(encv.subarray(78), ['sinf'])[0]; + const tenc = findBox(sinf, ['schi', 'tenc'])[0]; + expect(tenc[7]).to.equal(8); + }); + + it('tenc default_KID is all zeros (to be patched later)', function () { + const stsd = findBox(result, [ + 'moov', + 'trak', + 'mdia', + 'minf', + 'stbl', + 'stsd', + ])[0]; + const encv = findBox(stsd.subarray(8), ['encv'])[0]; + const sinf = findBox(encv.subarray(78), ['sinf'])[0]; + const tenc = findBox(sinf, ['schi', 'tenc'])[0]; + const kid = tenc.subarray(8, 24); + expect(kid.every((b) => b === 0)).to.be.true; + }); + + it('parseInitSegment reports video track as encrypted', function () { + const parsed = parseInitSegment(result); + expect(parsed.video?.encrypted).to.be.true; + }); + + it('parseInitSegment preserves the avc1 codec string', function () { + const parsed = parseInitSegment(result); + expect(parsed.video?.codec).to.match(/^avc1/); + }); + }); + + describe('audio+video init segment', function () { + let result: Uint8Array; + + beforeEach(function () { + result = fakeEncryption(makeClearAudioVideoInitSegment()); + }); + + it('replaces mp4a sample entry with enca', function () { + const traks = findBox(result, ['moov', 'trak']); + const audioTrak = traks.find((trak) => { + const hdlr = findBox(trak, ['mdia', 'hdlr'])[0]; + return hdlr && bin2str(hdlr.subarray(8, 12)) === 'soun'; + }); + expect(audioTrak).to.exist; + const stsd = findBox(audioTrak!, [ + 'mdia', + 'minf', + 'stbl', + 'stsd', + ])[0]; + const sampleEntries = stsd.subarray(8); + const fourCC = bin2str(sampleEntries.subarray(4, 8)); + expect(fourCC).to.equal('enca'); + }); + + it('enca sinf/frma contains the original mp4a codec', function () { + const traks = findBox(result, ['moov', 'trak']); + const audioTrak = traks.find((trak) => { + const hdlr = findBox(trak, ['mdia', 'hdlr'])[0]; + return hdlr && bin2str(hdlr.subarray(8, 12)) === 'soun'; + }); + const stsd = findBox(audioTrak!, ['mdia', 'minf', 'stbl', 'stsd'])[0]; + const enca = findBox(stsd.subarray(8), ['enca'])[0]; + // enca children start after 28 bytes of audio sample entry header + const sinf = findBox(enca.subarray(28), ['sinf'])[0]; + const frma = findBox(sinf, ['frma'])[0]; + expect(bin2str(frma)).to.equal('mp4a'); + }); + + it('parseInitSegment reports both tracks as encrypted', function () { + const parsed = parseInitSegment(result); + expect(parsed.video?.encrypted).to.be.true; + expect(parsed.audio?.encrypted).to.be.true; + }); + }); + + describe('already-encrypted init segment', function () { + it('is returned unchanged when video track is already encv', function () { + const clear = makeClearVideoInitSegment(); + const encrypted = fakeEncryption(clear); + const doubleEncrypted = fakeEncryption(encrypted); + // The stsd should still have encv, not encv wrapping encv + const stsd = findBox(doubleEncrypted, [ + 'moov', + 'trak', + 'mdia', + 'minf', + 'stbl', + 'stsd', + ])[0]; + const sampleEntries = stsd.subarray(8); + expect(bin2str(sampleEntries.subarray(4, 8))).to.equal('encv'); + // frma should still be avc1, not encv + const encv = findBox(sampleEntries, ['encv'])[0]; + const sinf = findBox(encv.subarray(78), ['sinf'])[0]; + const frma = findBox(sinf, ['frma'])[0]; + expect(bin2str(frma)).to.equal('avc1'); + }); + }); +}); From c5485e487afe6665eb3ed515646858ccec04ac46 Mon Sep 17 00:00:00 2001 From: Ben Roberts Date: Thu, 30 Apr 2026 15:04:03 +0200 Subject: [PATCH 2/9] feat: enhance fakeEncryption function to support codec box replacement --- src/utils/mp4-tools.ts | 169 +++++++++++++++++++++++++++------- tests/unit/utils/mp4-tools.ts | 2 +- 2 files changed, 135 insertions(+), 36 deletions(-) diff --git a/src/utils/mp4-tools.ts b/src/utils/mp4-tools.ts index c78deefb6ef..68c5e430699 100644 --- a/src/utils/mp4-tools.ts +++ b/src/utils/mp4-tools.ts @@ -596,54 +596,153 @@ export function patchEncyptionData( * start (step 3 is baked into this). */ export function fakeEncryption(clearInitSegment: Uint8Array): Uint8Array { - console.log('[eme] Generating fake encrypted init segment', clearInitSegment); - const result = clearInitSegment.slice(); - const traks = findBox(result, ['moov', 'trak']); - traks.forEach((trak) => { + const base = clearInitSegment.byteOffset; + + // Collect codec boxes that need to be replaced (avc1→encv, mp4a→enca). + // Each entry records the original box position (including its 8-byte header) and the + // full replacement box so we can stitch a new, correctly-sized buffer. + const replacements: Array<{ + boxStart: number; // offset of original box header in clearInitSegment + boxSize: number; // total original box size (header + content) + newBox: Uint8Array; + }> = []; + + findBox(clearInitSegment, ['moov', 'trak']).forEach((trak) => { const stsd = findBox(trak, [ 'mdia', 'minf', 'stbl', 'stsd', ])[0] as BoxDataOrUndefined; - if (!stsd) return; const sampleEntries = stsd.subarray(8); - const avc1Entries = findBox(sampleEntries, ['avc1']); - avc1Entries.forEach((avc1) => { - const avc1Index = avc1.byteOffset - result.byteOffset; - const encv = new Uint8Array(avc1.length + 78); - encv.set(avc1, 78); - const encvIndex = avc1Index - 28; - writeUint32(encv, 0, encv.length + 8); - encv.set([0x65, 0x6e, 0x63, 0x76], 4); // 'encv' - const sinf = new Uint8Array(32); - writeUint32(sinf, 0, sinf.length + 8); - sinf.set([0x73, 0x69, 0x6e, 0x66], 4); // 'sinf' - const frma = new Uint8Array(16); - writeUint32(frma, 0, frma.length + 8); - frma.set([0x66, 0x72, 0x6d, 0x61], 4); // 'frma' - frma.set(avc1.subarray(4, 8), 8); // original codec fourCC - const schm = new Uint8Array(16); - writeUint32(schm, 0, schm.length + 8); - schm.set([0x73, 0x63, 0x68, 0x6d], 4); // 'schm' - schm.set([0x63, 0x65, 0x6e, 0x63], 8); // 'cenc' - const tenc = new Uint8Array(24); - writeUint32(tenc, 0, tenc.length + 8); - tenc.set([0x73, 0x63, 0x68, 0x69], 4); // 'schi' - tenc.set([0x74, 0x65, 0x6e, 0x63], 8); // 'tenc' - tenc[16] = 1; // default_isProtected - tenc[17] = 8; // default_Per_Sample_IV_Size - sinf.set(frma, 8); - sinf.set(schm, 8 + frma.length); - sinf.set(tenc, 8 + frma.length + schm.length); - encv.set(sinf, 78 + avc1.length); - stsd.set(encv, avc1Index - 28); + + findBox(sampleEntries, ['avc1']).forEach((avc1) => { + replacements.push({ + boxStart: avc1.byteOffset - base - 8, + boxSize: avc1.length + 8, + newBox: buildEncBox(avc1, [0x65, 0x6e, 0x63, 0x76], [0x61, 0x76, 0x63, 0x31]), // encv, avc1 + }); + }); + findBox(sampleEntries, ['mp4a']).forEach((mp4a) => { + replacements.push({ + boxStart: mp4a.byteOffset - base - 8, + boxSize: mp4a.length + 8, + newBox: buildEncBox(mp4a, [0x65, 0x6e, 0x63, 0x61], [0x6d, 0x70, 0x34, 0x61]), // enca, mp4a + }); }); }); + + // Already encrypted (or no recognised codec entries) — return unchanged. + if (replacements.length === 0) return clearInitSegment; + replacements.sort((a, b) => a.boxStart - b.boxStart); + + // Build a new buffer by stitching the original with each codec box replaced. + const totalExtra = replacements.reduce( + (sum, r) => sum + r.newBox.length - r.boxSize, + 0, + ); + const result = new Uint8Array( + clearInitSegment.length + totalExtra, + ) as Uint8Array; + + let srcPos = 0; + let dstPos = 0; + replacements.forEach((r) => { + result.set(clearInitSegment.subarray(srcPos, r.boxStart), dstPos); + dstPos += r.boxStart - srcPos; + result.set(r.newBox, dstPos); + dstPos += r.newBox.length; + srcPos = r.boxStart + r.boxSize; + }); + result.set(clearInitSegment.subarray(srcPos), dstPos); + + // Patch the size fields of every ancestor box (moov → trak → mdia → minf → stbl → stsd). + // Ancestor boxes always begin before their descendants, so a box at original position P + // sits at P + (sum of deltas from replacements that came before P) in the new buffer. + [ + ['moov'], + ['moov', 'trak'], + ['moov', 'trak', 'mdia'], + ['moov', 'trak', 'mdia', 'minf'], + ['moov', 'trak', 'mdia', 'minf', 'stbl'], + ['moov', 'trak', 'mdia', 'minf', 'stbl', 'stsd'], + ].forEach((path) => { + findBox(clearInitSegment, path).forEach((box) => { + const origStart = box.byteOffset - base - 8; + const origEnd = origStart + box.length + 8; + let delta = 0; + let priorShift = 0; + replacements.forEach((r) => { + if (r.boxStart < origStart) priorShift += r.newBox.length - r.boxSize; + if (r.boxStart >= origStart && r.boxStart + r.boxSize <= origEnd) + delta += r.newBox.length - r.boxSize; + }); + if (delta !== 0) { + const newStart = origStart + priorShift; + writeUint32(result, newStart, readUint32(result, newStart) + delta); + } + }); + }); + return result; } +// Builds a full enc box (encv or enca) wrapping the original codec content plus a sinf. +// codecContent is the box content returned by findBox (no header). +// encFourCC / origFourCC are 4-byte arrays of char codes. +function buildEncBox( + codecContent: Uint8Array, + encFourCC: number[], + origFourCC: number[], +): Uint8Array { + const sinf = buildSinf(origFourCC); + const box = new Uint8Array(8 + codecContent.length + sinf.length); + writeUint32(box, 0, box.length); + box.set(encFourCC, 4); + box.set(codecContent, 8); + box.set(sinf, 8 + codecContent.length); + return box; +} + +// Builds an 80-byte sinf box: frma(12) + schm(20) + schi(40). +function buildSinf(origFourCC: number[]): Uint8Array { + // frma: [size=12][frma][original_format] + const frma = new Uint8Array(12); + writeUint32(frma, 0, 12); + frma.set([0x66, 0x72, 0x6d, 0x61], 4); // 'frma' + frma.set(origFourCC, 8); + + // schm: [size=20][schm][version/flags=0][scheme_type=cenc][scheme_version=0x00010000] + const schm = new Uint8Array(20); + writeUint32(schm, 0, 20); + schm.set([0x73, 0x63, 0x68, 0x6d], 4); // 'schm' + schm.set([0x63, 0x65, 0x6e, 0x63], 12); // 'cenc' at content bytes 4–7 + schm.set([0x00, 0x01, 0x00, 0x00], 16); // scheme_version = 1.0 + + // tenc: [size=32][tenc][v/f=0][reserved=0,0][isProtected=1][IV_size=8][KID=16×0] + const tenc = new Uint8Array(32); + writeUint32(tenc, 0, 32); + tenc.set([0x74, 0x65, 0x6e, 0x63], 4); // 'tenc' + tenc[14] = 1; // default_isProtected — content byte 6 + tenc[15] = 8; // default_Per_Sample_IV_Size — content byte 7 + + // schi: [size=40][schi][tenc(32)] + const schi = new Uint8Array(40); + writeUint32(schi, 0, 40); + schi.set([0x73, 0x63, 0x68, 0x69], 4); // 'schi' + schi.set(tenc, 8); + + // sinf: [size=80][sinf][frma(12)][schm(20)][schi(40)] + const sinf = new Uint8Array(80); + writeUint32(sinf, 0, 80); + sinf.set([0x73, 0x69, 0x6e, 0x66], 4); // 'sinf' + sinf.set(frma, 8); + sinf.set(schm, 20); + sinf.set(schi, 40); + return sinf; +} + export function parseKeyIdsFromTenc( initSegment: Uint8Array, ): Uint8Array[] { diff --git a/tests/unit/utils/mp4-tools.ts b/tests/unit/utils/mp4-tools.ts index 501b84f6d91..31b948250a2 100644 --- a/tests/unit/utils/mp4-tools.ts +++ b/tests/unit/utils/mp4-tools.ts @@ -82,7 +82,7 @@ describe('fakeEncryption', function () { 'stsd', ])[0]; // sampleEntries starts at stsd+8 (skipping stsd version/flags) - debugger; + const sampleEntries = stsd.subarray(8); const fourCC = bin2str(sampleEntries.subarray(4, 8)); expect(fourCC).to.equal('encv'); From 2e78d35ac44e7aae83cba63b247d6c3ec7278157 Mon Sep 17 00:00:00 2001 From: Ben Roberts Date: Thu, 30 Apr 2026 15:10:43 +0200 Subject: [PATCH 3/9] feat: integrate fakeEncryption in patchEncyptionData for enhanced handling of encrypted segments --- src/remux/passthrough-remuxer.ts | 13 +++++++++---- src/utils/mp4-tools.ts | 5 +++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/remux/passthrough-remuxer.ts b/src/remux/passthrough-remuxer.ts index f9cb2b58887..63ca372ec8e 100644 --- a/src/remux/passthrough-remuxer.ts +++ b/src/remux/passthrough-remuxer.ts @@ -5,7 +5,7 @@ import { import { ElementaryStreamTypes } from '../loader/fragment'; import { getCodecCompatibleName } from '../utils/codecs'; import { type ILogger, Logger } from '../utils/logger'; -import { patchEncyptionData } from '../utils/mp4-tools'; +import { fakeEncryption, patchEncyptionData } from '../utils/mp4-tools'; import { getSampleData, parseInitSegment } from '../utils/mp4-tools'; import type { HlsConfig } from '../config'; import type { HlsEventEmitter } from '../events'; @@ -91,11 +91,14 @@ class PassThroughRemuxer extends Logger implements Remuxer { this.initData = undefined; return; } - const { audio, video } = (this.initData = parseInitSegment(initSegment)); - if (decryptdata) { - patchEncyptionData(initSegment, decryptdata); + const { audio, video } = parseInitSegment(initSegment); + if (!audio?.encrypted && !video?.encrypted) { + initSegment = fakeEncryption(initSegment); + } + initSegment = patchEncyptionData(initSegment, decryptdata) ?? initSegment; } else { + const { audio, video } = parseInitSegment(initSegment); const eitherTrack = audio || video; if (eitherTrack?.encrypted) { this.warn( @@ -104,6 +107,8 @@ class PassThroughRemuxer extends Logger implements Remuxer { } } + const { audio, video } = (this.initData = parseInitSegment(initSegment)); + // Get codec from initSegment if (audio) { audioCodec = getParsedTrackCodec( diff --git a/src/utils/mp4-tools.ts b/src/utils/mp4-tools.ts index 68c5e430699..0992a4bd973 100644 --- a/src/utils/mp4-tools.ts +++ b/src/utils/mp4-tools.ts @@ -569,9 +569,9 @@ function addLeadingZero(num: number): string { export function patchEncyptionData( initSegment: Uint8Array | undefined, decryptdata: DecryptData | null, -) { +): Uint8Array | undefined { if (!initSegment || !decryptdata) { - return; + return initSegment; } const keyId = decryptdata.keyId; if (keyId && decryptdata.isCommonEncryption) { @@ -588,6 +588,7 @@ export function patchEncyptionData( } }); } + return initSegment; } /** * Takes a clear init segment and returns a new one where every avc1 sample entry is wrapped From 394a056ffa775c02f0f4e137795d745ccfe5d19f Mon Sep 17 00:00:00 2001 From: Ben Roberts Date: Thu, 30 Apr 2026 15:15:22 +0200 Subject: [PATCH 4/9] feat: enhance patchEncryptionData to handle PlayReady key IDs and add leGuidToUuid function for UUID conversion --- src/utils/mp4-tools.ts | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/utils/mp4-tools.ts b/src/utils/mp4-tools.ts index 0992a4bd973..c5ab7f6022b 100644 --- a/src/utils/mp4-tools.ts +++ b/src/utils/mp4-tools.ts @@ -1,5 +1,6 @@ import { utf8ArrayToStr } from '@svta/common-media-library/utils/utf8ArrayToStr'; import { arrayToHex } from './hex'; +import { KeySystemFormats } from './mediakeys-helper'; import { ElementaryStreamTypes } from '../loader/fragment'; import { logger } from '../utils/logger'; import type { KeySystemIds } from './mediakeys-helper'; @@ -573,8 +574,13 @@ export function patchEncyptionData( if (!initSegment || !decryptdata) { return initSegment; } - const keyId = decryptdata.keyId; + const { keyId } = decryptdata; if (keyId && decryptdata.isCommonEncryption) { + // PlayReady key IDs are LE GUIDs; tenc default_KID must be a BE UUID. + const effectiveKeyId = + decryptdata.keyFormat === KeySystemFormats.PLAYREADY + ? leGuidToUuid(keyId) + : keyId; applyToTencBoxes(initSegment, (tenc, isAudio) => { // Look for default key id (keyID offset is always 8 within the tenc box): const tencKeyId = tenc.subarray(8, 24); @@ -582,14 +588,32 @@ export function patchEncyptionData( logger.log( `[eme] Patching keyId in 'enc${ isAudio ? 'a' : 'v' - }>sinf>>tenc' box: ${arrayToHex(tencKeyId)} -> ${arrayToHex(keyId)}`, + }>sinf>>tenc' box: ${arrayToHex(tencKeyId)} -> ${arrayToHex(effectiveKeyId)}`, ); - tenc.set(keyId, 8); + tenc.set(effectiveKeyId, 8); } }); } return initSegment; } + +// PlayReady key IDs are LE GUIDs (Data1/2/3 stored little-endian). +// CENC tenc default_KID requires a standard big-endian UUID. +// Swap the first three groups: bytes 0-3, bytes 4-5, bytes 6-7. +function leGuidToUuid( + guid: Uint8Array, +): Uint8Array { + const uuid = new Uint8Array(guid) as Uint8Array; + uuid[0] = guid[3]; + uuid[1] = guid[2]; + uuid[2] = guid[1]; + uuid[3] = guid[0]; + uuid[4] = guid[5]; + uuid[5] = guid[4]; + uuid[6] = guid[7]; + uuid[7] = guid[6]; + return uuid; +} /** * Takes a clear init segment and returns a new one where every avc1 sample entry is wrapped * as encv (and mp4a as enca), each with a sinf box containing frma (original codec), schm (cenc), From d6fc69ba6ba5d767f6762c64e706c12cf66b7138 Mon Sep 17 00:00:00 2001 From: Ben Roberts Date: Tue, 5 May 2026 10:18:03 +0200 Subject: [PATCH 5/9] feat: implement PlayReady recommendation support and enhance DRM handling --- demo/main.js | 30 +++++++++++++++++++++++++- src/controller/eme-controller.ts | 36 +++++++++++++++++++++++++------ src/demux/transmuxer-interface.ts | 28 +++++++++++++++++++++++- src/loader/fragment.ts | 4 ++++ src/remux/passthrough-remuxer.ts | 21 +++++++++++++++++- src/utils/mediakeys-helper.ts | 9 +++++--- src/utils/mp4-tools.ts | 28 ++++++++++++++++++------ tests/unit/utils/mp4-tools.ts | 8 +++---- 8 files changed, 142 insertions(+), 22 deletions(-) diff --git a/demo/main.js b/demo/main.js index 50514a9ef2e..e4a77a76827 100644 --- a/demo/main.js +++ b/demo/main.js @@ -28,9 +28,37 @@ if (demoConfig) { const hlsjsDefaults = { debug: true, - enableWorker: true, + enableWorker: false, lowLatencyMode: true, backBufferLength: 60 * 1.5, + emeEnabled: true, + drmSystems: { + "com.microsoft.playready": { + licenseUrl: "https://shield-drm.imggaming.com/api/v2/license" + } + }, + drmSystemOptions: { + "videoRobustness": "3000", + "audioRobustness": "3000" + }, + licenseXhrSetup: async function(xhr) { + const res = await fetch("https://shield-api.imggaming.com/admin/v1/ovp/dice/client/dce.sandbox/action/sign_test_content_token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ttl_seconds: 300, + claims: { + eid: "786df1e4-9a75-4052-8625-204ba23b2bae", + aid: "00000000-0000-0000-0000-000000000000", + did: "00000000-0000-0000-0000-000000000000", + def: "uhd2" + } + }) + }); + const { token } = await res.json(); + xhr.setRequestHeader("Authorization", "Bearer " + token); + xhr.setRequestHeader("X-DRM-INFO", btoa(JSON.stringify({ system: "com.microsoft.playready" }))); + } }; let enableStreaming = getDemoConfigPropOrDefault('enableStreaming', true); diff --git a/src/controller/eme-controller.ts b/src/controller/eme-controller.ts index 54902c85cd2..5ed98bc9b09 100644 --- a/src/controller/eme-controller.ts +++ b/src/controller/eme-controller.ts @@ -138,7 +138,12 @@ class EMEController extends Logger implements ComponentAPI { private getLicenseServerUrl(keySystem: KeySystems): string | undefined { const { drmSystems, widevineLicenseUrl } = this.config; - const keySystemConfiguration = drmSystems?.[keySystem]; + // const keySystemConfiguration = drmSystems?.[keySystem]; + const keySystemConfiguration = + drmSystems?.[keySystem] ?? + (keySystem === KeySystems.PLAYREADY_RECOMMENDATION + ? drmSystems?.[KeySystems.PLAYREADY] + : undefined); if (keySystemConfiguration) { return keySystemConfiguration.licenseUrl; @@ -162,8 +167,12 @@ class EMEController extends Logger implements ComponentAPI { private getServerCertificateUrl(keySystem: KeySystems): string | void { const { drmSystems } = this.config; - const keySystemConfiguration = drmSystems?.[keySystem]; - + // const keySystemConfiguration = drmSystems?.[keySystem]; + const keySystemConfiguration = + drmSystems?.[keySystem] ?? + (keySystem === KeySystems.PLAYREADY_RECOMMENDATION + ? drmSystems?.[KeySystems.PLAYREADY] + : undefined); if (keySystemConfiguration) { return keySystemConfiguration.serverCertificateUrl; } else { @@ -439,7 +448,13 @@ class EMEController extends Logger implements ComponentAPI { .filter( (value) => !!value && keySystemsInConfig.indexOf(value) !== -1, ) as any as KeySystems[]; - + // Chrome on Windows registers PlayReady as the recommendation variant. + // When the standard key system is in the attempt list, add the recommendation + // variant as an immediate fallback so attemptKeySystemAccess tries it next. + // if (keySystemsToAttempt.indexOf(KeySystems.PLAYREADY) !== -1) { + // const idx = keySystemsToAttempt.indexOf(KeySystems.PLAYREADY); + // keySystemsToAttempt.splice(idx + 1, 0, KeySystems.PLAYREADY_RECOMMENDATION); + // } return this.selectKeySystem(keySystemsToAttempt); } @@ -570,7 +585,7 @@ class EMEController extends Logger implements ComponentAPI { const keySystemsToAttempt = keySystem ? [keySystem] : getKeySystemsForConfig(this.config); - return this.attemptKeySystemAccess(keySystemsToAttempt); + return this.getKeySystemSelectionPromise(keySystemsToAttempt); } return mediaKeySessionContext; } @@ -593,6 +608,15 @@ class EMEController extends Logger implements ComponentAPI { })}`, ); } + // Add recommendation variant as fallback if base PlayReady is in the list + const playreadyIdx = keySystemsToAttempt.indexOf(KeySystems.PLAYREADY); + if ( + playreadyIdx !== -1 && + keySystemsToAttempt.indexOf(KeySystems.PLAYREADY_RECOMMENDATION) === -1 + ) { + keySystemsToAttempt = keySystemsToAttempt.slice(); + keySystemsToAttempt.splice(playreadyIdx + 1, 0, KeySystems.PLAYREADY_RECOMMENDATION); + } return this.attemptKeySystemAccess(keySystemsToAttempt); } @@ -1332,7 +1356,7 @@ class EMEController extends Logger implements ComponentAPI { this.setupLicenseXHR(xhr, url, keySessionContext, licenseChallenge) .then(({ xhr, licenseChallenge }) => { - if (keySessionContext.keySystem == KeySystems.PLAYREADY) { + if (keySessionContext.keySystem == KeySystems.PLAYREADY || keySessionContext.keySystem == KeySystems.PLAYREADY_RECOMMENDATION) { licenseChallenge = this.unpackPlayReadyKeyMessage( xhr, licenseChallenge, diff --git a/src/demux/transmuxer-interface.ts b/src/demux/transmuxer-interface.ts index 50f79aa527b..cde298318b1 100644 --- a/src/demux/transmuxer-interface.ts +++ b/src/demux/transmuxer-interface.ts @@ -198,8 +198,34 @@ export default class TransmuxerInterface { chunkMeta.transmuxing.start = self.performance.now(); const { instanceNo, transmuxer } = this; const timeOffset = part ? part.start : frag.start; + console.log('$$$ TransmuxerInterface push', { + id: this.id, + sn: chunkMeta.sn, + part: chunkMeta.part, + level: chunkMeta.level, + timeOffset, + accurateTimeOffset, + }); // TODO: push "clear-lead" decrypt data for unencrypted fragments in streams with encrypted ones - const decryptdata = frag.decryptdata; + // const decryptdata = frag.decryptdata; + + // For clear-lead segments (frag.decryptdata is null), still pass the PlayReady + // key from the first encrypted fragment so generateInitSegment can call + // fakeEncryption and mark the SourceBuffer pipeline as encrypted from the start. + // PlayReady LevelKey.key is null so transmuxer.push won't attempt AES decryption + // on the clear segment data — only the init segment processing is affected. + let decryptdata = frag.decryptdata; + if (decryptdata == null) { + const levelDetails = this.hls.levels[frag.level]?.details; + const encryptedFrag = levelDetails?.encryptedFragments?.[0]; + if (encryptedFrag?.levelkeys) { + decryptdata = + Object.values(encryptedFrag.levelkeys).find( + (k) => k?.isCommonEncryption, + ) ?? null; + } + } + const lastFrag = this.frag; const discontinuity = !(lastFrag && frag.cc === lastFrag.cc); diff --git a/src/loader/fragment.ts b/src/loader/fragment.ts index 621647eb563..2523220cf91 100644 --- a/src/loader/fragment.ts +++ b/src/loader/fragment.ts @@ -283,6 +283,10 @@ export class Fragment extends BaseSegment { return this._decryptdata; } + set decryptdata(value: LevelKey | null) { + this._decryptdata = value; + } + get end(): number { return this.start + this.duration; } diff --git a/src/remux/passthrough-remuxer.ts b/src/remux/passthrough-remuxer.ts index 63ca372ec8e..fb12e55dcb3 100644 --- a/src/remux/passthrough-remuxer.ts +++ b/src/remux/passthrough-remuxer.ts @@ -6,7 +6,7 @@ import { ElementaryStreamTypes } from '../loader/fragment'; import { getCodecCompatibleName } from '../utils/codecs'; import { type ILogger, Logger } from '../utils/logger'; import { fakeEncryption, patchEncyptionData } from '../utils/mp4-tools'; -import { getSampleData, parseInitSegment } from '../utils/mp4-tools'; +import { findBox, getSampleData, parseInitSegment, patchTencIsProtected } from '../utils/mp4-tools'; import type { HlsConfig } from '../config'; import type { HlsEventEmitter } from '../events'; import type { DecryptData } from '../loader/level-key'; @@ -29,6 +29,7 @@ import type { TimestampOffset } from '../utils/timescale-conversion'; class PassThroughRemuxer extends Logger implements Remuxer { private emitInitSegment: boolean = false; + private encryptedInitPatched = false; private audioCodec?: string; private videoCodec?: string; private initData?: InitData; @@ -79,6 +80,7 @@ class PassThroughRemuxer extends Logger implements Remuxer { // if (decryptdata) decryptdata.keyFormat = "com.microsoft.playready.recommendation"; this.generateInitSegment(initSegment, decryptdata); this.emitInitSegment = true; + this.encryptedInitPatched = false; } private generateInitSegment( @@ -186,6 +188,23 @@ class PassThroughRemuxer extends Logger implements Remuxer { // The binary segment data is added to the videoTrack in the mp4demuxer. We don't check to see if the data is only // audio or video (or both); adding it to video was an arbitrary choice. const data = videoTrack.samples; + + if (!this.encryptedInitPatched && findBox(data, ['moof', 'traf', 'senc']).length > 0) { + this.encryptedInitPatched = true; + if (this.initTracks) { + const seen = new Set(); + // eslint-disable-next-line no-for-of-loops/no-for-of-loops + for (const track of Object.values(this.initTracks)) { + if (track?.initSegment && !seen.has(track.initSegment)) { + seen.add(track.initSegment); + patchTencIsProtected(track.initSegment as Uint8Array, true); + } + } + this.emitInitSegment = true; + } + } + + if (!data.length) { return result; } diff --git a/src/utils/mediakeys-helper.ts b/src/utils/mediakeys-helper.ts index 5d9f5e2f64e..23d1b99e312 100755 --- a/src/utils/mediakeys-helper.ts +++ b/src/utils/mediakeys-helper.ts @@ -10,6 +10,7 @@ export const enum KeySystems { CLEARKEY = 'org.w3.clearkey', FAIRPLAY = 'com.apple.fps', PLAYREADY = 'com.microsoft.playready', + PLAYREADY_RECOMMENDATION = 'com.microsoft.playready.recommendation', WIDEVINE = 'com.widevine.alpha', } @@ -67,7 +68,8 @@ export function keySystemDomainToKeySystemFormat( case KeySystems.FAIRPLAY: return KeySystemFormats.FAIRPLAY; case KeySystems.PLAYREADY: - return KeySystemFormats.PLAYREADY; + case KeySystems.PLAYREADY_RECOMMENDATION: + return KeySystemFormats.PLAYREADY; case KeySystems.WIDEVINE: return KeySystemFormats.WIDEVINE; case KeySystems.CLEARKEY: @@ -122,6 +124,7 @@ export function getSupportedMediaKeySystemConfigurations( break; case KeySystems.WIDEVINE: case KeySystems.PLAYREADY: + case KeySystems.PLAYREADY_RECOMMENDATION: initDataTypes = ['cenc']; break; case KeySystems.CLEARKEY: @@ -152,12 +155,12 @@ function createMediaKeySystemConfigurations( drmSystemOptions.sessionType || 'temporary', ], audioCapabilities: audioCodecs.map((codec) => ({ - contentType: `audio/mp4; codecs=${codec}`, + contentType: `audio/mp4; codecs="${codec}"`, robustness: drmSystemOptions.audioRobustness || '', encryptionScheme: drmSystemOptions.audioEncryptionScheme || null, })), videoCapabilities: videoCodecs.map((codec) => ({ - contentType: `video/mp4; codecs=${codec}`, + contentType: `video/mp4; codecs="${codec}"`, robustness: drmSystemOptions.videoRobustness || '', encryptionScheme: drmSystemOptions.videoEncryptionScheme || null, })), diff --git a/src/utils/mp4-tools.ts b/src/utils/mp4-tools.ts index c5ab7f6022b..964e55ece7e 100644 --- a/src/utils/mp4-tools.ts +++ b/src/utils/mp4-tools.ts @@ -617,10 +617,11 @@ function leGuidToUuid( /** * Takes a clear init segment and returns a new one where every avc1 sample entry is wrapped * as encv (and mp4a as enca), each with a sinf box containing frma (original codec), schm (cenc), - * and schi/tenc. The tenc needs default_isProtected=1 and default_Per_Sample_IV_Size=8 from the - * start (step 3 is baked into this). + * and schi/tenc. */ -export function fakeEncryption(clearInitSegment: Uint8Array): Uint8Array { +export function fakeEncryption( + clearInitSegment: Uint8Array +): Uint8Array { const base = clearInitSegment.byteOffset; // Collect codec boxes that need to be replaced (avc1→encv, mp4a→enca). @@ -745,12 +746,12 @@ function buildSinf(origFourCC: number[]): Uint8Array { schm.set([0x63, 0x65, 0x6e, 0x63], 12); // 'cenc' at content bytes 4–7 schm.set([0x00, 0x01, 0x00, 0x00], 16); // scheme_version = 1.0 - // tenc: [size=32][tenc][v/f=0][reserved=0,0][isProtected=1][IV_size=8][KID=16×0] + // tenc: [size=32][tenc][v/f=0][reserved=0,0][isProtected=0][IV_size=0][KID=16×0] const tenc = new Uint8Array(32); writeUint32(tenc, 0, 32); tenc.set([0x74, 0x65, 0x6e, 0x63], 4); // 'tenc' - tenc[14] = 1; // default_isProtected — content byte 6 - tenc[15] = 8; // default_Per_Sample_IV_Size — content byte 7 + tenc[14] = 0; // default_isProtected + tenc[15] = 0; // default_Per_Sample_IV_Size // schi: [size=40][schi][tenc(32)] const schi = new Uint8Array(40); @@ -808,6 +809,21 @@ function applyToTencBoxes( }); } +export function patchTencIsProtected( + initSegment: Uint8Array, + encrypted: boolean, +): void { + applyToTencBoxes(initSegment, (tenc) => { + tenc[6] = encrypted ? 1 : 0; // default_isProtected + if (!encrypted || tenc[7] === 0) { + // Only update IV_size when disabling encryption, or when it was 0 + // (IV_size=0 means this tenc was built by buildSinf via fakeEncryption; + // real packager-built tenc will have 8 or 16 here and must be preserved) + tenc[7] = encrypted ? 8 : 0; // default_Per_Sample_IV_Size + } + }); +} + export function parseSinf(sinf: Uint8Array): BoxDataOrUndefined { const schm = findBox(sinf, ['schm'])[0] as BoxDataOrUndefined; if (schm) { diff --git a/tests/unit/utils/mp4-tools.ts b/tests/unit/utils/mp4-tools.ts index 31b948250a2..84d754061b4 100644 --- a/tests/unit/utils/mp4-tools.ts +++ b/tests/unit/utils/mp4-tools.ts @@ -134,7 +134,7 @@ describe('fakeEncryption', function () { expect(bin2str(schm.subarray(4, 8))).to.equal('cenc'); }); - it('tenc sets default_isProtected = 1', function () { + it('tenc sets default_isProtected = 0', function () { const stsd = findBox(result, [ 'moov', 'trak', @@ -147,10 +147,10 @@ describe('fakeEncryption', function () { const sinf = findBox(encv.subarray(78), ['sinf'])[0]; const tenc = findBox(sinf, ['schi', 'tenc'])[0]; // tenc layout: [0–3] version/flags, [4–5] reserved, [6] isProtected, [7] IV size, [8–23] KID - expect(tenc[6]).to.equal(1); + expect(tenc[6]).to.equal(0); }); - it('tenc sets default_Per_Sample_IV_Size = 8', function () { + it('tenc sets default_Per_Sample_IV_Size = 0', function () { const stsd = findBox(result, [ 'moov', 'trak', @@ -162,7 +162,7 @@ describe('fakeEncryption', function () { const encv = findBox(stsd.subarray(8), ['encv'])[0]; const sinf = findBox(encv.subarray(78), ['sinf'])[0]; const tenc = findBox(sinf, ['schi', 'tenc'])[0]; - expect(tenc[7]).to.equal(8); + expect(tenc[7]).to.equal(0); }); it('tenc default_KID is all zeros (to be patched later)', function () { From 2191f034853242cff8fb6eece23c77b475ce3c24 Mon Sep 17 00:00:00 2001 From: Ben Roberts Date: Thu, 7 May 2026 18:11:44 +0200 Subject: [PATCH 6/9] revert some previous changes and add logic to prefetch the first encrypted init segment and patch the first (clear) init segment - work in progress --- demo/main.js | 51 ++++---- src/controller/buffer-controller.ts | 190 +++++++++++++++++++++++++++- src/demux/transmuxer-interface.ts | 9 +- src/remux/passthrough-remuxer.ts | 36 +----- src/utils/mp4-tools.ts | 52 +++----- tests/test-streams.js | 5 + 6 files changed, 246 insertions(+), 97 deletions(-) diff --git a/demo/main.js b/demo/main.js index e4a77a76827..ac28d6751fa 100644 --- a/demo/main.js +++ b/demo/main.js @@ -33,32 +33,39 @@ const hlsjsDefaults = { backBufferLength: 60 * 1.5, emeEnabled: true, drmSystems: { - "com.microsoft.playready": { - licenseUrl: "https://shield-drm.imggaming.com/api/v2/license" - } + 'com.microsoft.playready': { + licenseUrl: 'https://shield-drm.imggaming.com/api/v2/license', + }, }, drmSystemOptions: { - "videoRobustness": "3000", - "audioRobustness": "3000" + videoRobustness: '3000', + audioRobustness: '3000', }, - licenseXhrSetup: async function(xhr) { - const res = await fetch("https://shield-api.imggaming.com/admin/v1/ovp/dice/client/dce.sandbox/action/sign_test_content_token", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - ttl_seconds: 300, - claims: { - eid: "786df1e4-9a75-4052-8625-204ba23b2bae", - aid: "00000000-0000-0000-0000-000000000000", - did: "00000000-0000-0000-0000-000000000000", - def: "uhd2" - } - }) - }); + requiresEncryptionInfoInAllInitSegments: true, + licenseXhrSetup: async function (xhr) { + const res = await fetch( + 'https://shield-api.imggaming.com/admin/v1/ovp/dice/client/dce.sandbox/action/sign_test_content_token', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ttl_seconds: 300, + claims: { + eid: '786df1e4-9a75-4052-8625-204ba23b2bae', + aid: '00000000-0000-0000-0000-000000000000', + did: '00000000-0000-0000-0000-000000000000', + def: 'uhd2', + }, + }), + } + ); const { token } = await res.json(); - xhr.setRequestHeader("Authorization", "Bearer " + token); - xhr.setRequestHeader("X-DRM-INFO", btoa(JSON.stringify({ system: "com.microsoft.playready" }))); - } + xhr.setRequestHeader('Authorization', 'Bearer ' + token); + xhr.setRequestHeader( + 'X-DRM-INFO', + btoa(JSON.stringify({ system: 'com.microsoft.playready' })) + ); + }, }; let enableStreaming = getDemoConfigPropOrDefault('enableStreaming', true); diff --git a/src/controller/buffer-controller.ts b/src/controller/buffer-controller.ts index 1836e7bb010..fcf6070498a 100755 --- a/src/controller/buffer-controller.ts +++ b/src/controller/buffer-controller.ts @@ -17,11 +17,20 @@ import { isCompatibleTrackChange, isManagedMediaSource, } from '../utils/mediasource-helper'; +import { + appendUint8Array, + bin2str, + findBox, + hasMoofData, + parseInitSegment, + patchTencIsProtected, + readUint32, +} from '../utils/mp4-tools'; import { stringify } from '../utils/safe-json-stringify'; import type { FragmentTracker } from './fragment-tracker'; import type { HlsConfig } from '../config'; import type Hls from '../hls'; -import type { MediaFragment, Part } from '../loader/fragment'; +import type { Fragment, MediaFragment, Part } from '../loader/fragment'; import type { LevelDetails } from '../loader/level-details'; import type { AttachMediaSourceData, @@ -120,8 +129,11 @@ export default class BufferController extends Logger implements ComponentAPI { [null, null], ]; + private firstVideoInitSegmentAppended: boolean = false; + constructor(hls: Hls, fragmentTracker: FragmentTracker) { super('buffer-controller', hls.logger); + this.debug('$$$ BufferController constructor'); this.hls = hls; this.fragmentTracker = fragmentTracker; this.appendSource = isManagedMediaSource( @@ -254,6 +266,7 @@ export default class BufferController extends Logger implements ComponentAPI { event: Events.MANIFEST_PARSED, data: ManifestParsedData, ) { + this.log('$$$ BufferController onManifestParsed', data); // in case of alt audio 2 BUFFER_CODECS events will be triggered, one per stream controller // sourcebuffers will be created all at once when the expected nb of tracks will be reached // in case alt audio is not used, only one BUFFER_CODEC event will be fired from main stream controller @@ -1679,6 +1692,143 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe } } + private preLoadFirstEncryptedInitSegmentData( + firstEncryptedInitSegment: Fragment, + ): Promise { + return new Promise((resolve, reject) => { + if (firstEncryptedInitSegment.data) { + resolve(firstEncryptedInitSegment.data as Uint8Array); + } + // remove the resource file from the base URL to get the true base URL for the init segment + // e.g. https://sample-videos-zyrkp2nj.s3-eu-west-1.amazon…ncrypted/hls_fmp4_cenc_pw/video_348000/index.m3u + const urlBase = firstEncryptedInitSegment.base.url.replace( + /\/[^/]*$/, + '/', + ); + const initSegmentUrl = urlBase + firstEncryptedInitSegment.relurl; + fetch(initSegmentUrl) + .then((response) => { + if (!response.ok) { + throw new Error( + `Failed to fetch init segment data from ${initSegmentUrl}: ${response.statusText}`, + ); + } + response + .arrayBuffer() + .then((arrayBuffer) => { + resolve(new Uint8Array(arrayBuffer)); + }) + .catch((error) => { + reject(error); + }); + }) + .catch((error) => { + reject(error); + }); + }); + } + + private patchClearInitSegment( + clearInitSegmentData: Uint8Array, + firstEncryptedInitSegmentData: Uint8Array, + ): Uint8Array { + this.debug( + '$$$$ First clear init segment without PSSH boxes:', + parseInitSegment(clearInitSegmentData), + clearInitSegmentData, + ); + this.debug( + '$$$$ First encrypted init segment with PSSH boxes:', + parseInitSegment(firstEncryptedInitSegmentData), + firstEncryptedInitSegmentData, + ); + + // PSSH boxes are children of moov — extract them by iterating moov's content + const psshBoxes: Uint8Array[] = []; + const moovContent = findBox(firstEncryptedInitSegmentData, ['moov'])[0]; + if (moovContent) { + let offset = 0; + while (offset < moovContent.length) { + const size = readUint32(moovContent, offset); + if (size < 8) break; + const type = bin2str(moovContent.subarray(offset + 4, offset + 8)); + if (type === 'pssh') { + psshBoxes.push(moovContent.subarray(offset, offset + size)); + } + offset += size; + } + } + + if (psshBoxes.length === 0) { + this.debug('$$$$ No PSSH boxes found in encrypted init segment'); + return clearInitSegmentData; + } + + // Rebuild the clear init segment, inserting PSSH boxes immediately before moov + const parts: Uint8Array[] = []; + let psshInserted = false; + let offset = 0; + while (offset < clearInitSegmentData.length) { + const size = readUint32(clearInitSegmentData, offset); + if (size < 8) break; + const type = bin2str( + clearInitSegmentData.subarray(offset + 4, offset + 8), + ); + if (type === 'moov' && !psshInserted) { + psshBoxes.forEach((pssh) => parts.push(pssh)); + psshInserted = true; + } + parts.push(clearInitSegmentData.subarray(offset, offset + size)); + offset += size; + } + + this.debug( + `$$$$ Copied ${psshBoxes.length} PSSH box(es) from encrypted init segment to clear init segment`, + ); + const patchedInitSegment = parts.reduce(appendUint8Array); + this.debug( + '$$$$ Clear init segment with PSSH boxes patched in:', + parseInitSegment(patchedInitSegment), + ); + return patchedInitSegment; + } + + private patchClearInitSegmentForSL3000Compatability( + clearInitSegmentData: Uint8Array, + ): Promise { + return new Promise((resolve, reject) => { + // parse the data and extract the first encrypted init segment + const { details } = this; + if (!details) { + return reject( + new Error( + 'Details must be available to find first encrypted init segment', + ), + ); + } + const firstEncryptedInitSegment = + details.encryptedFragments?.[0].initSegment; + if (!firstEncryptedInitSegment) { + return reject(new Error('No encrypted init segment found in details')); + } + patchTencIsProtected(clearInitSegmentData); + this.preLoadFirstEncryptedInitSegmentData(firstEncryptedInitSegment) + .then((firstEncryptedInitSegmentData) => { + const patchedData = this.patchClearInitSegment( + clearInitSegmentData, + firstEncryptedInitSegmentData, + ); + this.debug( + '$$$$ Finished patching init segment for SL3000 compatibility, appending to source buffer', + ); + resolve(patchedData); + }) + .catch((error) => { + reject(error); + }); + }); + } + // This method must result in an updateend event; if append is not called, onSBUpdateEnd must be called manually private appendExecutor( data: Uint8Array, @@ -1693,7 +1843,43 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe } track.ending = false; track.ended = false; - sb.appendBuffer(data); + if ( + !this.firstVideoInitSegmentAppended && + type === 'video' && + !hasMoofData(data) + ) { + this.firstVideoInitSegmentAppended = true; + const firstInitSegmentParsed = parseInitSegment(data); + if (firstInitSegmentParsed[1]?.stsd.encrypted) { + this.debug( + '$$$$ First video init segment is encrypted, no patching required for SL3000 compatibility', + ); + sb.appendBuffer(data); + return; + } + this.debug( + '$$$$ First video init segment detected, patching for SL3000 compatibility', + firstInitSegmentParsed, + ); + + this.patchClearInitSegmentForSL3000Compatability(data) + .then((patchedData) => { + const currentSb = this.tracks[type]?.buffer; + if (!currentSb || this.mediaSource?.readyState !== 'open') { + this.onSBUpdateEnd('video'); + return; + } + currentSb.appendBuffer(patchedData); + }) + .catch((error) => { + this.warn( + `$$$$ Error patching init segment for SL3000 compatibility: ${error}`, + ); + this.onSBUpdateEnd('video'); + }); + } else { + sb.appendBuffer(data); + } } private blockUntilOpen(callback: () => void) { diff --git a/src/demux/transmuxer-interface.ts b/src/demux/transmuxer-interface.ts index cde298318b1..28c650d986c 100644 --- a/src/demux/transmuxer-interface.ts +++ b/src/demux/transmuxer-interface.ts @@ -198,14 +198,7 @@ export default class TransmuxerInterface { chunkMeta.transmuxing.start = self.performance.now(); const { instanceNo, transmuxer } = this; const timeOffset = part ? part.start : frag.start; - console.log('$$$ TransmuxerInterface push', { - id: this.id, - sn: chunkMeta.sn, - part: chunkMeta.part, - level: chunkMeta.level, - timeOffset, - accurateTimeOffset, - }); + // TODO: push "clear-lead" decrypt data for unencrypted fragments in streams with encrypted ones // const decryptdata = frag.decryptdata; diff --git a/src/remux/passthrough-remuxer.ts b/src/remux/passthrough-remuxer.ts index fb12e55dcb3..a8373ca01dd 100644 --- a/src/remux/passthrough-remuxer.ts +++ b/src/remux/passthrough-remuxer.ts @@ -5,8 +5,8 @@ import { import { ElementaryStreamTypes } from '../loader/fragment'; import { getCodecCompatibleName } from '../utils/codecs'; import { type ILogger, Logger } from '../utils/logger'; -import { fakeEncryption, patchEncyptionData } from '../utils/mp4-tools'; -import { findBox, getSampleData, parseInitSegment, patchTencIsProtected } from '../utils/mp4-tools'; +import { patchEncyptionData } from '../utils/mp4-tools'; +import { getSampleData, parseInitSegment } from '../utils/mp4-tools'; import type { HlsConfig } from '../config'; import type { HlsEventEmitter } from '../events'; import type { DecryptData } from '../loader/level-key'; @@ -29,7 +29,6 @@ import type { TimestampOffset } from '../utils/timescale-conversion'; class PassThroughRemuxer extends Logger implements Remuxer { private emitInitSegment: boolean = false; - private encryptedInitPatched = false; private audioCodec?: string; private videoCodec?: string; private initData?: InitData; @@ -74,13 +73,10 @@ class PassThroughRemuxer extends Logger implements Remuxer { videoCodec: string | undefined, decryptdata: DecryptData | null, ) { - console.log('$$$ PassThroughRemuxer - resetInitSegment called with audioCodec', audioCodec, 'videoCodec', videoCodec, 'decryptdata', JSON.stringify(decryptdata)); this.audioCodec = audioCodec; this.videoCodec = videoCodec; - // if (decryptdata) decryptdata.keyFormat = "com.microsoft.playready.recommendation"; this.generateInitSegment(initSegment, decryptdata); this.emitInitSegment = true; - this.encryptedInitPatched = false; } private generateInitSegment( @@ -93,14 +89,12 @@ class PassThroughRemuxer extends Logger implements Remuxer { this.initData = undefined; return; } + + const { audio, video } = (this.initData = parseInitSegment(initSegment)); + if (decryptdata) { - const { audio, video } = parseInitSegment(initSegment); - if (!audio?.encrypted && !video?.encrypted) { - initSegment = fakeEncryption(initSegment); - } - initSegment = patchEncyptionData(initSegment, decryptdata) ?? initSegment; + patchEncyptionData(initSegment, decryptdata); } else { - const { audio, video } = parseInitSegment(initSegment); const eitherTrack = audio || video; if (eitherTrack?.encrypted) { this.warn( @@ -109,8 +103,6 @@ class PassThroughRemuxer extends Logger implements Remuxer { } } - const { audio, video } = (this.initData = parseInitSegment(initSegment)); - // Get codec from initSegment if (audio) { audioCodec = getParsedTrackCodec( @@ -189,22 +181,6 @@ class PassThroughRemuxer extends Logger implements Remuxer { // audio or video (or both); adding it to video was an arbitrary choice. const data = videoTrack.samples; - if (!this.encryptedInitPatched && findBox(data, ['moof', 'traf', 'senc']).length > 0) { - this.encryptedInitPatched = true; - if (this.initTracks) { - const seen = new Set(); - // eslint-disable-next-line no-for-of-loops/no-for-of-loops - for (const track of Object.values(this.initTracks)) { - if (track?.initSegment && !seen.has(track.initSegment)) { - seen.add(track.initSegment); - patchTencIsProtected(track.initSegment as Uint8Array, true); - } - } - this.emitInitSegment = true; - } - } - - if (!data.length) { return result; } diff --git a/src/utils/mp4-tools.ts b/src/utils/mp4-tools.ts index 964e55ece7e..aa4bc45a873 100644 --- a/src/utils/mp4-tools.ts +++ b/src/utils/mp4-tools.ts @@ -576,11 +576,6 @@ export function patchEncyptionData( } const { keyId } = decryptdata; if (keyId && decryptdata.isCommonEncryption) { - // PlayReady key IDs are LE GUIDs; tenc default_KID must be a BE UUID. - const effectiveKeyId = - decryptdata.keyFormat === KeySystemFormats.PLAYREADY - ? leGuidToUuid(keyId) - : keyId; applyToTencBoxes(initSegment, (tenc, isAudio) => { // Look for default key id (keyID offset is always 8 within the tenc box): const tencKeyId = tenc.subarray(8, 24); @@ -588,39 +583,22 @@ export function patchEncyptionData( logger.log( `[eme] Patching keyId in 'enc${ isAudio ? 'a' : 'v' - }>sinf>>tenc' box: ${arrayToHex(tencKeyId)} -> ${arrayToHex(effectiveKeyId)}`, + }>sinf>>tenc' box: ${arrayToHex(tencKeyId)} -> ${arrayToHex(keyId)}`, ); - tenc.set(effectiveKeyId, 8); + tenc.set(keyId, 8); } }); } return initSegment; } -// PlayReady key IDs are LE GUIDs (Data1/2/3 stored little-endian). -// CENC tenc default_KID requires a standard big-endian UUID. -// Swap the first three groups: bytes 0-3, bytes 4-5, bytes 6-7. -function leGuidToUuid( - guid: Uint8Array, -): Uint8Array { - const uuid = new Uint8Array(guid) as Uint8Array; - uuid[0] = guid[3]; - uuid[1] = guid[2]; - uuid[2] = guid[1]; - uuid[3] = guid[0]; - uuid[4] = guid[5]; - uuid[5] = guid[4]; - uuid[6] = guid[7]; - uuid[7] = guid[6]; - return uuid; -} /** * Takes a clear init segment and returns a new one where every avc1 sample entry is wrapped * as encv (and mp4a as enca), each with a sinf box containing frma (original codec), schm (cenc), * and schi/tenc. */ export function fakeEncryption( - clearInitSegment: Uint8Array + clearInitSegment: Uint8Array, ): Uint8Array { const base = clearInitSegment.byteOffset; @@ -647,14 +625,22 @@ export function fakeEncryption( replacements.push({ boxStart: avc1.byteOffset - base - 8, boxSize: avc1.length + 8, - newBox: buildEncBox(avc1, [0x65, 0x6e, 0x63, 0x76], [0x61, 0x76, 0x63, 0x31]), // encv, avc1 + newBox: buildEncBox( + avc1, + [0x65, 0x6e, 0x63, 0x76], + [0x61, 0x76, 0x63, 0x31], + ), // encv, avc1 }); }); findBox(sampleEntries, ['mp4a']).forEach((mp4a) => { replacements.push({ boxStart: mp4a.byteOffset - base - 8, boxSize: mp4a.length + 8, - newBox: buildEncBox(mp4a, [0x65, 0x6e, 0x63, 0x61], [0x6d, 0x70, 0x34, 0x61]), // enca, mp4a + newBox: buildEncBox( + mp4a, + [0x65, 0x6e, 0x63, 0x61], + [0x6d, 0x70, 0x34, 0x61], + ), // enca, mp4a }); }); }); @@ -811,16 +797,12 @@ function applyToTencBoxes( export function patchTencIsProtected( initSegment: Uint8Array, - encrypted: boolean, ): void { + console.log('$$$$ patching tenc isProtected and IV size'); applyToTencBoxes(initSegment, (tenc) => { - tenc[6] = encrypted ? 1 : 0; // default_isProtected - if (!encrypted || tenc[7] === 0) { - // Only update IV_size when disabling encryption, or when it was 0 - // (IV_size=0 means this tenc was built by buildSinf via fakeEncryption; - // real packager-built tenc will have 8 or 16 here and must be preserved) - tenc[7] = encrypted ? 8 : 0; // default_Per_Sample_IV_Size - } + tenc[6] = 1; // default_isProtected + // TODO - hardcoding 16 here but could be 8 - should get this from the real encrypted init segment + tenc[7] = 16; // default_Per_Sample_IV_Size }); } diff --git a/tests/test-streams.js b/tests/test-streams.js index ec12f9ac926..e5813cd9105 100644 --- a/tests/test-streams.js +++ b/tests/test-streams.js @@ -47,6 +47,11 @@ function createTestStreamWithConfig(target, config) { } module.exports = { + ben: { + url: 'https://sample-videos-zyrkp2nj.s3-eu-west-1.amazonaws.com/big-buck-bunny-clear-to-encrypted/hls_fmp4_cenc_pw/master.m3u8', + description: 'Big Buck Bunny - clear to encrypted, fMP4, CENC, PlayReady', + abr: true, + }, bbb: { url: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', description: 'Big Buck Bunny - adaptive qualities', From 94904b2a97792351e1a8d230997c3384d7a929b1 Mon Sep 17 00:00:00 2001 From: Ben Roberts Date: Sat, 9 May 2026 14:10:00 +0200 Subject: [PATCH 7/9] improving the playready workaround - work still in progress --- package-lock.json | 17 ++ package.json | 3 + src/controller/base-stream-controller.ts | 20 ++- src/controller/buffer-controller.ts | 188 +---------------------- src/controller/state.mmd | 31 ++++ src/controller/stream-controller.ts | 28 +++- src/loader/fragment-loader.ts | 14 +- src/loader/playlist-loader.ts | 6 +- src/utils/mp4-tools.ts | 35 ++++- src/utils/playready-workaround.ts | 108 +++++++++++++ 10 files changed, 249 insertions(+), 201 deletions(-) create mode 100644 src/controller/state.mmd diff --git a/package-lock.json b/package-lock.json index 306c471a782..b06a60c18ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "hls.js", "version": "1.6.17", "license": "Apache-2.0", + "dependencies": { + "mp4box": "^2.3.0" + }, "devDependencies": { "@babel/core": "7.28.0", "@babel/helper-module-imports": "7.27.1", @@ -10712,6 +10715,15 @@ "node": "*" } }, + "node_modules/mp4box": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/mp4box/-/mp4box-2.3.0.tgz", + "integrity": "sha512-nnABYbdh4UguEYyV+uRwQBi1tbb8kXka2Fx9yKzmDKAeh8gkvRKYxoK1XDd8GQIjSfN4rvsXrW1CBo4yRQJZDA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=20.8.1" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -22157,6 +22169,11 @@ "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", "dev": true }, + "mp4box": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/mp4box/-/mp4box-2.3.0.tgz", + "integrity": "sha512-nnABYbdh4UguEYyV+uRwQBi1tbb8kXka2Fx9yKzmDKAeh8gkvRKYxoK1XDd8GQIjSfN4rvsXrW1CBo4yRQJZDA==" + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", diff --git a/package.json b/package.json index 8ca6bf02b85..1ecade4bd3c 100644 --- a/package.json +++ b/package.json @@ -135,5 +135,8 @@ "typescript": "5.8.3", "url-toolkit": "2.2.5", "wrangler": "4.26.1" + }, + "dependencies": { + "mp4box": "^2.3.0" } } diff --git a/src/controller/base-stream-controller.ts b/src/controller/base-stream-controller.ts index 7932325a707..f2084874eec 100644 --- a/src/controller/base-stream-controller.ts +++ b/src/controller/base-stream-controller.ts @@ -40,6 +40,7 @@ import { updateFragPTSDTS, } from '../utils/level-helper'; import { appendUint8Array } from '../utils/mp4-tools'; +import { patchClearInitSegment } from '../utils/playready-workaround'; import TimeRanges from '../utils/time-ranges'; import type { FragmentTracker } from './fragment-tracker'; import type { HlsConfig } from '../config'; @@ -122,6 +123,7 @@ export default class BaseStreamController protected initPTS: TimestampOffset[] = []; protected buffering: boolean = true; protected loadingParts: boolean = false; + protected firstEncryptedInitSegmentData: Uint8Array | null = null; private loopSn?: string | number; constructor( @@ -646,7 +648,6 @@ export default class BaseStreamController return data; }) .then((data: FragLoadedData) => { - console.log('$$$ _loadInitSegment - loaded init segment, checking if decryption is needed', data); const { hls } = this; const { frag, payload } = data; const decryptData = frag.decryptdata; @@ -690,11 +691,24 @@ export default class BaseStreamController }, }); data.payload = decryptedData; - console.log('$$$ _loadInitSegment - calling this.completeInitSegmentLoad with decrypted data', data); return this.completeInitSegmentLoad(data); }); } - console.log('$$$ _loadInitSegment - calling this.completeInitSegmentLoad with non-decrypted data', data); + console.log( + '$$$ _loadInitSegment - no decryption, payload byteLength:', + payload?.byteLength, + data, + ); + if (this.firstEncryptedInitSegmentData) { + console.log( + '$$$ _loadInitSegment - TODO patching init segment with encryption data from first encrypted fragment', + this.firstEncryptedInitSegmentData?.byteLength, + ); + data.frag.data = patchClearInitSegment( + new Uint8Array(data.payload), + this.firstEncryptedInitSegmentData, + ); + } return this.completeInitSegmentLoad(data); }) .catch((reason) => { diff --git a/src/controller/buffer-controller.ts b/src/controller/buffer-controller.ts index fcf6070498a..32e410d0b09 100755 --- a/src/controller/buffer-controller.ts +++ b/src/controller/buffer-controller.ts @@ -17,20 +17,11 @@ import { isCompatibleTrackChange, isManagedMediaSource, } from '../utils/mediasource-helper'; -import { - appendUint8Array, - bin2str, - findBox, - hasMoofData, - parseInitSegment, - patchTencIsProtected, - readUint32, -} from '../utils/mp4-tools'; import { stringify } from '../utils/safe-json-stringify'; import type { FragmentTracker } from './fragment-tracker'; import type { HlsConfig } from '../config'; import type Hls from '../hls'; -import type { Fragment, MediaFragment, Part } from '../loader/fragment'; +import type { MediaFragment, Part } from '../loader/fragment'; import type { LevelDetails } from '../loader/level-details'; import type { AttachMediaSourceData, @@ -129,8 +120,6 @@ export default class BufferController extends Logger implements ComponentAPI { [null, null], ]; - private firstVideoInitSegmentAppended: boolean = false; - constructor(hls: Hls, fragmentTracker: FragmentTracker) { super('buffer-controller', hls.logger); this.debug('$$$ BufferController constructor'); @@ -1692,143 +1681,6 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe } } - private preLoadFirstEncryptedInitSegmentData( - firstEncryptedInitSegment: Fragment, - ): Promise { - return new Promise((resolve, reject) => { - if (firstEncryptedInitSegment.data) { - resolve(firstEncryptedInitSegment.data as Uint8Array); - } - // remove the resource file from the base URL to get the true base URL for the init segment - // e.g. https://sample-videos-zyrkp2nj.s3-eu-west-1.amazon…ncrypted/hls_fmp4_cenc_pw/video_348000/index.m3u - const urlBase = firstEncryptedInitSegment.base.url.replace( - /\/[^/]*$/, - '/', - ); - const initSegmentUrl = urlBase + firstEncryptedInitSegment.relurl; - fetch(initSegmentUrl) - .then((response) => { - if (!response.ok) { - throw new Error( - `Failed to fetch init segment data from ${initSegmentUrl}: ${response.statusText}`, - ); - } - response - .arrayBuffer() - .then((arrayBuffer) => { - resolve(new Uint8Array(arrayBuffer)); - }) - .catch((error) => { - reject(error); - }); - }) - .catch((error) => { - reject(error); - }); - }); - } - - private patchClearInitSegment( - clearInitSegmentData: Uint8Array, - firstEncryptedInitSegmentData: Uint8Array, - ): Uint8Array { - this.debug( - '$$$$ First clear init segment without PSSH boxes:', - parseInitSegment(clearInitSegmentData), - clearInitSegmentData, - ); - this.debug( - '$$$$ First encrypted init segment with PSSH boxes:', - parseInitSegment(firstEncryptedInitSegmentData), - firstEncryptedInitSegmentData, - ); - - // PSSH boxes are children of moov — extract them by iterating moov's content - const psshBoxes: Uint8Array[] = []; - const moovContent = findBox(firstEncryptedInitSegmentData, ['moov'])[0]; - if (moovContent) { - let offset = 0; - while (offset < moovContent.length) { - const size = readUint32(moovContent, offset); - if (size < 8) break; - const type = bin2str(moovContent.subarray(offset + 4, offset + 8)); - if (type === 'pssh') { - psshBoxes.push(moovContent.subarray(offset, offset + size)); - } - offset += size; - } - } - - if (psshBoxes.length === 0) { - this.debug('$$$$ No PSSH boxes found in encrypted init segment'); - return clearInitSegmentData; - } - - // Rebuild the clear init segment, inserting PSSH boxes immediately before moov - const parts: Uint8Array[] = []; - let psshInserted = false; - let offset = 0; - while (offset < clearInitSegmentData.length) { - const size = readUint32(clearInitSegmentData, offset); - if (size < 8) break; - const type = bin2str( - clearInitSegmentData.subarray(offset + 4, offset + 8), - ); - if (type === 'moov' && !psshInserted) { - psshBoxes.forEach((pssh) => parts.push(pssh)); - psshInserted = true; - } - parts.push(clearInitSegmentData.subarray(offset, offset + size)); - offset += size; - } - - this.debug( - `$$$$ Copied ${psshBoxes.length} PSSH box(es) from encrypted init segment to clear init segment`, - ); - const patchedInitSegment = parts.reduce(appendUint8Array); - this.debug( - '$$$$ Clear init segment with PSSH boxes patched in:', - parseInitSegment(patchedInitSegment), - ); - return patchedInitSegment; - } - - private patchClearInitSegmentForSL3000Compatability( - clearInitSegmentData: Uint8Array, - ): Promise { - return new Promise((resolve, reject) => { - // parse the data and extract the first encrypted init segment - const { details } = this; - if (!details) { - return reject( - new Error( - 'Details must be available to find first encrypted init segment', - ), - ); - } - const firstEncryptedInitSegment = - details.encryptedFragments?.[0].initSegment; - if (!firstEncryptedInitSegment) { - return reject(new Error('No encrypted init segment found in details')); - } - patchTencIsProtected(clearInitSegmentData); - this.preLoadFirstEncryptedInitSegmentData(firstEncryptedInitSegment) - .then((firstEncryptedInitSegmentData) => { - const patchedData = this.patchClearInitSegment( - clearInitSegmentData, - firstEncryptedInitSegmentData, - ); - this.debug( - '$$$$ Finished patching init segment for SL3000 compatibility, appending to source buffer', - ); - resolve(patchedData); - }) - .catch((error) => { - reject(error); - }); - }); - } - // This method must result in an updateend event; if append is not called, onSBUpdateEnd must be called manually private appendExecutor( data: Uint8Array, @@ -1843,43 +1695,7 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe } track.ending = false; track.ended = false; - if ( - !this.firstVideoInitSegmentAppended && - type === 'video' && - !hasMoofData(data) - ) { - this.firstVideoInitSegmentAppended = true; - const firstInitSegmentParsed = parseInitSegment(data); - if (firstInitSegmentParsed[1]?.stsd.encrypted) { - this.debug( - '$$$$ First video init segment is encrypted, no patching required for SL3000 compatibility', - ); - sb.appendBuffer(data); - return; - } - this.debug( - '$$$$ First video init segment detected, patching for SL3000 compatibility', - firstInitSegmentParsed, - ); - - this.patchClearInitSegmentForSL3000Compatability(data) - .then((patchedData) => { - const currentSb = this.tracks[type]?.buffer; - if (!currentSb || this.mediaSource?.readyState !== 'open') { - this.onSBUpdateEnd('video'); - return; - } - currentSb.appendBuffer(patchedData); - }) - .catch((error) => { - this.warn( - `$$$$ Error patching init segment for SL3000 compatibility: ${error}`, - ); - this.onSBUpdateEnd('video'); - }); - } else { - sb.appendBuffer(data); - } + sb.appendBuffer(data); } private blockUntilOpen(callback: () => void) { diff --git a/src/controller/state.mmd b/src/controller/state.mmd new file mode 100644 index 00000000000..9185d116261 --- /dev/null +++ b/src/controller/state.mmd @@ -0,0 +1,31 @@ +stateDiagram-v2 + [*] --> STOPPED : controller created + + STOPPED --> IDLE : startLoad()\nsetInterval(100ms) + + IDLE --> WAITING_LEVEL : level details\nnot yet loaded + IDLE --> KEY_LOADING : fragment is\nencrypted + IDLE --> FRAG_LOADING : fragment selected,\nno encryption + + WAITING_LEVEL --> IDLE : LEVEL_LOADED\nevent fires + + KEY_LOADING --> IDLE : KEY_LOADED\nevent fires + + FRAG_LOADING --> PARSING : download complete,\ntransmuxer processes data + FRAG_LOADING --> FRAG_LOADING_WAITING_RETRY : load error,\nretry configured + + FRAG_LOADING_WAITING_RETRY --> IDLE : retry delay elapsed + + PARSING --> PARSED : transmux complete,\ntiming updated + PARSED --> IDLE : buffer append complete\n(FRAG_BUFFERED event) + + IDLE --> ENDED : all segments buffered + + ENDED --> IDLE : seek or buffer flush + + IDLE --> STOPPED : stopLoad() + FRAG_LOADING --> STOPPED : stopLoad() + + FRAG_LOADING --> ERROR : fatal load error + PARSING --> ERROR : fatal parse error + ERROR --> STOPPED : stopLoad() diff --git a/src/controller/stream-controller.ts b/src/controller/stream-controller.ts index d4b1d18f332..586b89d13bf 100644 --- a/src/controller/stream-controller.ts +++ b/src/controller/stream-controller.ts @@ -15,6 +15,7 @@ import { addEventListener, removeEventListener, } from '../utils/event-listener-helper'; +import { preLoadFirstEncryptedInitSegmentData } from '../utils/playready-workaround'; import { useAlternateAudio } from '../utils/rendition-helper'; import type { FragmentTracker } from './fragment-tracker'; import type Hls from '../hls'; @@ -389,7 +390,32 @@ export default class StreamController fragState === FragmentState.PARTIAL ) { if (!isMediaFragment(frag)) { - this._loadInitSegment(frag, level); + if (!this.firstEncryptedInitSegmentData) { + // Pre-fetch first encrypted init segment to obtain its decryption data + const details = this.getLevelDetails(); + const firstEncryptedInit = + details?.encryptedFragments?.[0].initSegment; + if (firstEncryptedInit) { + preLoadFirstEncryptedInitSegmentData(firstEncryptedInit) + .then((data) => { + this.firstEncryptedInitSegmentData = data; + this._loadInitSegment(frag, level); + }) + .catch((error) => { + console.log( + '$$$$ Failed to pre-load first encrypted init segment data:', + error, + ); + // TODO handle this error case, e.g. by loading the init segment without patching it + // with encryption data, which might lead to decryption failure but at least would + // allow playback to start in some cases (e.g. if the init segment contains clear + // key data or if the browser is able to handle the encrypted init segment without + // patching) + }); + } + } else { + this._loadInitSegment(frag, level); + } } else if (this.bitrateTest) { this.log( `Fragment ${frag.sn} of level ${frag.level} is being downloaded to test bitrate and will not be buffered`, diff --git a/src/loader/fragment-loader.ts b/src/loader/fragment-loader.ts index 3e6834c74ef..060546d262f 100644 --- a/src/loader/fragment-loader.ts +++ b/src/loader/fragment-loader.ts @@ -104,13 +104,13 @@ export default class FragmentLoader { frag.decryptdata.iv = new Uint8Array(payload.slice(0, 16)); payload = payload.slice(16); } - if ( - this.config.requiresEncryptionInfoInAllInitSegments && - frag.sn === 'initSegment' - ) { - payload = fakeEncryption(new Uint8Array(payload)) - .buffer as ArrayBuffer; - } + // if ( + // this.config.requiresEncryptionInfoInAllInitSegments && + // frag.sn === 'initSegment' + // ) { + // payload = fakeEncryption(new Uint8Array(payload)) + // .buffer as ArrayBuffer; + // } resolve({ frag, part: null, diff --git a/src/loader/playlist-loader.ts b/src/loader/playlist-loader.ts index 3fd67f306c6..9e939194e56 100644 --- a/src/loader/playlist-loader.ts +++ b/src/loader/playlist-loader.ts @@ -557,9 +557,9 @@ class PlaylistLoader implements NetworkComponentAPI { this.variableList, ); - if (hls.config.requiresEncryptionInfoInAllInitSegments) { - applyPlayReadyWorkaroundToLevelDetails(levelDetails); - } + // if (hls.config.requiresEncryptionInfoInAllInitSegments) { + // applyPlayReadyWorkaroundToLevelDetails(levelDetails); + // } // We have done our first request (Manifest-type) and receive // not a master playlist but a chunk-list (track/level) diff --git a/src/utils/mp4-tools.ts b/src/utils/mp4-tools.ts index aa4bc45a873..ca44e0a770d 100644 --- a/src/utils/mp4-tools.ts +++ b/src/utils/mp4-tools.ts @@ -1,6 +1,6 @@ import { utf8ArrayToStr } from '@svta/common-media-library/utils/utf8ArrayToStr'; +import * as MP4Box from 'mp4box'; import { arrayToHex } from './hex'; -import { KeySystemFormats } from './mediakeys-helper'; import { ElementaryStreamTypes } from '../loader/fragment'; import { logger } from '../utils/logger'; import type { KeySystemIds } from './mediakeys-helper'; @@ -795,6 +795,39 @@ function applyToTencBoxes( }); } +export function dumpInitSegment( + message: string, + initSegment: Uint8Array, + // log: (...args: Array) => void, + label: string, + parsed: boolean = false, +): void { + if (parsed) { + const mp4boxfile = MP4Box.createFile(); + const now = new Date(); + const stamp = `${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}-${String(now.getMinutes()).padStart(2, '0')}-${String(now.getSeconds()).padStart(2, '0')}-${String(now.getMilliseconds()).padStart(3, '0')}`; + const dumpFile = `${label}-${stamp}.json`; + mp4boxfile.onReady = (info) => { + const json = JSON.stringify({ message, info }, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `dump/${dumpFile}`; + a.click(); + URL.revokeObjectURL(url); + console.log( + `$$$$ '${label}' init segment dump saved to ~/Downloads/${dumpFile}`, + ); + }; + const buffer = initSegment.buffer as ArrayBuffer & { fileStart: number }; + buffer.fileStart = 0; + mp4boxfile.appendBuffer(buffer); + } else { + console.log(`$$$$ ${message} (json):`, initSegment); + } +} + export function patchTencIsProtected( initSegment: Uint8Array, ): void { diff --git a/src/utils/playready-workaround.ts b/src/utils/playready-workaround.ts index d90a080d8e0..2b9f2a1158e 100644 --- a/src/utils/playready-workaround.ts +++ b/src/utils/playready-workaround.ts @@ -12,11 +12,13 @@ import { KeySystemFormats } from './mediakeys-helper'; import { appendUint8Array, bin2str, + dumpInitSegment, findBox, mp4Box, readUint32, writeUint32, } from './mp4-tools'; +import type { Fragment } from '../hls'; import type { LevelDetails } from '../loader/level-details'; /** @@ -116,6 +118,9 @@ function createFakeSinfBox( } export function fakeEncryption(initSegment: Uint8Array) { + console.log( + '$$$$ Applying fake encryption to clear init segment to ensure encryption info is available for all init segments', + ); const initSegmentCopy = new Uint8Array(initSegment); const moov = findBox(initSegmentCopy, ['moov'])[0]; @@ -272,3 +277,106 @@ export function fakeEncryption(initSegment: Uint8Array) { return modifiedInitSegment; } } + +export function patchClearInitSegment( + clearInitSegmentData: Uint8Array, + firstEncryptedInitSegmentData: Uint8Array, +): Uint8Array { + dumpInitSegment( + '$$$$ First clear init segment without PSSH boxes', + clearInitSegmentData, + 'clear', + ); + dumpInitSegment( + '$$$$ First encrypted init segment with PSSH boxes', + firstEncryptedInitSegmentData, + 'encrypted', + ); + + // PSSH boxes are children of moov — extract them by iterating moov's content + const psshBoxes: Uint8Array[] = []; + const moovContent = findBox(firstEncryptedInitSegmentData, ['moov'])[0]; + if (moovContent) { + let offset = 0; + while (offset < moovContent.length) { + const size = readUint32(moovContent, offset); + if (size < 8) break; + const type = bin2str(moovContent.subarray(offset + 4, offset + 8)); + if (type === 'pssh') { + psshBoxes.push(moovContent.subarray(offset, offset + size)); + } + offset += size; + } + } + + if (psshBoxes.length === 0) { + this.debug('$$$$ No PSSH boxes found in encrypted init segment'); + return clearInitSegmentData; + } + + // Rebuild the clear init segment, inserting PSSH boxes inside moov (at end of moov content) + const parts: Uint8Array[] = []; + let offset = 0; + while (offset < clearInitSegmentData.length) { + const size = readUint32(clearInitSegmentData, offset); + if (size < 8) break; + const type = bin2str(clearInitSegmentData.subarray(offset + 4, offset + 8)); + if (type === 'moov') { + // PSSH boxes must be inside moov (not at file level) for MediaFoundation/PlayReady to + // initialise the hardware-protected decode path before the first encrypted frame arrives + const psshData = psshBoxes.reduce(appendUint8Array); + const newMoovSize = size + psshData.length; + const newMoov = new Uint8Array(newMoovSize); + newMoov.set(clearInitSegmentData.subarray(offset, offset + size), 0); + writeUint32(newMoov, 0, newMoovSize); + newMoov.set(psshData, size); + parts.push(newMoov); + } else { + parts.push(clearInitSegmentData.subarray(offset, offset + size)); + } + offset += size; + } + console.log( + `$$$$ Copied ${psshBoxes.length} PSSH box(es) from encrypted init segment to clear init segment`, + ); + const patchedClearInitSegment = parts.reduce(appendUint8Array); + dumpInitSegment( + '$$$$ Clear init segment with PSSH boxes patched in', + patchedClearInitSegment, + 'patched', + ); + return patchedClearInitSegment; +} + +export function preLoadFirstEncryptedInitSegmentData( + firstEncryptedInitSegment: Fragment, +): Promise { + return new Promise((resolve, reject) => { + if (firstEncryptedInitSegment.data) { + resolve(firstEncryptedInitSegment.data as Uint8Array); + } + // remove the resource file from the base URL to get the true base URL for the init segment + // e.g. https://sample-videos-zyrkp2nj.s3-eu-west-1.amazon…ncrypted/hls_fmp4_cenc_pw/video_348000/index.m3u + const urlBase = firstEncryptedInitSegment.base.url.replace(/\/[^/]*$/, '/'); + const initSegmentUrl = urlBase + firstEncryptedInitSegment.relurl; + fetch(initSegmentUrl) + .then((response) => { + if (!response.ok) { + throw new Error( + `Failed to fetch init segment data from ${initSegmentUrl}: ${response.statusText}`, + ); + } + response + .arrayBuffer() + .then((arrayBuffer) => { + resolve(new Uint8Array(arrayBuffer)); + }) + .catch((error) => { + reject(error); + }); + }) + .catch((error) => { + reject(error); + }); + }); +} From e5981115ce9bc34efce07e3f1d6d114e767880b5 Mon Sep 17 00:00:00 2001 From: Ben Roberts Date: Tue, 12 May 2026 18:52:26 +0200 Subject: [PATCH 8/9] add logic to patch clear media segments with drm data - work in progress --- src/controller/base-stream-controller.ts | 31 +- src/controller/eme-controller.ts | 89 ++- src/controller/stream-controller.ts | 29 +- src/loader/fragment-loader.ts | 8 - src/utils/mp4-tools.ts | 206 +----- src/utils/playready-workaround.ts | 778 +++++++++++++++-------- tests/unit/utils/mp4-tools.ts | 264 -------- 7 files changed, 677 insertions(+), 728 deletions(-) delete mode 100644 tests/unit/utils/mp4-tools.ts diff --git a/src/controller/base-stream-controller.ts b/src/controller/base-stream-controller.ts index f2084874eec..aa859e0bcb0 100644 --- a/src/controller/base-stream-controller.ts +++ b/src/controller/base-stream-controller.ts @@ -39,7 +39,7 @@ import { getPartWith, updateFragPTSDTS, } from '../utils/level-helper'; -import { appendUint8Array } from '../utils/mp4-tools'; +import { appendUint8Array, dumpSegment } from '../utils/mp4-tools'; import { patchClearInitSegment } from '../utils/playready-workaround'; import TimeRanges from '../utils/time-ranges'; import type { FragmentTracker } from './fragment-tracker'; @@ -124,8 +124,11 @@ export default class BaseStreamController protected buffering: boolean = true; protected loadingParts: boolean = false; protected firstEncryptedInitSegmentData: Uint8Array | null = null; + protected waitingForInitSegmentAppend: boolean = false; private loopSn?: string | number; + private encryptedInitSegmentPatched: boolean = false; + constructor( hls: Hls, fragmentTracker: FragmentTracker, @@ -331,6 +334,7 @@ export default class BaseStreamController return; } this.loadingParts = false; + this.encryptedInitSegmentPatched = false; this.fragmentTracker.removeAllFragments(); this.stopLoad(); } @@ -536,9 +540,15 @@ export default class BaseStreamController } if ('payload' in data) { + dumpSegment( + 'Dumping media segment', + new Uint8Array(data.payload), + 'media segment', + ); this.log( `Loaded ${frag.type} sn: ${frag.sn} of ${this.playlistLabel()} ${frag.level}`, ); + data.payload = this.patchMediaSegment(data.payload); this.hls.trigger(Events.FRAG_LOADED, data); } @@ -554,6 +564,10 @@ export default class BaseStreamController }); } + protected patchMediaSegment(payload: ArrayBuffer): ArrayBuffer { + return payload; + } + protected clearTrackerIfNeeded(frag: Fragment) { const { fragmentTracker } = this; const fragState = fragmentTracker.getState(frag); @@ -699,15 +713,21 @@ export default class BaseStreamController payload?.byteLength, data, ); - if (this.firstEncryptedInitSegmentData) { + if ( + this.firstEncryptedInitSegmentData && + !this.encryptedInitSegmentPatched + ) { + this.encryptedInitSegmentPatched = true; console.log( '$$$ _loadInitSegment - TODO patching init segment with encryption data from first encrypted fragment', this.firstEncryptedInitSegmentData?.byteLength, ); - data.frag.data = patchClearInitSegment( + const patched = patchClearInitSegment( new Uint8Array(data.payload), this.firstEncryptedInitSegmentData, ); + data.frag.data = patched; + data.payload = patched.buffer; } return this.completeInitSegmentLoad(data); }) @@ -729,7 +749,10 @@ export default class BaseStreamController if (this.state !== State.STOPPED) { this.state = State.IDLE; } - data.frag.data = new Uint8Array(data.payload); + if (!data.frag.data) { + data.frag.data = new Uint8Array(data.payload); + } + this.waitingForInitSegmentAppend = false; stats.parsing.start = stats.buffering.start = self.performance.now(); stats.parsing.end = stats.buffering.end = self.performance.now(); this.tick(); diff --git a/src/controller/eme-controller.ts b/src/controller/eme-controller.ts index 5ed98bc9b09..7f50c6b2b66 100644 --- a/src/controller/eme-controller.ts +++ b/src/controller/eme-controller.ts @@ -98,6 +98,7 @@ class EMEController extends Logger implements ComponentAPI { ? [EMEController.CDMCleanupPromise] : []; private bannedKeyIds: { [keyId: string]: MediaKeyStatus | undefined } = {}; + private pendingCencInitData: ArrayBuffer | null = null; constructor(hls: Hls) { super('eme', hls.logger); @@ -140,10 +141,10 @@ class EMEController extends Logger implements ComponentAPI { const { drmSystems, widevineLicenseUrl } = this.config; // const keySystemConfiguration = drmSystems?.[keySystem]; const keySystemConfiguration = - drmSystems?.[keySystem] ?? - (keySystem === KeySystems.PLAYREADY_RECOMMENDATION - ? drmSystems?.[KeySystems.PLAYREADY] - : undefined); + drmSystems?.[keySystem] ?? + (keySystem === KeySystems.PLAYREADY_RECOMMENDATION + ? drmSystems?.[KeySystems.PLAYREADY] + : undefined); if (keySystemConfiguration) { return keySystemConfiguration.licenseUrl; @@ -169,10 +170,10 @@ class EMEController extends Logger implements ComponentAPI { const { drmSystems } = this.config; // const keySystemConfiguration = drmSystems?.[keySystem]; const keySystemConfiguration = - drmSystems?.[keySystem] ?? - (keySystem === KeySystems.PLAYREADY_RECOMMENDATION - ? drmSystems?.[KeySystems.PLAYREADY] - : undefined); + drmSystems?.[keySystem] ?? + (keySystem === KeySystems.PLAYREADY_RECOMMENDATION + ? drmSystems?.[KeySystems.PLAYREADY] + : undefined); if (keySystemConfiguration) { return keySystemConfiguration.serverCertificateUrl; } else { @@ -451,10 +452,14 @@ class EMEController extends Logger implements ComponentAPI { // Chrome on Windows registers PlayReady as the recommendation variant. // When the standard key system is in the attempt list, add the recommendation // variant as an immediate fallback so attemptKeySystemAccess tries it next. - // if (keySystemsToAttempt.indexOf(KeySystems.PLAYREADY) !== -1) { - // const idx = keySystemsToAttempt.indexOf(KeySystems.PLAYREADY); - // keySystemsToAttempt.splice(idx + 1, 0, KeySystems.PLAYREADY_RECOMMENDATION); - // } + if (keySystemsToAttempt.indexOf(KeySystems.PLAYREADY) !== -1) { + const idx = keySystemsToAttempt.indexOf(KeySystems.PLAYREADY); + keySystemsToAttempt.splice( + idx + 1, + 0, + KeySystems.PLAYREADY_RECOMMENDATION, + ); + } return this.selectKeySystem(keySystemsToAttempt); } @@ -615,7 +620,11 @@ class EMEController extends Logger implements ComponentAPI { keySystemsToAttempt.indexOf(KeySystems.PLAYREADY_RECOMMENDATION) === -1 ) { keySystemsToAttempt = keySystemsToAttempt.slice(); - keySystemsToAttempt.splice(playreadyIdx + 1, 0, KeySystems.PLAYREADY_RECOMMENDATION); + keySystemsToAttempt.splice( + playreadyIdx + 1, + 0, + KeySystems.PLAYREADY_RECOMMENDATION, + ); } return this.attemptKeySystemAccess(keySystemsToAttempt); } @@ -646,6 +655,48 @@ class EMEController extends Logger implements ComponentAPI { this.keyFormatPromise .then((keySystemFormat) => { const keySystem = keySystemFormatToKeySystemDomain(keySystemFormat); + if (initDataType === 'cenc') { + // Handle CENC encrypted events for non-FairPlay key systems (e.g., PlayReady). + // This fires when we append a patched init segment containing encv+sinf+PSSH + // for a clear segment that had no PSSH. Find the session that was created from + // playlist key info but had generateRequest() skipped due to missing PSSH, and + // supply the PSSH from this event so the CDM can activate. + const { keyIdToKeySessionPromise, mediaKeySessions } = this; + for (let i = 0; i < mediaKeySessions.length; i++) { + const keyContext = mediaKeySessions[i]; + if ( + keySystemDomainToKeySystemFormat(keyContext.keySystem) !== + keySystemFormat + ) { + continue; + } + const decryptdata = keyContext.decryptdata; + if (decryptdata.pssh) { + continue; + } + const keyId = getKeyIdString(decryptdata); + decryptdata.pssh = new Uint8Array(initData); + const existing = + keyIdToKeySessionPromise[keyId] || Promise.resolve(keyContext); + const cencPromise = existing.then(() => + this.generateRequestWithPreferredKeySession( + keyContext, + initDataType, + initData, + 'encrypted-event-key-match', + ), + ); + cencPromise.catch((error) => this.handleError(error)); + keyIdToKeySessionPromise[keyId] = cencPromise; + return; + } + this.log( + `Storing PSSH from "${event.type}" event for key-system ${keySystem} (no session exists yet)`, + ); + this.pendingCencInitData = initData; + return; + } + if (initDataType !== 'sinf' || keySystem !== KeySystems.FAIRPLAY) { this.log( `Ignoring "${event.type}" event with init data type: "${initDataType}" for selected key-system ${keySystem}`, @@ -800,6 +851,13 @@ class EMEController extends Logger implements ComponentAPI { } } + if (initData === null && this.pendingCencInitData) { + this.log('Using stored CENC PSSH for generateRequest'); + initData = this.pendingCencInitData; + this.pendingCencInitData = null; + context.decryptdata.pssh = new Uint8Array(initData); + } + if (initData === null) { this.log(`Skipping key-session request for "${reason}" (no initData)`); return Promise.resolve(context); @@ -1356,7 +1414,10 @@ class EMEController extends Logger implements ComponentAPI { this.setupLicenseXHR(xhr, url, keySessionContext, licenseChallenge) .then(({ xhr, licenseChallenge }) => { - if (keySessionContext.keySystem == KeySystems.PLAYREADY || keySessionContext.keySystem == KeySystems.PLAYREADY_RECOMMENDATION) { + if ( + keySessionContext.keySystem == KeySystems.PLAYREADY || + keySessionContext.keySystem == KeySystems.PLAYREADY_RECOMMENDATION + ) { licenseChallenge = this.unpackPlayReadyKeyMessage( xhr, licenseChallenge, diff --git a/src/controller/stream-controller.ts b/src/controller/stream-controller.ts index 586b89d13bf..626bebe1fff 100644 --- a/src/controller/stream-controller.ts +++ b/src/controller/stream-controller.ts @@ -1,3 +1,4 @@ +import { M } from 'mp4box/dist/log-DO1-_KSL'; import BaseStreamController, { State } from './base-stream-controller'; import { findFragmentByPTS } from './fragment-finders'; import { FragmentState } from './fragment-tracker'; @@ -15,7 +16,10 @@ import { addEventListener, removeEventListener, } from '../utils/event-listener-helper'; -import { preLoadFirstEncryptedInitSegmentData } from '../utils/playready-workaround'; +import { + patchClearMediaSegment, + preLoadFirstEncryptedInitSegmentData, +} from '../utils/playready-workaround'; import { useAlternateAudio } from '../utils/rendition-helper'; import type { FragmentTracker } from './fragment-tracker'; import type Hls from '../hls'; @@ -236,6 +240,10 @@ export default class StreamController } private doTickIdle() { + if (this.waitingForInitSegmentAppend) { + return; + } + const { hls, levelLastLoaded, levels, media } = this; // if start level not parsed yet OR @@ -396,6 +404,7 @@ export default class StreamController const firstEncryptedInit = details?.encryptedFragments?.[0].initSegment; if (firstEncryptedInit) { + this.waitingForInitSegmentAppend = true; preLoadFirstEncryptedInitSegmentData(firstEncryptedInit) .then((data) => { this.firstEncryptedInitSegmentData = data; @@ -406,11 +415,8 @@ export default class StreamController '$$$$ Failed to pre-load first encrypted init segment data:', error, ); - // TODO handle this error case, e.g. by loading the init segment without patching it - // with encryption data, which might lead to decryption failure but at least would - // allow playback to start in some cases (e.g. if the init segment contains clear - // key data or if the browser is able to handle the encrypted init segment without - // patching) + this.waitingForInitSegmentAppend = false; + this._loadInitSegment(frag, level); }); } } else { @@ -821,7 +827,18 @@ export default class StreamController } } + protected patchMediaSegment(payload: ArrayBuffer): ArrayBuffer { + if (!this.firstEncryptedInitSegmentData) { + return payload; + } + return patchClearMediaSegment( + new Uint8Array(payload), + this.firstEncryptedInitSegmentData, + ).buffer as ArrayBuffer; + } + protected _handleFragmentLoadProgress(data: FragLoadedData) { + console.log('$$$$ onFragLoadProgress', data); const frag = data.frag as MediaFragment; const { part, payload } = data; const { levels } = this; diff --git a/src/loader/fragment-loader.ts b/src/loader/fragment-loader.ts index 060546d262f..4802df133b1 100644 --- a/src/loader/fragment-loader.ts +++ b/src/loader/fragment-loader.ts @@ -1,6 +1,5 @@ import { ErrorDetails, ErrorTypes } from '../errors'; import { getLoaderConfigWithoutReties } from '../utils/error-helper'; -import { fakeEncryption } from '../utils/playready-workaround'; import type { BaseSegment, Fragment, Part } from './fragment'; import type { HlsConfig } from '../config'; import type { @@ -104,13 +103,6 @@ export default class FragmentLoader { frag.decryptdata.iv = new Uint8Array(payload.slice(0, 16)); payload = payload.slice(16); } - // if ( - // this.config.requiresEncryptionInfoInAllInitSegments && - // frag.sn === 'initSegment' - // ) { - // payload = fakeEncryption(new Uint8Array(payload)) - // .buffer as ArrayBuffer; - // } resolve({ frag, part: null, diff --git a/src/utils/mp4-tools.ts b/src/utils/mp4-tools.ts index ca44e0a770d..43a57269ef3 100644 --- a/src/utils/mp4-tools.ts +++ b/src/utils/mp4-tools.ts @@ -592,169 +592,6 @@ export function patchEncyptionData( return initSegment; } -/** - * Takes a clear init segment and returns a new one where every avc1 sample entry is wrapped - * as encv (and mp4a as enca), each with a sinf box containing frma (original codec), schm (cenc), - * and schi/tenc. - */ -export function fakeEncryption( - clearInitSegment: Uint8Array, -): Uint8Array { - const base = clearInitSegment.byteOffset; - - // Collect codec boxes that need to be replaced (avc1→encv, mp4a→enca). - // Each entry records the original box position (including its 8-byte header) and the - // full replacement box so we can stitch a new, correctly-sized buffer. - const replacements: Array<{ - boxStart: number; // offset of original box header in clearInitSegment - boxSize: number; // total original box size (header + content) - newBox: Uint8Array; - }> = []; - - findBox(clearInitSegment, ['moov', 'trak']).forEach((trak) => { - const stsd = findBox(trak, [ - 'mdia', - 'minf', - 'stbl', - 'stsd', - ])[0] as BoxDataOrUndefined; - if (!stsd) return; - const sampleEntries = stsd.subarray(8); - - findBox(sampleEntries, ['avc1']).forEach((avc1) => { - replacements.push({ - boxStart: avc1.byteOffset - base - 8, - boxSize: avc1.length + 8, - newBox: buildEncBox( - avc1, - [0x65, 0x6e, 0x63, 0x76], - [0x61, 0x76, 0x63, 0x31], - ), // encv, avc1 - }); - }); - findBox(sampleEntries, ['mp4a']).forEach((mp4a) => { - replacements.push({ - boxStart: mp4a.byteOffset - base - 8, - boxSize: mp4a.length + 8, - newBox: buildEncBox( - mp4a, - [0x65, 0x6e, 0x63, 0x61], - [0x6d, 0x70, 0x34, 0x61], - ), // enca, mp4a - }); - }); - }); - - // Already encrypted (or no recognised codec entries) — return unchanged. - if (replacements.length === 0) return clearInitSegment; - replacements.sort((a, b) => a.boxStart - b.boxStart); - - // Build a new buffer by stitching the original with each codec box replaced. - const totalExtra = replacements.reduce( - (sum, r) => sum + r.newBox.length - r.boxSize, - 0, - ); - const result = new Uint8Array( - clearInitSegment.length + totalExtra, - ) as Uint8Array; - - let srcPos = 0; - let dstPos = 0; - replacements.forEach((r) => { - result.set(clearInitSegment.subarray(srcPos, r.boxStart), dstPos); - dstPos += r.boxStart - srcPos; - result.set(r.newBox, dstPos); - dstPos += r.newBox.length; - srcPos = r.boxStart + r.boxSize; - }); - result.set(clearInitSegment.subarray(srcPos), dstPos); - - // Patch the size fields of every ancestor box (moov → trak → mdia → minf → stbl → stsd). - // Ancestor boxes always begin before their descendants, so a box at original position P - // sits at P + (sum of deltas from replacements that came before P) in the new buffer. - [ - ['moov'], - ['moov', 'trak'], - ['moov', 'trak', 'mdia'], - ['moov', 'trak', 'mdia', 'minf'], - ['moov', 'trak', 'mdia', 'minf', 'stbl'], - ['moov', 'trak', 'mdia', 'minf', 'stbl', 'stsd'], - ].forEach((path) => { - findBox(clearInitSegment, path).forEach((box) => { - const origStart = box.byteOffset - base - 8; - const origEnd = origStart + box.length + 8; - let delta = 0; - let priorShift = 0; - replacements.forEach((r) => { - if (r.boxStart < origStart) priorShift += r.newBox.length - r.boxSize; - if (r.boxStart >= origStart && r.boxStart + r.boxSize <= origEnd) - delta += r.newBox.length - r.boxSize; - }); - if (delta !== 0) { - const newStart = origStart + priorShift; - writeUint32(result, newStart, readUint32(result, newStart) + delta); - } - }); - }); - - return result; -} - -// Builds a full enc box (encv or enca) wrapping the original codec content plus a sinf. -// codecContent is the box content returned by findBox (no header). -// encFourCC / origFourCC are 4-byte arrays of char codes. -function buildEncBox( - codecContent: Uint8Array, - encFourCC: number[], - origFourCC: number[], -): Uint8Array { - const sinf = buildSinf(origFourCC); - const box = new Uint8Array(8 + codecContent.length + sinf.length); - writeUint32(box, 0, box.length); - box.set(encFourCC, 4); - box.set(codecContent, 8); - box.set(sinf, 8 + codecContent.length); - return box; -} - -// Builds an 80-byte sinf box: frma(12) + schm(20) + schi(40). -function buildSinf(origFourCC: number[]): Uint8Array { - // frma: [size=12][frma][original_format] - const frma = new Uint8Array(12); - writeUint32(frma, 0, 12); - frma.set([0x66, 0x72, 0x6d, 0x61], 4); // 'frma' - frma.set(origFourCC, 8); - - // schm: [size=20][schm][version/flags=0][scheme_type=cenc][scheme_version=0x00010000] - const schm = new Uint8Array(20); - writeUint32(schm, 0, 20); - schm.set([0x73, 0x63, 0x68, 0x6d], 4); // 'schm' - schm.set([0x63, 0x65, 0x6e, 0x63], 12); // 'cenc' at content bytes 4–7 - schm.set([0x00, 0x01, 0x00, 0x00], 16); // scheme_version = 1.0 - - // tenc: [size=32][tenc][v/f=0][reserved=0,0][isProtected=0][IV_size=0][KID=16×0] - const tenc = new Uint8Array(32); - writeUint32(tenc, 0, 32); - tenc.set([0x74, 0x65, 0x6e, 0x63], 4); // 'tenc' - tenc[14] = 0; // default_isProtected - tenc[15] = 0; // default_Per_Sample_IV_Size - - // schi: [size=40][schi][tenc(32)] - const schi = new Uint8Array(40); - writeUint32(schi, 0, 40); - schi.set([0x73, 0x63, 0x68, 0x69], 4); // 'schi' - schi.set(tenc, 8); - - // sinf: [size=80][sinf][frma(12)][schm(20)][schi(40)] - const sinf = new Uint8Array(80); - writeUint32(sinf, 0, 80); - sinf.set([0x73, 0x69, 0x6e, 0x66], 4); // 'sinf' - sinf.set(frma, 8); - sinf.set(schm, 20); - sinf.set(schi, 40); - return sinf; -} - export function parseKeyIdsFromTenc( initSegment: Uint8Array, ): Uint8Array[] { @@ -795,36 +632,46 @@ function applyToTencBoxes( }); } -export function dumpInitSegment( +export function dumpSegment( message: string, - initSegment: Uint8Array, + segment: Uint8Array, // log: (...args: Array) => void, label: string, parsed: boolean = false, ): void { - if (parsed) { - const mp4boxfile = MP4Box.createFile(); + console.log('$$$$ dumping segment for', message); + function downloadBlob(blob: Blob, extension: string) { const now = new Date(); const stamp = `${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}-${String(now.getMinutes()).padStart(2, '0')}-${String(now.getSeconds()).padStart(2, '0')}-${String(now.getMilliseconds()).padStart(3, '0')}`; - const dumpFile = `${label}-${stamp}.json`; + const safeLabel = label.replace(/[^a-z0-9]+/gi, '_').toLowerCase(); + const dumpFile = `${safeLabel}-${stamp}.${extension}`; + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `dump/${dumpFile}`; + a.click(); + URL.revokeObjectURL(url); + console.log( + `$$$$ '${label}' segment dump saved to ~/Downloads/${dumpFile}`, + ); + } + + if (parsed) { + const mp4boxfile = MP4Box.createFile(); mp4boxfile.onReady = (info) => { const json = JSON.stringify({ message, info }, null, 2); const blob = new Blob([json], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `dump/${dumpFile}`; - a.click(); - URL.revokeObjectURL(url); - console.log( - `$$$$ '${label}' init segment dump saved to ~/Downloads/${dumpFile}`, - ); + downloadBlob(blob, 'json'); }; - const buffer = initSegment.buffer as ArrayBuffer & { fileStart: number }; + const buffer = segment.buffer as ArrayBuffer & { fileStart: number }; buffer.fileStart = 0; mp4boxfile.appendBuffer(buffer); } else { - console.log(`$$$$ ${message} (json):`, initSegment); + console.log(`$$$$ ${message} (json):`, segment); + const hex = Array.from(segment, (b) => + b.toString(16).padStart(2, '0'), + ).join(' '); + downloadBlob(new Blob([hex], { type: 'text/plain' }), 'txt'); } } @@ -834,7 +681,6 @@ export function patchTencIsProtected( console.log('$$$$ patching tenc isProtected and IV size'); applyToTencBoxes(initSegment, (tenc) => { tenc[6] = 1; // default_isProtected - // TODO - hardcoding 16 here but could be 8 - should get this from the real encrypted init segment tenc[7] = 16; // default_Per_Sample_IV_Size }); } diff --git a/src/utils/playready-workaround.ts b/src/utils/playready-workaround.ts index 2b9f2a1158e..aea9b92c423 100644 --- a/src/utils/playready-workaround.ts +++ b/src/utils/playready-workaround.ts @@ -1,24 +1,14 @@ -/** - * PlayReady DRM Workarounds - * - * This module provides workarounds for PlayReady DRM compatibility issues, - * particularly with Xbox One and Microsoft Edge browsers. It includes functions - * to patch level details and fake encryption in MP4 init segments to ensure - * smooth playback of DRM-protected content. - */ - import { logger } from './logger'; import { KeySystemFormats } from './mediakeys-helper'; import { - appendUint8Array, bin2str, - dumpInitSegment, + dumpSegment, findBox, - mp4Box, + readUint16, readUint32, writeUint32, } from './mp4-tools'; -import type { Fragment } from '../hls'; +import type { Fragment, MediaFragment } from '../hls'; import type { LevelDetails } from '../loader/level-details'; /** @@ -46,235 +36,335 @@ export function applyPlayReadyWorkaroundToLevelDetails( } } -/** - * Creates a fake sinf (protection scheme information) box for MP4. - * - * This function generates a sinf box that mimics encryption information, - * which is required for PlayReady DRM compatibility on certain platforms. - * - * @param encType - The encryption type bytes (e.g., 'encv' or 'enca') - * @param fourCC - The four-character code of the original sample entry - * @param entry - The original sample entry data - * @returns A Uint8Array containing the modified entry with the fake sinf box - */ -function createFakeSinfBox( - encType: Uint8Array, - fourCC: string, - entry: Uint8Array, -) { - const entryCopy = new Uint8Array(entry); - entryCopy.set(encType, 4); - - const sinf = mp4Box( - [0x73, 0x69, 0x6e, 0x66], // 'sinf' - mp4Box( - [0x66, 0x72, 0x6d, 0x61], // 'frma' - new Uint8Array(fourCC.split('').map((c) => c.charCodeAt(0))), - ), - mp4Box( - [0x73, 0x63, 0x68, 0x6d], // 'schm' - new Uint8Array([ - 0x00, 0x00, 0x00, 0x00, 0x63, 0x65, 0x6e, 0x63, 0x00, 0x01, 0x00, 0x00, - ]), - ), - mp4Box( - [0x73, 0x63, 0x68, 0x69], // 'schi' - mp4Box( - [0x74, 0x65, 0x6e, 0x63], // 'tenc' - new Uint8Array([ - 0x00, // version 0 - 0x00, - 0x00, - 0x00, // flags - 0x00, - 0x00, // Reserved fields - 0x01, // Default protected: true - 0x08, // Default per-sample IV size: 8 - 0x00, // Default KID (Key ID) - 16 bytes of zeros - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - ]), - ), - ), - ); - - // Append the sinf box to the modified entry and update the size - const entryWithSinf = appendUint8Array(entryCopy, sinf); - writeUint32(entryWithSinf, 0, entryWithSinf.length); - return entryWithSinf; -} - -export function fakeEncryption(initSegment: Uint8Array) { - console.log( - '$$$$ Applying fake encryption to clear init segment to ensure encryption info is available for all init segments', +export function patchClearMediaSegment( + clearSegmentData: Uint8Array, + _firstEncryptedSegmentData: Uint8Array, +): Uint8Array { + dumpSegment( + '$$$$ Clear media segment before CENC sample-group patch', + clearSegmentData, + 'clear media', ); - const initSegmentCopy = new Uint8Array(initSegment); - const moov = findBox(initSegmentCopy, ['moov'])[0]; - if (!moov) { - return initSegment; + const moofArr = findBox(clearSegmentData, ['moof']); + if (!moofArr.length) { + logger.warn('[playready-workaround] patchClearMediaSegment: no moof found'); + return clearSegmentData; } + const moof = moofArr[0]; - // Only patch single trak files (no audio+video) - const traks = findBox(moov, ['trak']); - if (!traks || traks.length > 1) { - return initSegment; + const trafArr = findBox(moof, ['traf']); + if (!trafArr.length) { + logger.warn('[playready-workaround] patchClearMediaSegment: no traf found'); + return clearSegmentData; } + const traf = trafArr[0]; - const trak = traks[0]; - const mdia = findBox(trak, ['mdia'])[0]; - const minf = findBox(mdia, ['minf'])[0]; - const stbl = findBox(minf, ['stbl'])[0]; - const stsdBox = findBox(stbl, ['stsd'])[0]; - if (!mdia || !minf || !stbl || !stsdBox) { - return initSegment; + const trunArr = findBox(traf, ['trun']); + if (!trunArr.length) { + logger.warn('[playready-workaround] patchClearMediaSegment: no trun found'); + return clearSegmentData; } + const trun = trunArr[0]; + // trun content layout: version(1) + flags(3) + sample_count(4) + [data_offset(4)] + ... + const sampleCount = readUint32(trun, 4); + // data_offset_present is flag bit 0x000001; flags are big-endian in bytes 1-3, + // so trun[3] is the least-significant byte + const dataOffsetPresent = trun[3] & 0x01; - // Patch stsd box - const entryCount = readUint32(stsdBox, 4); - let entryOffset = 8; - const newEntries: Uint8Array[] = []; - for (let i = 0; i < entryCount; i++) { - const size = readUint32(stsdBox, entryOffset); - const fourCC = bin2str(stsdBox.subarray(entryOffset + 4, entryOffset + 8)); - const entry = stsdBox.subarray(entryOffset, entryOffset + size); - let boxType: Uint8Array | undefined = undefined; - switch (fourCC) { - case 'avc1': - case 'avc2': - case 'avc3': - case 'avc4': - boxType = new Uint8Array([0x65, 0x6e, 0x63, 0x76]); // 'encv' - break; - case 'mp4a': - boxType = new Uint8Array([0x65, 0x6e, 0x63, 0x61]); // 'enca' - break; - default: - break; + // Find existing sbgp boxes: preserve roll, abort if seig already present + const sbgpArr = findBox(traf, ['sbgp']); + let insertionOffset = -1; + for (let i = 0; i < sbgpArr.length; i++) { + const sbgp = sbgpArr[i]; + // sbgp content: version/flags(4) + grouping_type(4) + ... + const groupingType = bin2str(sbgp.subarray(4, 8)); + if (groupingType === 'seig') { + logger.debug( + '[playready-workaround] patchClearMediaSegment: seig already present, skipping', + ); + return clearSegmentData; } - - if (boxType) { - const encEntry = createFakeSinfBox(boxType, fourCC, entry); - // For Xbox One & Edge, we cut and insert at the start of the source box. - // For other platforms, we cut and insert at the end of the source box. It's - // not clear why this is necessary on Xbox One, but it seems to be evidence - // of another bug in the firmware implementation of MediaSource & EME. - // TODO: needs more tests - if (navigator.userAgent.match(/Edge?\//)) { - newEntries.push(encEntry); - newEntries.push(entry); - } else { - newEntries.push(entry); - newEntries.push(encEntry); - } - } else { - newEntries.push(entry); + if (groupingType === 'roll') { + // Insert after the end of this roll sbgp box (byteOffset is absolute in clearData) + insertionOffset = sbgp.byteOffset + sbgp.length; } + } - entryOffset += size; + // Fallback: insert after trun if no roll sbgp found + if (insertionOffset === -1) { + insertionOffset = trun.byteOffset + trun.length; } - // Rebuild stsd box with new entries - const stsdHeader = stsdBox.subarray(0, 8); - writeUint32(stsdHeader, 4, newEntries.length); - const newStsd = mp4Box([0x73, 0x74, 0x73, 0x64], stsdHeader, ...newEntries); - const stsdOffset = stsdBox.byteOffset - trak.byteOffset - 8; + const INSERTED_BYTES = 72; // 44 (sgpd) + 28 (sbgp) - // Update sizes of parent boxes - writeUint32( - trak, - stbl.byteOffset - trak.byteOffset - 8, - stbl.length - stsdBox.length + newStsd.length, + // Build sgpd(seig) — 44 bytes + // Layout: size(4) + "sgpd"(4) + version=1/flags=0(4) + grouping_type="seig"(4) + + // default_length=20(4) + entry_count=1(4) + entry(20) + // Entry: isProtected=0(3) + perSampleIvSize=0(1) + zero-KID(16) — all zero + const sgpdSeig = new Uint8Array(44); + writeUint32(sgpdSeig, 0, 44); + sgpdSeig[4] = 0x73; + sgpdSeig[5] = 0x67; + sgpdSeig[6] = 0x70; + sgpdSeig[7] = 0x64; // "sgpd" + sgpdSeig[8] = 0x01; // version=1 + sgpdSeig[12] = 0x73; + sgpdSeig[13] = 0x65; + sgpdSeig[14] = 0x69; + sgpdSeig[15] = 0x67; // "seig" + writeUint32(sgpdSeig, 16, 20); // default_length + writeUint32(sgpdSeig, 20, 1); // entry_count + // bytes 24-43: entry (all zero — isProtected=0, perSampleIvSize=0, KID=0×16) + + // Build sbgp(seig) — 28 bytes + // Layout: size(4) + "sbgp"(4) + version=0/flags=0(4) + grouping_type="seig"(4) + + // entry_count=1(4) + sample_count(4) + group_description_index=1(4) + const sbgpSeig = new Uint8Array(28); + writeUint32(sbgpSeig, 0, 28); + sbgpSeig[4] = 0x73; + sbgpSeig[5] = 0x62; + sbgpSeig[6] = 0x67; + sbgpSeig[7] = 0x70; // "sbgp" + // bytes 8-11: version=0, flags=0 (already zero) + sbgpSeig[12] = 0x73; + sbgpSeig[13] = 0x65; + sbgpSeig[14] = 0x69; + sbgpSeig[15] = 0x67; // "seig" + writeUint32(sbgpSeig, 16, 1); // entry_count + writeUint32(sbgpSeig, 20, sampleCount); // sample_count + writeUint32(sbgpSeig, 24, 1); // group_description_index + + // Assemble patched segment + const newData = new Uint8Array(clearSegmentData.length + INSERTED_BYTES); + newData.set(clearSegmentData.subarray(0, insertionOffset), 0); + newData.set(sgpdSeig, insertionOffset); + newData.set(sbgpSeig, insertionOffset + 44); + newData.set( + clearSegmentData.subarray(insertionOffset), + insertionOffset + INSERTED_BYTES, ); + + // Update moof.size and traf.size — both box headers are before insertionOffset + // so their positions in newData are unchanged + const moofSizeOffset = moof.byteOffset - 8; writeUint32( - trak, - minf.byteOffset - trak.byteOffset - 8, - minf.length - stsdBox.length + newStsd.length, + newData, + moofSizeOffset, + readUint32(newData, moofSizeOffset) + INSERTED_BYTES, ); + const trafSizeOffset = traf.byteOffset - 8; writeUint32( - trak, - mdia.byteOffset - trak.byteOffset - 8, - mdia.length - stsdBox.length + newStsd.length, + newData, + trafSizeOffset, + readUint32(newData, trafSizeOffset) + INSERTED_BYTES, + ); + + // If trun.data_offset is present, bump it by the number of bytes inserted before mdat + if (dataOffsetPresent) { + const trunDataOffsetPos = trun.byteOffset + 8; // after version(1)+flags(3)+sample_count(4) + writeUint32( + newData, + trunDataOffsetPos, + readUint32(newData, trunDataOffsetPos) + INSERTED_BYTES, + ); + } + + // Update sidx referenced_size if a sidx box precedes moof + updateSidxReferencedSize( + clearSegmentData, + newData, + INSERTED_BYTES, + moofSizeOffset, ); - // Rebuild trak with patched stsd box - let patchedTrak = trak; - if (stsdOffset > 0) { - patchedTrak = new Uint8Array( - trak.length - stsdBox.length + newStsd.length - 8, + validatePatchedSegment( + clearSegmentData, + newData, + INSERTED_BYTES, + moofSizeOffset, + trafSizeOffset, + dataOffsetPresent ? trun.byteOffset + 8 : -1, + ); + + dumpSegment( + '$$$$ Clear media segment after CENC sample-group patch', + newData, + 'patched media', + ); + return newData; +} + +/** + * sidx content layout (bytes within the box content, i.e. after the 8-byte header): + * 0 : version (1 byte) + * 1-3 : flags (3 bytes) + * 4-7 : reference_ID + * 8-11 : timescale + * v0: 12-15 earliest_presentation_time, 16-19 first_offset + * v1: 12-19 earliest_presentation_time, 20-27 first_offset + * v0: 20-21 reserved, 22-23 reference_count + * v1: 28-29 reserved, 30-31 reference_count + * v0: entries start at 24 (each entry = 12 bytes) + * v1: entries start at 32 + * + * Each 12-byte reference entry: + * [0-3] reference_type(1 bit) | referenced_size(31 bits) + * [4-7] subsegment_duration + * [8-11] SAP flags + */ +function updateSidxReferencedSize( + clearData: Uint8Array, + newData: Uint8Array, + insertedBytes: number, + moofOffset: number, +): void { + const sidxArr = findBox(clearData, ['sidx']); + if (!sidxArr.length) return; + + const sidx = sidxArr[0]; + + // Reject a sidx that starts at or after moof — it cannot reference the patched subsegment + if (sidx.byteOffset - 8 >= moofOffset) { + logger.warn( + '[playready-workaround] sidx appears after moof, skipping update', + ); + return; + } + + const version = sidx[0]; + const refCountOffset = version === 0 ? 22 : 30; + const entriesStart = version === 0 ? 24 : 32; + + const referenceCount = readUint16(sidx, refCountOffset); + + if (referenceCount === 0) { + logger.warn( + '[playready-workaround] sidx has 0 references, skipping update', ); - patchedTrak.set(trak.subarray(0, stsdOffset), 0); - patchedTrak.set(newStsd, stsdOffset); - patchedTrak.set( - trak.subarray(stsdOffset + stsdBox.length + 8), - stsdOffset + newStsd.length, + return; + } + + if (referenceCount > 1) { + // Cannot determine which reference covers the patched subsegment without + // tracking byte ranges, so fail loudly rather than updating blindly. + logger.warn( + `[playready-workaround] sidx has ${referenceCount} references; cannot identify affected reference — sidx not updated`, ); + return; } - // Rebuild moov with patched trak - let moovRest = moov; - const trakOffset = trak.byteOffset - moov.byteOffset; - writeUint32(moovRest, trakOffset - 8, patchedTrak.length + 8); + // Single reference: it must cover the moof+mdat that follows sidx, so update it. + // sidx.byteOffset is absolute within clearData; sidx is before insertionOffset so + // its position is unchanged in newData. + const refFieldOffset = sidx.byteOffset + entriesStart; + const ref = readUint32(newData, refFieldOffset); + const referenceType = ref & 0x80000000; + const referencedSize = ref & 0x7fffffff; - if (trakOffset > 0) { - const before = moovRest.subarray(0, trakOffset); - const after = moovRest.subarray(trakOffset + trak.length); - const newMoov = new Uint8Array( - before.length + patchedTrak.length + after.length, + const newReferencedSize = referencedSize + insertedBytes; + if (newReferencedSize > 0x7fffffff) { + logger.warn( + '[playready-workaround] sidx referenced_size would overflow 31 bits, skipping update', ); - newMoov.set(before, 0); - newMoov.set(patchedTrak, before.length); - newMoov.set(after, before.length + patchedTrak.length); - moovRest = newMoov; + return; } - const patchedMoov = new Uint8Array(8 + moovRest.length); - patchedMoov.set( - initSegment.subarray(moov.byteOffset - 8, moov.byteOffset), - 0, - ); - patchedMoov.set(moovRest, 8); - writeUint32(patchedMoov, 0, moovRest.length + 8); - - // Now reconstruct the full MP4, replacing only the moov atom - const out: Uint8Array[] = []; - let offset = 0; - while (offset < initSegment.length) { - const size = readUint32(initSegment, offset); - const type = bin2str(initSegment.subarray(offset + 4, offset + 8)); - if (type === 'moov') { - out.push(patchedMoov); - } else { - out.push(initSegment.subarray(offset, offset + size)); + writeUint32(newData, refFieldOffset, referenceType | newReferencedSize); +} + +function validatePatchedSegment( + original: Uint8Array, + patched: Uint8Array, + insertedBytes: number, + moofOffset: number, + trafOffset: number, + trunDataOffsetPos: number, +): void { + const tag = '[playready-workaround] validation:'; + + if (patched.length !== original.length + insertedBytes) { + logger.warn( + `${tag} file size: expected ${original.length + insertedBytes}, got ${patched.length}`, + ); + } + + const origMoofSize = readUint32(original, moofOffset); + const patchedMoofSize = readUint32(patched, moofOffset); + if (patchedMoofSize !== origMoofSize + insertedBytes) { + logger.warn( + `${tag} moof.size: expected ${origMoofSize + insertedBytes}, got ${patchedMoofSize}`, + ); + } + + const origTrafSize = readUint32(original, trafOffset); + const patchedTrafSize = readUint32(patched, trafOffset); + if (patchedTrafSize !== origTrafSize + insertedBytes) { + logger.warn( + `${tag} traf.size: expected ${origTrafSize + insertedBytes}, got ${patchedTrafSize}`, + ); + } + + if (trunDataOffsetPos !== -1) { + const origDataOffset = readUint32(original, trunDataOffsetPos); + const patchedDataOffset = readUint32(patched, trunDataOffsetPos); + if (patchedDataOffset !== origDataOffset + insertedBytes) { + logger.warn( + `${tag} trun.data_offset: expected ${origDataOffset + insertedBytes}, got ${patchedDataOffset}`, + ); } - offset += size; } - const modifiedInitSegment = appendUint8Array(out[0], out[1]); - logger.debug( - 'Use fakeEncryption for clear to drm transition with PlayReady DRM', - ); - // Edge Windows needs the unmodified init segment to be appended after the - // patched one, otherwise video element throws following error: - // CHUNK_DEMUXER_ERROR_APPEND_FAILED: Sample encryption info is not - // available. - if (navigator.userAgent.match(/Edge?\//)) { - return appendUint8Array(modifiedInitSegment, initSegment); + // Verify mdat payload is byte-for-byte unchanged + const origMdatArr = findBox(original, ['mdat']); + const patchedMdatArr = findBox(patched, ['mdat']); + if (!origMdatArr.length || !patchedMdatArr.length) { + logger.warn(`${tag} mdat not found`); } else { - return modifiedInitSegment; + const origMdat = origMdatArr[0]; + const patchedMdat = patchedMdatArr[0]; + + if (patchedMdat.byteOffset !== origMdat.byteOffset + insertedBytes) { + logger.warn( + `${tag} mdat offset: expected ${origMdat.byteOffset + insertedBytes}, got ${patchedMdat.byteOffset}`, + ); + } + + if (origMdat.length !== patchedMdat.length) { + logger.warn( + `${tag} mdat length changed: ${origMdat.length} → ${patchedMdat.length}`, + ); + } else { + // Spot-check first and last 8 bytes to avoid O(n) scan on large payloads + const checkOffsets = [0, origMdat.length - 8]; + for (let ci = 0; ci < checkOffsets.length; ci++) { + const checkOffset = checkOffsets[ci]; + if (checkOffset < 0) continue; + for (let b = 0; b < 8; b++) { + if (origMdat[checkOffset + b] !== patchedMdat[checkOffset + b]) { + logger.warn( + `${tag} mdat payload differs at offset ${checkOffset + b}`, + ); + break; + } + } + } + } + } + + // Verify sidx referenced_size bit-31 (reference_type) is unchanged + const origSidxArr = findBox(original, ['sidx']); + const patchedSidxArr = findBox(patched, ['sidx']); + if (origSidxArr.length && patchedSidxArr.length) { + const origSidx = origSidxArr[0]; + const patchedSidx = patchedSidxArr[0]; + const version = origSidx[0]; + const entriesStart = version === 0 ? 24 : 32; + const origRefType = readUint32(origSidx, entriesStart) & 0x80000000; + const patchedRefType = readUint32(patchedSidx, entriesStart) & 0x80000000; + if (origRefType !== patchedRefType) { + logger.warn(`${tag} sidx reference_type bit changed`); + } } } @@ -282,70 +372,254 @@ export function patchClearInitSegment( clearInitSegmentData: Uint8Array, firstEncryptedInitSegmentData: Uint8Array, ): Uint8Array { - dumpInitSegment( + dumpSegment( '$$$$ First clear init segment without PSSH boxes', clearInitSegmentData, - 'clear', + 'clear init', ); - dumpInitSegment( + dumpSegment( '$$$$ First encrypted init segment with PSSH boxes', firstEncryptedInitSegmentData, - 'encrypted', + 'encrypted init', ); - // PSSH boxes are children of moov — extract them by iterating moov's content + // Collect PSSH boxes from the encrypted init segment. const psshBoxes: Uint8Array[] = []; - const moovContent = findBox(firstEncryptedInitSegmentData, ['moov'])[0]; - if (moovContent) { + const encryptedMoovContent = findBox(firstEncryptedInitSegmentData, [ + 'moov', + ])[0]; + if (encryptedMoovContent) { let offset = 0; - while (offset < moovContent.length) { - const size = readUint32(moovContent, offset); + while (offset < encryptedMoovContent.length) { + const size = readUint32(encryptedMoovContent, offset); if (size < 8) break; - const type = bin2str(moovContent.subarray(offset + 4, offset + 8)); + const type = bin2str( + encryptedMoovContent.subarray(offset + 4, offset + 8), + ); if (type === 'pssh') { - psshBoxes.push(moovContent.subarray(offset, offset + size)); + psshBoxes.push(encryptedMoovContent.subarray(offset, offset + size)); } offset += size; } } - if (psshBoxes.length === 0) { - this.debug('$$$$ No PSSH boxes found in encrypted init segment'); + logger.debug('No PSSH boxes found in encrypted init segment'); + dumpSegment( + '$$$$ Clear init segment unchanged (no PSSH found)', + clearInitSegmentData, + 'patched init', + ); return clearInitSegmentData; } - // Rebuild the clear init segment, inserting PSSH boxes inside moov (at end of moov content) - const parts: Uint8Array[] = []; - let offset = 0; - while (offset < clearInitSegmentData.length) { - const size = readUint32(clearInitSegmentData, offset); - if (size < 8) break; - const type = bin2str(clearInitSegmentData.subarray(offset + 4, offset + 8)); - if (type === 'moov') { - // PSSH boxes must be inside moov (not at file level) for MediaFoundation/PlayReady to - // initialise the hardware-protected decode path before the first encrypted frame arrives - const psshData = psshBoxes.reduce(appendUint8Array); - const newMoovSize = size + psshData.length; - const newMoov = new Uint8Array(newMoovSize); - newMoov.set(clearInitSegmentData.subarray(offset, offset + size), 0); - writeUint32(newMoov, 0, newMoovSize); - newMoov.set(psshData, size); - parts.push(newMoov); - } else { - parts.push(clearInitSegmentData.subarray(offset, offset + size)); - } - offset += size; + // Extract the sinf box from the encrypted init's encv sample entry. + // We inject this sinf into the clear init's avc1 entry (renamed to encv) + // so Chrome sets up an encrypted decode pipeline. We preserve the clear + // init's own avcC/codec params — replacing the whole stsd would break + // every level other than the one the encrypted init was packaged for. + const encryptedStsdContent = findBox(firstEncryptedInitSegmentData, [ + 'moov', + 'trak', + 'mdia', + 'minf', + 'stbl', + 'stsd', + ])[0]; + if (!encryptedStsdContent) { + logger.warn( + '[playready-workaround] Could not find encrypted stsd, falling back to PSSH-only patch', + ); + return patchPsshOnly(clearInitSegmentData, psshBoxes); + } + const encryptedSampleEntries = encryptedStsdContent.subarray(8); + const encv = findBox(encryptedSampleEntries, ['encv'])[0]; + if (!encv) { + logger.warn( + '[playready-workaround] Could not find encv, falling back to PSSH-only patch', + ); + return patchPsshOnly(clearInitSegmentData, psshBoxes); + } + // Skip the 78-byte VisualSampleEntry fixed fields to reach child boxes. + const encvChildren = encv.subarray(78); + const sinfContent = findBox(encvChildren, ['sinf'])[0]; + if (!sinfContent) { + logger.warn( + '[playready-workaround] Could not find sinf, falling back to PSSH-only patch', + ); + return patchPsshOnly(clearInitSegmentData, psshBoxes); + } + // Copy sinf (with its 8-byte header) so we can mutate default_isProtected. + // Layout: sinf(8) + frma(12) + schm(20) + schi(8) + tenc(8) + tenc_content(24). + // default_isProtected is at byte 62 of the full box (offset 54 within tenc content). + const sinfBox = firstEncryptedInitSegmentData.slice( + sinfContent.byteOffset - 8, + sinfContent.byteOffset + sinfContent.length, + ); + sinfBox[62] = 1; // default_IsProtected = 1 + sinfBox[63] = 16; // default_Per_Sample_IV_Size = 16 + + // Find the clear stsd and ancestor boxes. + const clearStsdContent = findBox(clearInitSegmentData, [ + 'moov', + 'trak', + 'mdia', + 'minf', + 'stbl', + 'stsd', + ])[0]; + const clearMoov = findBox(clearInitSegmentData, ['moov'])[0]; + const clearTrak = findBox(clearInitSegmentData, ['moov', 'trak'])[0]; + const clearMdia = findBox(clearInitSegmentData, ['moov', 'trak', 'mdia'])[0]; + const clearMinf = findBox(clearInitSegmentData, [ + 'moov', + 'trak', + 'mdia', + 'minf', + ])[0]; + const clearStbl = findBox(clearInitSegmentData, [ + 'moov', + 'trak', + 'mdia', + 'minf', + 'stbl', + ])[0]; + + if ( + !clearStsdContent || + !clearMoov || + !clearTrak || + !clearMdia || + !clearMinf || + !clearStbl + ) { + logger.warn( + '[playready-workaround] Could not find clear stsd or parent boxes, falling back to PSSH-only patch', + ); + return patchPsshOnly(clearInitSegmentData, psshBoxes); + } + + const sinfSize = sinfBox.length; + const psshTotal = psshBoxes.reduce((acc, b) => acc + b.length, 0); + // clearStsdBoxEnd = first byte after the stsd box = also end of the sole + // sample entry, so appending sinfBox here extends that entry. + const clearStsdBoxEnd = clearStsdContent.byteOffset + clearStsdContent.length; + const moovBoxOffset = clearMoov.byteOffset - 8; + const moovBoxEnd = moovBoxOffset + clearMoov.length + 8; + + const result = new Uint8Array( + clearInitSegmentData.length + sinfSize + psshTotal, + ); + + // 1. Copy everything up to and including the stsd box content unchanged. + result.set(clearInitSegmentData.subarray(0, clearStsdBoxEnd), 0); + + // 2. Insert sinf immediately after stsd content (inside the sample entry — + // sizes updated below make the parser treat it as a child of encv). + result.set(sinfBox, clearStsdBoxEnd); + + // 3. Copy from after stsd to end of moov. + result.set( + clearInitSegmentData.subarray(clearStsdBoxEnd, moovBoxEnd), + clearStsdBoxEnd + sinfSize, + ); + + // 4. Append PSSH boxes at the end of moov. + let psshDest = clearStsdBoxEnd + sinfSize + (moovBoxEnd - clearStsdBoxEnd); + for (let i = 0; i < psshBoxes.length; i++) { + result.set(psshBoxes[i], psshDest); + psshDest += psshBoxes[i].length; + } + + // 5. Copy any bytes after moov. + result.set(clearInitSegmentData.subarray(moovBoxEnd), psshDest); + + // 6. Rename the avc1 sample entry type to encv. + // stsd box header = 8, FullBox version+flags+count = 8, entry size = 4, + // so entry type sits at clearStsdBoxStart + 20. + const clearStsdBoxStart = clearStsdContent.byteOffset - 8; + const entryTypeOffset = clearStsdBoxStart + 20; + result[entryTypeOffset] = 0x65; // 'e' + result[entryTypeOffset + 1] = 0x6e; // 'n' + result[entryTypeOffset + 2] = 0x63; // 'c' + result[entryTypeOffset + 3] = 0x76; // 'v' + + // 7. Update box sizes. + // Sample entry (avc1→encv) grows by sinfSize. + const entryOffset = clearStsdBoxStart + 16; + writeUint32(result, entryOffset, readUint32(result, entryOffset) + sinfSize); + // Ancestors all start before the insertion point so their offsets are + // unchanged from clearInitSegmentData. + writeUint32( + result, + clearStsdBoxStart, + clearStsdContent.length + 8 + sinfSize, + ); + writeUint32( + result, + clearStbl.byteOffset - 8, + clearStbl.length + 8 + sinfSize, + ); + writeUint32( + result, + clearMinf.byteOffset - 8, + clearMinf.length + 8 + sinfSize, + ); + writeUint32( + result, + clearMdia.byteOffset - 8, + clearMdia.length + 8 + sinfSize, + ); + writeUint32( + result, + clearTrak.byteOffset - 8, + clearTrak.length + 8 + sinfSize, + ); + writeUint32( + result, + moovBoxOffset, + clearMoov.length + 8 + sinfSize + psshTotal, + ); + + dumpSegment( + '$$$$ Clear init segment: avc1→encv+sinf (isProtected=0) + PSSH injected', + result, + 'patched init', + ); + return result; +} + +function patchPsshOnly( + clearInitSegmentData: Uint8Array, + psshBoxes: Uint8Array[], +): Uint8Array { + const moovInClear = findBox(clearInitSegmentData, ['moov'])[0]; + if (!moovInClear) { + return clearInitSegmentData; } - console.log( - `$$$$ Copied ${psshBoxes.length} PSSH box(es) from encrypted init segment to clear init segment`, + const moovBoxOffset = moovInClear.byteOffset - 8; + const moovBoxSize = moovInClear.length + 8; + const psshTotal = psshBoxes.reduce((acc, b) => acc + b.length, 0); + const newMoovBoxSize = moovBoxSize + psshTotal; + const result = new Uint8Array(clearInitSegmentData.length + psshTotal); + result.set(clearInitSegmentData.subarray(0, moovBoxOffset), 0); + writeUint32(result, moovBoxOffset, newMoovBoxSize); + result.set( + clearInitSegmentData.subarray( + moovBoxOffset + 4, + moovBoxOffset + moovBoxSize, + ), + moovBoxOffset + 4, ); - const patchedClearInitSegment = parts.reduce(appendUint8Array); - dumpInitSegment( - '$$$$ Clear init segment with PSSH boxes patched in', - patchedClearInitSegment, - 'patched', + let psshDest = moovBoxOffset + moovBoxSize; + for (let i = 0; i < psshBoxes.length; i++) { + result.set(psshBoxes[i], psshDest); + psshDest += psshBoxes[i].length; + } + result.set( + clearInitSegmentData.subarray(moovBoxOffset + moovBoxSize), + moovBoxOffset + newMoovBoxSize, ); - return patchedClearInitSegment; + return result; } export function preLoadFirstEncryptedInitSegmentData( diff --git a/tests/unit/utils/mp4-tools.ts b/tests/unit/utils/mp4-tools.ts deleted file mode 100644 index 84d754061b4..00000000000 --- a/tests/unit/utils/mp4-tools.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { expect } from 'chai'; -import MP4 from '../../../src/remux/mp4-generator'; -import { - bin2str, - fakeEncryption, - findBox, - parseInitSegment, -} from '../../../src/utils/mp4-tools'; -import type { DemuxedAVC1 } from '../../../src/types/demuxer'; -import type { DemuxedAudioTrack } from '../../../src/types/demuxer'; - -// Minimal H.264 High profile SPS and PPS — enough for MP4 generator to produce a valid avc1 box -const MINIMAL_SPS = new Uint8Array([0x67, 0x64, 0x00, 0x1e, 0xac, 0xd9]); -const MINIMAL_PPS = new Uint8Array([0x68, 0xce, 0x38, 0x80]); - -function makeVideoTrack(): DemuxedAVC1 { - return { - id: 1, - pid: 1, - type: 'video', - segmentCodec: 'avc', - inputTimeScale: 90000, - timescale: 90000, - duration: 0, - width: 320, - height: 240, - pixelRatio: [1, 1], - sps: [MINIMAL_SPS], - pps: [MINIMAL_PPS], - samples: [], - dropped: 0, - sequenceNumber: 0, - }; -} - -function makeAudioTrack(): DemuxedAudioTrack { - return { - id: 2, - pid: 2, - type: 'audio', - segmentCodec: 'aac', - inputTimeScale: 48000, - timescale: 48000, - duration: 0, - channelCount: 2, - samplerate: 48000, - config: [0x11, 0x90], // AAC-LC, 48 kHz, 2ch - samples: [], - dropped: 0, - sequenceNumber: 0, - }; -} - -function makeClearVideoInitSegment(): Uint8Array { - MP4.init(); - return MP4.initSegment([makeVideoTrack()]) as Uint8Array; -} - -function makeClearAudioVideoInitSegment(): Uint8Array { - MP4.init(); - return MP4.initSegment([ - makeVideoTrack(), - makeAudioTrack(), - ]) as Uint8Array; -} - -describe('fakeEncryption', function () { - describe('video-only init segment', function () { - let result: Uint8Array; - - beforeEach(function () { - result = fakeEncryption(makeClearVideoInitSegment()); - }); - - it('replaces avc1 sample entry with encv', function () { - const stsd = findBox(result, [ - 'moov', - 'trak', - 'mdia', - 'minf', - 'stbl', - 'stsd', - ])[0]; - // sampleEntries starts at stsd+8 (skipping stsd version/flags) - - const sampleEntries = stsd.subarray(8); - const fourCC = bin2str(sampleEntries.subarray(4, 8)); - expect(fourCC).to.equal('encv'); - }); - - it('encv contains a sinf box', function () { - const stsd = findBox(result, [ - 'moov', - 'trak', - 'mdia', - 'minf', - 'stbl', - 'stsd', - ])[0]; - const encv = findBox(stsd.subarray(8), ['encv'])[0]; - // encv children start after 78 bytes of video sample entry header - const sinfs = findBox(encv.subarray(78), ['sinf']); - expect(sinfs).to.have.length(1); - }); - - it('sinf/frma contains the original avc1 codec', function () { - const stsd = findBox(result, [ - 'moov', - 'trak', - 'mdia', - 'minf', - 'stbl', - 'stsd', - ])[0]; - const encv = findBox(stsd.subarray(8), ['encv'])[0]; - const sinf = findBox(encv.subarray(78), ['sinf'])[0]; - const frma = findBox(sinf, ['frma'])[0]; - expect(bin2str(frma)).to.equal('avc1'); - }); - - it('sinf/schm declares cenc scheme', function () { - const stsd = findBox(result, [ - 'moov', - 'trak', - 'mdia', - 'minf', - 'stbl', - 'stsd', - ])[0]; - const encv = findBox(stsd.subarray(8), ['encv'])[0]; - const sinf = findBox(encv.subarray(78), ['sinf'])[0]; - const schm = findBox(sinf, ['schm'])[0]; - // scheme_type is at bytes 4–7 of schm content (after version/flags) - expect(bin2str(schm.subarray(4, 8))).to.equal('cenc'); - }); - - it('tenc sets default_isProtected = 0', function () { - const stsd = findBox(result, [ - 'moov', - 'trak', - 'mdia', - 'minf', - 'stbl', - 'stsd', - ])[0]; - const encv = findBox(stsd.subarray(8), ['encv'])[0]; - const sinf = findBox(encv.subarray(78), ['sinf'])[0]; - const tenc = findBox(sinf, ['schi', 'tenc'])[0]; - // tenc layout: [0–3] version/flags, [4–5] reserved, [6] isProtected, [7] IV size, [8–23] KID - expect(tenc[6]).to.equal(0); - }); - - it('tenc sets default_Per_Sample_IV_Size = 0', function () { - const stsd = findBox(result, [ - 'moov', - 'trak', - 'mdia', - 'minf', - 'stbl', - 'stsd', - ])[0]; - const encv = findBox(stsd.subarray(8), ['encv'])[0]; - const sinf = findBox(encv.subarray(78), ['sinf'])[0]; - const tenc = findBox(sinf, ['schi', 'tenc'])[0]; - expect(tenc[7]).to.equal(0); - }); - - it('tenc default_KID is all zeros (to be patched later)', function () { - const stsd = findBox(result, [ - 'moov', - 'trak', - 'mdia', - 'minf', - 'stbl', - 'stsd', - ])[0]; - const encv = findBox(stsd.subarray(8), ['encv'])[0]; - const sinf = findBox(encv.subarray(78), ['sinf'])[0]; - const tenc = findBox(sinf, ['schi', 'tenc'])[0]; - const kid = tenc.subarray(8, 24); - expect(kid.every((b) => b === 0)).to.be.true; - }); - - it('parseInitSegment reports video track as encrypted', function () { - const parsed = parseInitSegment(result); - expect(parsed.video?.encrypted).to.be.true; - }); - - it('parseInitSegment preserves the avc1 codec string', function () { - const parsed = parseInitSegment(result); - expect(parsed.video?.codec).to.match(/^avc1/); - }); - }); - - describe('audio+video init segment', function () { - let result: Uint8Array; - - beforeEach(function () { - result = fakeEncryption(makeClearAudioVideoInitSegment()); - }); - - it('replaces mp4a sample entry with enca', function () { - const traks = findBox(result, ['moov', 'trak']); - const audioTrak = traks.find((trak) => { - const hdlr = findBox(trak, ['mdia', 'hdlr'])[0]; - return hdlr && bin2str(hdlr.subarray(8, 12)) === 'soun'; - }); - expect(audioTrak).to.exist; - const stsd = findBox(audioTrak!, [ - 'mdia', - 'minf', - 'stbl', - 'stsd', - ])[0]; - const sampleEntries = stsd.subarray(8); - const fourCC = bin2str(sampleEntries.subarray(4, 8)); - expect(fourCC).to.equal('enca'); - }); - - it('enca sinf/frma contains the original mp4a codec', function () { - const traks = findBox(result, ['moov', 'trak']); - const audioTrak = traks.find((trak) => { - const hdlr = findBox(trak, ['mdia', 'hdlr'])[0]; - return hdlr && bin2str(hdlr.subarray(8, 12)) === 'soun'; - }); - const stsd = findBox(audioTrak!, ['mdia', 'minf', 'stbl', 'stsd'])[0]; - const enca = findBox(stsd.subarray(8), ['enca'])[0]; - // enca children start after 28 bytes of audio sample entry header - const sinf = findBox(enca.subarray(28), ['sinf'])[0]; - const frma = findBox(sinf, ['frma'])[0]; - expect(bin2str(frma)).to.equal('mp4a'); - }); - - it('parseInitSegment reports both tracks as encrypted', function () { - const parsed = parseInitSegment(result); - expect(parsed.video?.encrypted).to.be.true; - expect(parsed.audio?.encrypted).to.be.true; - }); - }); - - describe('already-encrypted init segment', function () { - it('is returned unchanged when video track is already encv', function () { - const clear = makeClearVideoInitSegment(); - const encrypted = fakeEncryption(clear); - const doubleEncrypted = fakeEncryption(encrypted); - // The stsd should still have encv, not encv wrapping encv - const stsd = findBox(doubleEncrypted, [ - 'moov', - 'trak', - 'mdia', - 'minf', - 'stbl', - 'stsd', - ])[0]; - const sampleEntries = stsd.subarray(8); - expect(bin2str(sampleEntries.subarray(4, 8))).to.equal('encv'); - // frma should still be avc1, not encv - const encv = findBox(sampleEntries, ['encv'])[0]; - const sinf = findBox(encv.subarray(78), ['sinf'])[0]; - const frma = findBox(sinf, ['frma'])[0]; - expect(bin2str(frma)).to.equal('avc1'); - }); - }); -}); From bc57bc6ee1fd7dc843d3dee4a719a751bcbece7d Mon Sep 17 00:00:00 2001 From: Ben Roberts Date: Wed, 13 May 2026 16:02:41 +0200 Subject: [PATCH 9/9] adding more debug code --- demo/main.js | 2 +- src/controller/base-stream-controller.ts | 18 +- src/controller/stream-controller.ts | 23 +- src/utils/playready-workaround.ts | 988 +++++++++++++++++++++-- tests/test-streams.js | 12 +- 5 files changed, 944 insertions(+), 99 deletions(-) diff --git a/demo/main.js b/demo/main.js index ac28d6751fa..8625002ee41 100644 --- a/demo/main.js +++ b/demo/main.js @@ -41,7 +41,7 @@ const hlsjsDefaults = { videoRobustness: '3000', audioRobustness: '3000', }, - requiresEncryptionInfoInAllInitSegments: true, + requiresEncryptionInfoInAllInitSegments: false, licenseXhrSetup: async function (xhr) { const res = await fetch( 'https://shield-api.imggaming.com/admin/v1/ovp/dice/client/dce.sandbox/action/sign_test_content_token', diff --git a/src/controller/base-stream-controller.ts b/src/controller/base-stream-controller.ts index aa859e0bcb0..3e5100fad74 100644 --- a/src/controller/base-stream-controller.ts +++ b/src/controller/base-stream-controller.ts @@ -124,6 +124,7 @@ export default class BaseStreamController protected buffering: boolean = true; protected loadingParts: boolean = false; protected firstEncryptedInitSegmentData: Uint8Array | null = null; + protected firstEncryptedMediaSegmentData: Uint8Array | null = null; protected waitingForInitSegmentAppend: boolean = false; private loopSn?: string | number; @@ -540,15 +541,17 @@ export default class BaseStreamController } if ('payload' in data) { - dumpSegment( - 'Dumping media segment', - new Uint8Array(data.payload), - 'media segment', - ); + // dumpSegment( + // 'media segment', + // new Uint8Array(data.payload), + // 'media segment', + // ); this.log( `Loaded ${frag.type} sn: ${frag.sn} of ${this.playlistLabel()} ${frag.level}`, ); - data.payload = this.patchMediaSegment(data.payload); + if (hls.config.requiresEncryptionInfoInAllInitSegments) { + data.payload = this.patchMediaSegment(data.payload); + } this.hls.trigger(Events.FRAG_LOADED, data); } @@ -715,7 +718,8 @@ export default class BaseStreamController ); if ( this.firstEncryptedInitSegmentData && - !this.encryptedInitSegmentPatched + !this.encryptedInitSegmentPatched && + hls.config.requiresEncryptionInfoInAllInitSegments ) { this.encryptedInitSegmentPatched = true; console.log( diff --git a/src/controller/stream-controller.ts b/src/controller/stream-controller.ts index 626bebe1fff..e390594c73d 100644 --- a/src/controller/stream-controller.ts +++ b/src/controller/stream-controller.ts @@ -1,4 +1,5 @@ import { M } from 'mp4box/dist/log-DO1-_KSL'; +import { resolveConfig } from 'prettier'; import BaseStreamController, { State } from './base-stream-controller'; import { findFragmentByPTS } from './fragment-finders'; import { FragmentState } from './fragment-tracker'; @@ -16,9 +17,11 @@ import { addEventListener, removeEventListener, } from '../utils/event-listener-helper'; +import { dumpSegment } from '../utils/mp4-tools'; import { patchClearMediaSegment, preLoadFirstEncryptedInitSegmentData, + preLoadFirstEncryptedMediaSegmentData, } from '../utils/playready-workaround'; import { useAlternateAudio } from '../utils/rendition-helper'; import type { FragmentTracker } from './fragment-tracker'; @@ -402,17 +405,21 @@ export default class StreamController // Pre-fetch first encrypted init segment to obtain its decryption data const details = this.getLevelDetails(); const firstEncryptedInit = - details?.encryptedFragments?.[0].initSegment; + details?.encryptedFragments?.[0]?.initSegment; if (firstEncryptedInit) { this.waitingForInitSegmentAppend = true; - preLoadFirstEncryptedInitSegmentData(firstEncryptedInit) - .then((data) => { - this.firstEncryptedInitSegmentData = data; + Promise.all([ + preLoadFirstEncryptedInitSegmentData(firstEncryptedInit), + preLoadFirstEncryptedMediaSegmentData(), + ]) + .then(([initData, mediaData]) => { + this.firstEncryptedInitSegmentData = initData; + this.firstEncryptedMediaSegmentData = mediaData; this._loadInitSegment(frag, level); }) .catch((error) => { console.log( - '$$$$ Failed to pre-load first encrypted init segment data:', + '$$$$ Failed to pre-load first encrypted segment data:', error, ); this.waitingForInitSegmentAppend = false; @@ -828,12 +835,14 @@ export default class StreamController } protected patchMediaSegment(payload: ArrayBuffer): ArrayBuffer { - if (!this.firstEncryptedInitSegmentData) { + if (!this.firstEncryptedMediaSegmentData) { return payload; } return patchClearMediaSegment( new Uint8Array(payload), - this.firstEncryptedInitSegmentData, + this.firstEncryptedMediaSegmentData, + 'moof-relative', + 'mirror-encrypted-senc-shape', ).buffer as ArrayBuffer; } diff --git a/src/utils/playready-workaround.ts b/src/utils/playready-workaround.ts index aea9b92c423..5c880a0df04 100644 --- a/src/utils/playready-workaround.ts +++ b/src/utils/playready-workaround.ts @@ -38,7 +38,13 @@ export function applyPlayReadyWorkaroundToLevelDetails( export function patchClearMediaSegment( clearSegmentData: Uint8Array, - _firstEncryptedSegmentData: Uint8Array, + firstEncryptedSegmentData: Uint8Array, + saioOffsetMode: 'moof-relative' | 'file-relative' = 'moof-relative', + clearPrerollCencMode: + | 'sgpd-sbgp' + | 'aux-info-only' + | 'mirror-encrypted-aux-info' + | 'mirror-encrypted-senc-shape' = 'sgpd-sbgp', ): Uint8Array { dumpSegment( '$$$$ Clear media segment before CENC sample-group patch', @@ -66,80 +72,197 @@ export function patchClearMediaSegment( return clearSegmentData; } const trun = trunArr[0]; - // trun content layout: version(1) + flags(3) + sample_count(4) + [data_offset(4)] + ... + + const trunFlags = parseTrunFlags(trun); const sampleCount = readUint32(trun, 4); - // data_offset_present is flag bit 0x000001; flags are big-endian in bytes 1-3, - // so trun[3] is the least-significant byte - const dataOffsetPresent = trun[3] & 0x01; + const dataOffsetPresent = !!(trunFlags & 0x000001); + const auxInfoOnly = clearPrerollCencMode === 'aux-info-only'; + const mirrorMode = clearPrerollCencMode === 'mirror-encrypted-aux-info'; + const mirrorSencShape = + clearPrerollCencMode === 'mirror-encrypted-senc-shape'; + const useUntypedBoxes = mirrorMode || mirrorSencShape; + const noSampleGroups = auxInfoOnly || mirrorMode || mirrorSencShape; - // Find existing sbgp boxes: preserve roll, abort if seig already present + // Guard: abort if seig sbgp already present (segment was already patched in sgpd-sbgp mode) const sbgpArr = findBox(traf, ['sbgp']); - let insertionOffset = -1; for (let i = 0; i < sbgpArr.length; i++) { - const sbgp = sbgpArr[i]; - // sbgp content: version/flags(4) + grouping_type(4) + ... - const groupingType = bin2str(sbgp.subarray(4, 8)); - if (groupingType === 'seig') { + if (bin2str(sbgpArr[i].subarray(4, 8)) === 'seig') { logger.debug( '[playready-workaround] patchClearMediaSegment: seig already present, skipping', ); return clearSegmentData; } - if (groupingType === 'roll') { - // Insert after the end of this roll sbgp box (byteOffset is absolute in clearData) - insertionOffset = sbgp.byteOffset + sbgp.length; - } - } - - // Fallback: insert after trun if no roll sbgp found - if (insertionOffset === -1) { - insertionOffset = trun.byteOffset + trun.length; - } - - const INSERTED_BYTES = 72; // 44 (sgpd) + 28 (sbgp) - - // Build sgpd(seig) — 44 bytes - // Layout: size(4) + "sgpd"(4) + version=1/flags=0(4) + grouping_type="seig"(4) + - // default_length=20(4) + entry_count=1(4) + entry(20) - // Entry: isProtected=0(3) + perSampleIvSize=0(1) + zero-KID(16) — all zero - const sgpdSeig = new Uint8Array(44); - writeUint32(sgpdSeig, 0, 44); - sgpdSeig[4] = 0x73; - sgpdSeig[5] = 0x67; - sgpdSeig[6] = 0x70; - sgpdSeig[7] = 0x64; // "sgpd" - sgpdSeig[8] = 0x01; // version=1 - sgpdSeig[12] = 0x73; - sgpdSeig[13] = 0x65; - sgpdSeig[14] = 0x69; - sgpdSeig[15] = 0x67; // "seig" - writeUint32(sgpdSeig, 16, 20); // default_length - writeUint32(sgpdSeig, 20, 1); // entry_count - // bytes 24-43: entry (all zero — isProtected=0, perSampleIvSize=0, KID=0×16) - - // Build sbgp(seig) — 28 bytes - // Layout: size(4) + "sbgp"(4) + version=0/flags=0(4) + grouping_type="seig"(4) + - // entry_count=1(4) + sample_count(4) + group_description_index=1(4) - const sbgpSeig = new Uint8Array(28); - writeUint32(sbgpSeig, 0, 28); - sbgpSeig[4] = 0x73; - sbgpSeig[5] = 0x62; - sbgpSeig[6] = 0x67; - sbgpSeig[7] = 0x70; // "sbgp" - // bytes 8-11: version=0, flags=0 (already zero) - sbgpSeig[12] = 0x73; - sbgpSeig[13] = 0x65; - sbgpSeig[14] = 0x69; - sbgpSeig[15] = 0x67; // "seig" - writeUint32(sbgpSeig, 16, 1); // entry_count - writeUint32(sbgpSeig, 20, sampleCount); // sample_count - writeUint32(sbgpSeig, 24, 1); // group_description_index + } + // In aux-info-only and mirror-encrypted-aux-info modes also guard against re-patching via senc presence + if (noSampleGroups && findBox(traf, ['senc']).length > 0) { + logger.debug( + '[playready-workaround] patchClearMediaSegment: senc already present, skipping', + ); + return clearSegmentData; + } + + // Determine insertion offset. + // aux-info-only / mirror-encrypted-aux-info: always right after trun (produces trun → saiz → saio → senc order). + // sgpd-sbgp: after roll sbgp if present (preserves standard sample-group ordering). + let insertionOffset = trun.byteOffset + trun.length; + if (!noSampleGroups) { + for (let i = 0; i < sbgpArr.length; i++) { + if (bin2str(sbgpArr[i].subarray(4, 8)) === 'roll') { + insertionOffset = sbgpArr[i].byteOffset + sbgpArr[i].length; + } + } + } + + // Resolve per-sample sizes for the senc box + let sampleSizes = readTrunSampleSizes(trun, trunFlags, sampleCount); + if (!sampleSizes.length) { + const defaultSize = readTfhdDefaultSampleSize(traf); + if (defaultSize > 0) { + sampleSizes = new Array(sampleCount).fill(defaultSize) as number[]; + } else { + logger.warn( + '[playready-workaround] patchClearMediaSegment: cannot determine sample sizes; senc will not be added', + ); + } + } + + // Build sgpd(seig) and sbgp(seig) only in sgpd-sbgp mode + let sgpdSeig: Uint8Array | null = null; + let sbgpSeig: Uint8Array | null = null; + if (!noSampleGroups) { + // sgpd(seig) — 44 bytes + // Layout: size(4) + "sgpd"(4) + version=1/flags=0(4) + grouping_type="seig"(4) + + // default_length=20(4) + entry_count=1(4) + entry(20) + // Entry: isProtected=0(3) + perSampleIvSize=0(1) + zero-KID(16) — all zero + sgpdSeig = new Uint8Array(44); + writeUint32(sgpdSeig, 0, 44); + sgpdSeig[4] = 0x73; + sgpdSeig[5] = 0x67; + sgpdSeig[6] = 0x70; + sgpdSeig[7] = 0x64; // "sgpd" + sgpdSeig[8] = 0x01; // version=1 + sgpdSeig[12] = 0x73; + sgpdSeig[13] = 0x65; + sgpdSeig[14] = 0x69; + sgpdSeig[15] = 0x67; // "seig" + writeUint32(sgpdSeig, 16, 20); // default_length + writeUint32(sgpdSeig, 20, 1); // entry_count + // bytes 24-43: entry (all zero — isProtected=0, perSampleIvSize=0, KID=0×16) + + // sbgp(seig) — 28 bytes + // Layout: size(4) + "sbgp"(4) + version=0/flags=0(4) + grouping_type="seig"(4) + + // entry_count=1(4) + sample_count(4) + group_description_index=1(4) + sbgpSeig = new Uint8Array(28); + writeUint32(sbgpSeig, 0, 28); + sbgpSeig[4] = 0x73; + sbgpSeig[5] = 0x62; + sbgpSeig[6] = 0x67; + sbgpSeig[7] = 0x70; // "sbgp" + // bytes 8-11: version=0, flags=0 (already zero) + sbgpSeig[12] = 0x73; + sbgpSeig[13] = 0x65; + sbgpSeig[14] = 0x69; + sbgpSeig[15] = 0x67; // "seig" + writeUint32(sbgpSeig, 16, 1); // entry_count + writeUint32(sbgpSeig, 20, sampleCount); // sample_count + writeUint32(sbgpSeig, 24, 1); // group_description_index + } + + // In mirror-encrypted-senc-shape mode, extract the raw senc box bytes from the first + // encrypted media segment so the patched clear senc is byte-for-byte identical. + let encryptedSencBox: Uint8Array | null = null; + if (mirrorSencShape) { + encryptedSencBox = extractEncryptedSencBox(firstEncryptedSegmentData); + if (!encryptedSencBox) { + logger.warn( + '[playready-workaround] patchClearMediaSegment: could not extract encrypted senc box; aborting patch', + ); + return clearSegmentData; + } + const encSampleCount = readUint32(encryptedSencBox, 12); + if (encSampleCount !== sampleCount) { + logger.warn( + `[playready-workaround] patchClearMediaSegment: encrypted senc.sample_count ${encSampleCount} !== clear trun.sample_count ${sampleCount}; aborting patch`, + ); + return clearSegmentData; + } + } + + // Build senc box — in mirrorSencShape mode use the raw encrypted senc bytes directly. + const sencBox: Uint8Array | null = mirrorSencShape + ? encryptedSencBox + : sampleSizes.length + ? buildSencBox(sampleSizes) + : null; + // Validate clear-only senc (not applicable when senc carries real encrypted records) + if (sencBox && !mirrorSencShape) { + validateSencBox(sencBox, sampleSizes, sampleCount); + } + + const SGPD_SBGP_BYTES = 72; // 44 (sgpd) + 28 (sbgp) + const moofStart = moof.byteOffset - 8; + + // Build saiz and saio to advertise inline senc auxiliary data to the parser. + // useUntypedBoxes (mirror-encrypted-aux-info / mirror-encrypted-senc-shape) uses + // untyped (flags=0) saiz/saio matching the real encrypted segment structure. + // aux-info-only and sgpd-sbgp use typed (flags=0x000001, aux_info_type="cenc") saiz/saio. + const saizBox = sencBox + ? useUntypedBoxes + ? mirrorSencShape && encryptedSencBox + ? buildUntypedSaizFromRawSencBox(encryptedSencBox) + : buildUntypedSaizBox(sampleCount) + : buildSaizBox(sampleSizes) + : null; + // Bytes prepended before saiz in the assembled gap: 0 when no sample groups, 72 in sgpd-sbgp. + const extraBytes = noSampleGroups ? 0 : SGPD_SBGP_BYTES; + // Compute where senc will land in the assembled buffer so the saio offset is accurate. + // saio size: 20 bytes untyped (mirror modes), 28 bytes typed (other modes). + const saioBoxSize = useUntypedBoxes ? 20 : 28; + const sencStartInPatched = + sencBox && saizBox + ? insertionOffset + extraBytes + saizBox.length + saioBoxSize + : -1; + // First IV is 16 bytes into senc: size(4)+type(4)+version/flags(4)+sample_count(4) = 16 + const firstIvAbsolute = + sencStartInPatched !== -1 ? sencStartInPatched + 16 : 0; + const saioOffsetValue = + sencStartInPatched !== -1 + ? saioOffsetMode === 'moof-relative' + ? firstIvAbsolute - moofStart + : firstIvAbsolute + : 0; + const saioBox = sencBox + ? useUntypedBoxes + ? buildUntypedSaioBox(saioOffsetValue) + : buildSaioBox(saioOffsetValue) + : null; + + const INSERTED_BYTES = + extraBytes + + (saizBox ? saizBox.length : 0) + + (saioBox ? saioBox.length : 0) + + (sencBox ? sencBox.length : 0); // Assemble patched segment const newData = new Uint8Array(clearSegmentData.length + INSERTED_BYTES); newData.set(clearSegmentData.subarray(0, insertionOffset), 0); - newData.set(sgpdSeig, insertionOffset); - newData.set(sbgpSeig, insertionOffset + 44); + let insertPos = insertionOffset; + if (!auxInfoOnly && sgpdSeig && sbgpSeig) { + newData.set(sgpdSeig, insertPos); + newData.set(sbgpSeig, insertPos + 44); + insertPos += SGPD_SBGP_BYTES; + } + if (saizBox) { + newData.set(saizBox, insertPos); + insertPos += saizBox.length; + } + if (saioBox) { + newData.set(saioBox, insertPos); + insertPos += saioBox.length; + } + if (sencBox) { + newData.set(sencBox, insertPos); + } newData.set( clearSegmentData.subarray(insertionOffset), insertionOffset + INSERTED_BYTES, @@ -147,11 +270,10 @@ export function patchClearMediaSegment( // Update moof.size and traf.size — both box headers are before insertionOffset // so their positions in newData are unchanged - const moofSizeOffset = moof.byteOffset - 8; writeUint32( newData, - moofSizeOffset, - readUint32(newData, moofSizeOffset) + INSERTED_BYTES, + moofStart, + readUint32(newData, moofStart) + INSERTED_BYTES, ); const trafSizeOffset = traf.byteOffset - 8; writeUint32( @@ -175,18 +297,29 @@ export function patchClearMediaSegment( clearSegmentData, newData, INSERTED_BYTES, - moofSizeOffset, + moofStart, ); validatePatchedSegment( clearSegmentData, newData, INSERTED_BYTES, - moofSizeOffset, + moofStart, trafSizeOffset, dataOffsetPresent ? trun.byteOffset + 8 : -1, ); + if (sencBox && saizBox && saioBox) { + validateSaizSaioSenc( + newData, + sampleCount, + saioOffsetValue, + saioOffsetMode, + clearPrerollCencMode, + mirrorSencShape ? encryptedSencBox : null, + ); + } + dumpSegment( '$$$$ Clear media segment after CENC sample-group patch', newData, @@ -195,6 +328,666 @@ export function patchClearMediaSegment( return newData; } +// Returns the 24-bit flags field from a trun box content (after the 8-byte box header). +function parseTrunFlags(trun: Uint8Array): number { + return (trun[1] << 16) | (trun[2] << 8) | trun[3]; +} + +// Returns per-sample sizes from trun when sample-size-present (0x000200) is set. +// Returns an empty array if the flag is absent — caller must resolve via tfhd default. +function readTrunSampleSizes( + trun: Uint8Array, + flags: number, + sampleCount: number, +): number[] { + if (!(flags & 0x000200)) return []; + + // Skip to the first per-sample entry after version(1)+flags(3)+sample_count(4)=8 + let offset = 8; + if (flags & 0x000001) offset += 4; // data_offset + if (flags & 0x000004) offset += 4; // first_sample_flags + + // Per-sample stride and offset of the size field within each entry + const hasDuration = !!(flags & 0x000100); + const hasFlags = !!(flags & 0x000400); + const hasComposition = !!(flags & 0x000800); + const sizeFieldOffset = hasDuration ? 4 : 0; + const perSampleStride = + (hasDuration ? 4 : 0) + 4 + (hasFlags ? 4 : 0) + (hasComposition ? 4 : 0); + + const sizes: number[] = []; + for (let i = 0; i < sampleCount; i++) { + sizes.push( + readUint32(trun, offset + i * perSampleStride + sizeFieldOffset), + ); + } + return sizes; +} + +// Returns the default_sample_size from tfhd (flag 0x000010), or 0 if not present. +function readTfhdDefaultSampleSize(traf: Uint8Array): number { + const tfhdArr = findBox(traf, ['tfhd']); + if (!tfhdArr.length) return 0; + const tfhd = tfhdArr[0]; + const flags = (tfhd[1] << 16) | (tfhd[2] << 8) | tfhd[3]; + let offset = 8; // version(1)+flags(3)+track_id(4) + if (flags & 0x000001) offset += 8; // base_data_offset (64-bit) + if (flags & 0x000002) offset += 4; // sample_description_index + if (flags & 0x000008) offset += 4; // default_sample_duration + if (flags & 0x000010) return readUint32(tfhd, offset); + return 0; +} + +// Extracts the raw senc box bytes (header + content) from the first encrypted media segment. +// Returns a copy so the returned slice is independent of the source buffer. +// Returns null if moof/traf/senc is absent or senc.flags !== 0x000002. +function extractEncryptedSencBox(encryptedData: Uint8Array): Uint8Array | null { + const moofArr = findBox(encryptedData, ['moof']); + if (!moofArr.length) { + logger.warn( + '[playready-workaround] extractEncryptedSencBox: no moof in encrypted segment', + ); + return null; + } + const trafArr = findBox(moofArr[0], ['traf']); + if (!trafArr.length) { + logger.warn( + '[playready-workaround] extractEncryptedSencBox: no traf in encrypted moof', + ); + return null; + } + const sencArr = findBox(trafArr[0], ['senc']); + if (!sencArr.length) { + logger.warn( + '[playready-workaround] extractEncryptedSencBox: no senc in encrypted traf', + ); + return null; + } + const senc = sencArr[0]; // content slice (after the 8-byte box header) + const flags = (senc[1] << 16) | (senc[2] << 8) | senc[3]; + if (flags !== 0x000002) { + logger.warn( + `[playready-workaround] extractEncryptedSencBox: unexpected senc flags 0x${flags.toString(16).padStart(6, '0')}`, + ); + return null; + } + // senc.byteOffset is absolute in the backing ArrayBuffer (assumed byteOffset=0 for top-level data). + return encryptedData.slice( + senc.byteOffset - 8, + senc.byteOffset + senc.length, + ); +} + +// Builds an untyped saiz box by walking a raw senc box's per-sample records. +// rawSencBox must include the 8-byte box header (size + "senc"). +// Uses compact form when all per-sample aux sizes are equal; per-sample array otherwise. +// Returns null if the senc structure is malformed. +function buildUntypedSaizFromRawSencBox( + rawSencBox: Uint8Array, +): Uint8Array | null { + // rawSencBox layout: size(4)+type(4)+version/flags(4)+sample_count(4) = 16 bytes header, + // then per-sample records: IV(16)+subsample_count(2)+N×[clear(2)+protected(4)] + if (rawSencBox.length < 16) return null; + const sampleCount = readUint32(rawSencBox, 12); + const auxSizes: number[] = []; + let pos = 16; + let uniform = true; + for (let i = 0; i < sampleCount; i++) { + if (pos + 18 > rawSencBox.length) return null; // IV(16) + subsample_count(2) + const subsampleCount = (rawSencBox[pos + 16] << 8) | rawSencBox[pos + 17]; + const sz = 18 + 6 * subsampleCount; // IV(16) + count(2) + N×6 + auxSizes.push(sz); + if (i > 0 && sz !== auxSizes[0]) uniform = false; + pos += sz; + } + + if (uniform && sampleCount > 0) { + const box = new Uint8Array(17); // compact untyped saiz + writeUint32(box, 0, 17); + box[4] = 0x73; + box[5] = 0x61; + box[6] = 0x69; + box[7] = 0x7a; // "saiz" + // version=0, flags=0 (bytes 8-11 zero) + box[12] = auxSizes[0]; // default_sample_info_size + writeUint32(box, 13, sampleCount); + return box; + } + + // Per-sample untyped saiz + const totalSize = 17 + sampleCount; + const box = new Uint8Array(totalSize); + writeUint32(box, 0, totalSize); + box[4] = 0x73; + box[5] = 0x61; + box[6] = 0x69; + box[7] = 0x7a; // "saiz" + // version=0, flags=0, default_sample_info_size=0 (bytes 8-12 zero) + writeUint32(box, 13, sampleCount); + for (let i = 0; i < sampleCount; i++) { + box[17 + i] = auxSizes[i]; + } + return box; +} + +// Generates a deterministic, non-repeating 16-byte IV for the given sample index. +// The counter (index + 1) is written as a big-endian 32-bit value in the last four bytes. +function generateIv(sampleIndex: number): Uint8Array { + const iv = new Uint8Array(16); + const counter = sampleIndex + 1; + iv[12] = (counter >>> 24) & 0xff; + iv[13] = (counter >>> 16) & 0xff; + iv[14] = (counter >>> 8) & 0xff; + iv[15] = counter & 0xff; + return iv; +} + +// Builds a senc box (version=0, flags=0x000002) with one all-clear subsample per sample. +// Samples larger than 65535 bytes are split into multiple all-clear subsamples. +// +// Per-sample layout: IV(16) + subsample_count(2) + N×[BytesOfClear(2)+BytesOfProtected(4)] +function buildSencBox(sampleSizes: number[]): Uint8Array { + const sampleCount = sampleSizes.length; + + // Pre-calculate per-sample subsample counts to size the box exactly once + const subsampleCounts: number[] = []; + let recordsSize = 0; + for (let i = 0; i < sampleCount; i++) { + const count = sampleSizes[i] === 0 ? 1 : Math.ceil(sampleSizes[i] / 65535); + subsampleCounts.push(count); + recordsSize += 16 + 2 + 6 * count; // IV + subsample_count + entries + } + + // FullBox header(8) + version/flags(4) + sample_count(4) = 16 bytes overhead + const totalSize = 16 + recordsSize; + const senc = new Uint8Array(totalSize); + + writeUint32(senc, 0, totalSize); + senc[4] = 0x73; + senc[5] = 0x65; + senc[6] = 0x6e; + senc[7] = 0x63; // "senc" + // version=0, flags=0x000002 (use-subsample-encryption) + senc[8] = 0x00; + senc[9] = 0x00; + senc[10] = 0x00; + senc[11] = 0x02; + writeUint32(senc, 12, sampleCount); + + let pos = 16; + for (let i = 0; i < sampleCount; i++) { + senc.set(generateIv(i), pos); + pos += 16; + + const subsampleCount = subsampleCounts[i]; + senc[pos] = (subsampleCount >>> 8) & 0xff; + senc[pos + 1] = subsampleCount & 0xff; + pos += 2; + + let remaining = sampleSizes[i]; + for (let j = 0; j < subsampleCount; j++) { + const clearBytes = Math.min(remaining, 65535); + remaining -= clearBytes; + senc[pos] = (clearBytes >>> 8) & 0xff; + senc[pos + 1] = clearBytes & 0xff; + // BytesOfProtectedData = 0 (already zero from Uint8Array initialisation) + pos += 6; + } + } + + return senc; +} + +function validateSencBox( + senc: Uint8Array, + sampleSizes: number[], + sampleCount: number, +): void { + const tag = '[playready-workaround] senc validation:'; + + if (senc[8] !== 0) { + logger.warn(`${tag} version should be 0, got ${senc[8]}`); + } + + const flags = (senc[9] << 16) | (senc[10] << 8) | senc[11]; + if (flags !== 0x000002) { + logger.warn( + `${tag} flags should be 0x000002, got 0x${flags.toString(16).padStart(6, '0')}`, + ); + } + + const encSampleCount = readUint32(senc, 12); + if (encSampleCount !== sampleCount) { + logger.warn( + `${tag} sample_count: expected ${sampleCount}, got ${encSampleCount}`, + ); + } + + const seenIvs = new Set(); + let pos = 16; + for (let i = 0; i < sampleCount; i++) { + const ivKey = Array.from(senc.subarray(pos, pos + 16)).join(','); + if (seenIvs.has(ivKey)) { + logger.warn(`${tag} duplicate IV at sample ${i}`); + } + seenIvs.add(ivKey); + pos += 16; + + const subsampleCount = (senc[pos] << 8) | senc[pos + 1]; + pos += 2; + + if (subsampleCount < 1) { + logger.warn(`${tag} sample ${i} has subsample_count < 1`); + } + + let totalBytes = 0; + for (let j = 0; j < subsampleCount; j++) { + const clearBytes = (senc[pos] << 8) | senc[pos + 1]; + const protectedBytes = readUint32(senc, pos + 2); + if (protectedBytes !== 0) { + logger.warn( + `${tag} sample ${i} subsample ${j} BytesOfProtectedData !== 0`, + ); + } + totalBytes += clearBytes + protectedBytes; + pos += 6; + } + + if (i < sampleSizes.length && totalBytes !== sampleSizes[i]) { + logger.warn( + `${tag} sample ${i} total bytes ${totalBytes} !== sample size ${sampleSizes[i]}`, + ); + } + } +} + +// Builds an untyped saiz box (version=0, flags=0, no aux_info_type fields). +// Used in mirror-encrypted-aux-info mode to match the real encrypted segment structure. +// +// Untyped compact layout (17 bytes = 0x11): +// size(4) + "saiz"(4) + version=0/flags=0(4) + +// default_sample_info_size=24(1) + sample_count(4) +function buildUntypedSaizBox(sampleCount: number): Uint8Array { + const box = new Uint8Array(17); + writeUint32(box, 0, 17); + box[4] = 0x73; + box[5] = 0x61; + box[6] = 0x69; + box[7] = 0x7a; // "saiz" + // version=0, flags=0 (bytes 8-11 already zero) + box[12] = 0x18; // default_sample_info_size = 24 (16 IV + 2 subsample_count + 6 per-entry) + writeUint32(box, 13, sampleCount); + return box; +} + +// Builds an untyped saio box (version=0, flags=0, no aux_info_type fields), single entry. +// Used in mirror-encrypted-aux-info mode to match the real encrypted segment structure. +// +// Untyped layout (20 bytes = 0x14): +// size(4) + "saio"(4) + version=0/flags=0(4) + +// entry_count=1(4) + offset[0](4) +function buildUntypedSaioBox(offset: number): Uint8Array { + const box = new Uint8Array(20); + writeUint32(box, 0, 20); + box[4] = 0x73; + box[5] = 0x61; + box[6] = 0x69; + box[7] = 0x6f; // "saio" + // version=0, flags=0 (bytes 8-11 already zero) + writeUint32(box, 12, 1); // entry_count + writeUint32(box, 16, offset); // offset[0] + return box; +} + +// Builds a typed saiz (sample auxiliary information sizes) box. +// flags=0x000001 signals aux_info_type/aux_info_type_parameter are present. +// Uses compact form when all senc aux records have the same size +// (one subsample per sample = IV(16) + count(2) + entry(6) = 24 bytes). +// Falls back to a per-sample size array for samples spanning multiple subsamples. +// +// Typed compact layout (25 bytes): +// size(4) + "saiz"(4) + version=0/flags=0x000001(4) + +// aux_info_type="cenc"(4) + aux_info_type_parameter=0(4) + +// default_sample_info_size(1) + sample_count(4) +function buildSaizBox(sampleSizes: number[]): Uint8Array { + const sampleCount = sampleSizes.length; + + // Mirror the subsample logic in buildSencBox to derive per-sample aux record sizes. + const auxSizes: number[] = []; + let uniform = true; + for (let i = 0; i < sampleCount; i++) { + const n = sampleSizes[i] === 0 ? 1 : Math.ceil(sampleSizes[i] / 65535); + const sz = 18 + 6 * n; // IV(16) + subsample_count(2) + n×[clear(2)+protected(4)] + auxSizes.push(sz); + if (i > 0 && sz !== auxSizes[0]) uniform = false; + } + + if (uniform && auxSizes.length > 0) { + // Typed compact form: 25 bytes + const box = new Uint8Array(25); + writeUint32(box, 0, 25); + box[4] = 0x73; + box[5] = 0x61; + box[6] = 0x69; + box[7] = 0x7a; // "saiz" + // version=0, flags=0x000001 + box[8] = 0x00; + box[9] = 0x00; + box[10] = 0x00; + box[11] = 0x01; + box[12] = 0x63; + box[13] = 0x65; + box[14] = 0x6e; + box[15] = 0x63; // aux_info_type = "cenc" + // aux_info_type_parameter = 0 (bytes 16-19, already zero) + box[20] = auxSizes[0]; // default_sample_info_size + writeUint32(box, 21, sampleCount); + return box; + } + + // Typed per-sample form: 25+N bytes + const totalSize = 25 + sampleCount; + const box = new Uint8Array(totalSize); + writeUint32(box, 0, totalSize); + box[4] = 0x73; + box[5] = 0x61; + box[6] = 0x69; + box[7] = 0x7a; // "saiz" + // version=0, flags=0x000001 + box[8] = 0x00; + box[9] = 0x00; + box[10] = 0x00; + box[11] = 0x01; + box[12] = 0x63; + box[13] = 0x65; + box[14] = 0x6e; + box[15] = 0x63; // aux_info_type = "cenc" + // aux_info_type_parameter = 0 (bytes 16-19, already zero) + // default_sample_info_size = 0 (byte 20, already zero — per-sample sizes follow) + writeUint32(box, 21, sampleCount); + for (let i = 0; i < sampleCount; i++) { + box[25 + i] = auxSizes[i]; + } + return box; +} + +// Builds a typed saio (sample auxiliary information offsets) box with version=0, a single entry. +// flags=0x000001 signals aux_info_type/aux_info_type_parameter are present. +// offset is either moof-relative or file-relative depending on saioOffsetMode in the caller. +// +// Typed layout (28 bytes): +// size(4) + "saio"(4) + version=0/flags=0x000001(4) + +// aux_info_type="cenc"(4) + aux_info_type_parameter=0(4) + +// entry_count=1(4) + offset[0](4) +function buildSaioBox(offset: number): Uint8Array { + const box = new Uint8Array(28); + writeUint32(box, 0, 28); + box[4] = 0x73; + box[5] = 0x61; + box[6] = 0x69; + box[7] = 0x6f; // "saio" + // version=0, flags=0x000001 + box[8] = 0x00; + box[9] = 0x00; + box[10] = 0x00; + box[11] = 0x01; + box[12] = 0x63; + box[13] = 0x65; + box[14] = 0x6e; + box[15] = 0x63; // aux_info_type = "cenc" + // aux_info_type_parameter = 0 (bytes 16-19, already zero) + writeUint32(box, 20, 1); // entry_count + writeUint32(box, 24, offset); + return box; +} + +// Validates that saiz, saio, and senc appear in the correct order inside traf, +// and that their metadata is self-consistent. +function validateSaizSaioSenc( + patched: Uint8Array, + expectedSampleCount: number, + expectedSaioOffset: number, + saioOffsetMode: 'moof-relative' | 'file-relative', + clearPrerollCencMode: + | 'sgpd-sbgp' + | 'aux-info-only' + | 'mirror-encrypted-aux-info' + | 'mirror-encrypted-senc-shape', + encryptedSencBox: Uint8Array | null = null, +): void { + const tag = '[playready-workaround] saiz/saio/senc validation:'; + const useUntypedBoxes = + clearPrerollCencMode === 'mirror-encrypted-aux-info' || + clearPrerollCencMode === 'mirror-encrypted-senc-shape'; + const mirrorMode = useUntypedBoxes; // alias kept for the branch logic below + + const moofArr = findBox(patched, ['moof']); + if (!moofArr.length) { + logger.warn(`${tag} moof not found`); + return; + } + const trafArr = findBox(moofArr[0], ['traf']); + if (!trafArr.length) { + logger.warn(`${tag} traf not found`); + return; + } + const traf = trafArr[0]; + + const saizArr = findBox(traf, ['saiz']); + const saioArr = findBox(traf, ['saio']); + const sencArr = findBox(traf, ['senc']); + + if (!saizArr.length) logger.warn(`${tag} saiz not found in traf`); + if (!saioArr.length) logger.warn(`${tag} saio not found in traf`); + if (!sencArr.length) logger.warn(`${tag} senc not found in traf`); + if (!saizArr.length || !saioArr.length || !sencArr.length) return; + + const saiz = saizArr[0]; + const saio = saioArr[0]; + const senc = sencArr[0]; + + // Order: saiz → saio → senc (byteOffset is the content start = after the 8-byte header, + // but relative ordering is preserved within the shared backing ArrayBuffer) + if (saiz.byteOffset >= saio.byteOffset) { + logger.warn(`${tag} saiz does not precede saio`); + } + if (saio.byteOffset >= senc.byteOffset) { + logger.warn(`${tag} saio does not precede senc`); + } + + // saiz validation — layout differs between typed (flags=0x000001) and untyped (flags=0) + const saizSize = readUint32(patched, saiz.byteOffset - 8); + const saizFlags = (saiz[1] << 16) | (saiz[2] << 8) | saiz[3]; + if (mirrorMode) { + // Untyped saiz: version(1)/flags(3) | default_sample_info_size(1) | sample_count(4) + const defaultSampleInfoSize = saiz[4]; + const saizSampleCount = readUint32(saiz, 5); + if (saizSize !== 0x11) { + logger.warn( + `${tag} saiz.size: expected 0x11, got 0x${saizSize.toString(16)}`, + ); + } + if (saizFlags !== 0) { + logger.warn( + `${tag} saiz.flags: expected 0, got 0x${saizFlags.toString(16).padStart(6, '0')}`, + ); + } + if (defaultSampleInfoSize !== 24) { + logger.warn( + `${tag} saiz.default_sample_info_size: expected 24, got ${defaultSampleInfoSize}`, + ); + } + if (saizSampleCount !== expectedSampleCount) { + logger.warn( + `${tag} saiz.sample_count: expected ${expectedSampleCount}, got ${saizSampleCount}`, + ); + } + } else { + // Typed saiz: version(1)/flags(3) | aux_info_type(4) | aux_info_type_parameter(4) | + // default_sample_info_size(1) | sample_count(4) + const saizAuxType = bin2str(saiz.subarray(4, 8)); + const saizAuxParam = readUint32(saiz, 8); + const defaultSampleInfoSize = saiz[12]; + const saizSampleCount = readUint32(saiz, 13); + if (saizSize !== 0x19) { + logger.warn( + `${tag} saiz.size: expected 0x19, got 0x${saizSize.toString(16)}`, + ); + } + if (saizFlags !== 0x000001) { + logger.warn( + `${tag} saiz.flags: expected 0x000001, got 0x${saizFlags.toString(16).padStart(6, '0')}`, + ); + } + if (saizAuxType !== 'cenc') { + logger.warn( + `${tag} saiz.aux_info_type: expected "cenc", got "${saizAuxType}"`, + ); + } + if (saizAuxParam !== 0) { + logger.warn( + `${tag} saiz.aux_info_type_parameter: expected 0, got ${saizAuxParam}`, + ); + } + if (defaultSampleInfoSize !== 24) { + logger.warn( + `${tag} saiz.default_sample_info_size: expected 24, got ${defaultSampleInfoSize}`, + ); + } + if (saizSampleCount !== expectedSampleCount) { + logger.warn( + `${tag} saiz.sample_count: expected ${expectedSampleCount}, got ${saizSampleCount}`, + ); + } + } + + // saio validation — layout differs between typed (flags=0x000001) and untyped (flags=0) + const saioSize = readUint32(patched, saio.byteOffset - 8); + const saioFlags = (saio[1] << 16) | (saio[2] << 8) | saio[3]; + if (mirrorMode) { + // Untyped saio: version(1)/flags(3) | entry_count(4) | offset[0](4) + const entryCount = readUint32(saio, 4); + const saioOffset = readUint32(saio, 8); + if (saioSize !== 0x14) { + logger.warn( + `${tag} saio.size: expected 0x14, got 0x${saioSize.toString(16)}`, + ); + } + if (saioFlags !== 0) { + logger.warn( + `${tag} saio.flags: expected 0, got 0x${saioFlags.toString(16).padStart(6, '0')}`, + ); + } + if (entryCount !== 1) { + logger.warn(`${tag} saio.entry_count: expected 1, got ${entryCount}`); + } + if (saioOffset !== expectedSaioOffset) { + logger.warn( + `${tag} saio.offset[0] (${saioOffsetMode}): expected 0x${expectedSaioOffset.toString(16).padStart(8, '0')}, got 0x${saioOffset.toString(16).padStart(8, '0')}`, + ); + } + } else { + // Typed saio: version(1)/flags(3) | aux_info_type(4) | aux_info_type_parameter(4) | + // entry_count(4) | offset[0](4) + const saioAuxType = bin2str(saio.subarray(4, 8)); + const saioAuxParam = readUint32(saio, 8); + const entryCount = readUint32(saio, 12); + const saioOffset = readUint32(saio, 16); + if (saioSize !== 0x1c) { + logger.warn( + `${tag} saio.size: expected 0x1c, got 0x${saioSize.toString(16)}`, + ); + } + if (saioFlags !== 0x000001) { + logger.warn( + `${tag} saio.flags: expected 0x000001, got 0x${saioFlags.toString(16).padStart(6, '0')}`, + ); + } + if (saioAuxType !== 'cenc') { + logger.warn( + `${tag} saio.aux_info_type: expected "cenc", got "${saioAuxType}"`, + ); + } + if (saioAuxParam !== 0) { + logger.warn( + `${tag} saio.aux_info_type_parameter: expected 0, got ${saioAuxParam}`, + ); + } + if (entryCount !== 1) { + logger.warn(`${tag} saio.entry_count: expected 1, got ${entryCount}`); + } + if (saioOffset !== expectedSaioOffset) { + logger.warn( + `${tag} saio.offset[0] (${saioOffsetMode}): expected 0x${expectedSaioOffset.toString(16).padStart(8, '0')}, got 0x${saioOffset.toString(16).padStart(8, '0')}`, + ); + } + } + + // senc content: version(1)/flags(3) | sample_count(4) + const sencFlags = (senc[1] << 16) | (senc[2] << 8) | senc[3]; + const sencSampleCount = readUint32(senc, 4); + if (sencSampleCount !== expectedSampleCount) { + logger.warn( + `${tag} senc.sample_count: expected ${expectedSampleCount}, got ${sencSampleCount}`, + ); + } + if (sencFlags !== 0x000002) { + logger.warn( + `${tag} senc.flags: expected 0x000002, got 0x${sencFlags.toString(16).padStart(6, '0')}`, + ); + } + + // Byte-for-byte comparison against encrypted reference senc (mirror-encrypted-senc-shape only). + // encryptedSencBox includes the 8-byte box header; the patched senc slice is read from patched[]. + if (encryptedSencBox) { + const patchedSencStart = senc.byteOffset - 8; + const patchedSencEnd = senc.byteOffset + senc.length; + const patchedSencRaw = patched.subarray(patchedSencStart, patchedSencEnd); + if (patchedSencRaw.length !== encryptedSencBox.length) { + logger.warn( + `${tag} senc size mismatch: expected 0x${encryptedSencBox.length.toString(16)}, got 0x${patchedSencRaw.length.toString(16)}`, + ); + } else { + let firstMismatch = -1; + for (let i = 0; i < patchedSencRaw.length; i++) { + if (patchedSencRaw[i] !== encryptedSencBox[i]) { + firstMismatch = i; + break; + } + } + if (firstMismatch !== -1) { + logger.warn( + `${tag} senc payload differs from encrypted reference at byte offset 0x${firstMismatch.toString(16)}`, + ); + } else { + logger.debug( + `${tag} senc payload matches encrypted reference byte-for-byte`, + ); + } + } + } + + // In aux-info-only and mirror-encrypted-aux-info modes, sgpd(seig) and sbgp(seig) must be absent + if (clearPrerollCencMode === 'aux-info-only' || mirrorMode) { + const sgpdArr = findBox(traf, ['sgpd']); + for (let i = 0; i < sgpdArr.length; i++) { + if (bin2str(sgpdArr[i].subarray(4, 8)) === 'seig') { + logger.warn( + `${tag} sgpd(seig) found in traf (must be absent in ${clearPrerollCencMode} mode)`, + ); + } + } + const sbgpSeigArr = findBox(traf, ['sbgp']); + for (let i = 0; i < sbgpSeigArr.length; i++) { + if (bin2str(sbgpSeigArr[i].subarray(4, 8)) === 'seig') { + logger.warn( + `${tag} sbgp(seig) found in traf (must be absent in ${clearPrerollCencMode} mode)`, + ); + } + } + } +} + /** * sidx content layout (bytes within the box content, i.e. after the 8-byte header): * 0 : version (1 byte) @@ -498,6 +1291,21 @@ export function patchClearInitSegment( return patchPsshOnly(clearInitSegmentData, psshBoxes); } + // Guard: bail out if the sample entry is already encv — patch was already applied and a second + // sinf insertion would produce two consecutive sinf boxes inside the encv sample entry. + const clearStsdBoxStart = clearStsdContent.byteOffset - 8; + const entryTypeOffset = clearStsdBoxStart + 20; + if ( + bin2str( + clearInitSegmentData.subarray(entryTypeOffset, entryTypeOffset + 4), + ) === 'encv' + ) { + logger.debug( + '[playready-workaround] patchClearInitSegment: encv already present, skipping re-patch', + ); + return clearInitSegmentData; + } + const sinfSize = sinfBox.length; const psshTotal = psshBoxes.reduce((acc, b) => acc + b.length, 0); // clearStsdBoxEnd = first byte after the stsd box = also end of the sole @@ -535,9 +1343,7 @@ export function patchClearInitSegment( // 6. Rename the avc1 sample entry type to encv. // stsd box header = 8, FullBox version+flags+count = 8, entry size = 4, - // so entry type sits at clearStsdBoxStart + 20. - const clearStsdBoxStart = clearStsdContent.byteOffset - 8; - const entryTypeOffset = clearStsdBoxStart + 20; + // so entry type sits at clearStsdBoxStart + 20 (both vars hoisted to the guard above). result[entryTypeOffset] = 0x65; // 'e' result[entryTypeOffset + 1] = 0x6e; // 'n' result[entryTypeOffset + 2] = 0x63; // 'c' @@ -622,15 +1428,39 @@ function patchPsshOnly( return result; } +const FIRST_ENCRYPTED_MEDIA_SEGMENT_URL = + 'https://sample-videos-zyrkp2nj.s3-eu-west-1.amazonaws.com/big-buck-bunny-clear-to-encrypted/hls_fmp4_cenc_pw/video_348000/encrypted/1.m4s'; + +export function preLoadFirstEncryptedMediaSegmentData(): Promise { + return fetch(FIRST_ENCRYPTED_MEDIA_SEGMENT_URL) + .then((response) => { + if (!response.ok) { + throw new Error( + `Failed to fetch first encrypted media segment: ${response.statusText}`, + ); + } + return response.arrayBuffer(); + }) + .then((arrayBuffer) => { + const data = new Uint8Array(arrayBuffer); + dumpSegment( + '$$$$ first encrypted media segment', + data, + 'encrypted media', + ); + return data; + }); +} + export function preLoadFirstEncryptedInitSegmentData( firstEncryptedInitSegment: Fragment, ): Promise { return new Promise((resolve, reject) => { if (firstEncryptedInitSegment.data) { resolve(firstEncryptedInitSegment.data as Uint8Array); + return; } - // remove the resource file from the base URL to get the true base URL for the init segment - // e.g. https://sample-videos-zyrkp2nj.s3-eu-west-1.amazon…ncrypted/hls_fmp4_cenc_pw/video_348000/index.m3u + // remove the resource filename from the base URL to get the directory const urlBase = firstEncryptedInitSegment.base.url.replace(/\/[^/]*$/, '/'); const initSegmentUrl = urlBase + firstEncryptedInitSegment.relurl; fetch(initSegmentUrl) @@ -640,17 +1470,9 @@ export function preLoadFirstEncryptedInitSegmentData( `Failed to fetch init segment data from ${initSegmentUrl}: ${response.statusText}`, ); } - response - .arrayBuffer() - .then((arrayBuffer) => { - resolve(new Uint8Array(arrayBuffer)); - }) - .catch((error) => { - reject(error); - }); + return response.arrayBuffer(); }) - .catch((error) => { - reject(error); - }); + .then((arrayBuffer) => resolve(new Uint8Array(arrayBuffer))) + .catch(reject); }); } diff --git a/tests/test-streams.js b/tests/test-streams.js index e5813cd9105..cadce31e5bd 100644 --- a/tests/test-streams.js +++ b/tests/test-streams.js @@ -47,7 +47,17 @@ function createTestStreamWithConfig(target, config) { } module.exports = { - ben: { + benlocal: { + url: 'http://127.0.0.1:8081/master.m3u8', + description: 'My locally packaged HLS stream', + abr: false, + }, + encrypted: { + url: 'https://sample-videos-zyrkp2nj.s3-eu-west-1.amazonaws.com/drm-test-vod/master.m3u8', + description: 'drm-test-vod', + abr: true, + }, + clearToEncrypted: { url: 'https://sample-videos-zyrkp2nj.s3-eu-west-1.amazonaws.com/big-buck-bunny-clear-to-encrypted/hls_fmp4_cenc_pw/master.m3u8', description: 'Big Buck Bunny - clear to encrypted, fMP4, CENC, PlayReady', abr: true,