From 621d5d32cbd71870164bb25031e19c06bef76433 Mon Sep 17 00:00:00 2001 From: Ben Roberts Date: Thu, 30 Apr 2026 12:07:24 +0200 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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/5] 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 () {