mediabunny 1.0.2 → 1.0.4

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.
Files changed (188) hide show
  1. package/README.md +4 -4
  2. package/dist/mediabunny.d.ts +4 -2
  3. package/dist/modules/codec-data.d.ts +95 -0
  4. package/dist/modules/codec-data.d.ts.map +1 -0
  5. package/dist/modules/codec-data.js +1103 -0
  6. package/dist/modules/codec.d.ts +250 -0
  7. package/dist/modules/codec.d.ts.map +1 -0
  8. package/dist/modules/codec.js +1100 -0
  9. package/dist/modules/conversion.d.ts +158 -0
  10. package/dist/modules/conversion.d.ts.map +1 -0
  11. package/dist/modules/conversion.js +866 -0
  12. package/dist/modules/custom-coder.d.ts +119 -0
  13. package/dist/modules/custom-coder.d.ts.map +1 -0
  14. package/dist/modules/custom-coder.js +91 -0
  15. package/dist/modules/demuxer.d.ts +17 -0
  16. package/dist/modules/demuxer.d.ts.map +1 -0
  17. package/dist/modules/demuxer.js +12 -0
  18. package/dist/modules/index.d.ts +25 -0
  19. package/dist/modules/index.d.ts.map +1 -0
  20. package/dist/modules/index.js +24 -0
  21. package/dist/modules/input-format.d.ts +121 -0
  22. package/dist/modules/input-format.d.ts.map +1 -0
  23. package/dist/modules/input-format.js +333 -0
  24. package/dist/modules/input-track.d.ts +138 -0
  25. package/dist/modules/input-track.d.ts.map +1 -0
  26. package/dist/modules/input-track.js +230 -0
  27. package/dist/modules/input.d.ts +55 -0
  28. package/dist/modules/input.d.ts.map +1 -0
  29. package/dist/modules/input.js +104 -0
  30. package/dist/modules/isobmff/isobmff-boxes.d.ts +202 -0
  31. package/dist/modules/isobmff/isobmff-boxes.d.ts.map +1 -0
  32. package/dist/modules/isobmff/isobmff-boxes.js +1111 -0
  33. package/dist/modules/isobmff/isobmff-demuxer.d.ts +163 -0
  34. package/dist/modules/isobmff/isobmff-demuxer.d.ts.map +1 -0
  35. package/dist/modules/isobmff/isobmff-demuxer.js +2080 -0
  36. package/dist/modules/isobmff/isobmff-misc.d.ts +14 -0
  37. package/dist/modules/isobmff/isobmff-misc.d.ts.map +1 -0
  38. package/dist/modules/isobmff/isobmff-misc.js +20 -0
  39. package/dist/modules/isobmff/isobmff-muxer.d.ts +142 -0
  40. package/dist/modules/isobmff/isobmff-muxer.d.ts.map +1 -0
  41. package/dist/modules/isobmff/isobmff-muxer.js +858 -0
  42. package/dist/modules/isobmff/isobmff-reader.d.ts +36 -0
  43. package/dist/modules/isobmff/isobmff-reader.d.ts.map +1 -0
  44. package/dist/modules/isobmff/isobmff-reader.js +105 -0
  45. package/dist/modules/matroska/ebml.d.ts +156 -0
  46. package/dist/modules/matroska/ebml.d.ts.map +1 -0
  47. package/dist/modules/matroska/ebml.js +529 -0
  48. package/dist/modules/matroska/matroska-demuxer.d.ts +119 -0
  49. package/dist/modules/matroska/matroska-demuxer.d.ts.map +1 -0
  50. package/dist/modules/matroska/matroska-demuxer.js +1330 -0
  51. package/dist/modules/matroska/matroska-misc.d.ts +14 -0
  52. package/dist/modules/matroska/matroska-misc.d.ts.map +1 -0
  53. package/dist/modules/matroska/matroska-misc.js +20 -0
  54. package/dist/modules/matroska/matroska-muxer.d.ts +71 -0
  55. package/dist/modules/matroska/matroska-muxer.d.ts.map +1 -0
  56. package/dist/modules/matroska/matroska-muxer.js +729 -0
  57. package/dist/modules/media-sink.d.ts +272 -0
  58. package/dist/modules/media-sink.d.ts.map +1 -0
  59. package/dist/modules/media-sink.js +1264 -0
  60. package/dist/modules/media-source.d.ts +230 -0
  61. package/dist/modules/media-source.d.ts.map +1 -0
  62. package/dist/modules/media-source.js +1095 -0
  63. package/dist/modules/misc.d.ts +127 -0
  64. package/dist/modules/misc.d.ts.map +1 -0
  65. package/dist/modules/misc.js +462 -0
  66. package/dist/modules/mp3/mp3-demuxer.d.ts +32 -0
  67. package/dist/modules/mp3/mp3-demuxer.d.ts.map +1 -0
  68. package/dist/modules/mp3/mp3-demuxer.js +160 -0
  69. package/dist/modules/mp3/mp3-misc.d.ts +37 -0
  70. package/dist/modules/mp3/mp3-misc.d.ts.map +1 -0
  71. package/dist/modules/mp3/mp3-misc.js +122 -0
  72. package/dist/modules/mp3/mp3-muxer.d.ts +27 -0
  73. package/dist/modules/mp3/mp3-muxer.d.ts.map +1 -0
  74. package/dist/modules/mp3/mp3-muxer.js +112 -0
  75. package/dist/modules/mp3/mp3-reader.d.ts +25 -0
  76. package/dist/modules/mp3/mp3-reader.d.ts.map +1 -0
  77. package/dist/modules/mp3/mp3-reader.js +73 -0
  78. package/dist/modules/mp3/mp3-writer.d.ts +30 -0
  79. package/dist/modules/mp3/mp3-writer.d.ts.map +1 -0
  80. package/dist/modules/mp3/mp3-writer.js +72 -0
  81. package/dist/modules/muxer.d.ts +32 -0
  82. package/dist/modules/muxer.d.ts.map +1 -0
  83. package/dist/modules/muxer.js +50 -0
  84. package/dist/modules/ogg/ogg-demuxer.d.ts +53 -0
  85. package/dist/modules/ogg/ogg-demuxer.d.ts.map +1 -0
  86. package/dist/modules/ogg/ogg-demuxer.js +701 -0
  87. package/dist/modules/ogg/ogg-misc.d.ts +27 -0
  88. package/dist/modules/ogg/ogg-misc.d.ts.map +1 -0
  89. package/dist/modules/ogg/ogg-misc.js +78 -0
  90. package/dist/modules/ogg/ogg-muxer.d.ts +58 -0
  91. package/dist/modules/ogg/ogg-muxer.d.ts.map +1 -0
  92. package/dist/modules/ogg/ogg-muxer.js +345 -0
  93. package/dist/modules/ogg/ogg-reader.d.ts +37 -0
  94. package/dist/modules/ogg/ogg-reader.d.ts.map +1 -0
  95. package/dist/modules/ogg/ogg-reader.js +104 -0
  96. package/dist/modules/output-format.d.ts +285 -0
  97. package/dist/modules/output-format.d.ts.map +1 -0
  98. package/dist/modules/output-format.js +385 -0
  99. package/dist/modules/output.d.ts +134 -0
  100. package/dist/modules/output.d.ts.map +1 -0
  101. package/dist/modules/output.js +269 -0
  102. package/dist/modules/packet.d.ts +86 -0
  103. package/dist/modules/packet.d.ts.map +1 -0
  104. package/dist/modules/packet.js +133 -0
  105. package/dist/modules/pcm.d.ts +12 -0
  106. package/dist/modules/pcm.d.ts.map +1 -0
  107. package/dist/modules/pcm.js +85 -0
  108. package/dist/modules/reader.d.ts +40 -0
  109. package/dist/modules/reader.d.ts.map +1 -0
  110. package/dist/modules/reader.js +153 -0
  111. package/dist/modules/sample.d.ts +186 -0
  112. package/dist/modules/sample.d.ts.map +1 -0
  113. package/dist/modules/sample.js +798 -0
  114. package/dist/modules/source.d.ts +76 -0
  115. package/dist/modules/source.d.ts.map +1 -0
  116. package/dist/modules/source.js +183 -0
  117. package/dist/modules/subtitles.d.ts +37 -0
  118. package/dist/modules/subtitles.d.ts.map +1 -0
  119. package/dist/modules/subtitles.js +89 -0
  120. package/dist/modules/target.d.ts +58 -0
  121. package/dist/modules/target.d.ts.map +1 -0
  122. package/dist/modules/target.js +63 -0
  123. package/dist/modules/wave/riff-reader.d.ts +19 -0
  124. package/dist/modules/wave/riff-reader.d.ts.map +1 -0
  125. package/dist/modules/wave/riff-reader.js +38 -0
  126. package/dist/modules/wave/riff-writer.d.ts +18 -0
  127. package/dist/modules/wave/riff-writer.d.ts.map +1 -0
  128. package/dist/modules/wave/riff-writer.js +25 -0
  129. package/dist/modules/wave/wave-demuxer.d.ts +42 -0
  130. package/dist/modules/wave/wave-demuxer.d.ts.map +1 -0
  131. package/dist/modules/wave/wave-demuxer.js +239 -0
  132. package/dist/modules/wave/wave-muxer.d.ts +27 -0
  133. package/dist/modules/wave/wave-muxer.d.ts.map +1 -0
  134. package/dist/modules/wave/wave-muxer.js +108 -0
  135. package/dist/modules/writer.d.ts +85 -0
  136. package/dist/modules/writer.d.ts.map +1 -0
  137. package/dist/modules/writer.js +347 -0
  138. package/package.json +14 -10
  139. package/src/codec-data.ts +1394 -0
  140. package/src/codec.ts +1392 -0
  141. package/src/conversion.ts +1199 -0
  142. package/src/custom-coder.ts +162 -0
  143. package/src/demuxer.ts +22 -0
  144. package/src/index.ts +148 -0
  145. package/src/input-format.ts +386 -0
  146. package/src/input-track.ts +341 -0
  147. package/src/input.ts +138 -0
  148. package/src/isobmff/isobmff-boxes.ts +1365 -0
  149. package/src/isobmff/isobmff-demuxer.ts +2646 -0
  150. package/src/isobmff/isobmff-misc.ts +29 -0
  151. package/src/isobmff/isobmff-muxer.ts +1210 -0
  152. package/src/isobmff/isobmff-reader.ts +137 -0
  153. package/src/matroska/ebml.ts +595 -0
  154. package/src/matroska/matroska-demuxer.ts +1677 -0
  155. package/src/matroska/matroska-misc.ts +29 -0
  156. package/src/matroska/matroska-muxer.ts +999 -0
  157. package/src/media-sink.ts +1577 -0
  158. package/src/media-source.ts +1406 -0
  159. package/src/misc.ts +584 -0
  160. package/src/mp3/mp3-demuxer.ts +228 -0
  161. package/src/mp3/mp3-misc.ts +154 -0
  162. package/src/mp3/mp3-muxer.ts +153 -0
  163. package/src/mp3/mp3-reader.ts +93 -0
  164. package/src/mp3/mp3-writer.ts +112 -0
  165. package/src/muxer.ts +88 -0
  166. package/src/ogg/ogg-demuxer.ts +985 -0
  167. package/src/ogg/ogg-misc.ts +116 -0
  168. package/src/ogg/ogg-muxer.ts +480 -0
  169. package/src/ogg/ogg-reader.ts +143 -0
  170. package/src/output-format.ts +689 -0
  171. package/src/output.ts +427 -0
  172. package/src/packet.ts +180 -0
  173. package/src/pcm.ts +112 -0
  174. package/src/reader.ts +205 -0
  175. package/src/sample.ts +1029 -0
  176. package/src/source.ts +279 -0
  177. package/src/subtitles.ts +131 -0
  178. package/src/target.ts +106 -0
  179. package/src/tsconfig.json +13 -0
  180. package/src/wave/riff-reader.ts +48 -0
  181. package/src/wave/riff-writer.ts +30 -0
  182. package/src/wave/wave-demuxer.ts +317 -0
  183. package/src/wave/wave-muxer.ts +145 -0
  184. package/src/writer.ts +461 -0
  185. /package/dist/{mediabunny.js → bundles/mediabunny.cjs} +0 -0
  186. /package/dist/{mediabunny.min.js → bundles/mediabunny.min.cjs} +0 -0
  187. /package/dist/{mediabunny.min.mjs → bundles/mediabunny.min.mjs} +0 -0
  188. /package/dist/{mediabunny.mjs → bundles/mediabunny.mjs} +0 -0
