video-fps-probe 0.3.0 → 0.4.0-beta.2

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 (94) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +19 -11
  3. package/dist/analyze.js +10 -1
  4. package/dist/api/codes.d.ts +6 -0
  5. package/dist/api/codes.js +90 -0
  6. package/dist/aspect/index.d.ts +13 -0
  7. package/dist/aspect/index.js +180 -0
  8. package/dist/aspect/rational.js +61 -0
  9. package/dist/cjs/analyze.d.cts +9 -0
  10. package/dist/cjs/api/agreement.d.cts +18 -0
  11. package/dist/cjs/api/codes.d.cts +6 -0
  12. package/dist/cjs/api/errors.d.cts +12 -0
  13. package/dist/cjs/api/guards.d.cts +4 -0
  14. package/dist/cjs/api/summary.d.cts +22 -0
  15. package/dist/cjs/aspect/index.d.cts +13 -0
  16. package/dist/cjs/aspect.cjs +1 -0
  17. package/dist/cjs/check/index.d.cts +60 -0
  18. package/dist/cjs/check.cjs +1 -0
  19. package/dist/cjs/chunks/boxes-B0T2nt3e.cjs +1 -0
  20. package/dist/cjs/chunks/build-DhETwJLO.cjs +1 -0
  21. package/dist/cjs/chunks/color-xqJHSfhZ.cjs +1 -0
  22. package/dist/cjs/chunks/compare-B_HKNwqg.cjs +1 -0
  23. package/dist/cjs/chunks/config-CSbXzEqr.cjs +1 -0
  24. package/dist/cjs/chunks/errors-0oBg1Ai6.cjs +1 -0
  25. package/dist/cjs/chunks/fallback-CyPIR3RM.cjs +1 -0
  26. package/dist/cjs/chunks/guards-DbQNcAZS.cjs +1 -0
  27. package/dist/cjs/chunks/inline-Bx-TQg8f.cjs +1 -0
  28. package/dist/cjs/chunks/isobmff-4RrRf9N-.cjs +1 -0
  29. package/dist/cjs/chunks/matroska-BhgWtuPU.cjs +1 -0
  30. package/dist/cjs/chunks/metadata-ECdzNLkE.cjs +1 -0
  31. package/dist/cjs/chunks/options-B_vLHxHd.cjs +1 -0
  32. package/dist/cjs/chunks/probe-Cn1DtL-C.cjs +1 -0
  33. package/dist/cjs/chunks/rational-Dq5Vza-Q.cjs +1 -0
  34. package/dist/cjs/chunks/reader-Dw11ozVU.cjs +1 -0
  35. package/dist/cjs/chunks/rfps-C-pyKnu8.cjs +1 -0
  36. package/dist/cjs/chunks/runtime-BgAlQQee.cjs +1 -0
  37. package/dist/cjs/chunks/segments-B1H6bDz-.cjs +1 -0
  38. package/dist/cjs/chunks/sniff-CXwqOGwh.cjs +1 -0
  39. package/dist/cjs/chunks/stats-DW7dVdFa.cjs +1 -0
  40. package/dist/cjs/client/types.d.cts +96 -0
  41. package/dist/cjs/color/index.d.cts +7 -0
  42. package/dist/cjs/color.cjs +1 -0
  43. package/dist/cjs/core/reader.d.cts +47 -0
  44. package/dist/cjs/core/types.d.cts +194 -0
  45. package/dist/cjs/engines/types.d.cts +69 -0
  46. package/dist/cjs/header/index.d.cts +28 -0
  47. package/dist/cjs/header/limits.d.cts +26 -0
  48. package/dist/cjs/header.cjs +1 -0
  49. package/dist/cjs/index.cjs +1 -0
  50. package/dist/cjs/index.d.cts +13 -0
  51. package/dist/cjs/plugins/types.d.cts +17 -0
  52. package/dist/cjs/presets/index.d.cts +19 -0
  53. package/dist/cjs/presets.cjs +1 -0
  54. package/dist/cjs/probe.d.cts +3 -0
  55. package/dist/cjs/url/index.d.cts +47 -0
  56. package/dist/cjs/url.cjs +1 -0
  57. package/dist/cjs/worker/protocol.d.cts +47 -0
  58. package/dist/color/codec-string.js +121 -0
  59. package/dist/color/config.js +199 -0
  60. package/dist/color/index.d.ts +7 -0
  61. package/dist/color/index.js +139 -0
  62. package/dist/color/names.js +33 -0
  63. package/dist/color/sps.js +275 -0
  64. package/dist/core/dispatch.js +18 -29
  65. package/dist/core/isobmff/boxes.js +6 -5
  66. package/dist/core/isobmff/build.js +401 -0
  67. package/dist/core/isobmff/config.js +34 -0
  68. package/dist/core/isobmff/header.js +677 -0
  69. package/dist/core/isobmff/index.js +9 -381
  70. package/dist/core/isobmff/moof.js +2 -2
  71. package/dist/core/isobmff/moov.js +3 -3
  72. package/dist/core/matroska/header.js +10 -4
  73. package/dist/core/matroska/index.js +11 -5
  74. package/dist/core/options.js +1 -0
  75. package/dist/core/reader.d.ts +3 -0
  76. package/dist/core/reader.js +13 -7
  77. package/dist/core/result.js +1 -0
  78. package/dist/core/stats.js +3 -0
  79. package/dist/core/types.d.ts +60 -1
  80. package/dist/engines/common.js +2 -0
  81. package/dist/header/index.d.ts +28 -0
  82. package/dist/header/index.js +182 -0
  83. package/dist/header/limits.d.ts +26 -0
  84. package/dist/header/limits.js +144 -0
  85. package/dist/header/metadata.js +2 -0
  86. package/dist/index.d.ts +1 -0
  87. package/dist/index.js +1 -0
  88. package/dist/standalone/own-isobmff.worker.js +1 -1
  89. package/dist/standalone/own-matroska.worker.js +1 -1
  90. package/dist/standalone/own.worker.js +1 -1
  91. package/dist/url/index.d.ts +47 -0
  92. package/dist/url/index.js +335 -0
  93. package/dist/worker/single.js +2 -1
  94. package/package.json +117 -2
