shifro 1.1.4 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -1,27 +1,428 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from "node:util";
3
- import { ALL_FORMATS, Conversion, FilePathSource, FilePathTarget, Input, Mp4OutputFormat, Output } from "mediabunny";
3
+ import { ALL_FORMATS, Conversion, EncodedPacket, FilePathSource, FilePathTarget, Input, Mp4OutputFormat, Output } from "mediabunny";
4
4
  //#region package.json
5
5
  var name = "shifro";
6
- var version = "1.1.4";
6
+ var version = "1.2.0";
7
7
  var author = "Vitaly Gashkov <vitalygashkov@vk.com>";
8
8
  //#endregion
9
+ //#region src/mediabunny-isobmff.ts
10
+ const binarySearchLessOrEqual = (items, needle, getValue) => {
11
+ let low = 0;
12
+ let high = items.length - 1;
13
+ let result = -1;
14
+ while (low <= high) {
15
+ const middle = low + Math.floor((high - low) / 2);
16
+ if (getValue(items[middle]) <= needle) {
17
+ result = middle;
18
+ low = middle + 1;
19
+ } else high = middle - 1;
20
+ }
21
+ return result;
22
+ };
23
+ const binarySearchExact = (items, needle, getValue) => {
24
+ let low = 0;
25
+ let high = items.length - 1;
26
+ while (low <= high) {
27
+ const middle = low + Math.floor((high - low) / 2);
28
+ const value = getValue(items[middle]);
29
+ if (value === needle) return middle;
30
+ if (value < needle) low = middle + 1;
31
+ else high = middle - 1;
32
+ }
33
+ return -1;
34
+ };
35
+ const readBytes = (slice, length) => {
36
+ const bytes = slice.bytes.slice(slice.bufferPos, slice.bufferPos + length);
37
+ slice.bufferPos += length;
38
+ return bytes;
39
+ };
40
+ const readU16Be = (slice) => {
41
+ const value = slice.view.getUint16(slice.bufferPos, false);
42
+ slice.bufferPos += 2;
43
+ return value;
44
+ };
45
+ const readU32Be = (slice) => {
46
+ const value = slice.view.getUint32(slice.bufferPos, false);
47
+ slice.bufferPos += 4;
48
+ return value;
49
+ };
50
+ const readSampleBytes = async (internalTrack, byteOffset, byteLength) => {
51
+ let slice = internalTrack.demuxer.reader.requestSlice(byteOffset, byteLength);
52
+ if (slice instanceof Promise) slice = await slice;
53
+ if (!slice) throw new Error("Failed to read encrypted sample bytes.");
54
+ return readBytes(slice, byteLength);
55
+ };
56
+ const resolveEncryptionAuxInfo = async (internalTrack, auxInfo) => {
57
+ if (auxInfo.resolved) return auxInfo.resolved;
58
+ const encryptionInfo = internalTrack.encryptionInfo;
59
+ if (!encryptionInfo || encryptionInfo.defaultPerSampleIvSize === null) throw new Error("Missing track encryption info.");
60
+ if (auxInfo.offset === null || auxInfo.sampleCount === 0) throw new Error("Incomplete saiz/saio info; cannot resolve encryption data.");
61
+ if (auxInfo.defaultSampleInfoSize === 0 && !auxInfo.sampleSizes) throw new Error("Invalid auxiliary encryption info: auxInfo.sampleSizes is required when auxInfo.defaultSampleInfoSize is 0.");
62
+ let totalSize = 0;
63
+ if (auxInfo.defaultSampleInfoSize > 0) totalSize = auxInfo.defaultSampleInfoSize * auxInfo.sampleCount;
64
+ else if (auxInfo.sampleSizes) for (const size of auxInfo.sampleSizes) totalSize += size;
65
+ let slice = internalTrack.demuxer.reader.requestSlice(auxInfo.offset, totalSize);
66
+ if (slice instanceof Promise) slice = await slice;
67
+ if (!slice) throw new Error("Failed to read auxiliary encryption info.");
68
+ const entries = [];
69
+ const ivSize = encryptionInfo.defaultPerSampleIvSize;
70
+ for (let i = 0; i < auxInfo.sampleCount; i++) {
71
+ const entrySize = auxInfo.defaultSampleInfoSize > 0 ? auxInfo.defaultSampleInfoSize : auxInfo.sampleSizes?.[i] ?? 0;
72
+ const iv = new Uint8Array(16);
73
+ if (ivSize > 0) iv.set(readBytes(slice, ivSize), 0);
74
+ else if (encryptionInfo.defaultConstantIv) iv.set(encryptionInfo.defaultConstantIv, 0);
75
+ let subsamples = null;
76
+ if (entrySize > ivSize) {
77
+ const subsampleCount = readU16Be(slice);
78
+ subsamples = [];
79
+ for (let j = 0; j < subsampleCount; j++) subsamples.push({
80
+ clearLen: readU16Be(slice),
81
+ protectedLen: readU32Be(slice)
82
+ });
83
+ }
84
+ entries.push({
85
+ iv,
86
+ subsamples
87
+ });
88
+ }
89
+ auxInfo.resolved = entries;
90
+ return entries;
91
+ };
92
+ const getSampleInfo = (sampleTable, sampleIndex) => {
93
+ const timingEntryIndex = binarySearchLessOrEqual(sampleTable.sampleTimingEntries, sampleIndex, (entry) => entry.startIndex);
94
+ const timingEntry = sampleTable.sampleTimingEntries[timingEntryIndex];
95
+ if (!timingEntry || timingEntry.startIndex + timingEntry.count <= sampleIndex) return null;
96
+ let presentationTimestamp = timingEntry.startDecodeTimestamp + (sampleIndex - timingEntry.startIndex) * timingEntry.delta;
97
+ const offsetEntryIndex = binarySearchLessOrEqual(sampleTable.sampleCompositionTimeOffsets, sampleIndex, (entry) => entry.startIndex);
98
+ const offsetEntry = sampleTable.sampleCompositionTimeOffsets[offsetEntryIndex];
99
+ if (offsetEntry && sampleIndex - offsetEntry.startIndex < offsetEntry.count) presentationTimestamp += offsetEntry.offset;
100
+ const sampleSize = sampleTable.sampleSizes[Math.min(sampleIndex, sampleTable.sampleSizes.length - 1)];
101
+ if (sampleSize === void 0) return null;
102
+ const chunkEntryIndex = binarySearchLessOrEqual(sampleTable.sampleToChunk, sampleIndex, (entry) => entry.startSampleIndex);
103
+ const chunkEntry = sampleTable.sampleToChunk[chunkEntryIndex];
104
+ if (!chunkEntry) return null;
105
+ const chunkIndex = chunkEntry.startChunkIndex + Math.floor((sampleIndex - chunkEntry.startSampleIndex) / chunkEntry.samplesPerChunk);
106
+ const chunkOffset = sampleTable.chunkOffsets[chunkIndex];
107
+ if (chunkOffset === void 0) return null;
108
+ const startSampleIndexOfChunk = chunkEntry.startSampleIndex + (chunkIndex - chunkEntry.startChunkIndex) * chunkEntry.samplesPerChunk;
109
+ let chunkSize = 0;
110
+ let sampleOffset = chunkOffset;
111
+ if (sampleTable.sampleSizes.length === 1) {
112
+ sampleOffset += sampleSize * (sampleIndex - startSampleIndexOfChunk);
113
+ chunkSize += sampleSize * chunkEntry.samplesPerChunk;
114
+ } else for (let i = startSampleIndexOfChunk; i < startSampleIndexOfChunk + chunkEntry.samplesPerChunk; i++) {
115
+ const currentSampleSize = sampleTable.sampleSizes[i];
116
+ if (currentSampleSize === void 0) return null;
117
+ if (i < sampleIndex) sampleOffset += currentSampleSize;
118
+ chunkSize += currentSampleSize;
119
+ }
120
+ let duration = timingEntry.delta;
121
+ if (sampleTable.presentationTimestamps && sampleTable.presentationTimestampIndexMap) {
122
+ const presentationIndex = sampleTable.presentationTimestampIndexMap[sampleIndex];
123
+ if (presentationIndex !== void 0 && presentationIndex < sampleTable.presentationTimestamps.length - 1) {
124
+ const nextEntry = sampleTable.presentationTimestamps[presentationIndex + 1];
125
+ if (nextEntry) duration = nextEntry.presentationTimestamp - presentationTimestamp;
126
+ }
127
+ }
128
+ const isKeyFrame = sampleTable.keySampleIndices ? binarySearchExact(sampleTable.keySampleIndices, sampleIndex, (value) => value) !== -1 : true;
129
+ return {
130
+ presentationTimestamp,
131
+ duration,
132
+ sampleOffset,
133
+ sampleSize,
134
+ chunkOffset,
135
+ chunkSize,
136
+ isKeyFrame
137
+ };
138
+ };
139
+ const psshBoxesAreEqual = (left, right) => {
140
+ if (left.systemId !== right.systemId) return false;
141
+ if (left.data.byteLength !== right.data.byteLength) return false;
142
+ for (let i = 0; i < left.data.byteLength; i++) if (left.data[i] !== right.data[i]) return false;
143
+ return true;
144
+ };
145
+ const collectCryptRanges = (subsamples, cryptByteBlock, skipByteBlock) => {
146
+ const ranges = [];
147
+ const hasPattern = cryptByteBlock !== 0 || skipByteBlock !== 0;
148
+ let cursor = 0;
149
+ for (const subsample of subsamples) {
150
+ cursor += subsample.clearLen;
151
+ if (!hasPattern) {
152
+ if (subsample.protectedLen > 0) ranges.push({
153
+ offset: cursor,
154
+ length: subsample.protectedLen
155
+ });
156
+ cursor += subsample.protectedLen;
157
+ continue;
158
+ }
159
+ let remaining = subsample.protectedLen;
160
+ let position = cursor;
161
+ while (remaining > 0) {
162
+ if (remaining < 16 * cryptByteBlock) break;
163
+ const cryptLength = 16 * cryptByteBlock;
164
+ ranges.push({
165
+ offset: position,
166
+ length: cryptLength
167
+ });
168
+ position += cryptLength;
169
+ remaining -= cryptLength;
170
+ const skipLength = Math.min(16 * skipByteBlock, remaining);
171
+ position += skipLength;
172
+ remaining -= skipLength;
173
+ }
174
+ cursor += subsample.protectedLen;
175
+ }
176
+ return ranges;
177
+ };
178
+ const cloneSubsamples = (subsamples) => {
179
+ if (!subsamples) return null;
180
+ return subsamples.map((subsample) => ({
181
+ clearLen: subsample.clearLen,
182
+ protectedLen: subsample.protectedLen
183
+ }));
184
+ };
185
+ const getPattern = (encryptionInfo) => {
186
+ const cryptByteBlock = encryptionInfo.defaultCryptByteBlock ?? 0;
187
+ const skipByteBlock = encryptionInfo.defaultSkipByteBlock ?? 0;
188
+ if (cryptByteBlock === 0 && skipByteBlock === 0) return null;
189
+ return {
190
+ cryptByteBlock,
191
+ skipByteBlock
192
+ };
193
+ };
194
+ const createProtectedDataLayout = (data, encryptionInfo, sampleEncryption) => {
195
+ if (!sampleEncryption.subsamples) return {
196
+ data,
197
+ merge: (decryptedData) => new Uint8Array(decryptedData)
198
+ };
199
+ if (encryptionInfo.scheme === "cbcs") return null;
200
+ const cryptRanges = collectCryptRanges(sampleEncryption.subsamples, encryptionInfo.defaultCryptByteBlock ?? 0, encryptionInfo.defaultSkipByteBlock ?? 0);
201
+ let totalCryptLength = 0;
202
+ for (const range of cryptRanges) totalCryptLength += range.length;
203
+ const protectedData = new Uint8Array(totalCryptLength);
204
+ let writeOffset = 0;
205
+ for (const range of cryptRanges) {
206
+ protectedData.set(data.subarray(range.offset, range.offset + range.length), writeOffset);
207
+ writeOffset += range.length;
208
+ }
209
+ return {
210
+ data: protectedData,
211
+ merge: (decryptedData) => {
212
+ const merged = new Uint8Array(data);
213
+ let readOffset = 0;
214
+ for (const range of cryptRanges) {
215
+ merged.set(decryptedData.subarray(readOffset, readOffset + range.length), range.offset);
216
+ readOffset += range.length;
217
+ }
218
+ return merged;
219
+ }
220
+ };
221
+ };
222
+ const isUint8Array = (value) => value instanceof Uint8Array;
223
+ const isIsobmffTrackBacking = (backing) => {
224
+ if (!backing || typeof backing !== "object") return false;
225
+ const maybeBacking = backing;
226
+ const internalTrack = maybeBacking.internalTrack;
227
+ return Boolean(internalTrack && internalTrack.demuxer && typeof internalTrack.demuxer.getSampleTableForTrack === "function" && maybeBacking.packetToSampleIndex instanceof WeakMap && maybeBacking.packetToFragmentLocation instanceof WeakMap);
228
+ };
229
+ const getTrackEncryptionInfo = (backing) => backing.internalTrack.encryptionInfo;
230
+ const getKeyId = (backing) => backing.internalTrack.encryptionInfo?.defaultKid ?? null;
231
+ const getEncryptedSample = async (backing, packet) => {
232
+ const internalTrack = backing.internalTrack;
233
+ const fragmentLocation = backing.packetToFragmentLocation.get(packet);
234
+ if (fragmentLocation) {
235
+ const fragment = fragmentLocation.fragment;
236
+ const sample = fragment.trackData.get(internalTrack.id)?.samples[fragmentLocation.sampleIndex];
237
+ if (!sample?.encryption) return null;
238
+ return {
239
+ data: await readSampleBytes(internalTrack, sample.byteOffset, sample.byteSize),
240
+ sampleEncryption: sample.encryption,
241
+ fragment
242
+ };
243
+ }
244
+ const sampleIndex = backing.packetToSampleIndex.get(packet);
245
+ if (sampleIndex === void 0) return null;
246
+ const sampleInfo = getSampleInfo(internalTrack.demuxer.getSampleTableForTrack(internalTrack), sampleIndex);
247
+ if (!sampleInfo) return null;
248
+ const auxInfo = internalTrack.encryptionAuxInfo;
249
+ if (!auxInfo) return null;
250
+ const sampleEncryption = (await resolveEncryptionAuxInfo(internalTrack, auxInfo))[sampleIndex];
251
+ if (!sampleEncryption) return null;
252
+ return {
253
+ data: await readSampleBytes(internalTrack, sampleInfo.sampleOffset, sampleInfo.sampleSize),
254
+ sampleEncryption,
255
+ fragment: null
256
+ };
257
+ };
258
+ const getPsshBoxes = (backing, keyId, fragment) => {
259
+ const { psshBoxes } = backing.internalTrack.demuxer;
260
+ if (!fragment) return psshBoxes;
261
+ const boxes = [...psshBoxes, ...fragment.psshBoxes].filter((psshBox) => psshBox.keyIds === null || psshBox.keyIds.includes(keyId));
262
+ for (let i = 0; i < boxes.length - 1; i++) for (let j = i + 1; j < boxes.length; j++) if (psshBoxesAreEqual(boxes[i], boxes[j])) {
263
+ boxes.splice(j, 1);
264
+ j--;
265
+ }
266
+ return boxes;
267
+ };
268
+ //#endregion
269
+ //#region src/isobmff-decrypt-sample-patcher.ts
270
+ const createMetadataOnlyOptions = (options) => ({
271
+ ...options,
272
+ metadataOnly: true,
273
+ verifyKeyPackets: false
274
+ });
275
+ var IsobmffDecryptSamplePatcher = class {
276
+ input;
277
+ decryptSample;
278
+ decryptProtectedData;
279
+ reportEncryptionInfo;
280
+ patchableTrackBackings = /* @__PURE__ */ new WeakSet();
281
+ patchTrackBackingsPromise = null;
282
+ constructor(input, decryptSample, decryptProtectedData, reportEncryptionInfo) {
283
+ this.input = input;
284
+ this.decryptSample = decryptSample;
285
+ this.decryptProtectedData = decryptProtectedData;
286
+ this.reportEncryptionInfo = reportEncryptionInfo;
287
+ }
288
+ async ensurePatched() {
289
+ this.patchTrackBackingsPromise ??= (async () => {
290
+ const tracks = await this.input.getTracks();
291
+ for (const track of tracks) {
292
+ const backing = track._backing;
293
+ if (!isIsobmffTrackBacking(backing) || this.patchableTrackBackings.has(backing)) continue;
294
+ this.patchableTrackBackings.add(backing);
295
+ this.patchTrackBacking(backing);
296
+ }
297
+ })();
298
+ await this.patchTrackBackingsPromise;
299
+ }
300
+ patchTrackBacking(backing) {
301
+ const packetToMetadataPacket = /* @__PURE__ */ new WeakMap();
302
+ const getOriginalPacket = (packet) => packetToMetadataPacket.get(packet) ?? packet;
303
+ const getFirstPacket = backing.getFirstPacket.bind(backing);
304
+ backing.getFirstPacket = async (options) => {
305
+ if (!this.shouldUseCustomDecryptSample(backing, options)) return getFirstPacket(options);
306
+ const metadataPacket = await getFirstPacket(createMetadataOnlyOptions(options));
307
+ return this.materializePacket(backing, metadataPacket, () => getFirstPacket(options), packetToMetadataPacket);
308
+ };
309
+ const getPacket = backing.getPacket.bind(backing);
310
+ backing.getPacket = async (timestamp, options) => {
311
+ if (!this.shouldUseCustomDecryptSample(backing, options)) return getPacket(timestamp, options);
312
+ const metadataPacket = await getPacket(timestamp, createMetadataOnlyOptions(options));
313
+ return this.materializePacket(backing, metadataPacket, () => getPacket(timestamp, options), packetToMetadataPacket);
314
+ };
315
+ const getNextPacket = backing.getNextPacket.bind(backing);
316
+ backing.getNextPacket = async (packet, options) => {
317
+ const originalPacket = getOriginalPacket(packet);
318
+ if (!this.shouldUseCustomDecryptSample(backing, options)) return getNextPacket(originalPacket, options);
319
+ const metadataPacket = await getNextPacket(originalPacket, createMetadataOnlyOptions(options));
320
+ return this.materializePacket(backing, metadataPacket, () => getNextPacket(originalPacket, options), packetToMetadataPacket);
321
+ };
322
+ const getKeyPacket = backing.getKeyPacket.bind(backing);
323
+ backing.getKeyPacket = async (timestamp, options) => {
324
+ if (!this.shouldUseCustomDecryptSample(backing, options)) return getKeyPacket(timestamp, options);
325
+ const metadataPacket = await getKeyPacket(timestamp, createMetadataOnlyOptions(options));
326
+ return this.materializePacket(backing, metadataPacket, () => getKeyPacket(timestamp, options), packetToMetadataPacket);
327
+ };
328
+ const getNextKeyPacket = backing.getNextKeyPacket.bind(backing);
329
+ backing.getNextKeyPacket = async (packet, options) => {
330
+ const originalPacket = getOriginalPacket(packet);
331
+ if (!this.shouldUseCustomDecryptSample(backing, options)) return getNextKeyPacket(originalPacket, options);
332
+ const metadataPacket = await getNextKeyPacket(originalPacket, createMetadataOnlyOptions(options));
333
+ return this.materializePacket(backing, metadataPacket, () => getNextKeyPacket(originalPacket, options), packetToMetadataPacket);
334
+ };
335
+ }
336
+ shouldUseCustomDecryptSample(backing, options) {
337
+ return Boolean(!options.metadataOnly && getTrackEncryptionInfo(backing)?.defaultIsProtected !== false);
338
+ }
339
+ async materializePacket(backing, metadataPacket, fallback, packetToMetadataPacket) {
340
+ if (!metadataPacket) return null;
341
+ const decryptedPacket = await this.decryptPacket(backing, metadataPacket, fallback);
342
+ if (decryptedPacket && decryptedPacket !== metadataPacket) packetToMetadataPacket.set(decryptedPacket, metadataPacket);
343
+ return decryptedPacket;
344
+ }
345
+ async decryptPacket(backing, metadataPacket, fallback) {
346
+ const encryptionInfo = getTrackEncryptionInfo(backing);
347
+ if (!encryptionInfo?.defaultIsProtected) return fallback();
348
+ const keyId = getKeyId(backing);
349
+ if (!keyId) throw new Error("Encrypted sample encountered without a key ID.");
350
+ const encryptedSample = await getEncryptedSample(backing, metadataPacket);
351
+ if (!encryptedSample) return fallback();
352
+ const psshBoxes = getPsshBoxes(backing, keyId, encryptedSample.fragment);
353
+ this.reportEncryptionInfo(keyId, psshBoxes);
354
+ const decryptedData = this.decryptSample ? await this.decryptSample({
355
+ data: encryptedSample.data,
356
+ keyId,
357
+ psshBoxes,
358
+ scheme: encryptionInfo.scheme,
359
+ iv: encryptedSample.sampleEncryption.iv.slice(),
360
+ timestamp: metadataPacket.microsecondTimestamp,
361
+ subsamples: cloneSubsamples(encryptedSample.sampleEncryption.subsamples),
362
+ pattern: getPattern(encryptionInfo)
363
+ }) : await this.decryptWithProtectedData(encryptionInfo, encryptedSample, metadataPacket.microsecondTimestamp, keyId, psshBoxes);
364
+ if (!isUint8Array(decryptedData)) throw new TypeError("decryption callback must return a Uint8Array.");
365
+ if (decryptedData.byteLength !== metadataPacket.byteLength) throw new Error("decryption callback must return the same number of bytes as the encrypted sample.");
366
+ return new EncodedPacket(decryptedData, metadataPacket.type, metadataPacket.timestamp, metadataPacket.duration, metadataPacket.sequenceNumber, metadataPacket.byteLength, metadataPacket.sideData);
367
+ }
368
+ async decryptWithProtectedData(encryptionInfo, encryptedSample, timestamp, keyId, psshBoxes) {
369
+ if (!this.decryptProtectedData) throw new Error("Encrypted sample encountered without a decryption callback.");
370
+ const protectedDataLayout = createProtectedDataLayout(encryptedSample.data, encryptionInfo, encryptedSample.sampleEncryption);
371
+ if (!protectedDataLayout) throw new Error("decryptProtectedData does not support cbcs samples with subsample encryption. Use decryptSample for this content.");
372
+ const decryptedProtectedData = await this.decryptProtectedData({
373
+ data: protectedDataLayout.data,
374
+ keyId,
375
+ psshBoxes,
376
+ scheme: encryptionInfo.scheme,
377
+ iv: encryptedSample.sampleEncryption.iv.slice(),
378
+ timestamp,
379
+ subsamples: [{
380
+ clearLen: 0,
381
+ protectedLen: protectedDataLayout.data.byteLength
382
+ }],
383
+ pattern: null
384
+ });
385
+ if (!isUint8Array(decryptedProtectedData)) throw new TypeError("decryptProtectedData must return a Uint8Array.");
386
+ if (decryptedProtectedData.byteLength !== protectedDataLayout.data.byteLength) throw new Error("decryptProtectedData must return the same number of bytes as the protected data input.");
387
+ return protectedDataLayout.merge(decryptedProtectedData);
388
+ }
389
+ };
390
+ //#endregion
9
391
  //#region src/index.ts