@@ -0,0 +1,1210 @@
1
+ /*!
2
+ * Copyright (c) 2025-present, Vanilagy and contributors
3
+ *
4
+ * This Source Code Form is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
9
+ import { Box, ftyp, IsobmffBoxWriter, mdat, mfra, moof, moov, vtta, vttc, vtte } from './isobmff-boxes';
10
+ import { Muxer } from '../muxer';
11
+ import { Output, OutputAudioTrack, OutputSubtitleTrack, OutputTrack, OutputVideoTrack } from '../output';
12
+ import { BufferTargetWriter, Writer } from '../writer';
13
+ import { assert, computeRationalApproximation, last, promiseWithResolvers } from '../misc';
14
+ import { IsobmffOutputFormatOptions, IsobmffOutputFormat, MovOutputFormat } from '../output-format';
15
+ import { inlineTimestampRegex, SubtitleConfig, SubtitleCue, SubtitleMetadata } from '../subtitles';
16
+ import {
17
+ parsePcmCodec,
18
+ PCM_AUDIO_CODECS,
19
+ PcmAudioCodec,
20
+ SubtitleCodec,
21
+ validateAudioChunkMetadata,
22
+ validateSubtitleMetadata,
23
+ validateVideoChunkMetadata,
24
+ } from '../codec';
25
+ import { BufferTarget } from '../target';
26
+ import { EncodedPacket, PacketType } from '../packet';
27
+ import {
28
+ extractAvcDecoderConfigurationRecord,
29
+ extractHevcDecoderConfigurationRecord,
30
+ serializeAvcDecoderConfigurationRecord,
31
+ serializeHevcDecoderConfigurationRecord,
32
+ transformAnnexBToLengthPrefixed,
33
+ } from '../codec-data';
34
+ import { buildIsobmffMimeType } from './isobmff-misc';
35
+
36
+ export const GLOBAL_TIMESCALE = 1000;
37
+ const TIMESTAMP_OFFSET = 2_082_844_800; // Seconds between Jan 1 1904 and Jan 1 1970
38
+
39
+ export type Sample = {
40
+ timestamp: number;
41
+ decodeTimestamp: number;
42
+ duration: number;
43
+ data: Uint8Array | null;
44
+ size: number;
45
+ type: PacketType;
46
+ timescaleUnitsToNextSample: number;
47
+ };
48
+
49
+ type Chunk = {
50
+ /** The lowest presentation timestamp in this chunk */
51
+ startTimestamp: number;
52
+ samples: Sample[];
53
+ offset: number | null;
54
+ // In the case of a fragmented file, this indicates the position of the moof box pointing to the data in this chunk
55
+ moofOffset: number | null;
56
+ };
57
+
58
+ export type IsobmffTrackData = {
59
+ muxer: IsobmffMuxer;
60
+ timescale: number;
61
+ samples: Sample[];
62
+ sampleQueue: Sample[]; // For fragmented files
63
+ timestampProcessingQueue: Sample[];
64
+
65
+ timeToSampleTable: { sampleCount: number; sampleDelta: number }[];
66
+ compositionTimeOffsetTable: { sampleCount: number; sampleCompositionTimeOffset: number }[];
67
+ lastTimescaleUnits: number | null;
68
+ lastSample: Sample | null;
69
+
70
+ finalizedChunks: Chunk[];
71
+ currentChunk: Chunk | null;
72
+ compactlyCodedChunkTable: {
73
+ firstChunk: number;
74
+ samplesPerChunk: number;
75
+ }[];
76
+ } & ({
77
+ track: OutputVideoTrack;
78
+ type: 'video';
79
+ info: {
80
+ width: number;
81
+ height: number;
82
+ decoderConfig: VideoDecoderConfig;
83
+ /**
84
+ * The "Annex B transformation" involves converting the raw packet data from Annex B to
85
+ * "MP4" (length-prefixed) format.
86
+ * https://stackoverflow.com/questions/24884827
87
+ */
88
+ requiresAnnexBTransformation: boolean;
89
+ };
90
+ } | {
91
+ track: OutputAudioTrack;
92
+ type: 'audio';
93
+ info: {
94
+ numberOfChannels: number;
95
+ sampleRate: number;
96
+ decoderConfig: AudioDecoderConfig;
97
+ /**
98
+ * The "PCM transformation" is making every sample in the sample table be exactly one PCM audio sample long.
99
+ * Some players expect this for PCM audio.
100
+ */
101
+ requiresPcmTransformation: boolean;
102
+ };
103
+ } | {
104
+ track: OutputSubtitleTrack;
105
+ type: 'subtitle';
106
+ info: {
107
+ config: SubtitleConfig;
108
+ };
109
+ lastCueEndTimestamp: number;
110
+ cueQueue: SubtitleCue[];
111
+ nextSourceId: number;
112
+ cueToSourceId: WeakMap<SubtitleCue, number>;
113
+ });
114
+
115
+ export type IsobmffVideoTrackData = IsobmffTrackData & { type: 'video' };
116
+ export type IsobmffAudioTrackData = IsobmffTrackData & { type: 'audio' };
117
+ export type IsobmffSubtitleTrackData = IsobmffTrackData & { type: 'subtitle' };
118
+
119
+ export const intoTimescale = (timeInSeconds: number, timescale: number, round = true) => {
120
+ const value = timeInSeconds * timescale;
121
+ return round ? Math.round(value) : value;
122
+ };
123
+
124
+ export class IsobmffMuxer extends Muxer {
125
+ private format: IsobmffOutputFormat;
126
+ private writer: Writer;
127
+ private boxWriter: IsobmffBoxWriter;
128
+ private fastStart: NonNullable<IsobmffOutputFormatOptions['fastStart']>;
129
+ private isFragmented: boolean;
130
+
131
+ isQuickTime: boolean;
132
+
133
+ private auxTarget = new BufferTarget();
134
+ private auxWriter = this.auxTarget._createWriter();
135
+ private auxBoxWriter = new IsobmffBoxWriter(this.auxWriter);
136
+
137
+ private mdat: Box | null = null;
138
+
139
+ private trackDatas: IsobmffTrackData[] = [];
140
+ private allTracksKnown = promiseWithResolvers();
141
+
142
+ private creationTime = Math.floor(Date.now() / 1000) + TIMESTAMP_OFFSET;
143
+ private finalizedChunks: Chunk[] = [];
144
+
145
+ private nextFragmentNumber = 1;
146
+ // Only relevant for fragmented files, to make sure new fragments start with the highest timestamp seen so far
147
+ private maxWrittenTimestamp = -Infinity;
148
+ private minimumFragmentDuration: number;
149
+
150
+ constructor(output: Output, format: IsobmffOutputFormat) {
151
+ super(output);
152
+
153
+ this.format = format;
154
+ this.writer = output._writer;
155
+ this.boxWriter = new IsobmffBoxWriter(this.writer);
156
+
157
+ this.isQuickTime = format instanceof MovOutputFormat;
158
+
159
+ // If the fastStart option isn't defined, enable in-memory fast start if the target is an ArrayBuffer, as the
160
+ // memory usage remains identical
161
+ const fastStartDefault = this.writer instanceof BufferTargetWriter ? 'in-memory' : false;
162
+ this.fastStart = format._options.fastStart ?? fastStartDefault;
163
+ this.isFragmented = this.fastStart === 'fragmented';
164
+
165
+ if (this.fastStart === 'in-memory' || this.isFragmented) {
166
+ this.writer.ensureMonotonicity = true;
167
+ }
168
+
169
+ this.minimumFragmentDuration = format._options.minimumFragmentDuration ?? 1;
170
+ }
171
+
172
+ async start() {
173
+ const release = await this.mutex.acquire();
174
+
175
+ const holdsAvc = this.output._tracks.some(x => x.type === 'video' && x.source._codec === 'avc');
176
+
177
+ // Write the header
178
+ {
179
+ if (this.format._options.onFtyp) {
180
+ this.writer.startTrackingWrites();
181
+ }
182
+
183
+ this.boxWriter.writeBox(ftyp({
184
+ isQuickTime: this.isQuickTime,
185
+ holdsAvc: holdsAvc,
186
+ fragmented: this.isFragmented,
187
+ }));
188
+
189
+ if (this.format._options.onFtyp) {
190
+ const { data, start } = this.writer.stopTrackingWrites();
191
+ this.format._options.onFtyp(data, start);
192
+ }
193
+ }
194
+
195
+ if (this.fastStart === 'in-memory') {
196
+ this.mdat = mdat(false);
197
+ } else if (this.isFragmented) {
198
+ // We write the moov box once we write out the first fragment to make sure we get the decoder configs
199
+ } else {
200
+ if (this.format._options.onMdat) {
201
+ this.writer.startTrackingWrites();
202
+ }
203
+
204
+ this.mdat = mdat(true); // Reserve large size by default, can refine this when finalizing.
205
+ this.boxWriter.writeBox(this.mdat);
206
+ }
207
+
208
+ await this.writer.flush();
209
+
210
+ release();
211
+ }
212
+
213
+ private allTracksAreKnown() {
214
+ for (const track of this.output._tracks) {
215
+ if (!track.source._closed && !this.trackDatas.some(x => x.track === track)) {
216
+ return false; // We haven't seen a sample from this open track yet
217
+ }
218
+ }
219
+
220
+ return true;
221
+ }
222
+
223
+ async getMimeType() {
224
+ await this.allTracksKnown.promise;
225
+
226
+ const codecStrings = this.trackDatas.map((trackData) => {
227
+ if (trackData.type === 'video') {
228
+ return trackData.info.decoderConfig.codec;
229
+ } else if (trackData.type === 'audio') {
230
+ return trackData.info.decoderConfig.codec;
231
+ } else {
232
+ const map: Record<SubtitleCodec, string> = {
233
+ webvtt: 'wvtt',
234
+ };
235
+ return map[trackData.track.source._codec];
236
+ }
237
+ });
238
+
239
+ return buildIsobmffMimeType({
240
+ isQuickTime: this.isQuickTime,
241
+ hasVideo: this.trackDatas.some(x => x.type === 'video'),
242
+ hasAudio: this.trackDatas.some(x => x.type === 'audio'),
243
+ codecStrings,
244
+ });
245
+ }
246
+
247
+ private getVideoTrackData(track: OutputVideoTrack, packet: EncodedPacket, meta?: EncodedVideoChunkMetadata) {
248
+ const existingTrackData = this.trackDatas.find(x => x.track === track);
249
+ if (existingTrackData) {
250
+ return existingTrackData as IsobmffVideoTrackData;
251
+ }
252
+
253
+ validateVideoChunkMetadata(meta);
254
+
255
+ assert(meta);
256
+ assert(meta.decoderConfig);
257
+
258
+ const decoderConfig = { ...meta.decoderConfig };
259
+ assert(decoderConfig.codedWidth !== undefined);
260
+ assert(decoderConfig.codedHeight !== undefined);
261
+
262
+ let requiresAnnexBTransformation = false;
263
+
264
+ if (track.source._codec === 'avc' && !decoderConfig.description) {
265
+ // ISOBMFF can only hold AVC in the AVCC format, not in Annex B, but the missing description indicates
266
+ // Annex B. This means we'll need to do some converterino.
267
+
268
+ const decoderConfigurationRecord = extractAvcDecoderConfigurationRecord(packet.data);
269
+ if (!decoderConfigurationRecord) {
270
+ throw new Error(
271
+ 'Couldn\'t extract an AVCDecoderConfigurationRecord from the AVC packet. Make sure the packets are'
272
+ + ' in Annex B format (as specified in ITU-T-REC-H.264) when not providing a description, or'
273
+ + ' provide a description (must be an AVCDecoderConfigurationRecord as specified in ISO 14496-15)'
274
+ + ' and ensure the packets are in AVCC format.',
275
+ );
276
+ }
277
+
278
+ decoderConfig.description = serializeAvcDecoderConfigurationRecord(decoderConfigurationRecord);
279
+ requiresAnnexBTransformation = true;
280
+ } else if (track.source._codec === 'hevc' && !decoderConfig.description) {
281
+ // ISOBMFF can only hold HEVC in the HEVC format, not in Annex B, but the missing description indicates
282
+ // Annex B. This means we'll need to do some converterino.
283
+
284
+ const decoderConfigurationRecord = extractHevcDecoderConfigurationRecord(packet.data);
285
+ if (!decoderConfigurationRecord) {
286
+ throw new Error(
287
+ 'Couldn\'t extract an HEVCDecoderConfigurationRecord from the HEVC packet. Make sure the packets'
288
+ + ' are in Annex B format (as specified in ITU-T-REC-H.265) when not providing a description, or'
289
+ + ' provide a description (must be an HEVCDecoderConfigurationRecord as specified in ISO 14496-15)'
290
+ + ' and ensure the packets are in HEVC format.',
291
+ );
292
+ }
293
+
294
+ decoderConfig.description = serializeHevcDecoderConfigurationRecord(decoderConfigurationRecord);
295
+ requiresAnnexBTransformation = true;
296
+ }
297
+
298
+ // The frame rate set by the user may not be an integer. Since timescale is an integer, we'll approximate the
299
+ // frame time (inverse of frame rate) with a rational number, then use that approximation's denominator
300
+ // as the timescale.
301
+ const timescale = computeRationalApproximation(1 / (track.metadata.frameRate ?? 57600), 1e6).denominator;
302
+
303
+ const newTrackData: IsobmffVideoTrackData = {
304
+ muxer: this,
305
+ track,
306
+ type: 'video',
307
+ info: {
308
+ width: decoderConfig.codedWidth,
309
+ height: decoderConfig.codedHeight,
310
+ decoderConfig: decoderConfig,
311
+ requiresAnnexBTransformation,
312
+ },
313
+ timescale,
314
+ samples: [],
315
+ sampleQueue: [],
316
+ timestampProcessingQueue: [],
317
+ timeToSampleTable: [],
318
+ compositionTimeOffsetTable: [],
319
+ lastTimescaleUnits: null,
320
+ lastSample: null,
321
+ finalizedChunks: [],
322
+ currentChunk: null,
323
+ compactlyCodedChunkTable: [],
324
+ };
325
+
326
+ this.trackDatas.push(newTrackData);
327
+ this.trackDatas.sort((a, b) => a.track.id - b.track.id);
328
+
329
+ if (this.allTracksAreKnown()) {
330
+ this.allTracksKnown.resolve();
331
+ }
332
+
333
+ return newTrackData;
334
+ }
335
+
336
+ private getAudioTrackData(track: OutputAudioTrack, meta?: EncodedAudioChunkMetadata) {
337
+ const existingTrackData = this.trackDatas.find(x => x.track === track);
338
+ if (existingTrackData) {
339
+ return existingTrackData as IsobmffAudioTrackData;
340
+ }
341
+
342
+ validateAudioChunkMetadata(meta);
343
+
344
+ assert(meta);
345
+ assert(meta.decoderConfig);
346
+
347
+ const newTrackData: IsobmffAudioTrackData = {
348
+ muxer: this,
349
+ track,
350
+ type: 'audio',
351
+ info: {
352
+ numberOfChannels: meta.decoderConfig.numberOfChannels,
353
+ sampleRate: meta.decoderConfig.sampleRate,
354
+ decoderConfig: meta.decoderConfig,
355
+ requiresPcmTransformation:
356
+ !this.isFragmented
357
+ && (PCM_AUDIO_CODECS as readonly string[]).includes(track.source._codec),
358
+ },
359
+ timescale: meta.decoderConfig.sampleRate,
360
+ samples: [],
361
+ sampleQueue: [],
362
+ timestampProcessingQueue: [],
363
+ timeToSampleTable: [],
364
+ compositionTimeOffsetTable: [],
365
+ lastTimescaleUnits: null,
366
+ lastSample: null,
367
+ finalizedChunks: [],
368
+ currentChunk: null,
369
+ compactlyCodedChunkTable: [],
370
+ };
371
+
372
+ this.trackDatas.push(newTrackData);
373
+ this.trackDatas.sort((a, b) => a.track.id - b.track.id);
374
+
375
+ if (this.allTracksAreKnown()) {
376
+ this.allTracksKnown.resolve();
377
+ }
378
+
379
+ return newTrackData;
380
+ }
381
+
382
+ private getSubtitleTrackData(track: OutputSubtitleTrack, meta?: SubtitleMetadata) {
383
+ const existingTrackData = this.trackDatas.find(x => x.track === track);
384
+ if (existingTrackData) {
385
+ return existingTrackData as IsobmffSubtitleTrackData;
386
+ }
387
+
388
+ validateSubtitleMetadata(meta);
389
+
390
+ assert(meta);
391
+ assert(meta.config);
392
+
393
+ const newTrackData: IsobmffSubtitleTrackData = {
394
+ muxer: this,
395
+ track,
396
+ type: 'subtitle',
397
+ info: {
398
+ config: meta.config,
399
+ },
400
+ timescale: 1000, // Reasonable
401
+ samples: [],
402
+ sampleQueue: [],
403
+ timestampProcessingQueue: [],
404
+ timeToSampleTable: [],
405
+ compositionTimeOffsetTable: [],
406
+ lastTimescaleUnits: null,
407
+ lastSample: null,
408
+ finalizedChunks: [],
409
+ currentChunk: null,
410
+ compactlyCodedChunkTable: [],
411
+
412
+ lastCueEndTimestamp: 0,
413
+ cueQueue: [],
414
+ nextSourceId: 0,
415
+ cueToSourceId: new WeakMap(),
416
+ };
417
+
418
+ this.trackDatas.push(newTrackData);
419
+ this.trackDatas.sort((a, b) => a.track.id - b.track.id);
420
+
421
+ if (this.allTracksAreKnown()) {
422
+ this.allTracksKnown.resolve();
423
+ }
424
+
425
+ return newTrackData;
426
+ }
427
+
428
+ async addEncodedVideoPacket(track: OutputVideoTrack, packet: EncodedPacket, meta?: EncodedVideoChunkMetadata) {
429
+ const release = await this.mutex.acquire();
430
+
431
+ try {
432
+ const trackData = this.getVideoTrackData(track, packet, meta);
433
+
434
+ let packetData = packet.data;
435
+ if (trackData.info.requiresAnnexBTransformation) {
436
+ const transformedData = transformAnnexBToLengthPrefixed(packetData);
437
+ if (!transformedData) {
438
+ throw new Error(
439
+ 'Failed to transform packet data. Make sure all packets are provided in Annex B format, as'
440
+ + ' specified in ITU-T-REC-H.264 and ITU-T-REC-H.265.',
441
+ );
442
+ }
443
+
444
+ packetData = transformedData;
445
+ }
446
+
447
+ const timestamp = this.validateAndNormalizeTimestamp(
448
+ trackData.track,
449
+ packet.timestamp,
450
+ packet.type === 'key',
451
+ );
452
+ const internalSample = this.createSampleForTrack(
453
+ trackData,
454
+ packetData,
455
+ timestamp,
456
+ packet.duration,
457
+ packet.type,
458
+ );
459
+
460
+ await this.registerSample(trackData, internalSample);
461
+ } finally {
462
+ release();
463
+ }
464
+ }
465
+
466
+ async addEncodedAudioPacket(track: OutputAudioTrack, packet: EncodedPacket, meta?: EncodedAudioChunkMetadata) {
467
+ const release = await this.mutex.acquire();
468
+
469
+ try {
470
+ const trackData = this.getAudioTrackData(track, meta);
471
+
472
+ const timestamp = this.validateAndNormalizeTimestamp(
473
+ trackData.track,
474
+ packet.timestamp,
475
+ packet.type === 'key',
476
+ );
477
+ const internalSample = this.createSampleForTrack(
478
+ trackData,
479
+ packet.data,
480
+ timestamp,
481
+ packet.duration,
482
+ packet.type,
483
+ );
484
+
485
+ if (trackData.info.requiresPcmTransformation) {
486
+ await this.maybePadWithSilence(trackData, timestamp);
487
+ }
488
+
489
+ await this.registerSample(trackData, internalSample);
490
+ } finally {
491
+ release();
492
+ }
493
+ }
494
+
495
+ private async maybePadWithSilence(trackData: IsobmffAudioTrackData, untilTimestamp: number) {
496
+ // The PCM transformation assumes that all samples are contiguous. This is not something that is enforced, so
497
+ // we need to pad the "holes" in between samples (and before the first sample) with additional
498
+ // "silence samples".
499
+
500
+ const lastSample = last(trackData.samples);
501
+ const lastEndTimestamp = lastSample
502
+ ? lastSample.timestamp + lastSample.duration
503
+ : 0;
504
+
505
+ const delta = untilTimestamp - lastEndTimestamp;
506
+ const deltaInTimescale = intoTimescale(delta, trackData.timescale);
507
+
508
+ if (deltaInTimescale > 0) {
509
+ const { sampleSize, silentValue } = parsePcmCodec(
510
+ trackData.info.decoderConfig.codec as PcmAudioCodec,
511
+ );
512
+ const samplesNeeded = deltaInTimescale * trackData.info.numberOfChannels;
513
+ const data = new Uint8Array(sampleSize * samplesNeeded).fill(silentValue);
514
+
515
+ const paddingSample = this.createSampleForTrack(
516
+ trackData,
517
+ new Uint8Array(data.buffer),
518
+ lastEndTimestamp,
519
+ delta,
520
+ 'key',
521
+ );
522
+ await this.registerSample(trackData, paddingSample);
523
+ }
524
+ }
525
+
526
+ async addSubtitleCue(track: OutputSubtitleTrack, cue: SubtitleCue, meta?: SubtitleMetadata) {
527
+ const release = await this.mutex.acquire();
528
+
529
+ try {
530
+ const trackData = this.getSubtitleTrackData(track, meta);
531
+
532
+ this.validateAndNormalizeTimestamp(trackData.track, cue.timestamp, true);
533
+
534
+ if (track.source._codec === 'webvtt') {
535
+ trackData.cueQueue.push(cue);
536
+ await this.processWebVTTCues(trackData, cue.timestamp);
537
+ } else {
538
+ // TODO
539
+ }
540
+ } finally {
541
+ release();
542
+ }
543
+ }
544
+
545
+ private async processWebVTTCues(trackData: IsobmffSubtitleTrackData, until: number) {
546
+ // WebVTT cues need to undergo special processing as empty sections need to be padded out with samples, and
547
+ // overlapping samples require special logic. The algorithm produces the format specified in ISO 14496-30.
548
+
549
+ while (trackData.cueQueue.length > 0) {
550
+ const timestamps = new Set<number>([]);
551
+ for (const cue of trackData.cueQueue) {
552
+ assert(cue.timestamp <= until);
553
+ assert(trackData.lastCueEndTimestamp <= cue.timestamp + cue.duration);
554
+
555
+ timestamps.add(Math.max(cue.timestamp, trackData.lastCueEndTimestamp)); // Start timestamp
556
+ timestamps.add(cue.timestamp + cue.duration); // End timestamp
557
+ }
558
+
559
+ const sortedTimestamps = [...timestamps].sort((a, b) => a - b);
560
+
561
+ // These are the timestamps of the next sample we'll create:
562
+ const sampleStart = sortedTimestamps[0]!;
563
+ const sampleEnd = sortedTimestamps[1] ?? sampleStart;
564
+
565
+ if (until < sampleEnd) {
566
+ break;
567
+ }
568
+
569
+ // We may need to pad out empty space with an vtte box
570
+ if (trackData.lastCueEndTimestamp < sampleStart) {
571
+ this.auxWriter.seek(0);
572
+ const box = vtte();
573
+ this.auxBoxWriter.writeBox(box);
574
+
575
+ const body = this.auxWriter.getSlice(0, this.auxWriter.getPos());
576
+ const sample = this.createSampleForTrack(
577
+ trackData,
578
+ body,
579
+ trackData.lastCueEndTimestamp,
580
+ sampleStart - trackData.lastCueEndTimestamp,
581
+ 'key',
582
+ );
583
+
584
+ await this.registerSample(trackData, sample);
585
+ trackData.lastCueEndTimestamp = sampleStart;
586
+ }
587
+
588
+ this.auxWriter.seek(0);
589
+
590
+ for (let i = 0; i < trackData.cueQueue.length; i++) {
591
+ const cue = trackData.cueQueue[i]!;
592
+
593
+ if (cue.timestamp >= sampleEnd) {
594
+ break;
595
+ }
596
+
597
+ inlineTimestampRegex.lastIndex = 0;
598
+ const containsTimestamp = inlineTimestampRegex.test(cue.text);
599
+
600
+ const endTimestamp = cue.timestamp + cue.duration;
601
+ let sourceId = trackData.cueToSourceId.get(cue);
602
+ if (sourceId === undefined && sampleEnd < endTimestamp) {
603
+ // We know this cue will appear in more than one sample, therefore we need to mark it with a
604
+ // unique ID
605
+ sourceId = trackData.nextSourceId++;
606
+ trackData.cueToSourceId.set(cue, sourceId);
607
+ }
608
+
609
+ if (cue.notes) {
610
+ // Any notes/comments are included in a special vtta box
611
+ const box = vtta(cue.notes);
612
+ this.auxBoxWriter.writeBox(box);
613
+ }
614
+
615
+ const box = vttc(
616
+ cue.text,
617
+ containsTimestamp ? sampleStart : null,
618
+ cue.identifier ?? null,
619
+ cue.settings ?? null,
620
+ sourceId ?? null,
621
+ );
622
+ this.auxBoxWriter.writeBox(box);
623
+
624
+ if (endTimestamp === sampleEnd) {
625
+ // The cue won't appear in any future sample, so we're done with it
626
+ trackData.cueQueue.splice(i--, 1);
627
+ }
628
+ }
629
+
630
+ const body = this.auxWriter.getSlice(0, this.auxWriter.getPos());
631
+ const sample = this.createSampleForTrack(trackData, body, sampleStart, sampleEnd - sampleStart, 'key');
632
+
633
+ await this.registerSample(trackData, sample);
634
+ trackData.lastCueEndTimestamp = sampleEnd;
635
+ }
636
+ }
637
+
638
+ private createSampleForTrack(
639
+ trackData: IsobmffTrackData,
640
+ data: Uint8Array,
641
+ timestamp: number,
642
+ duration: number,
643
+ type: PacketType,
644
+ ) {
645
+ const sample: Sample = {
646
+ timestamp,
647
+ decodeTimestamp: timestamp, // This may be refined later
648
+ duration,
649
+ data,
650
+ size: data.byteLength,
651
+ type,
652
+ timescaleUnitsToNextSample: intoTimescale(duration, trackData.timescale), // Will be refined
653
+ };
654
+
655
+ return sample;
656
+ }
657
+
658
+ private processTimestamps(trackData: IsobmffTrackData, nextSample?: Sample) {
659
+ if (trackData.timestampProcessingQueue.length === 0) {
660
+ return;
661
+ }
662
+
663
+ if (trackData.type === 'audio' && trackData.info.requiresPcmTransformation) {
664
+ let totalDuration = 0;
665
+
666
+ // Compute the total duration in the track timescale (which is equal to the amount of PCM audio samples)
667
+ // and simply say that's how many new samples there are.
668
+
669
+ for (let i = 0; i < trackData.timestampProcessingQueue.length; i++) {
670
+ const sample = trackData.timestampProcessingQueue[i]!;
671
+ const duration = intoTimescale(sample.duration, trackData.timescale);
672
+ totalDuration += duration;
673
+ }
674
+
675
+ if (trackData.timeToSampleTable.length === 0) {
676
+ trackData.timeToSampleTable.push({
677
+ sampleCount: totalDuration,
678
+ sampleDelta: 1,
679
+ });
680
+ } else {
681
+ const lastEntry = last(trackData.timeToSampleTable)!;
682
+ lastEntry.sampleCount += totalDuration;
683
+ }
684
+
685
+ trackData.timestampProcessingQueue.length = 0;
686
+ return;
687
+ }
688
+
689
+ const sortedTimestamps = trackData.timestampProcessingQueue.map(x => x.timestamp).sort((a, b) => a - b);
690
+
691
+ for (let i = 0; i < trackData.timestampProcessingQueue.length; i++) {
692
+ const sample = trackData.timestampProcessingQueue[i]!;
693
+
694
+ // Since the user only supplies presentation time, but these may be out of order, we reverse-engineer from
695
+ // that a sensible decode timestamp. The notion of a decode timestamp doesn't really make sense
696
+ // (presentation timestamp & decode order are all you need), but it is a concept in ISOBMFF so we need to
697
+ // model it.
698
+ sample.decodeTimestamp = sortedTimestamps[i]!;
699
+
700
+ if (!this.isFragmented && trackData.lastTimescaleUnits === null) {
701
+ // In non-fragmented files, the first decode timestamp is always zero. If the first presentation
702
+ // timestamp isn't zero, we'll simply use the composition time offset to achieve it.
703
+ sample.decodeTimestamp = 0;
704
+ }
705
+
706
+ const sampleCompositionTimeOffset
707
+ = intoTimescale(sample.timestamp - sample.decodeTimestamp, trackData.timescale);
708
+ const durationInTimescale = intoTimescale(sample.duration, trackData.timescale);
709
+
710
+ if (trackData.lastTimescaleUnits !== null) {
711
+ assert(trackData.lastSample);
712
+
713
+ const timescaleUnits = intoTimescale(sample.decodeTimestamp, trackData.timescale, false);
714
+ const delta = Math.round(timescaleUnits - trackData.lastTimescaleUnits);
715
+ assert(delta >= 0);
716
+
717
+ trackData.lastTimescaleUnits += delta;
718
+ trackData.lastSample.timescaleUnitsToNextSample = delta;
719
+
720
+ if (!this.isFragmented) {
721
+ let lastTableEntry = last(trackData.timeToSampleTable);
722
+ assert(lastTableEntry);
723
+
724
+ if (lastTableEntry.sampleCount === 1) {
725
+ lastTableEntry.sampleDelta = delta;
726
+
727
+ const entryBefore = trackData.timeToSampleTable[trackData.timeToSampleTable.length - 2];
728
+ if (entryBefore && entryBefore.sampleDelta === delta) {
729
+ // If the delta is the same as the previous one, merge the two entries
730
+ entryBefore.sampleCount++;
731
+ trackData.timeToSampleTable.pop();
732
+ lastTableEntry = entryBefore;
733
+ }
734
+ } else if (lastTableEntry.sampleDelta !== delta) {
735
+ // The delta has changed, so we need a new entry to reach the current sample
736
+ lastTableEntry.sampleCount--;
737
+ trackData.timeToSampleTable.push(lastTableEntry = {
738
+ sampleCount: 1,
739
+ sampleDelta: delta,
740
+ });
741
+ }
742
+
743
+ if (lastTableEntry.sampleDelta === durationInTimescale) {
744
+ // The sample's duration matches the delta, so we can increment the count
745
+ lastTableEntry.sampleCount++;
746
+ } else {
747
+ // Add a new entry in order to maintain the last sample's true duration
748
+ trackData.timeToSampleTable.push({
749
+ sampleCount: 1,
750
+ sampleDelta: durationInTimescale,
751
+ });
752
+ }
753
+
754
+ const lastCompositionTimeOffsetTableEntry = last(trackData.compositionTimeOffsetTable);
755
+ assert(lastCompositionTimeOffsetTableEntry);
756
+
757
+ if (
758
+ lastCompositionTimeOffsetTableEntry.sampleCompositionTimeOffset === sampleCompositionTimeOffset
759
+ ) {
760
+ // Simply increment the count
761
+ lastCompositionTimeOffsetTableEntry.sampleCount++;
762
+ } else {
763
+ // The composition time offset has changed, so create a new entry with the new composition time
764
+ // offset
765
+ trackData.compositionTimeOffsetTable.push({
766
+ sampleCount: 1,
767
+ sampleCompositionTimeOffset: sampleCompositionTimeOffset,
768
+ });
769
+ }
770
+ }
771
+ } else {
772
+ // Decode timestamp of the first sample
773
+ trackData.lastTimescaleUnits = intoTimescale(sample.decodeTimestamp, trackData.timescale, false);
774
+
775
+ if (!this.isFragmented) {
776
+ trackData.timeToSampleTable.push({
777
+ sampleCount: 1,
778
+ sampleDelta: durationInTimescale,
779
+ });
780
+ trackData.compositionTimeOffsetTable.push({
781
+ sampleCount: 1,
782
+ sampleCompositionTimeOffset: sampleCompositionTimeOffset,
783
+ });
784
+ }
785
+ }
786
+
787
+ trackData.lastSample = sample;
788
+ }
789
+
790
+ trackData.timestampProcessingQueue.length = 0;
791
+
792
+ assert(trackData.lastSample);
793
+ assert(trackData.lastTimescaleUnits !== null);
794
+
795
+ if (nextSample !== undefined && trackData.lastSample.timescaleUnitsToNextSample === 0) {
796
+ assert(nextSample.type === 'key');
797
+
798
+ // Given the next sample, we can make a guess about the duration of the last sample. This avoids having
799
+ // the last sample's duration in each fragment be "0" for fragmented files. The guess we make here is
800
+ // actually correct most of the time, since typically, no delta frame with a lower timestamp follows the key
801
+ // frame (although it can happen).
802
+ const timescaleUnits = intoTimescale(nextSample.timestamp, trackData.timescale, false);
803
+ const delta = Math.round(timescaleUnits - trackData.lastTimescaleUnits);
804
+ trackData.lastSample.timescaleUnitsToNextSample = delta;
805
+ }
806
+ }
807
+
808
+ private async registerSample(trackData: IsobmffTrackData, sample: Sample) {
809
+ if (sample.type === 'key') {
810
+ this.processTimestamps(trackData, sample);
811
+ }
812
+ trackData.timestampProcessingQueue.push(sample);
813
+
814
+ if (this.isFragmented) {
815
+ trackData.sampleQueue.push(sample);
816
+ await this.interleaveSamples();
817
+ } else {
818
+ await this.addSampleToTrack(trackData, sample);
819
+ }
820
+ }
821
+
822
+ private async addSampleToTrack(trackData: IsobmffTrackData, sample: Sample) {
823
+ if (!this.isFragmented) {
824
+ trackData.samples.push(sample);
825
+ }
826
+
827
+ let beginNewChunk = false;
828
+ if (!trackData.currentChunk) {
829
+ beginNewChunk = true;
830
+ } else {
831
+ // Timestamp don't need to be monotonic (think B-frames), so we may need to update the start timestamp of
832
+ // the chunk
833
+ trackData.currentChunk.startTimestamp = Math.min(
834
+ trackData.currentChunk.startTimestamp,
835
+ sample.timestamp,
836
+ );
837
+
838
+ const currentChunkDuration = sample.timestamp - trackData.currentChunk.startTimestamp;
839
+
840
+ if (this.isFragmented) {
841
+ // We can only finalize this fragment (and begin a new one) if we know that each track will be able to
842
+ // start the new one with a key frame.
843
+ const keyFrameQueuedEverywhere = this.trackDatas.every((otherTrackData) => {
844
+ if (trackData === otherTrackData) {
845
+ return sample.type === 'key';
846
+ }
847
+
848
+ const firstQueuedSample = otherTrackData.sampleQueue[0];
849
+ if (firstQueuedSample) {
850
+ return firstQueuedSample.type === 'key';
851
+ }
852
+
853
+ return otherTrackData.track.source._closed;
854
+ });
855
+
856
+ if (
857
+ currentChunkDuration >= this.minimumFragmentDuration
858
+ && keyFrameQueuedEverywhere
859
+ && sample.timestamp > this.maxWrittenTimestamp
860
+ ) {
861
+ beginNewChunk = true;
862
+ await this.finalizeFragment();
863
+ }
864
+ } else {
865
+ beginNewChunk = currentChunkDuration >= 0.5; // Chunk is long enough, we need a new one
866
+ }
867
+ }
868
+
869
+ if (beginNewChunk) {
870
+ if (trackData.currentChunk) {
871
+ await this.finalizeCurrentChunk(trackData);
872
+ }
873
+
874
+ trackData.currentChunk = {
875
+ startTimestamp: sample.timestamp,
876
+ samples: [],
877
+ offset: null,
878
+ moofOffset: null,
879
+ };
880
+ }
881
+
882
+ assert(trackData.currentChunk);
883
+ trackData.currentChunk.samples.push(sample);
884
+
885
+ if (this.isFragmented) {
886
+ this.maxWrittenTimestamp = Math.max(this.maxWrittenTimestamp, sample.timestamp);
887
+ }
888
+ }
889
+
890
+ private async finalizeCurrentChunk(trackData: IsobmffTrackData) {
891
+ assert(!this.isFragmented);
892
+
893
+ if (!trackData.currentChunk) return;
894
+
895
+ trackData.finalizedChunks.push(trackData.currentChunk);
896
+ this.finalizedChunks.push(trackData.currentChunk);
897
+
898
+ let sampleCount = trackData.currentChunk.samples.length;
899
+ if (trackData.type === 'audio' && trackData.info.requiresPcmTransformation) {
900
+ sampleCount = trackData.currentChunk.samples
901
+ .reduce((acc, sample) => acc + intoTimescale(sample.duration, trackData.timescale), 0);
902
+ }
903
+
904
+ if (
905
+ trackData.compactlyCodedChunkTable.length === 0
906
+ || last(trackData.compactlyCodedChunkTable)!.samplesPerChunk !== sampleCount
907
+ ) {
908
+ trackData.compactlyCodedChunkTable.push({
909
+ firstChunk: trackData.finalizedChunks.length, // 1-indexed
910
+ samplesPerChunk: sampleCount,
911
+ });
912
+ }
913
+
914
+ if (this.fastStart === 'in-memory') {
915
+ trackData.currentChunk.offset = 0; // We'll compute the proper offset when finalizing
916
+ return;
917
+ }
918
+
919
+ // Write out the data
920
+ trackData.currentChunk.offset = this.writer.getPos();
921
+ for (const sample of trackData.currentChunk.samples) {
922
+ assert(sample.data);
923
+ this.writer.write(sample.data);
924
+ sample.data = null; // Can be GC'd
925
+ }
926
+
927
+ await this.writer.flush();
928
+ }
929
+
930
+ private async interleaveSamples(isFinalCall = false) {
931
+ assert(this.isFragmented);
932
+
933
+ if (!isFinalCall) {
934
+ if (!this.allTracksAreKnown()) {
935
+ return; // We can't interleave yet as we don't yet know how many tracks we'll truly have
936
+ }
937
+ }
938
+
939
+ outer:
940
+ while (true) {
941
+ let trackWithMinTimestamp: IsobmffTrackData | null = null;
942
+ let minTimestamp = Infinity;
943
+
944
+ for (const trackData of this.trackDatas) {
945
+ if (!isFinalCall && trackData.sampleQueue.length === 0 && !trackData.track.source._closed) {
946
+ break outer;
947
+ }
948
+
949
+ if (trackData.sampleQueue.length > 0 && trackData.sampleQueue[0]!.timestamp < minTimestamp) {
950
+ trackWithMinTimestamp = trackData;
951
+ minTimestamp = trackData.sampleQueue[0]!.timestamp;
952
+ }
953
+ }
954
+
955
+ if (!trackWithMinTimestamp) {
956
+ break;
957
+ }
958
+
959
+ const sample = trackWithMinTimestamp.sampleQueue.shift()!;
960
+ await this.addSampleToTrack(trackWithMinTimestamp, sample);
961
+ }
962
+ }
963
+
964
+ private async finalizeFragment(flushWriter = true) {
965
+ assert(this.isFragmented);
966
+
967
+ const fragmentNumber = this.nextFragmentNumber++;
968
+
969
+ if (fragmentNumber === 1) {
970
+ if (this.format._options.onMoov) {
971
+ this.writer.startTrackingWrites();
972
+ }
973
+
974
+ // Write the moov box now that we have all decoder configs
975
+ const movieBox = moov(this.trackDatas, this.creationTime, true);
976
+ this.boxWriter.writeBox(movieBox);
977
+
978
+ if (this.format._options.onMoov) {
979
+ const { data, start } = this.writer.stopTrackingWrites();
980
+ this.format._options.onMoov(data, start);
981
+ }
982
+ }
983
+
984
+ // Not all tracks need to be present in every fragment
985
+ const tracksInFragment = this.trackDatas.filter(x => x.currentChunk);
986
+
987
+ // Create an initial moof box and measure it; we need this to know where the following mdat box will begin
988
+ const moofBox = moof(fragmentNumber, tracksInFragment);
989
+ const moofOffset = this.writer.getPos();
990
+ const mdatStartPos = moofOffset + this.boxWriter.measureBox(moofBox);
991
+
992
+ // Header with large size. We always reserve 16 bytes for it even if we don't end up using the large size.
993
+ const mdatHeaderSize = 16;
994
+
995
+ let currentPos = mdatStartPos + mdatHeaderSize;
996
+ let fragmentStartTimestamp = Infinity;
997
+ for (const trackData of tracksInFragment) {
998
+ trackData.currentChunk!.offset = currentPos;
999
+ trackData.currentChunk!.moofOffset = moofOffset;
1000
+
1001
+ for (const sample of trackData.currentChunk!.samples) {
1002
+ currentPos += sample.size;
1003
+ }
1004
+
1005
+ fragmentStartTimestamp = Math.min(fragmentStartTimestamp, trackData.currentChunk!.startTimestamp);
1006
+ }
1007
+
1008
+ const mdatSize = currentPos - mdatStartPos;
1009
+
1010
+ if (this.format._options.onMoof) {
1011
+ this.writer.startTrackingWrites();
1012
+ }
1013
+
1014
+ const newMoofBox = moof(fragmentNumber, tracksInFragment);
1015
+ this.boxWriter.writeBox(newMoofBox);
1016
+
1017
+ if (this.format._options.onMoof) {
1018
+ const { data, start } = this.writer.stopTrackingWrites();
1019
+ this.format._options.onMoof(data, start, fragmentStartTimestamp);
1020
+ }
1021
+
1022
+ assert(this.writer.getPos() === mdatStartPos);
1023
+
1024
+ if (this.format._options.onMdat) {
1025
+ this.writer.startTrackingWrites();
1026
+ }
1027
+
1028
+ const mdatBox = mdat(mdatSize >= 2 ** 32);
1029
+ mdatBox.size = mdatSize;
1030
+ this.boxWriter.writeBox(mdatBox);
1031
+
1032
+ this.writer.seek(mdatStartPos + mdatHeaderSize);
1033
+
1034
+ // Write sample data
1035
+ for (const trackData of tracksInFragment) {
1036
+ for (const sample of trackData.currentChunk!.samples) {
1037
+ this.writer.write(sample.data!);
1038
+ sample.data = null; // Can be GC'd
1039
+ }
1040
+ }
1041
+
1042
+ if (this.format._options.onMdat) {
1043
+ const { data, start } = this.writer.stopTrackingWrites();
1044
+ this.format._options.onMdat(data, start);
1045
+ }
1046
+
1047
+ for (const trackData of tracksInFragment) {
1048
+ trackData.finalizedChunks.push(trackData.currentChunk!);
1049
+ this.finalizedChunks.push(trackData.currentChunk!);
1050
+ trackData.currentChunk = null;
1051
+ }
1052
+
1053
+ if (flushWriter) {
1054
+ await this.writer.flush();
1055
+ }
1056
+ }
1057
+
1058
+ // eslint-disable-next-line @typescript-eslint/no-misused-promises
1059
+ override async onTrackClose(track: OutputTrack) {
1060
+ const release = await this.mutex.acquire();
1061
+
1062
+ if (track.type === 'subtitle' && track.source._codec === 'webvtt') {
1063
+ const trackData = this.trackDatas.find(x => x.track === track) as IsobmffSubtitleTrackData;
1064
+ if (trackData) {
1065
+ await this.processWebVTTCues(trackData, Infinity);
1066
+ }
1067
+ }
1068
+
1069
+ if (this.allTracksAreKnown()) {
1070
+ this.allTracksKnown.resolve();
1071
+ }
1072
+
1073
+ if (this.isFragmented) {
1074
+ // Since a track is now closed, we may be able to write out chunks that were previously waiting
1075
+ await this.interleaveSamples();
1076
+ }
1077
+
1078
+ release();
1079
+ }
1080
+
1081
+ /** Finalizes the file, making it ready for use. Must be called after all video and audio chunks have been added. */
1082
+ async finalize() {
1083
+ const release = await this.mutex.acquire();
1084
+
1085
+ this.allTracksKnown.resolve();
1086
+
1087
+ for (const trackData of this.trackDatas) {
1088
+ if (trackData.type === 'subtitle' && trackData.track.source._codec === 'webvtt') {
1089
+ await this.processWebVTTCues(trackData, Infinity);
1090
+ }
1091
+ }
1092
+
1093
+ if (this.isFragmented) {
1094
+ await this.interleaveSamples(true);
1095
+
1096
+ for (const trackData of this.trackDatas) {
1097
+ this.processTimestamps(trackData);
1098
+ }
1099
+
1100
+ await this.finalizeFragment(false); // Don't flush the last fragment as we will flush it with the mfra box
1101
+ } else {
1102
+ for (const trackData of this.trackDatas) {
1103
+ this.processTimestamps(trackData);
1104
+ await this.finalizeCurrentChunk(trackData);
1105
+ }
1106
+ }
1107
+
1108
+ if (this.fastStart === 'in-memory') {
1109
+ assert(this.mdat);
1110
+ let mdatSize: number;
1111
+
1112
+ // We know how many chunks there are, but computing the chunk positions requires an iterative approach:
1113
+ // In order to know where the first chunk should go, we first need to know the size of the moov box. But we
1114
+ // cannot write a proper moov box without first knowing all chunk positions. So, we generate a tentative
1115
+ // moov box with placeholder values (0) for the chunk offsets to be able to compute its size. If it then
1116
+ // turns out that appending all chunks exceeds 4 GiB, we need to repeat this process, now with the co64 box
1117
+ // being used in the moov box instead, which will make it larger. After that, we definitely know the final
1118
+ // size of the moov box and can compute the proper chunk positions.
1119
+
1120
+ for (let i = 0; i < 2; i++) {
1121
+ const movieBox = moov(this.trackDatas, this.creationTime);
1122
+ const movieBoxSize = this.boxWriter.measureBox(movieBox);
1123
+ mdatSize = this.boxWriter.measureBox(this.mdat);
1124
+ let currentChunkPos = this.writer.getPos() + movieBoxSize + mdatSize;
1125
+
1126
+ for (const chunk of this.finalizedChunks) {
1127
+ chunk.offset = currentChunkPos;
1128
+ for (const { data } of chunk.samples) {
1129
+ assert(data);
1130
+ currentChunkPos += data.byteLength;
1131
+ mdatSize += data.byteLength;
1132
+ }
1133
+ }
1134
+
1135
+ if (currentChunkPos < 2 ** 32) break;
1136
+ if (mdatSize >= 2 ** 32) this.mdat.largeSize = true;
1137
+ }
1138
+
1139
+ if (this.format._options.onMoov) {
1140
+ this.writer.startTrackingWrites();
1141
+ }
1142
+
1143
+ const movieBox = moov(this.trackDatas, this.creationTime);
1144
+ this.boxWriter.writeBox(movieBox);
1145
+
1146
+ if (this.format._options.onMoov) {
1147
+ const { data, start } = this.writer.stopTrackingWrites();
1148
+ this.format._options.onMoov(data, start);
1149
+ }
1150
+
1151
+ if (this.format._options.onMdat) {
1152
+ this.writer.startTrackingWrites();
1153
+ }
1154
+
1155
+ this.mdat.size = mdatSize!;
1156
+ this.boxWriter.writeBox(this.mdat);
1157
+
1158
+ for (const chunk of this.finalizedChunks) {
1159
+ for (const sample of chunk.samples) {
1160
+ assert(sample.data);
1161
+ this.writer.write(sample.data);
1162
+ sample.data = null;
1163
+ }
1164
+ }
1165
+
1166
+ if (this.format._options.onMdat) {
1167
+ const { data, start } = this.writer.stopTrackingWrites();
1168
+ this.format._options.onMdat(data, start);
1169
+ }
1170
+ } else if (this.isFragmented) {
1171
+ // Append the mfra box to the end of the file for better random access
1172
+ const startPos = this.writer.getPos();
1173
+ const mfraBox = mfra(this.trackDatas);
1174
+ this.boxWriter.writeBox(mfraBox);
1175
+
1176
+ // Patch the 'size' field of the mfro box at the end of the mfra box now that we know its actual size
1177
+ const mfraBoxSize = this.writer.getPos() - startPos;
1178
+ this.writer.seek(this.writer.getPos() - 4);
1179
+ this.boxWriter.writeU32(mfraBoxSize);
1180
+ } else {
1181
+ assert(this.mdat);
1182
+
1183
+ const mdatPos = this.boxWriter.offsets.get(this.mdat);
1184
+ assert(mdatPos !== undefined);
1185
+ const mdatSize = this.writer.getPos() - mdatPos;
1186
+ this.mdat.size = mdatSize;
1187
+ this.mdat.largeSize = mdatSize >= 2 ** 32; // Only use the large size if we need it
1188
+ this.boxWriter.patchBox(this.mdat);
1189
+
1190
+ if (this.format._options.onMdat) {
1191
+ const { data, start } = this.writer.stopTrackingWrites();
1192
+ this.format._options.onMdat(data, start);
1193
+ }
1194
+
1195
+ if (this.format._options.onMoov) {
1196
+ this.writer.startTrackingWrites();
1197
+ }
1198
+
1199
+ const movieBox = moov(this.trackDatas, this.creationTime);
1200
+ this.boxWriter.writeBox(movieBox);
1201
+
1202
+ if (this.format._options.onMoov) {
1203
+ const { data, start } = this.writer.stopTrackingWrites();
1204
+ this.format._options.onMoov(data, start);
1205
+ }
1206
+ }
1207
+
1208
+ release();
1209
+ }
1210
+ }