@@ -1,392 +1,19 @@
1
- import { emptyAnalysis, emptyTrack } from "../result.js";
1
+ import { emptyAnalysis } from "../result.js";
2
2
  import { analyzeTimeline, rateProfileOf } from "../stats.js";
3
- import { toFfprobeString } from "../rational.js";
4
- import { IsoBmffError, readBoxHeader } from "./boxes.js";
5
- import { codecNameFor, isVideoFourcc } from "./codecs.js";
3
+ import { buildVideoTrack, containerOf, emulateFfprobeExtent, formatDuration, isAbort, isVideoTrack, layoutOf, pickPrimary, reportedIds, walk } from "./build.js";
4
+ import { whichErrorCode } from "../reader.js";
6
5
  import { newMovie } from "./model.js";
7
- import { parseMoof, parseSidx } from "./moof.js";
8
- import { parseMoov } from "./moov.js";
9
- import { mfraMoofOffsets, newPrefetch, prefetchNext, prefetched } from "./prefetch.js";
10
- import { averageRates, displayRotation, editInfo, ffprobeStage1Rate, missingMandatoryAtoms, moovSamplesInFile, presentationWindow, rescaleRound, sampleTotals, timelineSegments, trackTiming, } from "./track.js";
11
6
  export { IsoBmffError } from "./boxes.js";
