shifro 1.1.5 → 1.2.1

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/README.md CHANGED
@@ -16,7 +16,7 @@ Run `npm install -g shifro` to install the command-line tool globally.
16
16
  shifro --key eb676abbcb345e96bbcf616630f1a3da:100b6c20940f779a4589152b57d2dacb ./input.mp4 ./output.mp4
17
17
  ```
18
18
 
19
- >You can also use `npx shifro ...` to run the CLI tool without installing it globally
19
+ > You can also use `npx shifro ...` to run the CLI tool without installing it globally
20
20
 
21
21
  This CLI can be used as an alternative to the [mp4decrypt](https://www.bento4.com/documentation/mp4decrypt/) or [shaka-packager](https://shaka-project.github.io/shaka-packager/html/documentation.html#raw-key-encryption-options).
22
22
 
@@ -38,7 +38,7 @@ async function decrypt() {
38
38
  ['4d97930a3d7b55fa81d0028653f5e499', '429ec76475e7a952d224d8ef867f12b6'],
39
39
  ['d21373c0b8ab5ba9954742bcdfb5f48b', '150a6c7d7dee6a91b74dccfce5b31928'],
40
40
  ]),
41
- // Optinally, you can use this callback to handle encryption information (like key ID and PSSH boxes)
41
+ // Optionally, you can use this callback to handle encryption information (like key ID and PSSH boxes)
42
42
  handleEncryptionInfo: ({ keyId, psshBoxes }) => {
43
43
  console.group(`Key ID: ${keyId}`);
44
44
  for (const psshBox of psshBoxes) {
@@ -57,6 +57,38 @@ async function decrypt() {
57
57
  }
58
58
  ```
59
59
 
60
+ #### Decrypting file with an external AES backend
61
+
62
+ ```ts
63
+ import {
64
+ Decryption,
65
+ FilePathSource,
66
+ FilePathTarget,
67
+ Input,
68
+ Output,
69
+ decryptBytes,
70
+ type EncryptedBytes,
71
+ } from 'shifro';
72
+
73
+ // Decrypt bytes with your DRM library
74
+ async function decryptProtectedData({ scheme, data, iv }: EncryptedBytes) {
75
+ // Your decryption logic here
76
+ }
77
+
78
+ async function runDecryption() {
79
+ const decryption = await Decryption.init({
80
+ input: new Input({
81
+ source: new FilePathSource('./input.mp4'),
82
+ // `decryptBytes` - helper function that allows to handle only the encrypted bytes. Clear sample bytes are not included.
83
+ decryptSample: decryptBytes(decryptProtectedData),
84
+ }),
85
+ output: new Output({ target: new FilePathTarget('./output.mp4') }),
86
+ });
87
+
88
+ await decryption.execute();
89
+ }
90
+ ```
91
+
60
92
  #### Decrypting file using browser's Web Streams API
61
93
 
