Skip to content

Commit 6679509

Browse files
author
배홍준[Media Player Tech]
committed
fix(subtitle): decrypt LL-HLS VTT AES parts per-part
SubtitleStreamController only decrypts AES-encrypted VTT data at full-segment boundaries via _handleFragmentLoadComplete. When low-latency parts are in use, each part arrives through the progress callback (_handleFragmentLoadProgress), which the base class leaves as an empty no-op for subtitles. Encrypted parts therefore never fire FRAG_DECRYPTED on the part path, leaving subtitle parsing stuck and adding a segment-duration latency to encrypted VTT captions. This change overrides _handleFragmentLoadProgress in SubtitleStreamController to decrypt each encrypted VTT part as an independent AES-CBC stream using the segment-level IV, and emits FRAG_DECRYPTED with the part reference so the timeline-controller can anchor cues at part.start when appropriate. The full- segment path is reorganised to share the same decryptPayload helper. A 'part: Part | null' field is added to FragDecryptedData and to the two existing FRAG_DECRYPTED emit sites (base-stream-controller init-segment path and subtitle-stream-controller full-segment path) so consumers can distinguish part-level decryption from segment-level decryption.
1 parent 8814807 commit 6679509

5 files changed

Lines changed: 244 additions & 47 deletions

File tree

