mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-27 02:43:48 +02:00
Add support for SAMPLE-AES and SAMPLE-AES-CTR decryption, add "Common Encryption" support to ISOBMFF demuxer, add InputFormatOptions, rewrite ISOBMFF track codec resolution
This commit is contained in:
+10
-2
@@ -28,10 +28,17 @@
|
||||
return;
|
||||
*/
|
||||
|
||||
const manifest = new Mediabunny.Input({
|
||||
source: new Mediabunny.UrlSource('https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8'),
|
||||
const input = new Mediabunny.Input({
|
||||
source: new Mediabunny.UrlSource('https://storage.googleapis.com/shaka-demo-assets/angel-one-widevine-hls/hls.m3u8'),
|
||||
formats: Mediabunny.ALL_FORMATS,
|
||||
});
|
||||
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
const sink = new Mediabunny.EncodedPacketSink(videoTrack);
|
||||
console.log(await sink.getFirstPacket());
|
||||
|
||||
/*
|
||||
return
|
||||
|
||||
const tracks = await manifest.getTracks();
|
||||
console.log(tracks);
|
||||
@@ -64,6 +71,7 @@
|
||||
console.log(await videoTrack.computeDuration({ skipLiveWait: true }));
|
||||
|
||||
manifest.dispose()
|
||||
*/
|
||||
|
||||
/*
|
||||
let last = -Infinity;
|
||||
|
||||
@@ -323,6 +323,48 @@ You can lower `fac` to move playback closer to the live edge (1.5 works fine too
|
||||
|
||||
You can make `fac` larger to increase resilience against flaky internet or an unreliable media producer.
|
||||
|
||||
## Encrypted content
|
||||
|
||||
Some HLS playlists carry encrypted media data. Mediabunny can read data that has been encoded with the following encryption schemes:
|
||||
- `'AES-128'`
|
||||
- `'SAMPLE-AES'`
|
||||
- `'SAMPLE-AES-CTR'`
|
||||
|
||||
---
|
||||
|
||||
`'SAMPLE-AES'` and `'SAMPLE-AES-CTR'` are currently only supported for ISOBMFF media files. They are most commonly seen in DRM-encrypted content (Widevine, FairPlay, etc.) for which Mediabunny has no way of obtaining the decryption keys by itself, since commercial CDMs don't expose keys to userland (that's the whole point of DRM).
|
||||
|
||||
Mediabunny is still able to decrypt the data if you provide it with the decryption keys directly using [`InputOptions.formatOptions.isobmff.resolveKeyId`](../api/IsobmffInputFormatOptions#resolvekeyid). An example:
|
||||
```ts
|
||||
// Maps each key ID to a concrete decryption key
|
||||
const keyMap = new Map([
|
||||
['4d97930a3d7b55fa81d0028653f5e499', '429ec76475e7a952d224d8ef867f12b6'],
|
||||
['d21373c0b8ab5ba9954742bcdfb5f48b', '150a6c7d7dee6a91b74dccfce5b31928'],
|
||||
['6f1729072b4a5cd288c916e11846b89e', 'a84b4bd66901874556093454c075e2c6'],
|
||||
['800aacaa522958ae888062b5695db6bf', '775dbf7289c4cc5847becd571f536ff2'],
|
||||
['67b30c86756f57c5a0a38a23ac8c9178', 'efa2878c2ccf6dd47ab349fcf90e6259'],
|
||||
]);
|
||||
|
||||
using input = new Input({
|
||||
source: new UrlSource(
|
||||
'https://storage.googleapis.com/shaka-demo-assets/angel-one-widevine-hls/hls.m3u8',
|
||||
),
|
||||
formats: ALL_FORMATS,
|
||||
formatOptions: {
|
||||
isobmff: {
|
||||
resolveKeyId: ({ keyId }) => {
|
||||
const key = keyMap.get(keyId);
|
||||
if (!key) {
|
||||
throw new Error('Unknown key ID.');
|
||||
}
|
||||
|
||||
return key;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Subtitles
|
||||
|
||||
Reading subtitles from HLS playlists is not currently supported. Sorry!
|
||||
@@ -45,6 +45,8 @@ export type HlsEncryptionInfo = {
|
||||
keyUri: string;
|
||||
iv: Uint8Array | null;
|
||||
keyFormat: string;
|
||||
} | {
|
||||
method: 'SAMPLE-AES' | 'SAMPLE-AES-CTR';
|
||||
};
|
||||
|
||||
export type HlsSegmentLocation = {
|
||||
@@ -192,7 +194,7 @@ export class HlsSegmentedInput extends SegmentedInput {
|
||||
}
|
||||
|
||||
let key = currentKey;
|
||||
if (key && !key.iv) {
|
||||
if (key && key.method === 'AES-128' && !key.iv) {
|
||||
// "the Media Sequence Number is to be used as the IV when decrypting a Media Segment, by
|
||||
// putting its big-endian binary representation into a 16-octet (128-bit) buffer and padding
|
||||
// (on the left) with zeros"
|
||||
@@ -351,11 +353,31 @@ export class HlsSegmentedInput extends SegmentedInput {
|
||||
}
|
||||
}
|
||||
|
||||
const keyFormat = attributes.get('keyformat') ?? 'identity';
|
||||
if (keyFormat !== 'identity') {
|
||||
throw new Error(
|
||||
'For AES-128 encryption, only the \'identity\' KEYFORMAT is currently supported. If you'
|
||||
+ ' think other formats should be supported, please raise an issue.',
|
||||
);
|
||||
}
|
||||
|
||||
currentKey = {
|
||||
method: 'AES-128',
|
||||
keyUri: joinPaths(this.path, uri),
|
||||
iv,
|
||||
keyFormat: attributes.get('keyformat') ?? 'identity',
|
||||
keyFormat,
|
||||
};
|
||||
} else if (method === 'SAMPLE-AES' || method === 'SAMPLE-AES-CTR') {
|
||||
const keyFormat = attributes.get('keyformat') ?? 'identity';
|
||||
if (keyFormat === 'identity') {
|
||||
throw new Error(
|
||||
'For SAMPLE-AES and SAMPLE-AES-CTR encryption, the \'identity\' KEYFORMAT is not'
|
||||
+ ' supported. If you think this format should be supported, please raise an issue.',
|
||||
);
|
||||
}
|
||||
|
||||
currentKey = {
|
||||
method,
|
||||
};
|
||||
} else {
|
||||
throw new Error(
|
||||
@@ -425,7 +447,8 @@ export class HlsSegmentedInput extends SegmentedInput {
|
||||
accumulatedTime = dateTimeSeconds; // Snap the accumulated time to the datetime
|
||||
} else if (line === TAG_DISCONTINUITY) {
|
||||
currentFirstSegment = null;
|
||||
currentInitSegment = null;
|
||||
// Note: the init segment is not reset; the #EXT-X-MAP statement simply lasts until the next
|
||||
// #EXT-X-MAP statement.
|
||||
} else if (line.startsWith(TAG_TARGETDURATION)) {
|
||||
const value = line.slice(TAG_TARGETDURATION.length);
|
||||
const duration = Number(value);
|
||||
@@ -548,7 +571,11 @@ export class HlsSegmentedInput extends SegmentedInput {
|
||||
let ref: SourceRef;
|
||||
const needsSlice = hlsSegment.location.offset > 0 || hlsSegment.location.length !== null;
|
||||
|
||||
if (!hlsSegment.encryption) {
|
||||
if (
|
||||
!hlsSegment.encryption
|
||||
|| hlsSegment.encryption.method === 'SAMPLE-AES'
|
||||
|| hlsSegment.encryption.method === 'SAMPLE-AES-CTR'
|
||||
) {
|
||||
ref = await this.input._getSourceCached(request);
|
||||
|
||||
if (needsSlice) {
|
||||
@@ -560,8 +587,9 @@ export class HlsSegmentedInput extends SegmentedInput {
|
||||
ref.free();
|
||||
ref = sliceRef;
|
||||
}
|
||||
} else {
|
||||
assert(hlsSegment.encryption.iv);
|
||||
} else if (hlsSegment.encryption.method === 'AES-128') {
|
||||
const encryption = hlsSegment.encryption;
|
||||
assert(encryption.iv);
|
||||
|
||||
let ciphertextRef = await this.input._getSourceCached(request);
|
||||
if (needsSlice) {
|
||||
@@ -579,7 +607,7 @@ export class HlsSegmentedInput extends SegmentedInput {
|
||||
|
||||
const stream = createAes128CbcDecryptStream(ciphertextReader, async () => {
|
||||
using keyRef = await this.input._getSourceCached(
|
||||
{ path: hlsSegment.encryption!.keyUri, isRoot: false },
|
||||
{ path: encryption.keyUri, isRoot: false },
|
||||
ENCRYPTION_KEY_CACHE_GROUP,
|
||||
);
|
||||
const keyReader = new Reader(keyRef.source);
|
||||
@@ -589,22 +617,41 @@ export class HlsSegmentedInput extends SegmentedInput {
|
||||
}
|
||||
const key = readBytes(keySlice, AES_128_BLOCK_SIZE);
|
||||
|
||||
return { key, iv: hlsSegment.encryption!.iv! };
|
||||
return { key, iv: encryption.iv! };
|
||||
}, () => {
|
||||
ciphertextRef.free();
|
||||
});
|
||||
|
||||
ref = new ReadableStreamSource(stream).ref();
|
||||
} else {
|
||||
assert(false);
|
||||
}
|
||||
|
||||
return ref!;
|
||||
return ref;
|
||||
},
|
||||
),
|
||||
// Do not allow recursive HLS. Cool on paper, but allows for nasty infinite-depth request trees.
|
||||
formats: this.input._formats.filter(x => !(x instanceof HlsInputFormat)),
|
||||
initInput: initInput ?? undefined,
|
||||
formatOptions: this.input._formatOptions,
|
||||
});
|
||||
|
||||
input._onFormatDetermined = (format) => {
|
||||
if (
|
||||
(hlsSegment.encryption?.method === 'SAMPLE-AES' || hlsSegment.encryption?.method === 'SAMPLE-AES-CTR')
|
||||
&& !format._isIsobmff
|
||||
) {
|
||||
// These methods can also be used for formats such as MPEG-TS
|
||||
// eslint-disable-next-line @stylistic/max-len
|
||||
// (see https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/HLS_Sample_Encryption/Encryption/Encryption.html)
|
||||
// but we don't support them there yet, so instead of silently decrypting nothing, we throw an error.
|
||||
throw new Error(
|
||||
'The SAMPLE-AES and SAMPLE-AES-CTR encryption methods are currently only supported for'
|
||||
+ ' ISOBMFF files.',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
this.inputCache.push({
|
||||
segment: hlsSegment,
|
||||
input,
|
||||
|
||||
@@ -179,9 +179,11 @@ export {
|
||||
} from './source';
|
||||
export {
|
||||
InputFormat,
|
||||
InputFormatOptions,
|
||||
AdtsInputFormat,
|
||||
FlacInputFormat,
|
||||
IsobmffInputFormat,
|
||||
IsobmffInputFormatOptions,
|
||||
HlsInputFormat,
|
||||
MatroskaInputFormat,
|
||||
Mp3InputFormat,
|
||||
|
||||
@@ -35,6 +35,7 @@ import { TS_PACKET_SIZE } from './mpeg-ts/mpeg-ts-misc';
|
||||
import { HlsDemuxer } from './hls/hls-demuxer';
|
||||
import { HLS_MIME_TYPE } from './hls/hls-misc';
|
||||
import { PathedSource } from './source';
|
||||
import { MaybePromise } from './misc';
|
||||
|
||||
/**
|
||||
* Base class representing an input media file format.
|
||||
@@ -52,6 +53,12 @@ export abstract class InputFormat {
|
||||
abstract get name(): string;
|
||||
/** Returns the typical base MIME type of the input format. */
|
||||
abstract get mimeType(): string;
|
||||
|
||||
/**
|
||||
* Provided for tree-shakable checking.
|
||||
* @internal
|
||||
*/
|
||||
_isIsobmff = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,6 +94,9 @@ export abstract class IsobmffInputFormat extends InputFormat {
|
||||
_createDemuxer(input: Input) {
|
||||
return new IsobmffDemuxer(input);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
override _isIsobmff = true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -702,3 +712,45 @@ export const ALL_FORMATS: InputFormat[] = [HLS, MP4, QTFF, MATROSKA, WEBM, WAVE,
|
||||
* @public
|
||||
*/
|
||||
export const HLS_FORMATS: InputFormat[] = [HLS, MP4, QTFF, MP3, ADTS, MPEG_TS];
|
||||
|
||||
/**
|
||||
* Additional per-format configuration.
|
||||
* @group Input formats
|
||||
* @public
|
||||
*/
|
||||
export type InputFormatOptions = {
|
||||
/** ISOBMFF-specific configuration. */
|
||||
isobmff?: IsobmffInputFormatOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Additional ISOBMFF input configuration.
|
||||
* @group Input formats
|
||||
* @public
|
||||
*/
|
||||
export type IsobmffInputFormatOptions = {
|
||||
/**
|
||||
* A callback that gets invoked for each key ID required for sample content decryption. The key ID is provided as a
|
||||
* 32-character lowercase hexadecimal string.
|
||||
*
|
||||
* Must return or resolve to a 32-character hexadecimal string or a 16-byte `Uint8Array`.
|
||||
*/
|
||||
resolveKeyId?: (options: {
|
||||
/** The key ID that is to be resolved to a key. This is a 32-character lowercase hexadecimal string. */
|
||||
keyId: string;
|
||||
}) => MaybePromise<Uint8Array | string>;
|
||||
};
|
||||
|
||||
export const validateInputFormatOptions = (options: InputFormatOptions, prefix: string) => {
|
||||
if (!options || typeof options !== 'object') {
|
||||
throw new TypeError(`${prefix}, when provided, must be an object.`);
|
||||
}
|
||||
if (options.isobmff !== undefined) {
|
||||
if (!options.isobmff || typeof options.isobmff !== 'object') {
|
||||
throw new TypeError(`${prefix}.isobmff, when provided, must be an object.`);
|
||||
}
|
||||
if (options.isobmff.resolveKeyId !== undefined && typeof options.isobmff.resolveKeyId !== 'function') {
|
||||
throw new TypeError(`${prefix}.isobmff.resolveKeyId, when provided, must be a function.`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
+15
-1
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { Demuxer, DurationMetadataRequestOptions } from './demuxer';
|
||||
import { InputFormat } from './input-format';
|
||||
import { InputFormat, InputFormatOptions, validateInputFormatOptions } from './input-format';
|
||||
import {
|
||||
InputAudioTrack,
|
||||
InputAudioTrackBacking,
|
||||
@@ -74,6 +74,9 @@ export type InputOptions<S extends Source = Source> = {
|
||||
* The use of this field depends on the input format.
|
||||
*/
|
||||
initInput?: Input;
|
||||
|
||||
/** Can be used to specify additional per-format configuration. */
|
||||
formatOptions?: InputFormatOptions;
|
||||
};
|
||||
|
||||
type SourceCacheEntry = {
|
||||
@@ -141,6 +144,11 @@ export class Input<S extends Source = Source> extends EventEmitter<InputEvents>
|
||||
promise: Promise<SourceCacheEntry>;
|
||||
}[] = [];
|
||||
|
||||
/** @internal */
|
||||
_formatOptions: InputFormatOptions;
|
||||
/** @internal */
|
||||
_onFormatDetermined: ((format: InputFormat) => void) | null = null;
|
||||
|
||||
/** True if the input has been disposed. */
|
||||
get disposed() {
|
||||
return this._disposed;
|
||||
@@ -168,9 +176,13 @@ export class Input<S extends Source = Source> extends EventEmitter<InputEvents>
|
||||
if (options.initInput !== undefined && !(options.initInput instanceof Input)) {
|
||||
throw new TypeError('options.initInput, when provided, must be an Input.');
|
||||
}
|
||||
if (options.formatOptions !== undefined) {
|
||||
validateInputFormatOptions(options.formatOptions, 'formatOptions');
|
||||
}
|
||||
|
||||
this._formats = options.formats;
|
||||
this._initInput = options.initInput ?? null;
|
||||
this._formatOptions = options.formatOptions ?? {};
|
||||
|
||||
if (options.source instanceof Source) {
|
||||
this._rootRef = options.source.ref();
|
||||
@@ -277,6 +289,8 @@ export class Input<S extends Source = Source> extends EventEmitter<InputEvents>
|
||||
const canRead = await format._canReadInput(this);
|
||||
if (canRead) {
|
||||
this._format = format;
|
||||
this._onFormatDetermined?.(format);
|
||||
|
||||
return format._createDemuxer(this);
|
||||
}
|
||||
}
|
||||
|
||||
+757
-182
File diff suppressed because it is too large
Load Diff
+11
@@ -199,10 +199,21 @@ export class AsyncMutex {
|
||||
}
|
||||
}
|
||||
|
||||
export const HEX_STRING_REGEX = /^[0-9a-fA-F]+$/;
|
||||
|
||||
export const bytesToHexString = (bytes: Uint8Array) => {
|
||||
return [...bytes].map(x => x.toString(16).padStart(2, '0')).join('');
|
||||
};
|
||||
|
||||
export const hexStringToBytes = (hexString: string) => {
|
||||
assert(hexString.length % 2 === 0);
|
||||
const bytes = new Uint8Array(hexString.length / 2);
|
||||
for (let i = 0; i < hexString.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hexString.slice(i, i + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
|
||||
export const reverseBitsU32 = (x: number): number => {
|
||||
x = ((x >> 1) & 0x55555555) | ((x & 0x55555555) << 1);
|
||||
x = ((x >> 2) & 0x33333333) | ((x & 0x33333333) << 2);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ALL_FORMATS, BufferSource, EncodedPacketSink, Input, InputAudioTrack, InputVideoTrack, UrlSource } from 'mediabunny';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
import { HLS, HLS_FORMATS, HlsInputFormat } from '../../src/input-format.js';
|
||||
import { assert, rejectAfter } from '../../src/misc.js';
|
||||
import { assert, hexStringToBytes, rejectAfter } from '../../src/misc.js';
|
||||
import { CustomPathedSource } from '../../src/source.js';
|
||||
|
||||
// A lot of test cases taken from:
|
||||
@@ -869,3 +869,82 @@ root.m3u8
|
||||
|
||||
await expect(input.getTracks()).rejects.toThrow('unsupported');
|
||||
});
|
||||
|
||||
test.concurrent.only('Widevine encryption (SAMPLE-AES-CTR) fails without keys', async () => {
|
||||
using input = new Input({
|
||||
source: new UrlSource('https://storage.googleapis.com/shaka-demo-assets/angel-one-widevine-hls/hls.m3u8'),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
assert(videoTrack);
|
||||
|
||||
const sink = new EncodedPacketSink(videoTrack);
|
||||
await expect(sink.getPacket(Infinity)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test.concurrent.only('Widevine encryption (SAMPLE-AES-CTR) succeeds with string keys', async () => {
|
||||
const keyMap = new Map([
|
||||
['4d97930a3d7b55fa81d0028653f5e499', '429ec76475e7a952d224d8ef867f12b6'],
|
||||
['d21373c0b8ab5ba9954742bcdfb5f48b', '150a6c7d7dee6a91b74dccfce5b31928'],
|
||||
['6f1729072b4a5cd288c916e11846b89e', 'a84b4bd66901874556093454c075e2c6'],
|
||||
['800aacaa522958ae888062b5695db6bf', '775dbf7289c4cc5847becd571f536ff2'],
|
||||
['67b30c86756f57c5a0a38a23ac8c9178', 'efa2878c2ccf6dd47ab349fcf90e6259'],
|
||||
]);
|
||||
|
||||
using input = new Input({
|
||||
source: new UrlSource('https://storage.googleapis.com/shaka-demo-assets/angel-one-widevine-hls/hls.m3u8'),
|
||||
formats: ALL_FORMATS,
|
||||
formatOptions: {
|
||||
isobmff: {
|
||||
resolveKeyId: ({ keyId }) => {
|
||||
const key = keyMap.get(keyId);
|
||||
assert(key);
|
||||
|
||||
return key;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
assert(videoTrack);
|
||||
|
||||
const sink = new EncodedPacketSink(videoTrack);
|
||||
const lastPacket = await sink.getPacket(Infinity);
|
||||
assert(lastPacket);
|
||||
expect(lastPacket.timestamp + lastPacket.duration).toBe(60);
|
||||
});
|
||||
|
||||
test.concurrent.only('Widevine encryption (SAMPLE-AES-CTR) succeeds with buffer keys', async () => {
|
||||
const keyMap = new Map([
|
||||
['4d97930a3d7b55fa81d0028653f5e499', hexStringToBytes('429ec76475e7a952d224d8ef867f12b6')],
|
||||
['d21373c0b8ab5ba9954742bcdfb5f48b', hexStringToBytes('150a6c7d7dee6a91b74dccfce5b31928')],
|
||||
['6f1729072b4a5cd288c916e11846b89e', hexStringToBytes('a84b4bd66901874556093454c075e2c6')],
|
||||
['800aacaa522958ae888062b5695db6bf', hexStringToBytes('775dbf7289c4cc5847becd571f536ff2')],
|
||||
['67b30c86756f57c5a0a38a23ac8c9178', hexStringToBytes('efa2878c2ccf6dd47ab349fcf90e6259')],
|
||||
]);
|
||||
|
||||
using input = new Input({
|
||||
source: new UrlSource('https://storage.googleapis.com/shaka-demo-assets/angel-one-widevine-hls/hls.m3u8'),
|
||||
formats: ALL_FORMATS,
|
||||
formatOptions: {
|
||||
isobmff: {
|
||||
resolveKeyId: ({ keyId }) => {
|
||||
const key = keyMap.get(keyId);
|
||||
assert(key);
|
||||
|
||||
return key;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
assert(videoTrack);
|
||||
|
||||
const sink = new EncodedPacketSink(videoTrack);
|
||||
const lastPacket = await sink.getPacket(Infinity);
|
||||
assert(lastPacket);
|
||||
expect(lastPacket.timestamp + lastPacket.duration).toBe(60);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user