62
94
  ```ts
package/dist/cli.mjs CHANGED
@@ -1,27 +1,344 @@
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.5";
6
+ var version = "1.2.1";
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 cloneSubsamples = (subsamples) => {
146
+ if (!subsamples) return null;
147
+ return subsamples.map((subsample) => ({
148
+ clearLen: subsample.clearLen,
149
+ protectedLen: subsample.protectedLen
150
+ }));
151
+ };
152
+ const getPattern = (encryptionInfo) => {
153
+ const cryptByteBlock = encryptionInfo.defaultCryptByteBlock ?? 0;
154
+ const skipByteBlock = encryptionInfo.defaultSkipByteBlock ?? 0;
155
+ if (cryptByteBlock === 0 && skipByteBlock === 0) return null;
156
+ return {
157
+ cryptByteBlock,
158
+ skipByteBlock
159
+ };
160
+ };
161
+ const isUint8Array = (value) => value instanceof Uint8Array;
162
+ const isIsobmffTrackBacking = (backing) => {
163
+ if (!backing || typeof backing !== "object") return false;
164
+ const maybeBacking = backing;
165
+ const internalTrack = maybeBacking.internalTrack;
166
+ return Boolean(internalTrack && internalTrack.demuxer && typeof internalTrack.demuxer.getSampleTableForTrack === "function" && maybeBacking.packetToSampleIndex instanceof WeakMap && maybeBacking.packetToFragmentLocation instanceof WeakMap);
167
+ };
168
+ const getTrackEncryptionInfo = (backing) => backing.internalTrack.encryptionInfo;
169
+ const getKeyId = (backing) => backing.internalTrack.encryptionInfo?.defaultKid ?? null;
170
+ const getEncryptedSample = async (backing, packet) => {
171
+ const internalTrack = backing.internalTrack;
172
+ const fragmentLocation = backing.packetToFragmentLocation.get(packet);
173
+ if (fragmentLocation) {
174
+ const fragment = fragmentLocation.fragment;
175
+ const sample = fragment.trackData.get(internalTrack.id)?.samples[fragmentLocation.sampleIndex];
176
+ if (!sample?.encryption) return null;
177
+ return {
178
+ data: await readSampleBytes(internalTrack, sample.byteOffset, sample.byteSize),
179
+ sampleEncryption: sample.encryption,
180
+ fragment
181
+ };
182
+ }
183
+ const sampleIndex = backing.packetToSampleIndex.get(packet);
184
+ if (sampleIndex === void 0) return null;
185
+ const sampleInfo = getSampleInfo(internalTrack.demuxer.getSampleTableForTrack(internalTrack), sampleIndex);
186
+ if (!sampleInfo) return null;
187
+ const auxInfo = internalTrack.encryptionAuxInfo;
188
+ if (!auxInfo) return null;
189
+ const sampleEncryption = (await resolveEncryptionAuxInfo(internalTrack, auxInfo))[sampleIndex];
190
+ if (!sampleEncryption) return null;
191
+ return {
192
+ data: await readSampleBytes(internalTrack, sampleInfo.sampleOffset, sampleInfo.sampleSize),
193
+ sampleEncryption,
194
+ fragment: null
195
+ };
196
+ };
197
+ const getPsshBoxes = (backing, keyId, fragment) => {
198
+ const { psshBoxes } = backing.internalTrack.demuxer;
199
+ if (!fragment) return psshBoxes;
200
+ const boxes = [...psshBoxes, ...fragment.psshBoxes].filter((psshBox) => psshBox.keyIds === null || psshBox.keyIds.includes(keyId));
201
+ for (let i = 0; i < boxes.length - 1; i++) for (let j = i + 1; j < boxes.length; j++) if (psshBoxesAreEqual(boxes[i], boxes[j])) {
202
+ boxes.splice(j, 1);
203
+ j--;
204
+ }
205
+ return boxes;
206
+ };
207
+ //#endregion
208
+ //#region src/isobmff-decrypt-sample-patcher.ts
209
+ const createMetadataOnlyOptions = (options) => ({
210
+ ...options,
211
+ metadataOnly: true,
212
+ verifyKeyPackets: false
213
+ });
214
+ var IsobmffDecryptSamplePatcher = class {
215
+ input;
216
+ decryptSample;
217
+ reportEncryptionInfo;
218
+ patchableTrackBackings = /* @__PURE__ */ new WeakSet();
219
+ patchTrackBackingsPromise = null;
220
+ constructor(input, decryptSample, reportEncryptionInfo) {
221
+ this.input = input;
222
+ this.decryptSample = decryptSample;
223
+ this.reportEncryptionInfo = reportEncryptionInfo;
224
+ }
225
+ async ensurePatched() {
226
+ this.patchTrackBackingsPromise ??= (async () => {
227
+ const tracks = await this.input.getTracks();
228
+ for (const track of tracks) {
229
+ const backing = track._backing;
230
+ if (!isIsobmffTrackBacking(backing) || this.patchableTrackBackings.has(backing)) continue;
231
+ this.patchableTrackBackings.add(backing);
232
+ this.patchTrackBacking(backing);
233
+ }
234
+ })();
235
+ await this.patchTrackBackingsPromise;
236
+ }
237
+ patchTrackBacking(backing) {
238
+ const packetToMetadataPacket = /* @__PURE__ */ new WeakMap();
239
+ const getOriginalPacket = (packet) => packetToMetadataPacket.get(packet) ?? packet;
240
+ const getFirstPacket = backing.getFirstPacket.bind(backing);
241
+ backing.getFirstPacket = async (options) => {
242
+ if (!this.shouldUseCustomDecryptSample(backing, options)) return getFirstPacket(options);
243
+ const metadataPacket = await getFirstPacket(createMetadataOnlyOptions(options));
244
+ return this.materializePacket(backing, metadataPacket, () => getFirstPacket(options), packetToMetadataPacket);
245
+ };
246
+ const getPacket = backing.getPacket.bind(backing);
247
+ backing.getPacket = async (timestamp, options) => {
248
+ if (!this.shouldUseCustomDecryptSample(backing, options)) return getPacket(timestamp, options);
249
+ const metadataPacket = await getPacket(timestamp, createMetadataOnlyOptions(options));
250
+ return this.materializePacket(backing, metadataPacket, () => getPacket(timestamp, options), packetToMetadataPacket);
251
+ };
252
+ const getNextPacket = backing.getNextPacket.bind(backing);
253
+ backing.getNextPacket = async (packet, options) => {
254
+ const originalPacket = getOriginalPacket(packet);
255
+ if (!this.shouldUseCustomDecryptSample(backing, options)) return getNextPacket(originalPacket, options);
256
+ const metadataPacket = await getNextPacket(originalPacket, createMetadataOnlyOptions(options));
257
+ return this.materializePacket(backing, metadataPacket, () => getNextPacket(originalPacket, options), packetToMetadataPacket);
258
+ };
259
+ const getKeyPacket = backing.getKeyPacket.bind(backing);
260
+ backing.getKeyPacket = async (timestamp, options) => {
261
+ if (!this.shouldUseCustomDecryptSample(backing, options)) return getKeyPacket(timestamp, options);
262
+ const metadataPacket = await getKeyPacket(timestamp, createMetadataOnlyOptions(options));
263
+ return this.materializePacket(backing, metadataPacket, () => getKeyPacket(timestamp, options), packetToMetadataPacket);
264
+ };
265
+ const getNextKeyPacket = backing.getNextKeyPacket.bind(backing);
266
+ backing.getNextKeyPacket = async (packet, options) => {
267
+ const originalPacket = getOriginalPacket(packet);
268
+ if (!this.shouldUseCustomDecryptSample(backing, options)) return getNextKeyPacket(originalPacket, options);
269
+ const metadataPacket = await getNextKeyPacket(originalPacket, createMetadataOnlyOptions(options));
270
+ return this.materializePacket(backing, metadataPacket, () => getNextKeyPacket(originalPacket, options), packetToMetadataPacket);
271
+ };
272
+ }
273
+ shouldUseCustomDecryptSample(backing, options) {
274
+ return Boolean(!options.metadataOnly && getTrackEncryptionInfo(backing)?.defaultIsProtected !== false);
275
+ }
276
+ async materializePacket(backing, metadataPacket, fallback, packetToMetadataPacket) {
277
+ if (!metadataPacket) return null;
278
+ const decryptedPacket = await this.decryptPacket(backing, metadataPacket, fallback);
279
+ if (decryptedPacket && decryptedPacket !== metadataPacket) packetToMetadataPacket.set(decryptedPacket, metadataPacket);
280
+ return decryptedPacket;
281
+ }
282
+ async decryptPacket(backing, metadataPacket, fallback) {
283
+ const encryptionInfo = getTrackEncryptionInfo(backing);
284
+ if (!encryptionInfo?.defaultIsProtected) return fallback();
285
+ const keyId = getKeyId(backing);
286
+ if (!keyId) throw new Error("Encrypted sample encountered without a key ID.");
287
+ const encryptedSample = await getEncryptedSample(backing, metadataPacket);
288
+ if (!encryptedSample) return fallback();
289
+ const psshBoxes = getPsshBoxes(backing, keyId, encryptedSample.fragment);
290
+ this.reportEncryptionInfo(keyId, psshBoxes);
291
+ const decryptedData = await this.decryptSample({
292
+ data: encryptedSample.data,
293
+ keyId,
294
+ psshBoxes,
295
+ scheme: encryptionInfo.scheme,
296
+ iv: encryptedSample.sampleEncryption.iv.slice(),
297
+ timestamp: metadataPacket.microsecondTimestamp,
298
+ subsamples: cloneSubsamples(encryptedSample.sampleEncryption.subsamples),
299
+ pattern: getPattern(encryptionInfo)
300
+ });
301
+ if (!isUint8Array(decryptedData)) throw new TypeError("decryption callback must return a Uint8Array.");
302
+ if (decryptedData.byteLength !== metadataPacket.byteLength) throw new Error("decryption callback must return the same number of bytes as the encrypted sample.");
303
+ return new EncodedPacket(decryptedData, metadataPacket.type, metadataPacket.timestamp, metadataPacket.duration, metadataPacket.sequenceNumber, metadataPacket.byteLength, metadataPacket.sideData);
304
+ }
305
+ };
306
+ //#endregion
9
307
  //#region src/index.ts
308
+ const createEncryptionReporter = (handleEncryptionInfo) => {
309
+ const reportedEncryptionInfo = /* @__PURE__ */ new Set();
310
+ return (keyId, psshBoxes) => {
311
+ if (!handleEncryptionInfo || reportedEncryptionInfo.has(keyId)) return;
312
+ reportedEncryptionInfo.add(keyId);
313
+ handleEncryptionInfo({
314
+ keyId,
315
+ psshBoxes
316
+ });
317
+ };
318
+ };
10
319
  var Input$1 = class extends Input {
11
- constructor({ keys, handleEncryptionInfo, ...options }) {
320
+ decryptSamplePatcher;
321
+ constructor({ keys, decryptSample, handleEncryptionInfo, ...options }) {
322
+ if (!keys?.size && !decryptSample) throw new Error("Either keys or decryptSample must be provided.");
323
+ const reportEncryptionInfo = createEncryptionReporter(handleEncryptionInfo);
12
324
  super({
13
325
  ...options,
14
326
  formats: ALL_FORMATS,
15
327
  formatOptions: { isobmff: { resolveKeyId: ({ keyId, psshBoxes }) => {
16
- handleEncryptionInfo?.({
17
- keyId,
18
- psshBoxes
19
- });
20
- const key = keys.get(keyId);
328
+ reportEncryptionInfo(keyId, psshBoxes);
329
+ const key = keys?.get(keyId);
21
330
  if (!key) throw new Error("Unknown key ID.");
22
331
  return key;
23
332
  } } }
24
333
  });
334
+ this.decryptSamplePatcher = decryptSample ? new IsobmffDecryptSamplePatcher({ getTracks: (query) => this.getTracksWithoutPatching(query) }, decryptSample, reportEncryptionInfo) : null;
335
+ }
336
+ async getTracks(query) {
337
+ await this.decryptSamplePatcher?.ensurePatched();
338
+ return super.getTracks(query);
339
+ }
340
+ async getTracksWithoutPatching(query) {
341
+ return super.getTracks(query);
25
342
  }
26
343
  };
27
344
  var Output$1 = class extends Output {
package/dist/index.d.mts CHANGED
@@ -1,21 +1,97 @@
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, MaybePromise, 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 EncryptionScheme = '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 EncryptedSample = {
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: EncryptionScheme; /** 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
+ /** Options passed to {@link decryptBytes}. */
37
+ type EncryptedBytes = Omit<EncryptedSample, 'data'> & {
38
+ /** Only the encrypted bytes that should be decrypted. Clear sample bytes are not included. */data: Uint8Array;
39
+ };
40
+ /**
41
+ * Advanced sample decryption callback.
42
+ *
43
+ * The callback receives the full encrypted sample layout and must return the fully decrypted sample bytes with the
44
+ * exact same length. This is the most general integration point and should be used when a backend needs explicit
45
+ * subsample and pattern metadata.
46
+ */
47
+ type DecryptSample = (options: EncryptedSample) => MaybePromise<Uint8Array>;
48
+ /** Raw byte decryption callback used by {@link decryptBytes}. */
49
+ type DecryptBytesCallback = (options: EncryptedBytes) => MaybePromise<Uint8Array>;
50
+ declare const decryptBytes: (decryptBytesCallback: DecryptBytesCallback) => DecryptSample;
51
+ //#endregion
52
+ //#region src/index.d.ts
53
+ /**
54
+ * Options for creating a `shifro` {@link Input}.
55
+ *
56
+ * In addition to the regular Mediabunny input options, `shifro` accepts either raw content keys or one of the
57
+ * decryption callbacks used to delegate decryption to another library.
58
+ */
59
+ type ShifroInputOptions = Omit<MediabunnyInputOptions, 'formats' | 'formatOptions'> & {
60
+ /**
61
+ * Content decryption keys indexed by key ID.
62
+ *
63
+ * Use this when the caller already has the actual content keys and wants `shifro`/Mediabunny to perform the
64
+ * sample decryption internally.
65
+ */
66
+ keys?: KeyMap;
67
+ /**
68
+ * Advanced decryption callback that receives the full encrypted sample layout.
69
+ *
70
+ * Use this when the decryption backend needs explicit access to the sample's clear/protected layout, subsample
71
+ * metadata, or pattern encryption information.
72
+ */
73
+ decryptSample?: DecryptSample;
74
+ /**
75
+ * Optional callback invoked when encryption metadata is discovered for a sample.
76
+ *
77
+ * This is useful when the caller wants access to the key ID and PSSH boxes without providing the key directly,
78
+ * for example to perform external DRM license handling.
79
+ */
80
+ handleEncryptionInfo?: (options: {
81
+ keyId: string;
82
+ psshBoxes: PsshBox[];
83
+ }) => void;
84
+ };
7
85
  declare class Input extends Input$1 {
86
+ private readonly decryptSamplePatcher;
8
87
  constructor({
9
88
  keys,
89
+ decryptSample,
10
90
  handleEncryptionInfo,
11
91
  ...options
12
- }: Omit<InputOptions, 'formats' | 'formatOptions'> & {
13
- keys: KeyMap;
14
- handleEncryptionInfo?: (options: {
15
- keyId: string;
16
- psshBoxes: PsshBox[];
17
- }) => void;
18
- });
92
+ }: ShifroInputOptions);
93
+ getTracks(query?: InputTrackQuery<InputTrack>): Promise<InputTrack[]>;
94
+ private getTracksWithoutPatching;
19
95
  }
20
96
  declare class Output extends Output$1 {
21
97
  constructor({
@@ -25,4 +101,4 @@ declare class Output extends Output$1 {
25
101
  });
26
102
  }
27
103
  //#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 };
104
+ export { type DecryptBytesCallback, type DecryptSample, Conversion as Decryption, type EncryptedBytes, type EncryptedSample, type EncryptionPattern, FilePathSource, FilePathTarget, Input, type MediabunnyInputOptions as InputOptions, type Key, type KeyId, type KeyMap, MkvOutputFormat, MovOutputFormat, Mp3OutputFormat, Mp4OutputFormat, Output, type OutputFormat, type OutputOptions, ReadableStreamSource, ShifroInputOptions, StreamSource, type StreamSourceOptions, StreamTarget, type StreamTargetOptions, type SubsampleEncryption, UrlSource, decryptBytes };
package/dist/index.mjs CHANGED
@@ -1,20 +1,464 @@
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/decrypt-sample.ts
3
+ const AES_BLOCK_SIZE = 16;
4
+ const isUint8Array$1 = (value) => value instanceof Uint8Array;
5
+ const cloneSubsamples$1 = (subsamples) => {
6
+ if (!subsamples) return null;
7
+ return subsamples.map((subsample) => ({
8
+ clearLen: subsample.clearLen,
9
+ protectedLen: subsample.protectedLen
10
+ }));
11
+ };
12
+ const clonePattern = (pattern) => {
13
+ if (!pattern) return null;
14
+ return {
15
+ cryptByteBlock: pattern.cryptByteBlock,
16
+ skipByteBlock: pattern.skipByteBlock
17
+ };
18
+ };
19
+ const collectProtectedRanges = (offset, protectedLen, pattern, fullBlocksOnly) => {
20
+ if (!pattern) {
21
+ const length = fullBlocksOnly ? protectedLen - protectedLen % AES_BLOCK_SIZE : protectedLen;
22
+ return length > 0 ? [{
23
+ offset,
24
+ length
25
+ }] : [];
26
+ }
27
+ if (pattern.cryptByteBlock <= 0) return [];
28
+ const ranges = [];
29
+ const cryptLen = AES_BLOCK_SIZE * pattern.cryptByteBlock;
30
+ const skipLen = AES_BLOCK_SIZE * pattern.skipByteBlock;
31
+ let remaining = protectedLen;
32
+ let position = offset;
33
+ while (remaining > 0) {
34
+ if (remaining < cryptLen) break;
35
+ ranges.push({
36
+ offset: position,
37
+ length: cryptLen
38
+ });
39
+ position += cryptLen;
40
+ remaining -= cryptLen;
41
+ const currentSkipLen = Math.min(skipLen, remaining);
42
+ position += currentSkipLen;
43
+ remaining -= currentSkipLen;
44
+ }
45
+ return ranges;
46
+ };
47
+ const collectCtrRanges = (options) => {
48
+ if (!options.subsamples) return [{
49
+ offset: 0,
50
+ length: options.data.byteLength
51
+ }];
52
+ const ranges = [];
53
+ let cursor = 0;
54
+ for (const subsample of options.subsamples) {
55
+ cursor += subsample.clearLen;
56
+ ranges.push(...collectProtectedRanges(cursor, subsample.protectedLen, options.pattern, false));
57
+ cursor += subsample.protectedLen;
58
+ }
59
+ return ranges;
60
+ };
61
+ const collectCbcsGroups = (options) => {
62
+ if (!options.subsamples) return [{
63
+ iv: options.iv.slice(),
64
+ ranges: [{
65
+ offset: 0,
66
+ length: options.data.byteLength - options.data.byteLength % AES_BLOCK_SIZE
67
+ }]
68
+ }];
69
+ if (!options.pattern) throw new Error("decryptBytes does not support cbcs subsample encryption without pattern encryption.");
70
+ const groups = [];
71
+ let cursor = 0;
72
+ for (const subsample of options.subsamples) {
73
+ cursor += subsample.clearLen;
74
+ groups.push({
75
+ iv: options.iv.slice(),
76
+ ranges: collectProtectedRanges(cursor, subsample.protectedLen, options.pattern, true)
77
+ });
78
+ cursor += subsample.protectedLen;
79
+ }
80
+ return groups;
81
+ };
82
+ const getRangeData = (data, ranges) => {
83
+ let totalLength = 0;
84
+ for (const range of ranges) totalLength += range.length;
85
+ const protectedData = new Uint8Array(totalLength);
86
+ let writeOffset = 0;
87
+ for (const range of ranges) {
88
+ protectedData.set(data.subarray(range.offset, range.offset + range.length), writeOffset);
89
+ writeOffset += range.length;
90
+ }
91
+ return protectedData;
92
+ };
93
+ const mergeRangeData = (target, decryptedData, ranges) => {
94
+ let readOffset = 0;
95
+ for (const range of ranges) {
96
+ target.set(decryptedData.subarray(readOffset, readOffset + range.length), range.offset);
97
+ readOffset += range.length;
98
+ }
99
+ };
100
+ const decryptGroups = async (options, groups, decryptBytesCallback) => {
101
+ const decryptedSample = new Uint8Array(options.data);
102
+ for (const group of groups) {
103
+ const encryptedData = getRangeData(options.data, group.ranges);
104
+ if (encryptedData.byteLength === 0) continue;
105
+ const decryptedData = await decryptBytesCallback({
106
+ data: encryptedData,
107
+ keyId: options.keyId,
108
+ psshBoxes: options.psshBoxes,
109
+ scheme: options.scheme,
110
+ iv: group.iv.slice(),
111
+ timestamp: options.timestamp,
112
+ subsamples: cloneSubsamples$1(options.subsamples),
113
+ pattern: clonePattern(options.pattern)
114
+ });
115
+ if (!isUint8Array$1(decryptedData)) throw new TypeError("decryptBytes callback must return a Uint8Array.");
116
+ if (decryptedData.byteLength !== encryptedData.byteLength) throw new Error("decryptBytes callback must return the same number of bytes as the encrypted input.");
117
+ mergeRangeData(decryptedSample, decryptedData, group.ranges);
118
+ }
119
+ return decryptedSample;
120
+ };
121
+ const decryptBytes = (decryptBytesCallback) => async (options) => {
122
+ if (options.scheme === "cbcs") return decryptGroups(options, collectCbcsGroups(options), decryptBytesCallback);
123
+ return decryptGroups(options, [{
124
+ iv: options.iv.slice(),
125
+ ranges: collectCtrRanges(options)
126
+ }], decryptBytesCallback);
127
+ };
128
+ //#endregion
129
+ //#region src/mediabunny-isobmff.ts
130
+ const binarySearchLessOrEqual = (items, needle, getValue) => {
131
+ let low = 0;
132
+ let high = items.length - 1;
133
+ let result = -1;
134
+ while (low <= high) {
135
+ const middle = low + Math.floor((high - low) / 2);
136
+ if (getValue(items[middle]) <= needle) {
137
+ result = middle;
138
+ low = middle + 1;
139
+ } else high = middle - 1;
140
+ }
141
+ return result;
142
+ };
143
+ const binarySearchExact = (items, needle, getValue) => {
144
+ let low = 0;
145
+ let high = items.length - 1;
146
+ while (low <= high) {
147
+ const middle = low + Math.floor((high - low) / 2);
148
+ const value = getValue(items[middle]);
149
+ if (value === needle) return middle;
150
+ if (value < needle) low = middle + 1;
151
+ else high = middle - 1;
152
+ }
153
+ return -1;
154
+ };
155
+ const readBytes = (slice, length) => {
156
+ const bytes = slice.bytes.slice(slice.bufferPos, slice.bufferPos + length);
157
+ slice.bufferPos += length;
158
+ return bytes;
159
+ };
160
+ const readU16Be = (slice) => {
161
+ const value = slice.view.getUint16(slice.bufferPos, false);
162
+ slice.bufferPos += 2;
163
+ return value;
164
+ };
165
+ const readU32Be = (slice) => {
166
+ const value = slice.view.getUint32(slice.bufferPos, false);
167
+ slice.bufferPos += 4;
168
+ return value;
169
+ };
170
+ const readSampleBytes = async (internalTrack, byteOffset, byteLength) => {
171
+ let slice = internalTrack.demuxer.reader.requestSlice(byteOffset, byteLength);
172
+ if (slice instanceof Promise) slice = await slice;
173
+ if (!slice) throw new Error("Failed to read encrypted sample bytes.");
174
+ return readBytes(slice, byteLength);
175
+ };
176
+ const resolveEncryptionAuxInfo = async (internalTrack, auxInfo) => {
177
+ if (auxInfo.resolved) return auxInfo.resolved;
178
+ const encryptionInfo = internalTrack.encryptionInfo;
179
+ if (!encryptionInfo || encryptionInfo.defaultPerSampleIvSize === null) throw new Error("Missing track encryption info.");
180
+ if (auxInfo.offset === null || auxInfo.sampleCount === 0) throw new Error("Incomplete saiz/saio info; cannot resolve encryption data.");
181
+ if (auxInfo.defaultSampleInfoSize === 0 && !auxInfo.sampleSizes) throw new Error("Invalid auxiliary encryption info: auxInfo.sampleSizes is required when auxInfo.defaultSampleInfoSize is 0.");
182
+ let totalSize = 0;
183
+ if (auxInfo.defaultSampleInfoSize > 0) totalSize = auxInfo.defaultSampleInfoSize * auxInfo.sampleCount;
184
+ else if (auxInfo.sampleSizes) for (const size of auxInfo.sampleSizes) totalSize += size;
185
+ let slice = internalTrack.demuxer.reader.requestSlice(auxInfo.offset, totalSize);
186
+ if (slice instanceof Promise) slice = await slice;
187
+ if (!slice) throw new Error("Failed to read auxiliary encryption info.");
188
+ const entries = [];
189
+ const ivSize = encryptionInfo.defaultPerSampleIvSize;
190
+ for (let i = 0; i < auxInfo.sampleCount; i++) {
191
+ const entrySize = auxInfo.defaultSampleInfoSize > 0 ? auxInfo.defaultSampleInfoSize : auxInfo.sampleSizes?.[i] ?? 0;
192
+ const iv = new Uint8Array(16);
193
+ if (ivSize > 0) iv.set(readBytes(slice, ivSize), 0);
194
+ else if (encryptionInfo.defaultConstantIv) iv.set(encryptionInfo.defaultConstantIv, 0);
195
+ let subsamples = null;
196
+ if (entrySize > ivSize) {
197
+ const subsampleCount = readU16Be(slice);
198
+ subsamples = [];
199
+ for (let j = 0; j < subsampleCount; j++) subsamples.push({
200
+ clearLen: readU16Be(slice),
201
+ protectedLen: readU32Be(slice)
202
+ });
203
+ }
204
+ entries.push({
205
+ iv,
206
+ subsamples
207
+ });
208
+ }
209
+ auxInfo.resolved = entries;
210
+ return entries;
211
+ };
212
+ const getSampleInfo = (sampleTable, sampleIndex) => {
213
+ const timingEntryIndex = binarySearchLessOrEqual(sampleTable.sampleTimingEntries, sampleIndex, (entry) => entry.startIndex);
214
+ const timingEntry = sampleTable.sampleTimingEntries[timingEntryIndex];
215
+ if (!timingEntry || timingEntry.startIndex + timingEntry.count <= sampleIndex) return null;
216
+ let presentationTimestamp = timingEntry.startDecodeTimestamp + (sampleIndex - timingEntry.startIndex) * timingEntry.delta;
217
+ const offsetEntryIndex = binarySearchLessOrEqual(sampleTable.sampleCompositionTimeOffsets, sampleIndex, (entry) => entry.startIndex);
218
+ const offsetEntry = sampleTable.sampleCompositionTimeOffsets[offsetEntryIndex];
219
+ if (offsetEntry && sampleIndex - offsetEntry.startIndex < offsetEntry.count) presentationTimestamp += offsetEntry.offset;
220
+ const sampleSize = sampleTable.sampleSizes[Math.min(sampleIndex, sampleTable.sampleSizes.length - 1)];
221
+ if (sampleSize === void 0) return null;
222
+ const chunkEntryIndex = binarySearchLessOrEqual(sampleTable.sampleToChunk, sampleIndex, (entry) => entry.startSampleIndex);
223
+ const chunkEntry = sampleTable.sampleToChunk[chunkEntryIndex];
224
+ if (!chunkEntry) return null;
225
+ const chunkIndex = chunkEntry.startChunkIndex + Math.floor((sampleIndex - chunkEntry.startSampleIndex) / chunkEntry.samplesPerChunk);
226
+ const chunkOffset = sampleTable.chunkOffsets[chunkIndex];
227
+ if (chunkOffset === void 0) return null;
228
+ const startSampleIndexOfChunk = chunkEntry.startSampleIndex + (chunkIndex - chunkEntry.startChunkIndex) * chunkEntry.samplesPerChunk;
229
+ let chunkSize = 0;
230
+ let sampleOffset = chunkOffset;
231
+ if (sampleTable.sampleSizes.length === 1) {
232
+ sampleOffset += sampleSize * (sampleIndex - startSampleIndexOfChunk);
233
+ chunkSize += sampleSize * chunkEntry.samplesPerChunk;
234
+ } else for (let i = startSampleIndexOfChunk; i < startSampleIndexOfChunk + chunkEntry.samplesPerChunk; i++) {
235
+ const currentSampleSize = sampleTable.sampleSizes[i];
236
+ if (currentSampleSize === void 0) return null;
237
+ if (i < sampleIndex) sampleOffset += currentSampleSize;
238
+ chunkSize += currentSampleSize;
239
+ }
240
+ let duration = timingEntry.delta;
241
+ if (sampleTable.presentationTimestamps && sampleTable.presentationTimestampIndexMap) {
242
+ const presentationIndex = sampleTable.presentationTimestampIndexMap[sampleIndex];
243
+ if (presentationIndex !== void 0 && presentationIndex < sampleTable.presentationTimestamps.length - 1) {
244
+ const nextEntry = sampleTable.presentationTimestamps[presentationIndex + 1];
245
+ if (nextEntry) duration = nextEntry.presentationTimestamp - presentationTimestamp;
246
+ }
247
+ }
248
+ const isKeyFrame = sampleTable.keySampleIndices ? binarySearchExact(sampleTable.keySampleIndices, sampleIndex, (value) => value) !== -1 : true;
249
+ return {
250
+ presentationTimestamp,
251
+ duration,
252
+ sampleOffset,
253
+ sampleSize,
254
+ chunkOffset,
255
+ chunkSize,
256
+ isKeyFrame
257
+ };
258
+ };
259
+ const psshBoxesAreEqual = (left, right) => {
260
+ if (left.systemId !== right.systemId) return false;
261
+ if (left.data.byteLength !== right.data.byteLength) return false;
262
+ for (let i = 0; i < left.data.byteLength; i++) if (left.data[i] !== right.data[i]) return false;
263
+ return true;
264
+ };
265
+ const cloneSubsamples = (subsamples) => {
266
+ if (!subsamples) return null;
267
+ return subsamples.map((subsample) => ({
268
+ clearLen: subsample.clearLen,
269
+ protectedLen: subsample.protectedLen
270
+ }));
271
+ };
272
+ const getPattern = (encryptionInfo) => {
273
+ const cryptByteBlock = encryptionInfo.defaultCryptByteBlock ?? 0;
274
+ const skipByteBlock = encryptionInfo.defaultSkipByteBlock ?? 0;
275
+ if (cryptByteBlock === 0 && skipByteBlock === 0) return null;
276
+ return {
277
+ cryptByteBlock,
278
+ skipByteBlock
279
+ };
280
+ };
281
+ const isUint8Array = (value) => value instanceof Uint8Array;
282
+ const isIsobmffTrackBacking = (backing) => {
283
+ if (!backing || typeof backing !== "object") return false;
284
+ const maybeBacking = backing;
285
+ const internalTrack = maybeBacking.internalTrack;
286
+ return Boolean(internalTrack && internalTrack.demuxer && typeof internalTrack.demuxer.getSampleTableForTrack === "function" && maybeBacking.packetToSampleIndex instanceof WeakMap && maybeBacking.packetToFragmentLocation instanceof WeakMap);
287
+ };
288
+ const getTrackEncryptionInfo = (backing) => backing.internalTrack.encryptionInfo;
289
+ const getKeyId = (backing) => backing.internalTrack.encryptionInfo?.defaultKid ?? null;
290
+ const getEncryptedSample = async (backing, packet) => {
291
+ const internalTrack = backing.internalTrack;
292
+ const fragmentLocation = backing.packetToFragmentLocation.get(packet);
293
+ if (fragmentLocation) {
294
+ const fragment = fragmentLocation.fragment;
295
+ const sample = fragment.trackData.get(internalTrack.id)?.samples[fragmentLocation.sampleIndex];
296
+ if (!sample?.encryption) return null;
297
+ return {
298
+ data: await readSampleBytes(internalTrack, sample.byteOffset, sample.byteSize),
299
+ sampleEncryption: sample.encryption,
300
+ fragment
301
+ };
302
+ }
303
+ const sampleIndex = backing.packetToSampleIndex.get(packet);
304
+ if (sampleIndex === void 0) return null;
305
+ const sampleInfo = getSampleInfo(internalTrack.demuxer.getSampleTableForTrack(internalTrack), sampleIndex);
306
+ if (!sampleInfo) return null;
307
+ const auxInfo = internalTrack.encryptionAuxInfo;
308
+ if (!auxInfo) return null;
309
+ const sampleEncryption = (await resolveEncryptionAuxInfo(internalTrack, auxInfo))[sampleIndex];
310
+ if (!sampleEncryption) return null;
311
+ return {
312
+ data: await readSampleBytes(internalTrack, sampleInfo.sampleOffset, sampleInfo.sampleSize),
313
+ sampleEncryption,
314
+ fragment: null
315
+ };
316
+ };
317
+ const getPsshBoxes = (backing, keyId, fragment) => {
318
+ const { psshBoxes } = backing.internalTrack.demuxer;
319
+ if (!fragment) return psshBoxes;
320
+ const boxes = [...psshBoxes, ...fragment.psshBoxes].filter((psshBox) => psshBox.keyIds === null || psshBox.keyIds.includes(keyId));
321
+ for (let i = 0; i < boxes.length - 1; i++) for (let j = i + 1; j < boxes.length; j++) if (psshBoxesAreEqual(boxes[i], boxes[j])) {
322
+ boxes.splice(j, 1);
323
+ j--;
324
+ }
325
+ return boxes;
326
+ };
327
+ //#endregion
328
+ //#region src/isobmff-decrypt-sample-patcher.ts
329
+ const createMetadataOnlyOptions = (options) => ({
330
+ ...options,
331
+ metadataOnly: true,
332
+ verifyKeyPackets: false
333
+ });
334
+ var IsobmffDecryptSamplePatcher = class {
335
+ input;
336
+ decryptSample;
337
+ reportEncryptionInfo;
338
+ patchableTrackBackings = /* @__PURE__ */ new WeakSet();
339
+ patchTrackBackingsPromise = null;
340
+ constructor(input, decryptSample, reportEncryptionInfo) {
341
+ this.input = input;
342
+ this.decryptSample = decryptSample;
343
+ this.reportEncryptionInfo = reportEncryptionInfo;
344
+ }
345
+ async ensurePatched() {
346
+ this.patchTrackBackingsPromise ??= (async () => {
347
+ const tracks = await this.input.getTracks();
348
+ for (const track of tracks) {
349
+ const backing = track._backing;
350
+ if (!isIsobmffTrackBacking(backing) || this.patchableTrackBackings.has(backing)) continue;
351
+ this.patchableTrackBackings.add(backing);
352
+ this.patchTrackBacking(backing);
353
+ }
354
+ })();
355
+ await this.patchTrackBackingsPromise;
356
+ }
357
+ patchTrackBacking(backing) {
358
+ const packetToMetadataPacket = /* @__PURE__ */ new WeakMap();
359
+ const getOriginalPacket = (packet) => packetToMetadataPacket.get(packet) ?? packet;
360
+ const getFirstPacket = backing.getFirstPacket.bind(backing);
361
+ backing.getFirstPacket = async (options) => {
362
+ if (!this.shouldUseCustomDecryptSample(backing, options)) return getFirstPacket(options);
363
+ const metadataPacket = await getFirstPacket(createMetadataOnlyOptions(options));
364
+ return this.materializePacket(backing, metadataPacket, () => getFirstPacket(options), packetToMetadataPacket);
365
+ };
366
+ const getPacket = backing.getPacket.bind(backing);
367
+ backing.getPacket = async (timestamp, options) => {
368
+ if (!this.shouldUseCustomDecryptSample(backing, options)) return getPacket(timestamp, options);
369
+ const metadataPacket = await getPacket(timestamp, createMetadataOnlyOptions(options));
370
+ return this.materializePacket(backing, metadataPacket, () => getPacket(timestamp, options), packetToMetadataPacket);
371
+ };
372
+ const getNextPacket = backing.getNextPacket.bind(backing);
373
+ backing.getNextPacket = async (packet, options) => {
374
+ const originalPacket = getOriginalPacket(packet);
375
+ if (!this.shouldUseCustomDecryptSample(backing, options)) return getNextPacket(originalPacket, options);
376
+ const metadataPacket = await getNextPacket(originalPacket, createMetadataOnlyOptions(options));
377
+ return this.materializePacket(backing, metadataPacket, () => getNextPacket(originalPacket, options), packetToMetadataPacket);
378
+ };
379
+ const getKeyPacket = backing.getKeyPacket.bind(backing);
380
+ backing.getKeyPacket = async (timestamp, options) => {
381
+ if (!this.shouldUseCustomDecryptSample(backing, options)) return getKeyPacket(timestamp, options);
382
+ const metadataPacket = await getKeyPacket(timestamp, createMetadataOnlyOptions(options));
383
+ return this.materializePacket(backing, metadataPacket, () => getKeyPacket(timestamp, options), packetToMetadataPacket);
384
+ };
385
+ const getNextKeyPacket = backing.getNextKeyPacket.bind(backing);
386
+ backing.getNextKeyPacket = async (packet, options) => {
387
+ const originalPacket = getOriginalPacket(packet);
388
+ if (!this.shouldUseCustomDecryptSample(backing, options)) return getNextKeyPacket(originalPacket, options);
389
+ const metadataPacket = await getNextKeyPacket(originalPacket, createMetadataOnlyOptions(options));
390
+ return this.materializePacket(backing, metadataPacket, () => getNextKeyPacket(originalPacket, options), packetToMetadataPacket);
391
+ };
392
+ }
393
+ shouldUseCustomDecryptSample(backing, options) {
394
+ return Boolean(!options.metadataOnly && getTrackEncryptionInfo(backing)?.defaultIsProtected !== false);
395
+ }
396
+ async materializePacket(backing, metadataPacket, fallback, packetToMetadataPacket) {
397
+ if (!metadataPacket) return null;
398
+ const decryptedPacket = await this.decryptPacket(backing, metadataPacket, fallback);
399
+ if (decryptedPacket && decryptedPacket !== metadataPacket) packetToMetadataPacket.set(decryptedPacket, metadataPacket);
400
+ return decryptedPacket;
401
+ }
402
+ async decryptPacket(backing, metadataPacket, fallback) {
403
+ const encryptionInfo = getTrackEncryptionInfo(backing);
404
+ if (!encryptionInfo?.defaultIsProtected) return fallback();
405
+ const keyId = getKeyId(backing);
406
+ if (!keyId) throw new Error("Encrypted sample encountered without a key ID.");
407
+ const encryptedSample = await getEncryptedSample(backing, metadataPacket);
408
+ if (!encryptedSample) return fallback();
409
+ const psshBoxes = getPsshBoxes(backing, keyId, encryptedSample.fragment);
410
+ this.reportEncryptionInfo(keyId, psshBoxes);
411
+ const decryptedData = await this.decryptSample({
412
+ data: encryptedSample.data,
413
+ keyId,
414
+ psshBoxes,
415
+ scheme: encryptionInfo.scheme,
416
+ iv: encryptedSample.sampleEncryption.iv.slice(),
417
+ timestamp: metadataPacket.microsecondTimestamp,
418
+ subsamples: cloneSubsamples(encryptedSample.sampleEncryption.subsamples),
419
+ pattern: getPattern(encryptionInfo)
420
+ });
421
+ if (!isUint8Array(decryptedData)) throw new TypeError("decryption callback must return a Uint8Array.");
422
+ if (decryptedData.byteLength !== metadataPacket.byteLength) throw new Error("decryption callback must return the same number of bytes as the encrypted sample.");
423
+ return new EncodedPacket(decryptedData, metadataPacket.type, metadataPacket.timestamp, metadataPacket.duration, metadataPacket.sequenceNumber, metadataPacket.byteLength, metadataPacket.sideData);
424
+ }
425
+ };
426
+ //#endregion
2
427
  //#region src/index.ts
428
+ const createEncryptionReporter = (handleEncryptionInfo) => {
429
+ const reportedEncryptionInfo = /* @__PURE__ */ new Set();
430
+ return (keyId, psshBoxes) => {
431
+ if (!handleEncryptionInfo || reportedEncryptionInfo.has(keyId)) return;
432
+ reportedEncryptionInfo.add(keyId);
433
+ handleEncryptionInfo({
434
+ keyId,
435
+ psshBoxes
436
+ });
437
+ };
438
+ };
3
439
  var Input = class extends Input$1 {
4
- constructor({ keys, handleEncryptionInfo, ...options }) {
440
+ decryptSamplePatcher;
441
+ constructor({ keys, decryptSample, handleEncryptionInfo, ...options }) {
442
+ if (!keys?.size && !decryptSample) throw new Error("Either keys or decryptSample must be provided.");
443
+ const reportEncryptionInfo = createEncryptionReporter(handleEncryptionInfo);
5
444
  super({
6
445
  ...options,
7
446
  formats: ALL_FORMATS,
8
447
  formatOptions: { isobmff: { resolveKeyId: ({ keyId, psshBoxes }) => {
9
- handleEncryptionInfo?.({
10
- keyId,
11
- psshBoxes
12
- });
13
- const key = keys.get(keyId);
448
+ reportEncryptionInfo(keyId, psshBoxes);
449
+ const key = keys?.get(keyId);
14
450
  if (!key) throw new Error("Unknown key ID.");
15
451
  return key;
16
452
  } } }
17
453
  });
454
+ this.decryptSamplePatcher = decryptSample ? new IsobmffDecryptSamplePatcher({ getTracks: (query) => this.getTracksWithoutPatching(query) }, decryptSample, reportEncryptionInfo) : null;
455
+ }
456
+ async getTracks(query) {
457
+ await this.decryptSamplePatcher?.ensurePatched();
458
+ return super.getTracks(query);
459
+ }
460
+ async getTracksWithoutPatching(query) {
461
+ return super.getTracks(query);
18
462
  }
19
463
  };
20
464
  var Output = class extends Output$1 {
@@ -26,4 +470,4 @@ var Output = class extends Output$1 {
26
470
  }
27
471
  };
28
472
  //#endregion
29
- export { Conversion as Decryption, FilePathSource, FilePathTarget, Input, MkvOutputFormat, MovOutputFormat, Mp3OutputFormat, Mp4OutputFormat, Output, ReadableStreamSource, StreamSource, StreamTarget, UrlSource };
473
+ export { Conversion as Decryption, FilePathSource, FilePathTarget, Input, MkvOutputFormat, MovOutputFormat, Mp3OutputFormat, Mp4OutputFormat, Output, ReadableStreamSource, StreamSource, StreamTarget, UrlSource, decryptBytes };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shifro",
3
- "version": "1.1.5",
3
+ "version": "1.2.1",
4
4
  "description": "A lightweight library and command-line tool for decrypting MP4 files",
5
5
  "files": [
6
6
  "dist"