api-extractor/report/hls.js.api.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1840,6 +1840,8 @@ export interface FragDecryptedData {
18401840
// (undocumented)
18411841
frag: Fragment;
18421842
// (undocumented)
1843+
part: Part | null;
1844+
// (undocumented)
18431845
payload: ArrayBuffer;
18441846
// (undocumented)
18451847
stats: {
@@ -5015,6 +5017,8 @@ export class SubtitleStreamController extends BaseStreamController implements Ne
50155017
// (undocumented)
50165018
_handleFragmentLoadComplete(fragLoadedData: FragLoadedData): void;
50175019
// (undocumented)
5020+
protected _handleFragmentLoadProgress(data: FragLoadedData): void;
5021+
// (undocumented)
50185022
protected loadFragment(frag: MediaFragment, level: Level, targetBufferTime: number): void;
50195023
// (undocumented)
50205024
get mediaBufferTimeRanges(): Bufferable;

src/controller/base-stream-controller.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -764,6 +764,7 @@ export default class BaseStreamController
764764
const endTime = self.performance.now();
765765
hls.trigger(Events.FRAG_DECRYPTED, {
766766
frag,
767+
part: null,
767768
payload: decryptedData,
768769
stats: {
769770
tstart: startTime,

src/controller/subtitle-stream-controller.ts

Lines changed: 93 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import BaseStreamController, { State } from './base-stream-controller';
22
import { FragmentState } from './fragment-tracker';
3+
import Decrypter from '../crypt/decrypter';
34
import { ErrorDetails, ErrorTypes } from '../errors';
45
import { Events } from '../events';
5-
import { isMediaFragment, type MediaFragment } from '../loader/fragment';
6+
import {
7+
type Fragment,
8+
isMediaFragment,
9+
type MediaFragment,
10+
type Part,
11+
} from '../loader/fragment';
612
import { Level } from '../types/level';
713
import { PlaylistLevelType } from '../types/loader';
814
import { BufferHelper } from '../utils/buffer-helper';
@@ -333,58 +339,98 @@ export class SubtitleStreamController
333339
this.tickImmediate();
334340
}
335341

336-
_handleFragmentLoadComplete(fragLoadedData: FragLoadedData) {
337-
const { frag, payload } = fragLoadedData;
338-
const decryptData = frag.decryptdata;
339-
const hls = this.hls;
340-
341-
if (this.fragContextChanged(frag)) {
342+
// Decrypts encrypted VTT parts as they arrive during LL-HLS playback. Without
343+
// this override, encrypted parts are only decrypted at full-segment boundaries
344+
// (`_handleFragmentLoadComplete`), which has two problems for LL-HLS:
345+
// 1. FRAG_DECRYPTED never fires for parts of a partially-loaded segment, so
346+
// the controller stays in FRAG_LOADING when subsequent ticks load the
347+
// tail parts of a segment whose head parts were buffered earlier.
348+
// 2. Subtitle latency degrades to a segment duration since cues cannot be
349+
// surfaced until the whole segment is assembled.
350+
// Each part is decrypted as a standalone AES-CBC stream using the segment-level
351+
// IV (per how full-segment encryption is signalled on LL-HLS parts).
352+
protected _handleFragmentLoadProgress(data: FragLoadedData) {
353+
const { frag, part, payload } = data;
354+
if (!part || !payload?.byteLength || !this.shouldDecrypt(frag)) {
342355
return;
343356
}
344-
// check to see if the payload needs to be decrypted
357+
this.decryptPayload(payload, frag, part);
358+
}
359+
360+
_handleFragmentLoadComplete(fragLoadedData: FragLoadedData) {
361+
const { frag, payload } = fragLoadedData;
345362
if (
346-
payload &&
347-
payload.byteLength > 0 &&
348-
decryptData?.key &&
349-
decryptData.iv &&
350-
isFullSegmentEncryption(decryptData.method)
363+
this.fragContextChanged(frag) ||
364+
!payload?.byteLength ||
365+
!this.shouldDecrypt(frag)
351366
) {
352-
const startTime = performance.now();
353-
// decrypt the subtitles
354-
this.decrypter
355-
.decrypt(
356-
new Uint8Array(payload),
357-
decryptData.key.buffer,
358-
decryptData.iv.buffer,
359-
getAesModeFromFullSegmentMethod(decryptData.method),
360-
)
361-
.catch((err) => {
362-
hls.trigger(Events.ERROR, {
363-
type: ErrorTypes.MEDIA_ERROR,
364-
details: ErrorDetails.FRAG_DECRYPT_ERROR,
365-
fatal: false,
366-
error: err,
367-
reason: err.message,
368-
frag,
369-
});
370-
throw err;
371-
})
372-
.then((decryptedData) => {
373-
const endTime = performance.now();
374-
hls.trigger(Events.FRAG_DECRYPTED, {
375-
frag,
376-
payload: decryptedData,
377-
stats: {
378-
tstart: startTime,
379-
tdecrypt: endTime,
380-
},
381-
});
382-
})
383-
.catch((err) => {
367+
return;
368+
}
369+
this.decryptPayload(payload, frag, null);
370+
}
371+
372+
// Whether the fragment is encrypted with a full-segment AES method and has the
373+
// key/iv material needed to decrypt right now.
374+
private shouldDecrypt(frag: Fragment): boolean {
375+
const d = frag.decryptdata;
376+
return !!(
377+
frag.encrypted &&
378+
d?.key &&
379+
d.iv &&
380+
isFullSegmentEncryption(d.method)
381+
);
382+
}
383+
384+
// Decrypts a single buffer (full segment or LL-HLS part) and emits
385+
// FRAG_DECRYPTED. On failure, emits ERROR; for the full-segment path
386+
// (part === null) the state is also reset to IDLE so the next tick can pick
387+
// up — for the part path the per-part FRAG_LOADED drives state transitions.
388+
// A new Decrypter is constructed per call so concurrent part decryptions do
389+
// not race on the software-decrypter's shared remainder state.
390+
private decryptPayload(
391+
payload: ArrayBuffer,
392+
frag: Fragment,
393+
part: Part | null,
394+
): void {
395+
const decryptData = frag.decryptdata!;
396+
const tstart = performance.now();
397+
const decrypter = new Decrypter(this.hls.config);
398+
decrypter
399+
.decrypt(
400+
new Uint8Array(payload),
401+
decryptData.key!.buffer,
402+
decryptData.iv!.buffer,
403+
getAesModeFromFullSegmentMethod(decryptData.method),
404+
)
405+
.then((plaintext) => {
406+
if (this.fragContextChanged(frag)) {
407+
return;
408+
}
409+
this.hls.trigger(Events.FRAG_DECRYPTED, {
410+
frag,
411+
part,
412+
payload: plaintext,
413+
stats: { tstart, tdecrypt: performance.now() },
414+
});
415+
})
416+
.catch((err) => {
417+
this.hls.trigger(Events.ERROR, {
418+
type: ErrorTypes.MEDIA_ERROR,
419+
details: ErrorDetails.FRAG_DECRYPT_ERROR,
420+
fatal: false,
421+
error: err,
422+
reason: err.message,
423+
frag,
424+
part,
425+
});
426+
if (!part) {
384427
this.warn(`${err.name}: ${err.message}`);
385428
this.state = State.IDLE;
386-
});
387-
}
429+
}
430+
})
431+
.finally(() => {
432+
decrypter.destroy();
433+
});
388434
}
389435

390436
doTick() {

src/types/events.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,7 @@ export interface PartsLoadedData {
422422

423423
export interface FragDecryptedData {
424424
frag: Fragment;
425+
part: Part | null;
425426
payload: ArrayBuffer;
426427
stats: {
427428
tstart: number;

tests/unit/controller/subtitle-stream-controller.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import sinonChai from 'sinon-chai';
44
import { State } from '../../../src/controller/base-stream-controller';
55
import { FragmentTracker } from '../../../src/controller/fragment-tracker';
66
import { SubtitleStreamController } from '../../../src/controller/subtitle-stream-controller';
7+
import Decrypter from '../../../src/crypt/decrypter';
8+
import { ErrorDetails } from '../../../src/errors';
79
import { Events } from '../../../src/events';
810
import Hls from '../../../src/hls';
911
import { Fragment } from '../../../src/loader/fragment';
@@ -164,4 +166,147 @@ describe('SubtitleStreamController', function () {
164166
expect(subtitleStreamController.fragPrevious).to.not.exist;
165167
});
166168
});
169+
170+
describe('_handleFragmentLoadProgress', function () {
171+
let sandbox;
172+
173+
beforeEach(function () {
174+
sandbox = sinon.createSandbox();
175+
});
176+
177+
afterEach(function () {
178+
sandbox.restore();
179+
});
180+
181+
function buildEncryptedSubtitleFrag() {
182+
const frag = new Fragment(PlaylistLevelType.SUBTITLE, '');
183+
const decryptdata = {
184+
encrypted: true,
185+
method: 'AES-128',
186+
key: new Uint8Array(16),
187+
iv: new Uint8Array(16),
188+
};
189+
// Override Fragment's `decryptdata` and `encrypted` getters with a
190+
// minimal LevelKey-like stub so we don't need to wire up `levelkeys`.
191+
Object.defineProperty(frag, 'decryptdata', {
192+
value: decryptdata,
193+
configurable: true,
194+
});
195+
Object.defineProperty(frag, 'encrypted', {
196+
value: true,
197+
configurable: true,
198+
});
199+
return frag;
200+
}
201+
202+
function buildPart(frag: Fragment) {
203+
return {
204+
index: 0,
205+
fragment: frag,
206+
start: 0,
207+
duration: 1,
208+
} as any;
209+
}
210+
211+
function flushPromises() {
212+
// Drain the .then -> .catch -> .finally microtask chain before asserting.
213+
let p = Promise.resolve();
214+
for (let i = 0; i < 5; i++) {
215+
p = p.then(() => undefined);
216+
}
217+
return p;
218+
}
219+
220+
function loadProgress(frag, part, payload) {
221+
// fragContextChanged compares frag against fragCurrent; align them so the
222+
// controller does not bail out of the post-decrypt continuation.
223+
subtitleStreamController.fragCurrent = frag;
224+
subtitleStreamController._handleFragmentLoadProgress({
225+
frag,
226+
part,
227+
payload,
228+
networkDetails: null,
229+
});
230+
}
231+
232+
it('does nothing when called without a part (full-segment progress)', function () {
233+
const triggerSpy = sandbox.spy(hls, 'trigger');
234+
const frag = buildEncryptedSubtitleFrag();
235+
loadProgress(frag, null, new ArrayBuffer(16));
236+
expect(triggerSpy).to.not.have.been.calledWith(Events.FRAG_DECRYPTED);
237+
});
238+
239+
it('does nothing when the payload is empty', function () {
240+
const triggerSpy = sandbox.spy(hls, 'trigger');
241+
const frag = buildEncryptedSubtitleFrag();
242+
loadProgress(frag, buildPart(frag), new ArrayBuffer(0));
243+
expect(triggerSpy).to.not.have.been.calledWith(Events.FRAG_DECRYPTED);
244+
});
245+
246+
it('does nothing when the fragment is not encrypted', function () {
247+
const triggerSpy = sandbox.spy(hls, 'trigger');
248+
const frag = new Fragment(PlaylistLevelType.SUBTITLE, '');
249+
loadProgress(frag, buildPart(frag), new ArrayBuffer(16));
250+
expect(triggerSpy).to.not.have.been.calledWith(Events.FRAG_DECRYPTED);
251+
});
252+
253+
it('decrypts an encrypted VTT part and triggers FRAG_DECRYPTED with the part', async function () {
254+
const plaintext = new ArrayBuffer(16);
255+
const decryptStub = sandbox
256+
.stub(Decrypter.prototype, 'decrypt')
257+
.resolves(plaintext);
258+
const triggerSpy = sandbox.spy(hls, 'trigger');
259+
260+
const frag = buildEncryptedSubtitleFrag();
261+
const part = buildPart(frag);
262+
loadProgress(frag, part, new ArrayBuffer(16));
263+
264+
await flushPromises();
265+
266+
expect(decryptStub).to.have.been.calledOnce;
267+
const calls = triggerSpy.getCalls();
268+
let decryptedCall;
269+
for (let i = 0; i < calls.length; i++) {
270+
if (calls[i].args[0] === Events.FRAG_DECRYPTED) {
271+
decryptedCall = calls[i];
272+
break;
273+
}
274+
}
275+
expect(decryptedCall, 'FRAG_DECRYPTED should have been triggered').to
276+
.exist;
277+
expect(decryptedCall.args[1].frag).to.equal(frag);
278+
expect(decryptedCall.args[1].part).to.equal(part);
279+
expect(decryptedCall.args[1].payload).to.equal(plaintext);
280+
});
281+
282+
it('emits ERROR when part decryption fails', async function () {
283+
sandbox.stub(Decrypter.prototype, 'decrypt').rejects(new Error('boom'));
284+
const triggerSpy = sandbox.spy(hls, 'trigger');
285+
286+
const frag = buildEncryptedSubtitleFrag();
287+
const part = buildPart(frag);
288+
loadProgress(frag, part, new ArrayBuffer(16));
289+
290+
await flushPromises();
291+
292+
const calls = triggerSpy.getCalls();
293+
let errorCall;
294+
for (let i = 0; i < calls.length; i++) {
295+
if (
296+
calls[i].args[0] === Events.ERROR &&
297+
calls[i].args[1]?.details === ErrorDetails.FRAG_DECRYPT_ERROR
298+
) {
299+
errorCall = calls[i];
300+
break;
301+
}
302+
}
303+
expect(errorCall, 'a FRAG_DECRYPT_ERROR should have been emitted').to
304+
.exist;
305+
expect(errorCall.args[1].frag).to.equal(frag);
306+
expect(errorCall.args[1].part).to.equal(part);
307+
// Note: `fatal` is intentionally not asserted — the error-controller may
308+
// mutate the event payload to escalate to fatal after our trigger.
309+
expect(triggerSpy).to.not.have.been.calledWith(Events.FRAG_DECRYPTED);
310+
});
311+
});
167312
});

0 commit comments

Comments
 (0)