12
- export const MOOV_SUSPECT_CAP = 16 * 1024 * 1024;
13
- export const MOOV_HARD_CAP = 256 * 1024 * 1024;
14
- export const MOOF_HARD_CAP = 64 * 1024 * 1024;
15
- const MAX_TOP_LEVEL_BOXES = 4_000_000;
16
- const SMALL_MDAT = 64 * 1024;
17
- const HEADER_PEEK = 16;
18
- function isAbort(e, reader) {
19
- return reader.signal?.aborted === true || e?.name === 'AbortError';
20
- }
21
- function containerOf(movie) {
22
- return movie.brand === 'qt ' || (!movie.hasFtyp && movie.topLevel.length > 0) ? 'mov' : 'mp4';
23
- }
24
- async function readMoov(reader, h, movie) {
25
- const size = reader.size;
26
- const beyond = h.start + h.size > size;
27
- if (!beyond && h.size > MOOV_HARD_CAP)
28
- throw new IsoBmffError('unsupported: moov larger than 256 MiB');
29
- const len = beyond ? Math.min(size - h.start, MOOV_SUSPECT_CAP) : h.size;
30
- const dv = await reader.view(h.start, len, 0);
31
- const mh = readBoxHeader(dv, 0, dv.byteLength);
32
- if (!mh)
33
- throw new IsoBmffError('incomplete file: moov is truncated');
34
- parseMoov(dv, mh, movie, beyond, h.start);
35
- if (beyond)
36
- movie.warnings.push('moov size exceeds the file; parsed what is there');
37
- }
38
- async function walk(reader, opts, movie) {
39
- const size = reader.size;
40
- let pos = 0;
41
- let ahead = opts.readAhead.head;
42
- let sawMoov = false;
43
- let boxes = 0;
44
- let afterMdat = false;
45
- const pf = newPrefetch();
46
- const look = async (at, need, readAhead) => prefetched(pf, at, need) ?? reader.view(at, need, readAhead);
47
- while (pos + 8 <= size) {
48
- reader.checkAbort();
49
- if (++boxes > MAX_TOP_LEVEL_BOXES) {
50
- movie.warnings.push('too many top-level boxes');
51
- break;
52
- }
53
- const tailRead = afterMdat && !sawMoov && size - pos <= MOOV_SUSPECT_CAP ? size - pos : ahead;
54
- const hv = await look(pos, Math.min(HEADER_PEEK, size - pos), tailRead);
55
- let rel;
56
- try {
57
- rel = readBoxHeader(hv, 0, size - pos, pos);
58
- }
59
- catch (e) {
60
- if (!sawMoov)
61
- throw e;
62
- movie.warnings.push(`ignored trailing data at ${pos}: ${e.message}`);
63
- break;
64
- }
65
- if (!rel)
66
- break;
67
- const h = { ...rel, start: pos, end: Math.min(pos + rel.size, size) };
68
- const boxEnd = pos + rel.size;
69
- const trailer = rel.truncated && sawMoov && h.type !== 'mdat' && h.type !== 'moof';
70
- if (rel.truncated) {
71
- movie.truncated = true;
72
- movie.warnings.push(trailer ? `ignored trailing data at ${pos}: not a box` : `'${h.type}' at ${pos} is truncated`);
73
- }
74
- const top = { type: h.type, start: pos, end: h.end, sidx: null };
75
- movie.topLevel.push(top);
76
- if (trailer)
77
- break;
78
- if (h.type === 'ftyp') {
79
- const fv = await reader.view(pos, Math.min(h.size, 1024));
80
- const p = h.headerSize;
81
- if (p + 4 <= fv.byteLength) {
82
- movie.hasFtyp = true;
83
- movie.brand = String.fromCharCode(fv.getUint8(p), fv.getUint8(p + 1), fv.getUint8(p + 2), fv.getUint8(p + 3));
84
- for (let q = p + 8; q + 4 <= fv.byteLength; q += 4) {
85
- movie.compatibleBrands.push(String.fromCharCode(fv.getUint8(q), fv.getUint8(q + 1), fv.getUint8(q + 2), fv.getUint8(q + 3)));
86
- }
87
- }
88
- }
89
- else if (h.type === 'moov') {
90
- if (sawMoov)
91
- movie.warnings.push(`ignored a second moov at ${pos}`);
92
- else {
93
- await readMoov(reader, h, movie);
94
- sawMoov = true;
95
- }
96
- }
97
- else if (h.type === 'moof') {
98
- if (!sawMoov)
99
- throw new IsoBmffError('corrupt file: movie fragment (moof) before moov');
100
- if (h.size > MOOF_HARD_CAP)
101
- throw new IsoBmffError(`corrupt file: moof larger than 64 MiB at ${pos}`);
102
- const want = Math.min(h.size + HEADER_PEEK, size - pos);
103
- const dv = await look(pos, want, 0);
104
- const mh = readBoxHeader(dv, 0, Math.min(h.size, dv.byteLength));
105
- if (mh)
106
- parseMoof(dv, mh, pos, movie.moofCount, movie, opts.maxExpandedSamples);
107
- movie.moofCount++;
108
- let gap = 0;
109
- try {
110
- const next = readBoxHeader(dv, h.size, dv.byteLength);
111
- if (next && next.type === 'mdat')
112
- gap = next.size;
113
- }
114
- catch {
115
- gap = 0;
116
- }
117
- ahead = gap <= SMALL_MDAT ? opts.readAhead.fragment : Math.min(65536, Math.max(1024, 2 * h.size + 64));
118
- if (gap > SMALL_MDAT) {
119
- if (!pf.hints.length && !pf.mfraTried && (size - pos) / (h.size + gap) > 16) {
120
- pf.mfraTried = true;
121
- pf.hints = await mfraMoofOffsets(reader);
122
- }
123
- if (pf.next < pf.hints.length && !prefetched(pf, pos + h.size + gap, HEADER_PEEK))
124
- await prefetchNext(reader, pf, pos, ahead);
125
- }
126
- if (opts.onProgress && movie.moofCount % 64 === 0)
127
- opts.onProgress({ phase: 'fragments', done: pos, total: size });
128
- }
129
- else if (h.type === 'sidx' && !rel.truncated) {
130
- const dv = await look(pos, h.size, 0);
131
- const sh = readBoxHeader(dv, 0, dv.byteLength);
132
- if (sh)
133
- top.sidx = parseSidx(dv, sh, pos);
134
- if (top.sidx && top.sidx.subsegments.length > 1 && !pf.hints.length)
135
- pf.hints = top.sidx.subsegments.map(([o]) => o);
136
- }
137
- else if (h.type === 'mfra') {
138
- break;
139
- }
140
- afterMdat = h.type === 'mdat';
141
- pos = boxEnd;
142
- }
143
- if (!sawMoov)
144
- throw new IsoBmffError('incomplete file: no moov');
145
- }
146
- async function emulateFfprobeExtent(reader, movie) {
147
- const size = reader.size;
148
- const ids = new Set(movie.tracks.map((t) => t.id));
149
- let foundMoov = false;
150
- let foundMdat = false;
151
- let complete = false;
152
- let mfraSize = null;
153
- let moofs = 0;
154
- for (const b of movie.topLevel) {
155
- if (b.type === 'moof')
156
- moofs++;
157
- else if (b.type === 'moov')
158
- foundMoov = true;
159
- else if (b.type === 'mdat' && b.end - b.start > 8)
160
- foundMdat = true;
161
- else if (b.type === 'sidx' && foundMoov && b.sidx && !b.sidx.hierarchical && ids.has(b.sidx.referenceId)) {
162
- let whole = b.sidx.coverageEnd === size;
163
- if (!whole && size >= 4) {
164
- if (mfraSize === null)
165
- mfraSize = (await reader.view(size - 4, 4, 0)).getUint32(0);
166
- whole = b.sidx.coverageEnd === size - mfraSize;
167
- }
168
- if (whole && b.sidx.firstOffset === 0)
169
- complete = true;
170
- }
171
- if (foundMoov && foundMdat && (complete || b.end === size)) {
172
- movie.ffprobeMoofLimit = moofs;
173
- return;
174
- }
175
- }
176
- }
177
- function isVideoTrack(tr, movie) {
178
- if (movie.chapterTrackIds.has(tr.id))
179
- return false;
180
- if (tr.handler === 'vide')
181
- return true;
182
- if (tr.handler === 'soun')
183
- return false;
184
- return isVideoFourcc(tr.originalFormat ?? tr.sampleEntry);
185
- }
186
- function buildVideoTrack(tr, movie, opts, ctx) {
187
- const t = emptyTrack(ctx.reportedId);
188
- const w = t.warnings;
189
- if (ctx.reportedId !== tr.id)
190
- w.push(`duplicate track_ID ${tr.id}: reported as ${ctx.reportedId}`);
191
- w.push(...tr.warnings);
192
- const auxiliary = tr.handler === 'auxv';
193
- if (auxiliary)
194
- w.push("auxiliary video track (handler 'auxv')");
195
- const broken = missingMandatoryAtoms(tr);
196
- if (broken)
197
- w.push('missing mandatory sample tables: ffprobe ignores the samples and timing of this track');
198
- const ts = tr.hasMdhd ? tr.timescale : movie.timescale;
199
- if (!tr.hasMdhd) {
200
- w.push('no mdhd: using the movie timescale');
201
- tr.timescale = ts;
202
- }
203
- t.fourccOrCodecId = tr.sampleEntry;
204
- t.codecName = codecNameFor(tr.originalFormat ?? tr.sampleEntry, tr.esdsObjectType, tr.hasHvcc);
205
- if (tr.originalFormat)
206
- w.push(`encrypted: ${tr.sampleEntry} of ${tr.originalFormat}`);
207
- if (tr.stsdCount > 1)
208
- w.push(`${tr.stsdCount} sample descriptions, first used`);
209
- t.width = tr.width;
210
- t.height = tr.height;
211
- t.rotation = displayRotation(tr.matrix, movie.matrix);
212
- t.timescale = ts;
213
- const totals = sampleTotals(tr);
214
- const truncated = movie.truncated;
215
- let presentFragments = 0;
216
- for (const f of tr.fragments)
217
- presentFragments += f.present;
218
- const total = totals.moovSamples + totals.fragmentSamples;
219
- const frames = totals.moovSamples + presentFragments;
220
- t.frames = frames;
221
- t.isStill = frames <= 1;
222
- if (totals.moovSamples > 0 && tr.fragments.length > 0) {
223
- w.push(`hybrid fragmented file: moov holds ${totals.moovSamples} of ${total} samples`);
224
- }
225
- if (frames < total)
226
- w.push(`truncated: ${total - frames} of ${total} samples missing`);
227
- const rates = averageRates(tr, movie);
228
- t.avgFps = rates.avg;
229
- t.avgFpsFfprobe = ctx.ffprobeFails ? null : rates.ffprobe;
230
- if (t.avgFpsFfprobe !== null && t.avgFpsFfprobe !== toFfprobeString(rates.avg)) {
231
- w.push(`ffprobe reads part of the fragments: avg_frame_rate ${t.avgFpsFfprobe}`);
232
- }
233
- if (rates.ffprobe === null && !ctx.ffprobeFails && total > 0)
234
- w.push('avg_frame_rate of ffprobe is not reproduced: its header values are incomplete or overrun a box');
235
- const r = ctx.ffprobeFails || broken ? null : ffprobeStage1Rate(tr, movie, totals.moovSamples, opts.maxExpandedSamples, w);
236
- t.rFpsFfprobe = r ? toFfprobeString(r) : null;
237
- const edits = editInfo(tr, movie.timescale, totals);
238
- if (!broken) {
239
- const timing = trackTiming(tr, movie, totals, edits);
240
- t.durationSec = timing.durationTs / ts;
241
- t.startSec = timing.startPts === null ? null : timing.startPts / ts;
242
- }
243
- let moovKeep = totals.moovSamples;
244
- if (tr.sampleCount !== null && tr.sampleCount < moovKeep) {
245
- w.push(`stts describes ${moovKeep} samples but the sample size table holds ${tr.sampleCount}`);
246
- moovKeep = tr.sampleCount;
247
- }
248
- if (broken) {
249
- t.presentedFrames = 0;
250
- finishProvenance(t);
251
- return { result: t, enabled: tr.enabled, auxiliary };
252
- }
253
- if (truncated && moovKeep > 0) {
254
- const inFile = moovSamplesInFile(tr, movie.fileSize, totals.moovSamples);
255
- if (inFile && inFile.complete < moovKeep) {
256
- w.push(`truncated: ${inFile.complete} of ${moovKeep} samples complete`);
257
- moovKeep = inFile.complete;
258
- }
259
- }
260
- let window = presentationWindow(tr, edits, w);
261
- if (window && !tr.cttsCounts && !tr.fragments.length && window.start <= 0 && (window.end <= window.start || window.end >= totals.moovDuration))
262
- window = null;
263
- const stats = analyzeTimeline({
264
- segments: timelineSegments(tr, moovKeep),
265
- timescale: ts,
266
- editWindow: window,
267
- maxExpandedSamples: opts.maxExpandedSamples,
268
- });
269
- w.push(...stats.warnings);
270
- t.presentedFrames = stats.presentedFrames;
271
- t.minFps = stats.minFps;
272
- t.maxFps = stats.maxFps;
273
- t.isVfr = stats.isVfr;
274
- t.vfrRatio = stats.vfrRatio;
275
- t.distinctDeltas = stats.distinctDeltas;
276
- t.duplicateTimestamps = stats.duplicateTimestamps;
277
- t.nominalFps = stats.nominalFps;
278
- t.dominantFps = stats.dominantFps;
279
- t.timestampsQuantized = stats.timestampsQuantized;
280
- if (stats.presentedAvgFps)
281
- t.avgFps = stats.presentedAvgFps;
282
- if (t.duplicateTimestamps > 0)
283
- w.push(`${t.duplicateTimestamps} duplicate timestamps`);
284
- finishProvenance(t);
285
- if (stats.presentedAvgFps)
286
- t.provenance.avgFps = 'exact';
287
- return { result: t, enabled: tr.enabled, auxiliary, profile: stats.profile };
288
- }
289
- function finishProvenance(t) {
290
- const p = t.provenance;
291
- for (const k of ['codec', 'dimensions', 'rotation', 'frames', 'avgFps'])
292
- p[k] = 'header';
293
- if (t.durationSec !== null)
294
- p.durationSec = 'header';
295
- if (t.startSec !== null)
296
- p.startSec = 'header';
297
- if (t.presentedFrames !== null)
298
- p.presentedFrames = 'exact';
299
- if (t.isVfr !== null) {
300
- p.minFps = 'exact';
301
- p.maxFps = 'exact';
302
- p.isVfr = 'exact';
303
- p.duplicateTimestamps = 'exact';
304
- }
305
- if (t.nominalFps)
306
- p.nominalFps = 'exact';
307
- if (t.dominantFps)
308
- p.dominantFps = 'exact';
309
- }
310
- function pickPrimary(built) {
311
- let best = null;
312
- const score = (b) => ((b.result.frames ?? 0) > 1 ? (b.auxiliary ? 0.5 : b.enabled ? 2 : 1) : 0);
313
- for (const b of built) {
314
- if (!best || score(b) > score(best) || (score(b) === score(best) && (b.result.frames ?? 0) > (best.result.frames ?? 0)))
315
- best = b;
316
- }
317
- return best ? best.result.trackId : null;
318
- }
319
- const DEFAULT_STREAM_TIMEBASE = 90000;
320
- function formatDuration(movie, videoTracks) {
321
- const NONE = Number.MAX_SAFE_INTEGER;
322
- let start = NONE;
323
- let startText = NONE;
324
- let end = -NONE;
325
- let endText = -NONE;
326
- let dur = -NONE;
327
- let durText = -NONE;
328
- for (const tr of movie.tracks) {
329
- if (!tr.hasMdhd)
330
- continue;
331
- const main = videoTracks.has(tr) || tr.handler === 'soun';
332
- if (missingMandatoryAtoms(tr)) {
333
- const d = rescaleRound(tr.mdhdDuration, 1e6, DEFAULT_STREAM_TIMEBASE);
334
- if (main)
335
- dur = Math.max(dur, d);
336
- else
337
- durText = Math.max(durText, d);
338
- continue;
339
- }
340
- const totals = sampleTotals(tr);
341
- const timing = trackTiming(tr, movie, totals, editInfo(tr, movie.timescale, totals));
342
- const d = rescaleRound(timing.durationTs, 1e6, tr.timescale);
343
- if (timing.startPts !== null) {
344
- const s = rescaleRound(timing.startPts, 1e6, tr.timescale);
345
- if (main) {
346
- start = Math.min(start, s);
347
- end = Math.max(end, s + d);
348
- }
349
- else {
350
- startText = Math.min(startText, s);
351
- endText = Math.max(endText, s + d);
352
- }
353
- }
354
- if (main)
355
- dur = Math.max(dur, d);
356
- else
357
- durText = Math.max(durText, d);
358
- }
359
- if (start === NONE || (start > startText && start - startText < 1e6))
360
- start = startText;
361
- if (end === -NONE || (end < endText && endText - end < 1e6))
362
- end = endText;
363
- if (dur === -NONE || (dur < durText && durText - dur < 1e6))
364
- dur = durText;
365
- if (start !== NONE && end !== -NONE && end >= start)
366
- dur = Math.max(dur, end - start);
367
- return dur > 0 ? dur / 1e6 : null;
368
- }
369
- function reportedIds(tracks) {
370
- const out = new Map();
371
- const seen = new Set();
372
- let next = tracks.reduce((m, t) => Math.max(m, t.id), 0) + 1;
373
- for (const t of tracks) {
374
- if (seen.has(t.id))
375
- out.set(t, next++);
376
- else {
377
- seen.add(t.id);
378
- out.set(t, t.id);
379
- }
380
- }
381
- return out;
382
- }
7
+ export { MOOF_HARD_CAP, MOOV_HARD_CAP, MOOV_SUSPECT_CAP } from "./build.js";
383
8
  export async function analyzeIsoBmffDetailed(reader, opts) {
384
9
  const movie = newMovie(reader.size);
385
10
  const fail = (e) => {
386
11
  if (isAbort(e, reader))
387
12
  throw e;
388
13
  const message = e instanceof Error ? e.message : String(e);
389
- const analysis = emptyAnalysis(containerOf(movie), e instanceof IsoBmffError || message.startsWith(`file read failed`) ? message : `corrupt file: ${message}`);
14
+ const analysis = emptyAnalysis(containerOf(movie), e?.errorCode ? message : `corrupt file: ${message}`);
15
+ analysis.errorCode = whichErrorCode(e);
16
+ analysis.layout = layoutOf(movie);
390
17
  analysis.brand = movie.brand;
391
18
  analysis.warnings = movie.warnings;
392
19
  return { analysis, movie };
@@ -406,13 +33,14 @@ export async function analyzeIsoBmffDetailed(reader, opts) {
406
33
  if ([...ids].some(([t, id]) => t.id !== id))
407
34
  movie.warnings.push('duplicate track_ID values');
408
35
  const videos = movie.tracks.filter((t) => isVideoTrack(t, movie));
409
- const built = videos.map((t) => buildVideoTrack(t, movie, opts, { reportedId: ids.get(t) ?? t.id, ffprobeFails }));
36
+ const built = videos.map((t) => buildVideoTrack(t, movie, opts, { reportedId: ids.get(t) ?? t.id, ffprobeFails }, analyzeTimeline));
410
37
  for (const b of built)
411
38
  b.result.rateProfile = await rateProfileOf(b.profile ?? null);
412
39
  analysis.videoTracks = built.map((b) => b.result);
413
40
  analysis.audioTrackCount = movie.tracks.filter((t) => t.handler === 'soun').length;
414
41
  analysis.primaryTrackId = pickPrimary(built);
415
42
  analysis.formatDurationSec = formatDuration(movie, new Set(videos));
43
+ analysis.layout = layoutOf(movie);
416
44
  return { analysis, movie };
417
45
  }
418
46
  catch (e) {
@@ -77,7 +77,7 @@ export function parseTrun(dv, p, end, tfhd, dataStartDefault, fileSize, warnings
77
77
  const overrun = record > 0 && count < declared;
78
78
  const hasDuration = (flags & TRUN_SAMPLE_DURATION) !== 0;
79
79
  if (!hasDuration && tfhd.defaultDuration === null && count > 0) {
80
- throw new IsoBmffError(`corrupt file: no sample duration for track ${tfhd.trackId}`);
80
+ throw new IsoBmffError('corrupt', `corrupt file: no sample duration for track ${tfhd.trackId}`);
81
81
  }
82
82
  const defaultDuration = tfhd.defaultDuration ?? 0;
83
83
  const durations = hasDuration ? new Uint32Array(count) : null;
@@ -196,7 +196,7 @@ export function parseSidx(dv, box, fileStart) {
196
196
  const p = box.start + box.headerSize;
197
197
  const v1 = p < box.end && dv.getUint8(p) === 1;
198
198
  if (p + (v1 ? 32 : 24) > box.end)
199
- throw new IsoBmffError(`corrupt file: box 'sidx' at ${fileStart} is truncated`);
199
+ throw new IsoBmffError('corrupt', `corrupt file: box 'sidx' at ${fileStart} is truncated`);
200
200
  let o = p + 12;
201
201
  const earliestPts = v1 ? boxU64(dv, o, 'sidx', fileStart) : dv.getUint32(o);
202
202
  const firstOffset = v1 ? boxU64(dv, o + 8, 'sidx', fileStart) : dv.getUint32(o + 4);
@@ -10,7 +10,7 @@ function* guard(gen, ctx, parent) {
10
10
  for (const c of gen) {
11
11
  if (c.truncated) {
12
12
  if (ctx.strict)
13
- throw new IsoBmffError('incomplete file: moov is truncated');
13
+ throw new IsoBmffError('truncated', 'incomplete file: moov is truncated');
14
14
  ctx.warnings.push(`'${c.type}' overruns '${parent}'`);
15
15
  }
16
16
  yield c;
@@ -207,7 +207,6 @@ function parseStsd(dv, h, tr, ctx) {
207
207
  }
208
208
  }
209
209
  function parseStbl(dv, h, tr, ctx) {
210
- tr.dv = dv;
211
210
  for (const c of children(dv, h, ctx)) {
212
211
  const p = payload(c);
213
212
  switch (c.type) {
@@ -281,6 +280,7 @@ function parseMdia(dv, h, tr, ctx) {
281
280
  }
282
281
  function parseTrak(dv, h, movie, ctx) {
283
282
  const tr = newTrack();
283
+ tr.dv = dv;
284
284
  for (const c of children(dv, h, ctx)) {
285
285
  const p = payload(c);
286
286
  if (c.type === 'tkhd' && p + 84 <= c.end) {
@@ -340,7 +340,7 @@ export function parseMoov(dv, h, movie, strict, base = 0) {
340
340
  }
341
341
  break;
342
342
  case 'cmov':
343
- throw new IsoBmffError('unsupported: compressed movie header (cmov)');
343
+ throw new IsoBmffError('unsupported', 'unsupported: compressed movie header (cmov)');
344
344
  }
345
345
  }
346
346
  }
@@ -1,10 +1,12 @@
1
1
  import { childUint, children, ID_ATTACHED_FILE, ID_ATTACHMENTS, ID_CLUSTER, ID_CODEC_DELAY, ID_CODEC_ID, ID_CODEC_PRIVATE, ID_CONTENT_ENCODINGS, ID_CRC32, ID_CUE_CLUSTER_POSITION, ID_CUE_POINT, ID_CUE_TRACK_POSITIONS, ID_CUES, ID_DEFAULT_DURATION, ID_DOC_TYPE, ID_DURATION, ID_EBML, ID_FILE_MEDIA_TYPE, ID_FRAME_RATE, ID_INFO, ID_PIXEL_HEIGHT, ID_PIXEL_WIDTH, ID_SEEK, ID_SEEK_HEAD, ID_SEEK_ID, ID_SEEK_POSITION, ID_SEGMENT, ID_SIMPLE_TAG, ID_TAG, ID_TAG_NAME, ID_TAG_STRING, ID_TAG_TRACK_UID, ID_TAGS, ID_TARGETS, ID_TIMESTAMP_SCALE, ID_TRACK_ENTRY, ID_TRACK_NUMBER, ID_TRACK_TIMESTAMP_SCALE, ID_TRACK_TYPE, ID_TRACK_UID, ID_TRACKS, ID_VIDEO, ID_VOID, LEVEL1_IDS, readFloat, readString, readUintBig, UNKNOWN_SIZE, vintLength, } from "./ebml.js";
2
+ import { withErrorCode } from "../reader.js";
2
3
  import { findLevel1Forward } from "./source.js";
3
4
  export const HEADER_MAX_READS = 5;
4
5
  export const HEADER_MAX_BYTES = 1024 * 1024;
5
6
  export const MAX_DEPTH = 16;
6
7
  const SPARSE_CHILD_MAX = 64 * 1024;
7
8
  const CODEC_PRIVATE_MAX = 4096;
9
+ const ID_FLAG_ENABLED = 0xb9;
8
10
  const SEGMENT_SEARCH_HOPS = 16;
9
11
  const SKIPPED_NAMES = new Map([
10
12
  [ID_SEEK_HEAD, 'SeekHead'],
@@ -17,7 +19,7 @@ const CHILDREN = new Map([
17
19
  [ID_SEEK, [ID_SEEK_ID, ID_SEEK_POSITION]],
18
20
  [ID_INFO, [ID_TIMESTAMP_SCALE, ID_DURATION]],
19
21
  [ID_TRACKS, [ID_TRACK_ENTRY]],
20
- [ID_TRACK_ENTRY, [ID_TRACK_NUMBER, ID_TRACK_UID, ID_TRACK_TYPE, ID_CODEC_ID, ID_CODEC_PRIVATE, ID_CONTENT_ENCODINGS, ID_DEFAULT_DURATION, ID_TRACK_TIMESTAMP_SCALE, ID_CODEC_DELAY, ID_VIDEO]],
22
+ [ID_TRACK_ENTRY, [ID_TRACK_NUMBER, ID_TRACK_UID, ID_TRACK_TYPE, ID_FLAG_ENABLED, ID_CODEC_ID, ID_CODEC_PRIVATE, ID_CONTENT_ENCODINGS, ID_DEFAULT_DURATION, ID_TRACK_TIMESTAMP_SCALE, ID_CODEC_DELAY, ID_VIDEO]],
21
23
  [ID_VIDEO, [ID_PIXEL_WIDTH, ID_PIXEL_HEIGHT, ID_FRAME_RATE]],
22
24
  [ID_TAGS, [ID_TAG]],
23
25
  [ID_TAG, [ID_TARGETS, ID_SIMPLE_TAG]],
@@ -74,6 +76,7 @@ export function parseMaster(h, b, start, end, parent, cues = null, entry = null)
74
76
  switch (c.id) {
75
77
  case ID_TRACK_ENTRY:
76
78
  t = newTrack();
79
+ t.entry = b.slice(p, p + n);
77
80
  nest();
78
81
  finishTrack(h, t);
79
82
  break;
@@ -111,6 +114,9 @@ export function parseMaster(h, b, start, end, parent, cues = null, entry = null)
111
114
  case ID_TRACK_TYPE:
112
115
  t.type = uint();
113
116
  break;
117
+ case ID_FLAG_ENABLED:
118
+ t.disabled = !uint();
119
+ break;
114
120
  case ID_CODEC_ID:
115
121
  t.codecId = readString(b, p, n);
116
122
  break;
@@ -294,7 +300,7 @@ export async function readMatroskaHeader(src) {
294
300
  };
295
301
  const ebml = await src.header(0);
296
302
  if (!ebml || ebml.id !== ID_EBML || ebml.size === UNKNOWN_SIZE || ebml.dataStart + ebml.size > src.size)
297
- throw new Error('invalid EBML header');
303
+ throw withErrorCode('corrupt', 'invalid EBML header');
298
304
  await src.load(ebml.dataStart, Math.min(ebml.size, 4096));
299
305
  const s0 = ebml.dataStart - src.base;
300
306
  for (const c of children(src.buf, s0, s0 + ebml.size))
@@ -305,7 +311,7 @@ export async function readMatroskaHeader(src) {
305
311
  if (!seg) {
306
312
  const q = await findLevel1Forward(src, ebml.dataStart + ebml.size, src.size, src.size, { canLoad });
307
313
  if (q < 0)
308
- throw new Error('no Segment element');
314
+ throw withErrorCode('no-header', 'no Segment element');
309
315
  h.warnings.push(`no valid Segment header: reading level-1 elements from ${q}`);
310
316
  h.segmentExtended = true;
311
317
  seg = { id: ID_SEGMENT, pos: q, size: UNKNOWN_SIZE, dataStart: q };
@@ -375,6 +381,6 @@ export async function readMatroskaHeader(src) {
375
381
  if (!seen.ids.has(ID_INFO))
376
382
  h.warnings.push('no Info element: TimestampScale 1000000 assumed');
377
383
  if (!h.tracks.length)
378
- throw new Error(seen.ids.has(ID_TRACKS) ? 'no complete TrackEntry in Tracks' : 'no Tracks element found');
384
+ throw seen.ids.has(ID_TRACKS) ? withErrorCode('truncated', 'no complete TrackEntry in Tracks') : withErrorCode('no-header', 'no Tracks element found');
379
385
  return h;
380
386
  }
@@ -44,7 +44,7 @@ export function declaredRate(defaultDuration) {
44
44
  }
45
45
  export function mergeRunStats(timescale, runs) {
46
46
  const hist = new Map();
47
- const out = { minFps: null, maxFps: null, isVfr: null, vfrRatio: 0, distinctDeltas: 0, duplicateTimestamps: 0, timestampsQuantized: false, medianDelta: null };
47
+ const out = { minFps: null, maxFps: null, isVfr: null, vfrRatio: 0, distinctDeltas: 0, duplicateTimestamps: 0, timestampsQuantized: false, medianDelta: null, medianFrameInterval: null };
48
48
  let total = 0;
49
49
  for (const r of runs) {
50
50
  out.duplicateTimestamps += r.duplicateTimestamps;
@@ -65,6 +65,8 @@ export function mergeRunStats(timescale, runs) {
65
65
  if (Math.abs(k - median) > tol)
66
66
  off += hist.get(k) ?? 0;
67
67
  out.medianDelta = median;
68
+ const rate = rateFromTicks(timescale, median);
69
+ out.medianFrameInterval = rate && { num: rate.den, den: rate.num, value: rate.den / rate.num };
68
70
  out.minFps = rateFromTicks(timescale, keys[keys.length - 1]);
69
71
  out.maxFps = rateFromTicks(timescale, keys[0]);
70
72
  out.isVfr = total >= 2 && off > 0;
@@ -80,15 +82,19 @@ function fillRates(r, s, prov) {
80
82
  r.distinctDeltas = s.distinctDeltas;
81
83
  r.duplicateTimestamps = s.duplicateTimestamps;
82
84
  r.timestampsQuantized = s.timestampsQuantized;
83
- for (const f of ['minFps', 'maxFps', 'isVfr', 'duplicateTimestamps'])
85
+ r.medianFrameInterval = s.medianFrameInterval;
86
+ for (const f of ['minFps', 'maxFps', 'isVfr', 'duplicateTimestamps', 'medianFrameInterval'])
84
87
  r.provenance[f] = prov;
85
88
  }
86
- function headerTrack(h, t) {
89
+ function headerTrack(h, t, opts) {
87
90
  const r = emptyTrack(t.number);
91
+ if (opts.codecConfig)
92
+ r.codecConfig = { family: 'matroska', trackEntry: t.entry ?? null };
88
93
  r.fourccOrCodecId = t.codecId;
89
94
  r.codecName = codecNameOf(t.codecId);
90
95
  r.width = t.width;
91
96
  r.height = t.height;
97
+ r.enabled = !t.disabled;
92
98
  r.timescale = 1e9 / (Math.trunc(h.timestampScale * t.trackTimestampScale) || h.timestampScale);
93
99
  r.provenance.codec = r.provenance.dimensions = 'header';
94
100
  const d = declaredRate(t.defaultDuration);
@@ -260,7 +266,7 @@ function pickEvenly(list, k) {
260
266
  return [...new Set(Array.from({ length: k }, (_, i) => list[Math.round((i * (list.length - 1)) / (k - 1))]))];
261
267
  }
262
268
  async function tailScan(src, h, video, first, opts) {
263
- let size = Math.max(opts.readAhead.tail, 4096);
269
+ let size = Math.max(opts.readAhead.tail || 0, 4096);
264
270
  let hi = h.segmentEnd;
265
271
  let maxAnyTicks = -Infinity;
266
272
  for (;;) {
@@ -393,7 +399,7 @@ export async function analyzeMatroska(reader, opts) {
393
399
  return a;
394
400
  }
395
401
  a.primaryTrackId = video[0].number;
396
- a.videoTracks = video.map((t) => headerTrack(h, t));
402
+ a.videoTracks = video.map((t) => headerTrack(h, t, opts));
397
403
  let mode = opts.matroskaMode;
398
404
  let first = null;
399
405
  if (mode !== 'header') {
@@ -24,6 +24,7 @@ function mergeDefined(base, patch) {
24
24
  }
25
25
  export function resolveOptions(opts = {}) {
26
26
  return {
27
+ ...opts,
27
28
  signal: opts.signal ?? null,
28
29
  matroskaMode: opts.matroskaMode ?? 'auto',
29
30
  exactBudgetMs: opts.exactBudgetMs ?? DEFAULT_EXACT_BUDGET_MS,
@@ -1,3 +1,4 @@
1
+ import type { FileErrorCode } from './types.js';
1
2
  export interface BlobSlice {
2
3
  arrayBuffer(): Promise<ArrayBuffer>;
3
4
  }
@@ -42,3 +43,5 @@ export declare function u64Big(dv: DataView, p: number): bigint;
42
43
  export declare function fixed16_16(dv: DataView, p: number): number;
43
44
  export declare function ascii(dv: DataView, p: number, length: number): string;
44
45
  export declare function fourcc(dv: DataView, p: number): string;
46
+ export declare function withErrorCode(code: FileErrorCode, message: string): Error;
47
+ export declare function whichErrorCode(e: unknown, fallback?: FileErrorCode): FileErrorCode;
@@ -2,9 +2,9 @@ import { throwIfAborted } from "./abort.js";
2
2
  export const DEFAULT_HEAD_WINDOW = 64 * 1024;
3
3
  export const DEFAULT_MAX_WINDOWS = 4;
4
4
  export const MAX_SAFE_OFFSET = Number.MAX_SAFE_INTEGER;
5
- function assertSafeCount(value, what) {
5
+ function assertSafeCount(value, what, kind = RangeError) {
6
6
  if (!Number.isSafeInteger(value) || value < 0)
7
- throw new RangeError(`${what} must be a non-negative safe integer, got ${value}`);
7
+ throw new kind(`${what} must be a non-negative safe integer, got ${value}`);
8
8
  }
9
9
  export function toSafeNumber(value, what = 'value') {
10
10
  if (value > BigInt(MAX_SAFE_OFFSET) || value < -BigInt(MAX_SAFE_OFFSET))
@@ -22,13 +22,13 @@ export class BlobReader {
22
22
  #reads = 0;
23
23
  #unique = 0;
24
24
  constructor(source, options = {}) {
25
- assertSafeCount(source.size, 'blob size');
25
+ assertSafeCount(source.size, 'blob size', TypeError);
26
26
  this.#source = source;
27
27
  this.size = source.size;
28
28
  this.headWindow = options.headWindow ?? DEFAULT_HEAD_WINDOW;
29
29
  this.maxWindows = Math.max(1, options.maxWindows ?? DEFAULT_MAX_WINDOWS);
30
30
  this.signal = options.signal ?? null;
31
- assertSafeCount(this.headWindow, 'headWindow');
31
+ assertSafeCount(this.headWindow, 'headWindow', TypeError);
32
32
  }
33
33
  get reads() {
34
34
  return this.#reads;
@@ -54,12 +54,12 @@ export class BlobReader {
54
54
  }
55
55
  catch (e) {
56
56
  this.checkAbort();
57
- throw e?.name === 'AbortError' ? e : Error(`file read failed: ${e}`);
57
+ throw e?.name === 'AbortError' ? e : withErrorCode('read-failed', `file read failed: ${e}`);
58
58
  }
59
59
  this.checkAbort();
60
60
  this.#reads++;
61
61
  if (ab.byteLength !== end - offset) {
62
- throw Error(`file read failed: short read at ${offset}: expected ${end - offset} bytes, got ${ab.byteLength}`);
62
+ throw withErrorCode('read-failed', `file read failed: short read at ${offset}: expected ${end - offset} bytes, got ${ab.byteLength}`);
63
63
  }
64
64
  this.#cover(offset, end);
65
65
  return new Uint8Array(ab);
@@ -98,7 +98,7 @@ export class BlobReader {
98
98
  async view(offset, length, readAhead = this.headWindow) {
99
99
  assertSafeCount(offset, 'offset');
100
100
  assertSafeCount(length, 'length');
101
- assertSafeCount(readAhead, 'readAhead');
101
+ assertSafeCount(readAhead, 'readAhead', TypeError);
102
102
  if (offset > this.size)
103
103
  throw new RangeError(`offset ${offset} is beyond the end of the file (${this.size})`);
104
104
  const end = Math.min(this.size, offset + length);
@@ -198,3 +198,9 @@ export function ascii(dv, p, length) {
198
198
  export function fourcc(dv, p) {
199
199
  return ascii(dv, p, 4).replace(/[^ -~]/g, '?');
200
200
  }
201
+ export function withErrorCode(code, message) {
202
+ return Object.assign(Error(message), { errorCode: code });
203
+ }
204
+ export function whichErrorCode(e, fallback = 'internal') {
205
+ return e?.errorCode ?? (e instanceof RangeError ? 'corrupt' : fallback);
206
+ }