392
+ const createEncryptionReporter = (handleEncryptionInfo) => {
393
+ const reportedEncryptionInfo = /* @__PURE__ */ new Set();
394
+ return (keyId, psshBoxes) => {
395
+ if (!handleEncryptionInfo || reportedEncryptionInfo.has(keyId)) return;
396
+ reportedEncryptionInfo.add(keyId);
397
+ handleEncryptionInfo({
398
+ keyId,
399
+ psshBoxes
400
+ });
401
+ };
402
+ };
10
403
  var Input$1 = class extends Input {
11
- constructor({ keys, handleEncryptionInfo, ...options }) {
404
+ decryptSamplePatcher;
405
+ constructor({ keys, decryptSample, decryptProtectedData, handleEncryptionInfo, ...options }) {
406
+ if (!keys?.size && !decryptSample && !decryptProtectedData) throw new Error("Either keys, decryptSample, or decryptProtectedData must be provided.");
407
+ const reportEncryptionInfo = createEncryptionReporter(handleEncryptionInfo);
12
408
  super({
13
409
  ...options,
14
410
  formats: ALL_FORMATS,
15
411
  formatOptions: { isobmff: { resolveKeyId: ({ keyId, psshBoxes }) => {
16
- handleEncryptionInfo?.({
17
- keyId,
18
- psshBoxes
19
- });
20
- const key = keys.get(keyId);
412
+ reportEncryptionInfo(keyId, psshBoxes);
413
+ const key = keys?.get(keyId);
21
414
  if (!key) throw new Error("Unknown key ID.");
22
415
  return key;
23
416
  } } }
24
417
  });
418
+ this.decryptSamplePatcher = decryptSample || decryptProtectedData ? new IsobmffDecryptSamplePatcher({ getTracks: (query) => this.getTracksWithoutPatching(query) }, decryptSample, decryptProtectedData, reportEncryptionInfo) : null;
419
+ }
420
+ async getTracks(query) {
421
+ await this.decryptSamplePatcher?.ensurePatched();
422
+ return super.getTracks(query);
423
+ }
424
+ async getTracksWithoutPatching(query) {
425
+ return super.getTracks(query);
25
426
  }
26
427
  };
27
428
  var Output$1 = class extends Output {
package/dist/index.d.mts CHANGED
@@ -1,21 +1,140 @@
1
- import { Conversion, FilePathSource, FilePathTarget, Input as Input$1, InputOptions, MkvOutputFormat, MovOutputFormat, Mp3OutputFormat, Mp4OutputFormat, Output as Output$1, OutputFormat, OutputOptions, PsshBox, ReadableStreamSource, StreamSource, StreamSourceOptions, StreamTarget, StreamTargetOptions, UrlSource } from "mediabunny";
1
+ import { Conversion, FilePathSource, FilePathTarget, Input as Input$1, InputOptions as MediabunnyInputOptions, InputTrack, InputTrackQuery, MkvOutputFormat, MovOutputFormat, Mp3OutputFormat, Mp4OutputFormat, Output as Output$1, OutputFormat, OutputOptions, PsshBox, ReadableStreamSource, StreamSource, StreamSourceOptions, StreamTarget, StreamTargetOptions, UrlSource } from "mediabunny";
2
2
 
3
- //#region src/index.d.ts
3
+ //#region src/decrypt-sample.d.ts
4
4
  type KeyId = string;
5
5
  type Key = string;
6
6
  type KeyMap = Map<KeyId, Key>;
7
+ type IsobmffScheme = 'cenc' | 'cens' | 'cbcs';
8
+ type SubsampleEncryption = {
9
+ /** Number of clear bytes that appear before the protected bytes in this subsample. */clearLen: number; /** Number of encrypted bytes in this subsample. */
10
+ protectedLen: number;
11
+ };
12
+ type EncryptionPattern = {
13
+ /** Number of consecutive 16-byte blocks that are encrypted in each pattern step. */cryptByteBlock: number; /** Number of consecutive 16-byte blocks that are skipped in each pattern step. */
14
+ skipByteBlock: number;
15
+ };
16
+ /**
17
+ * Options passed to {@link DecryptSample}.
18
+ *
19
+ * This is the advanced callback contract. `data` contains the full sample bytes exactly as they appear in the
20
+ * container, including any clear regions. `subsamples` and `pattern` describe which parts of `data` are protected.
21
+ *
22
+ * Use this callback when the decryption backend needs the original sample layout, or when working with content
23
+ * that cannot be safely represented as a single flattened protected-byte stream, such as patterned `cbcs`
24
+ * subsample encryption.
25
+ */
26
+ type IsobmffDecryptSampleOptions = {
27
+ /** Full sample bytes as stored in the container. Clear and protected regions are both present in this buffer. */data: Uint8Array; /** Key ID from the track encryption metadata. */
28
+ keyId: string; /** PSSH boxes associated with this key/sample and useful for DRM-specific key resolution. */
29
+ psshBoxes: PsshBox[]; /** Sample encryption scheme. */
30
+ scheme: IsobmffScheme; /** Per-sample IV resolved from container metadata. */
31
+ iv: Uint8Array; /** Presentation timestamp of the sample in microseconds. */
32
+ timestamp: number; /** Subsample layout describing which parts of `data` are clear vs protected, or `null` for whole-sample encryption. */
33
+ subsamples: SubsampleEncryption[] | null; /** Pattern encryption parameters, or `null` when no pattern is used. */
34
+ pattern: EncryptionPattern | null;
35
+ };
36
+ /**
37
+ * Options passed to {@link DecryptProtectedData}.
38
+ *
39
+ * This is the compatibility/simple callback contract. `data` contains only the protected byte stream that needs
40
+ * to be decrypted. `shifro` is responsible for extracting those protected bytes from the sample and inserting the
41
+ * decrypted result back into the sample afterward.
42
+ *
43
+ * This matches the old `transformSample` style API used by `wapter`.
44
+ */
45
+ type IsobmffDecryptProtectedDataOptions = {
46
+ /** Only the protected bytes that should be decrypted. Clear sample bytes are not included. */data: Uint8Array; /** Key ID from the track encryption metadata. */
47
+ keyId: string; /** PSSH boxes associated with this key/sample and useful for DRM-specific key resolution. */
48
+ psshBoxes: PsshBox[]; /** Sample encryption scheme. */
49
+ scheme: IsobmffScheme; /** Per-sample IV resolved from container metadata. */
50
+ iv: Uint8Array; /** Presentation timestamp of the sample in microseconds. */
51
+ timestamp: number;
52
+ /**
53
+ * Subsample layout for the protected-data buffer passed to this callback.
54
+ *
55
+ * Since `data` already contains only protected bytes, this is typically a single entry covering the whole buffer.
56
+ */
57
+ subsamples: SubsampleEncryption[];
58
+ /**
59
+ * Pattern metadata for the protected-data buffer passed to this callback.
60
+ *
61
+ * This is `null` when the flattened protected-data representation no longer has any skipped blocks to describe.
62
+ */
63
+ pattern: EncryptionPattern | null;
64
+ };
65
+ /**
66
+ * Advanced sample decryption callback.
67
+ *
68
+ * The callback receives the full encrypted sample layout and must return the fully decrypted sample bytes with the
69
+ * exact same length. This is the most general integration point and should be used when a backend needs explicit
70
+ * subsample and pattern metadata.
71
+ */
72
+ type DecryptSample = (options: IsobmffDecryptSampleOptions) => Uint8Array | Promise<Uint8Array>;
73
+ /**
74
+ * Simplified protected-data decryption callback.
75
+ *
76
+ * The callback receives only the protected byte stream extracted from a sample and must return the decrypted bytes
77
+ * for that protected region with the exact same length. `shifro` handles extraction from and reinsertion into the
78
+ * original sample.
79
+ *
80
+ * This is the recommended callback for integrations that only need a raw "decrypt these bytes" operation.
81
+ *
82
+ * Note: this simplified callback is not sufficient for every encryption layout. For example, `cbcs` samples with
83
+ * subsample encryption still require {@link DecryptSample}.
84
+ */
85
+ type DecryptProtectedData = (options: IsobmffDecryptProtectedDataOptions) => Uint8Array | Promise<Uint8Array>;
86
+ //#endregion
87
+ //#region src/index.d.ts
88
+ /**
89
+ * Options for creating a `shifro` {@link Input}.
90
+ *
91
+ * In addition to the regular Mediabunny input options, `shifro` accepts either raw content keys or one of the
92
+ * decryption callbacks used to delegate decryption to another library.
93
+ */
94
+ type ShifroInputOptions = Omit<MediabunnyInputOptions, 'formats' | 'formatOptions'> & {
95
+ /**
96
+ * Content decryption keys indexed by key ID.
97
+ *
98
+ * Use this when the caller already has the actual content keys and wants `shifro`/Mediabunny to perform the
99
+ * sample decryption internally.
100
+ */
101
+ keys?: KeyMap;
102
+ /**
103
+ * Advanced decryption callback that receives the full encrypted sample layout.
104
+ *
105
+ * Use this when the decryption backend needs explicit access to the sample's clear/protected layout, subsample
106
+ * metadata, or pattern encryption information.
107
+ */
108
+ decryptSample?: DecryptSample;
109
+ /**
110
+ * Simplified decryption callback that receives only the flattened protected byte stream.
111
+ *
112
+ * `shifro` extracts the protected bytes from the sample before calling this callback and reinserts the decrypted
113
+ * bytes back into the sample afterward.
114
+ */
115
+ decryptProtectedData?: DecryptProtectedData;
116
+ /**
117
+ * Optional callback invoked when encryption metadata is discovered for a sample.
118
+ *
119
+ * This is useful when the caller wants access to the key ID and PSSH boxes without providing the key directly,
120
+ * for example to perform external DRM license handling.
121
+ */
122
+ handleEncryptionInfo?: (options: {
123
+ keyId: string;
124
+ psshBoxes: PsshBox[];
125
+ }) => void;
126
+ };
7
127
  declare class Input extends Input$1 {
128
+ private readonly decryptSamplePatcher;
8
129
  constructor({
9
130
  keys,
131
+ decryptSample,
132
+ decryptProtectedData,
10
133
  handleEncryptionInfo,
11
134
  ...options
12
- }: Omit<InputOptions, 'formats' | 'formatOptions'> & {
13
- keys: KeyMap;
14
- handleEncryptionInfo?: (options: {
15
- keyId: string;
16
- psshBoxes: PsshBox[];
17
- }) => void;
18
- });
135
+ }: ShifroInputOptions);
136
+ getTracks(query?: InputTrackQuery<InputTrack>): Promise<InputTrack[]>;
137
+ private getTracksWithoutPatching;
19
138
  }
