mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-27 19:03:46 +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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user