20
139
  declare class Output extends Output$1 {
21
140
  constructor({
@@ -25,4 +144,4 @@ declare class Output extends Output$1 {
25
144
  });
26
145
  }
27
146
  //#endregion
28
- export { Conversion as Decryption, FilePathSource, FilePathTarget, Input, type InputOptions, type Key, type KeyId, type KeyMap, MkvOutputFormat, MovOutputFormat, Mp3OutputFormat, Mp4OutputFormat, Output, type OutputFormat, type OutputOptions, ReadableStreamSource, StreamSource, type StreamSourceOptions, StreamTarget, type StreamTargetOptions, UrlSource };
147
+ export { type DecryptProtectedData, type DecryptSample, Conversion as Decryption, FilePathSource, FilePathTarget, Input, type MediabunnyInputOptions as InputOptions, type IsobmffDecryptProtectedDataOptions, type IsobmffDecryptSampleOptions, type Key, type KeyId, type KeyMap, MkvOutputFormat, MovOutputFormat, Mp3OutputFormat, Mp4OutputFormat, Output, type OutputFormat, type OutputOptions, ReadableStreamSource, ShifroInputOptions, StreamSource, type StreamSourceOptions, StreamTarget, type StreamTargetOptions, UrlSource };
package/dist/index.mjs CHANGED
@@ -1,20 +1,421 @@
1
- import { ALL_FORMATS, Conversion, FilePathSource, FilePathTarget, Input as Input$1, MkvOutputFormat, MovOutputFormat, Mp3OutputFormat, Mp4OutputFormat, Output as Output$1, ReadableStreamSource, StreamSource, StreamTarget, UrlSource } from "mediabunny";
1
+ import { ALL_FORMATS, Conversion, EncodedPacket, FilePathSource, FilePathTarget, Input as Input$1, MkvOutputFormat, MovOutputFormat, Mp3OutputFormat, Mp4OutputFormat, Output as Output$1, ReadableStreamSource, StreamSource, StreamTarget, UrlSource } from "mediabunny";
2
+ //#region src/mediabunny-isobmff.ts
3
+ const binarySearchLessOrEqual = (items, needle, getValue) => {
4
+ let low = 0;
5
+ let high = items.length - 1;
6
+ let result = -1;
7
+ while (low <= high) {
8
+ const middle = low + Math.floor((high - low) / 2);
9
+ if (getValue(items[middle]) <= needle) {
10
+ result = middle;
11
+ low = middle + 1;
12
+ } else high = middle - 1;
13
+ }
14
+ return result;
15
+ };
16
+ const binarySearchExact = (items, needle, getValue) => {
17
+ let low = 0;
18
+ let high = items.length - 1;
19
+ while (low <= high) {
20
+ const middle = low + Math.floor((high - low) / 2);
21
+ const value = getValue(items[middle]);
22
+ if (value === needle) return middle;
23
+ if (value < needle) low = middle + 1;
24
+ else high = middle - 1;
25
+ }
26
+ return -1;
27
+ };
28
+ const readBytes = (slice, length) => {
29
+ const bytes = slice.bytes.slice(slice.bufferPos, slice.bufferPos + length);
30
+ slice.bufferPos += length;
31
+ return bytes;
32
+ };
33
+ const readU16Be = (slice) => {
34
+ const value = slice.view.getUint16(slice.bufferPos, false);
35
+ slice.bufferPos += 2;
36
+ return value;
37
+ };
38
+ const readU32Be = (slice) => {
39
+ const value = slice.view.getUint32(slice.bufferPos, false);
40
+ slice.bufferPos += 4;
41
+ return value;
42
+ };
43
+ const readSampleBytes = async (internalTrack, byteOffset, byteLength) => {
44
+ let slice = internalTrack.demuxer.reader.requestSlice(byteOffset, byteLength);
45
+ if (slice instanceof Promise) slice = await slice;
46
+ if (!slice) throw new Error("Failed to read encrypted sample bytes.");
47
+ return readBytes(slice, byteLength);
48
+ };
49
+ const resolveEncryptionAuxInfo = async (internalTrack, auxInfo) => {
50
+ if (auxInfo.resolved) return auxInfo.resolved;
51
+ const encryptionInfo = internalTrack.encryptionInfo;
52
+ if (!encryptionInfo || encryptionInfo.defaultPerSampleIvSize === null) throw new Error("Missing track encryption info.");
53
+ if (auxInfo.offset === null || auxInfo.sampleCount === 0) throw new Error("Incomplete saiz/saio info; cannot resolve encryption data.");
54
+ if (auxInfo.defaultSampleInfoSize === 0 && !auxInfo.sampleSizes) throw new Error("Invalid auxiliary encryption info: auxInfo.sampleSizes is required when auxInfo.defaultSampleInfoSize is 0.");
55
+ let totalSize = 0;
56
+ if (auxInfo.defaultSampleInfoSize > 0) totalSize = auxInfo.defaultSampleInfoSize * auxInfo.sampleCount;
57
+ else if (auxInfo.sampleSizes) for (const size of auxInfo.sampleSizes) totalSize += size;
58
+ let slice = internalTrack.demuxer.reader.requestSlice(auxInfo.offset, totalSize);
59
+ if (slice instanceof Promise) slice = await slice;
60
+ if (!slice) throw new Error("Failed to read auxiliary encryption info.");
61
+ const entries = [];
62
+ const ivSize = encryptionInfo.defaultPerSampleIvSize;
63
+ for (let i = 0; i < auxInfo.sampleCount; i++) {
64
+ const entrySize = auxInfo.defaultSampleInfoSize > 0 ? auxInfo.defaultSampleInfoSize : auxInfo.sampleSizes?.[i] ?? 0;
65
+ const iv = new Uint8Array(16);
66
+ if (ivSize > 0) iv.set(readBytes(slice, ivSize), 0);
67
+ else if (encryptionInfo.defaultConstantIv) iv.set(encryptionInfo.defaultConstantIv, 0);
68
+ let subsamples = null;
69
+ if (entrySize > ivSize) {
70
+ const subsampleCount = readU16Be(slice);
71
+ subsamples = [];
72
+ for (let j = 0; j < subsampleCount; j++) subsamples.push({
73
+ clearLen: readU16Be(slice),
74
+ protectedLen: readU32Be(slice)
75
+ });
76
+ }
77
+ entries.push({
78
+ iv,
79
+ subsamples
80
+ });
81
+ }
82
+ auxInfo.resolved = entries;
83
+ return entries;
84
+ };
85
+ const getSampleInfo = (sampleTable, sampleIndex) => {
86
+ const timingEntryIndex = binarySearchLessOrEqual(sampleTable.sampleTimingEntries, sampleIndex, (entry) => entry.startIndex);
87
+ const timingEntry = sampleTable.sampleTimingEntries[timingEntryIndex];
88
+ if (!timingEntry || timingEntry.startIndex + timingEntry.count <= sampleIndex) return null;
89
+ let presentationTimestamp = timingEntry.startDecodeTimestamp + (sampleIndex - timingEntry.startIndex) * timingEntry.delta;
90
+ const offsetEntryIndex = binarySearchLessOrEqual(sampleTable.sampleCompositionTimeOffsets, sampleIndex, (entry) => entry.startIndex);
91
+ const offsetEntry = sampleTable.sampleCompositionTimeOffsets[offsetEntryIndex];
92
+ if (offsetEntry && sampleIndex - offsetEntry.startIndex < offsetEntry.count) presentationTimestamp += offsetEntry.offset;
93
+ const sampleSize = sampleTable.sampleSizes[Math.min(sampleIndex, sampleTable.sampleSizes.length - 1)];
94
+ if (sampleSize === void 0) return null;
95
+ const chunkEntryIndex = binarySearchLessOrEqual(sampleTable.sampleToChunk, sampleIndex, (entry) => entry.startSampleIndex);
96
+ const chunkEntry = sampleTable.sampleToChunk[chunkEntryIndex];
97
+ if (!chunkEntry) return null;
98
+ const chunkIndex = chunkEntry.startChunkIndex + Math.floor((sampleIndex - chunkEntry.startSampleIndex) / chunkEntry.samplesPerChunk);
99
+ const chunkOffset = sampleTable.chunkOffsets[chunkIndex];
100
+ if (chunkOffset === void 0) return null;
101
+ const startSampleIndexOfChunk = chunkEntry.startSampleIndex + (chunkIndex - chunkEntry.startChunkIndex) * chunkEntry.samplesPerChunk;
102
+ let chunkSize = 0;
103
+ let sampleOffset = chunkOffset;
104
+ if (sampleTable.sampleSizes.length === 1) {
105
+ sampleOffset += sampleSize * (sampleIndex - startSampleIndexOfChunk);
106
+ chunkSize += sampleSize * chunkEntry.samplesPerChunk;
107
+ } else for (let i = startSampleIndexOfChunk; i < startSampleIndexOfChunk + chunkEntry.samplesPerChunk; i++) {
108
+ const currentSampleSize = sampleTable.sampleSizes[i];
109
+ if (currentSampleSize === void 0) return null;
110
+ if (i < sampleIndex) sampleOffset += currentSampleSize;
111
+ chunkSize += currentSampleSize;
112
+ }
113
+ let duration = timingEntry.delta;
114
+ if (sampleTable.presentationTimestamps && sampleTable.presentationTimestampIndexMap) {
115
+ const presentationIndex = sampleTable.presentationTimestampIndexMap[sampleIndex];
116
+ if (presentationIndex !== void 0 && presentationIndex < sampleTable.presentationTimestamps.length - 1) {
117
+ const nextEntry = sampleTable.presentationTimestamps[presentationIndex + 1];
118
+ if (nextEntry) duration = nextEntry.presentationTimestamp - presentationTimestamp;
119
+ }
120
+ }
121
+ const isKeyFrame = sampleTable.keySampleIndices ? binarySearchExact(sampleTable.keySampleIndices, sampleIndex, (value) => value) !== -1 : true;
122
+ return {
123
+ presentationTimestamp,
124
+ duration,
125
+ sampleOffset,
126
+ sampleSize,
127
+ chunkOffset,
128
+ chunkSize,
129
+ isKeyFrame
130
+ };
131
+ };
132
+ const psshBoxesAreEqual = (left, right) => {
133
+ if (left.systemId !== right.systemId) return false;
134
+ if (left.data.byteLength !== right.data.byteLength) return false;
135
+ for (let i = 0; i < left.data.byteLength; i++) if (left.data[i] !== right.data[i]) return false;
136
+ return true;
137
+ };
138
+ const collectCryptRanges = (subsamples, cryptByteBlock, skipByteBlock) => {
139
+ const ranges = [];
140
+ const hasPattern = cryptByteBlock !== 0 || skipByteBlock !== 0;
141
+ let cursor = 0;
142
+ for (const subsample of subsamples) {
143
+ cursor += subsample.clearLen;
144
+ if (!hasPattern) {
145
+ if (subsample.protectedLen > 0) ranges.push({
146
+ offset: cursor,
147
+ length: subsample.protectedLen
148
+ });
149
+ cursor += subsample.protectedLen;
150
+ continue;
151
+ }
152
+ let remaining = subsample.protectedLen;
153
+ let position = cursor;
154
+ while (remaining > 0) {
155
+ if (remaining < 16 * cryptByteBlock) break;
156
+ const cryptLength = 16 * cryptByteBlock;
157
+ ranges.push({
158
+ offset: position,
159
+ length: cryptLength
160
+ });
161
+ position += cryptLength;
162
+ remaining -= cryptLength;
163
+ const skipLength = Math.min(16 * skipByteBlock, remaining);
164
+ position += skipLength;
165
+ remaining -= skipLength;
166
+ }
167
+ cursor += subsample.protectedLen;
168
+ }
169
+ return ranges;
170
+ };
171
+ const cloneSubsamples = (subsamples) => {
172
+ if (!subsamples) return null;
173
+ return subsamples.map((subsample) => ({
174
+ clearLen: subsample.clearLen,
175
+ protectedLen: subsample.protectedLen
176
+ }));
177
+ };
178
+ const getPattern = (encryptionInfo) => {
179
+ const cryptByteBlock = encryptionInfo.defaultCryptByteBlock ?? 0;
180
+ const skipByteBlock = encryptionInfo.defaultSkipByteBlock ?? 0;
181
+ if (cryptByteBlock === 0 && skipByteBlock === 0) return null;
182
+ return {
183
+ cryptByteBlock,
184
+ skipByteBlock
185
+ };
186
+ };
187
+ const createProtectedDataLayout = (data, encryptionInfo, sampleEncryption) => {
188
+ if (!sampleEncryption.subsamples) return {
189
+ data,
190
+ merge: (decryptedData) => new Uint8Array(decryptedData)
191
+ };
192
+ if (encryptionInfo.scheme === "cbcs") return null;
193
+ const cryptRanges = collectCryptRanges(sampleEncryption.subsamples, encryptionInfo.defaultCryptByteBlock ?? 0, encryptionInfo.defaultSkipByteBlock ?? 0);
194
+ let totalCryptLength = 0;
195
+ for (const range of cryptRanges) totalCryptLength += range.length;
196
+ const protectedData = new Uint8Array(totalCryptLength);
197
+ let writeOffset = 0;
198
+ for (const range of cryptRanges) {
199
+ protectedData.set(data.subarray(range.offset, range.offset + range.length), writeOffset);
200
+ writeOffset += range.length;
201
+ }
202
+ return {
203
+ data: protectedData,
204
+ merge: (decryptedData) => {
205
+ const merged = new Uint8Array(data);
206
+ let readOffset = 0;
207
+ for (const range of cryptRanges) {
208
+ merged.set(decryptedData.subarray(readOffset, readOffset + range.length), range.offset);
209
+ readOffset += range.length;
210
+ }
211
+ return merged;
212
+ }
213
+ };
214
+ };
215
+ const isUint8Array = (value) => value instanceof Uint8Array;
216
+ const isIsobmffTrackBacking = (backing) => {
217
+ if (!backing || typeof backing !== "object") return false;
218
+ const maybeBacking = backing;
219
+ const internalTrack = maybeBacking.internalTrack;
220
+ return Boolean(internalTrack && internalTrack.demuxer && typeof internalTrack.demuxer.getSampleTableForTrack === "function" && maybeBacking.packetToSampleIndex instanceof WeakMap && maybeBacking.packetToFragmentLocation instanceof WeakMap);
221
+ };
222
+ const getTrackEncryptionInfo = (backing) => backing.internalTrack.encryptionInfo;
223
+ const getKeyId = (backing) => backing.internalTrack.encryptionInfo?.defaultKid ?? null;
224
+ const getEncryptedSample = async (backing, packet) => {
225
+ const internalTrack = backing.internalTrack;
226
+ const fragmentLocation = backing.packetToFragmentLocation.get(packet);
227
+ if (fragmentLocation) {
228
+ const fragment = fragmentLocation.fragment;
229
+ const sample = fragment.trackData.get(internalTrack.id)?.samples[fragmentLocation.sampleIndex];
230
+ if (!sample?.encryption) return null;
231
+ return {
232
+ data: await readSampleBytes(internalTrack, sample.byteOffset, sample.byteSize),
233
+ sampleEncryption: sample.encryption,
234
+ fragment
235
+ };
236
+ }
237
+ const sampleIndex = backing.packetToSampleIndex.get(packet);
238
+ if (sampleIndex === void 0) return null;
239
+ const sampleInfo = getSampleInfo(internalTrack.demuxer.getSampleTableForTrack(internalTrack), sampleIndex);
240
+ if (!sampleInfo) return null;
241
+ const auxInfo = internalTrack.encryptionAuxInfo;
242
+ if (!auxInfo) return null;
243
+ const sampleEncryption = (await resolveEncryptionAuxInfo(internalTrack, auxInfo))[sampleIndex];
244
+ if (!sampleEncryption) return null;
245
+ return {
246
+ data: await readSampleBytes(internalTrack, sampleInfo.sampleOffset, sampleInfo.sampleSize),
247
+ sampleEncryption,
248
+ fragment: null
249
+ };
250
+ };
251
+ const getPsshBoxes = (backing, keyId, fragment) => {
252
+ const { psshBoxes } = backing.internalTrack.demuxer;
253
+ if (!fragment) return psshBoxes;
254
+ const boxes = [...psshBoxes, ...fragment.psshBoxes].filter((psshBox) => psshBox.keyIds === null || psshBox.keyIds.includes(keyId));
255
+ for (let i = 0; i < boxes.length - 1; i++) for (let j = i + 1; j < boxes.length; j++) if (psshBoxesAreEqual(boxes[i], boxes[j])) {
256
+ boxes.splice(j, 1);
257
+ j--;
258
+ }
259
+ return boxes;
260
+ };
261
+ //#endregion
262
+ //#region src/isobmff-decrypt-sample-patcher.ts
263
+ const createMetadataOnlyOptions = (options) => ({
264
+ ...options,
265
+ metadataOnly: true,
266
+ verifyKeyPackets: false
267
+ });
268
+ var IsobmffDecryptSamplePatcher = class {
269
+ input;
270
+ decryptSample;
271
+ decryptProtectedData;
272
+ reportEncryptionInfo;
273
+ patchableTrackBackings = /* @__PURE__ */ new WeakSet();
274
+ patchTrackBackingsPromise = null;
275
+ constructor(input, decryptSample, decryptProtectedData, reportEncryptionInfo) {
276
+ this.input = input;
277
+ this.decryptSample = decryptSample;
278
+ this.decryptProtectedData = decryptProtectedData;
279
+ this.reportEncryptionInfo = reportEncryptionInfo;
280
+ }
281
+ async ensurePatched() {
282
+ this.patchTrackBackingsPromise ??= (async () => {
283
+ const tracks = await this.input.getTracks();
284
+ for (const track of tracks) {
285
+ const backing = track._backing;
286
+ if (!isIsobmffTrackBacking(backing) || this.patchableTrackBackings.has(backing)) continue;
287
+ this.patchableTrackBackings.add(backing);
288
+ this.patchTrackBacking(backing);
289
+ }
290
+ })();
291
+ await this.patchTrackBackingsPromise;
292
+ }
293
+ patchTrackBacking(backing) {
294
+ const packetToMetadataPacket = /* @__PURE__ */ new WeakMap();
295
+ const getOriginalPacket = (packet) => packetToMetadataPacket.get(packet) ?? packet;
296
+ const getFirstPacket = backing.getFirstPacket.bind(backing);
297
+ backing.getFirstPacket = async (options) => {
298
+ if (!this.shouldUseCustomDecryptSample(backing, options)) return getFirstPacket(options);
299
+ const metadataPacket = await getFirstPacket(createMetadataOnlyOptions(options));
300
+ return this.materializePacket(backing, metadataPacket, () => getFirstPacket(options), packetToMetadataPacket);
301
+ };
302
+ const getPacket = backing.getPacket.bind(backing);
303
+ backing.getPacket = async (timestamp, options) => {
304
+ if (!this.shouldUseCustomDecryptSample(backing, options)) return getPacket(timestamp, options);
305
+ const metadataPacket = await getPacket(timestamp, createMetadataOnlyOptions(options));
306
+ return this.materializePacket(backing, metadataPacket, () => getPacket(timestamp, options), packetToMetadataPacket);
307
+ };
308
+ const getNextPacket = backing.getNextPacket.bind(backing);
309
+ backing.getNextPacket = async (packet, options) => {
310
+ const originalPacket = getOriginalPacket(packet);
311
+ if (!this.shouldUseCustomDecryptSample(backing, options)) return getNextPacket(originalPacket, options);
312
+ const metadataPacket = await getNextPacket(originalPacket, createMetadataOnlyOptions(options));
313
+ return this.materializePacket(backing, metadataPacket, () => getNextPacket(originalPacket, options), packetToMetadataPacket);
314
+ };
315
+ const getKeyPacket = backing.getKeyPacket.bind(backing);
316
+ backing.getKeyPacket = async (timestamp, options) => {
317
+ if (!this.shouldUseCustomDecryptSample(backing, options)) return getKeyPacket(timestamp, options);
318
+ const metadataPacket = await getKeyPacket(timestamp, createMetadataOnlyOptions(options));
319
+ return this.materializePacket(backing, metadataPacket, () => getKeyPacket(timestamp, options), packetToMetadataPacket);
320
+ };
321
+ const getNextKeyPacket = backing.getNextKeyPacket.bind(backing);
322
+ backing.getNextKeyPacket = async (packet, options) => {
323
+ const originalPacket = getOriginalPacket(packet);
324
+ if (!this.shouldUseCustomDecryptSample(backing, options)) return getNextKeyPacket(originalPacket, options);
325
+ const metadataPacket = await getNextKeyPacket(originalPacket, createMetadataOnlyOptions(options));
326
+ return this.materializePacket(backing, metadataPacket, () => getNextKeyPacket(originalPacket, options), packetToMetadataPacket);
327
+ };
328
+ }
329
+ shouldUseCustomDecryptSample(backing, options) {
330
+ return Boolean(!options.metadataOnly && getTrackEncryptionInfo(backing)?.defaultIsProtected !== false);
331
+ }
332
+ async materializePacket(backing, metadataPacket, fallback, packetToMetadataPacket) {
333
+ if (!metadataPacket) return null;
334
+ const decryptedPacket = await this.decryptPacket(backing, metadataPacket, fallback);
335
+ if (decryptedPacket && decryptedPacket !== metadataPacket) packetToMetadataPacket.set(decryptedPacket, metadataPacket);
336
+ return decryptedPacket;
337
+ }
338
+ async decryptPacket(backing, metadataPacket, fallback) {
339
+ const encryptionInfo = getTrackEncryptionInfo(backing);
340
+ if (!encryptionInfo?.defaultIsProtected) return fallback();
341
+ const keyId = getKeyId(backing);
342
+ if (!keyId) throw new Error("Encrypted sample encountered without a key ID.");
343
+ const encryptedSample = await getEncryptedSample(backing, metadataPacket);
344
+ if (!encryptedSample) return fallback();
345
+ const psshBoxes = getPsshBoxes(backing, keyId, encryptedSample.fragment);
346
+ this.reportEncryptionInfo(keyId, psshBoxes);
347
+ const decryptedData = this.decryptSample ? await this.decryptSample({
348
+ data: encryptedSample.data,
349
+ keyId,
350
+ psshBoxes,
351
+ scheme: encryptionInfo.scheme,
352
+ iv: encryptedSample.sampleEncryption.iv.slice(),
353
+ timestamp: metadataPacket.microsecondTimestamp,
354
+ subsamples: cloneSubsamples(encryptedSample.sampleEncryption.subsamples),
355
+ pattern: getPattern(encryptionInfo)
356
+ }) : await this.decryptWithProtectedData(encryptionInfo, encryptedSample, metadataPacket.microsecondTimestamp, keyId, psshBoxes);
357
+ if (!isUint8Array(decryptedData)) throw new TypeError("decryption callback must return a Uint8Array.");
358
+ if (decryptedData.byteLength !== metadataPacket.byteLength) throw new Error("decryption callback must return the same number of bytes as the encrypted sample.");
359
+ return new EncodedPacket(decryptedData, metadataPacket.type, metadataPacket.timestamp, metadataPacket.duration, metadataPacket.sequenceNumber, metadataPacket.byteLength, metadataPacket.sideData);
360
+ }
361
+ async decryptWithProtectedData(encryptionInfo, encryptedSample, timestamp, keyId, psshBoxes) {
362
+ if (!this.decryptProtectedData) throw new Error("Encrypted sample encountered without a decryption callback.");
363
+ const protectedDataLayout = createProtectedDataLayout(encryptedSample.data, encryptionInfo, encryptedSample.sampleEncryption);
364
+ if (!protectedDataLayout) throw new Error("decryptProtectedData does not support cbcs samples with subsample encryption. Use decryptSample for this content.");
365
+ const decryptedProtectedData = await this.decryptProtectedData({
366
+ data: protectedDataLayout.data,
367
+ keyId,
368
+ psshBoxes,
369
+ scheme: encryptionInfo.scheme,
370
+ iv: encryptedSample.sampleEncryption.iv.slice(),
371
+ timestamp,
372
+ subsamples: [{
373
+ clearLen: 0,
374
+ protectedLen: protectedDataLayout.data.byteLength
375
+ }],
376
+ pattern: null
377
+ });
378
+ if (!isUint8Array(decryptedProtectedData)) throw new TypeError("decryptProtectedData must return a Uint8Array.");
379
+ if (decryptedProtectedData.byteLength !== protectedDataLayout.data.byteLength) throw new Error("decryptProtectedData must return the same number of bytes as the protected data input.");
380
+ return protectedDataLayout.merge(decryptedProtectedData);
381
+ }
382
+ };
383
+ //#endregion
2
384
  //#region src/index.ts
385
+ const createEncryptionReporter = (handleEncryptionInfo) => {
386
+ const reportedEncryptionInfo = /* @__PURE__ */ new Set();
387
+ return (keyId, psshBoxes) => {
388
+ if (!handleEncryptionInfo || reportedEncryptionInfo.has(keyId)) return;
389
+ reportedEncryptionInfo.add(keyId);
390
+ handleEncryptionInfo({
391
+ keyId,
392
+ psshBoxes
393
+ });
394
+ };
395
+ };
3
396
  var Input = class extends Input$1 {
4
- constructor({ keys, handleEncryptionInfo, ...options }) {
397
+ decryptSamplePatcher;
398
+ constructor({ keys, decryptSample, decryptProtectedData, handleEncryptionInfo, ...options }) {
399
+ if (!keys?.size && !decryptSample && !decryptProtectedData) throw new Error("Either keys, decryptSample, or decryptProtectedData must be provided.");
400
+ const reportEncryptionInfo = createEncryptionReporter(handleEncryptionInfo);
5
401
  super({
6
402
  ...options,
7
403
  formats: ALL_FORMATS,
8
404
  formatOptions: { isobmff: { resolveKeyId: ({ keyId, psshBoxes }) => {
9
- handleEncryptionInfo?.({
10
- keyId,
11
- psshBoxes
12
- });
13
- const key = keys.get(keyId);
405
+ reportEncryptionInfo(keyId, psshBoxes);
406
+ const key = keys?.get(keyId);
14
407
  if (!key) throw new Error("Unknown key ID.");
15
408
  return key;
16
409
  } } }
17
410
  });
411
+ this.decryptSamplePatcher = decryptSample || decryptProtectedData ? new IsobmffDecryptSamplePatcher({ getTracks: (query) => this.getTracksWithoutPatching(query) }, decryptSample, decryptProtectedData, reportEncryptionInfo) : null;
412
+ }
413
+ async getTracks(query) {
414
+ await this.decryptSamplePatcher?.ensurePatched();
415
+ return super.getTracks(query);
416
+ }
417
+ async getTracksWithoutPatching(query) {
418
+ return super.getTracks(query);
18
419
  }
19
420
  };
20
421
  var Output = class extends Output$1 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shifro",
3
- "version": "1.1.4",
3
+ "version": "1.2.0",
4
4
  "description": "A lightweight library and command-line tool for decrypting MP4 files",
5
5
  "files": [
6
6
  "dist"
@@ -55,15 +55,15 @@
55
55
  "node": ">=22.18"
56
56
  },
57
57
  "peerDependencies": {
58
- "mediabunny": "^1.44.2"
58
+ "mediabunny": "^1.45.2"
59
59
  },
60
60
  "devDependencies": {
61
- "@types/node": "^25.6.2",
62
- "oxfmt": "^0.48.0",
63
- "oxlint": "^1.63.0",
61
+ "@types/node": "^25.8.0",
62
+ "oxfmt": "^0.50.0",
63
+ "oxlint": "^1.65.0",
64
64
  "tsdown": "^0.22.0",
65
65
  "typescript": "~6.0.3",
66
- "vitest": "^4.1.5"
66
+ "vitest": "^4.1.6"
67
67
  },
68
68
  "scripts": {
69
69
  "start": "node ./dist/cli.mjs",