video-fps-probe 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/LICENSE +21 -0
  3. package/README.md +170 -2
  4. package/dist/analyze.d.ts +9 -0
  5. package/dist/analyze.js +5 -0
  6. package/dist/api/agreement.d.ts +18 -0
  7. package/dist/api/agreement.js +55 -0
  8. package/dist/api/errors.d.ts +12 -0
  9. package/dist/api/errors.js +10 -0
  10. package/dist/api/guards.d.ts +2 -0
  11. package/dist/api/guards.js +8 -0
  12. package/dist/api/summary.d.ts +22 -0
  13. package/dist/api/summary.js +25 -0
  14. package/dist/check/index.d.ts +60 -0
  15. package/dist/check/index.js +271 -0
  16. package/dist/client/compare.js +57 -0
  17. package/dist/client/fallback.js +43 -0
  18. package/dist/client/index.d.ts +11 -0
  19. package/dist/client/index.js +5 -0
  20. package/dist/client/inline.d.ts +19 -0
  21. package/dist/client/inline.js +43 -0
  22. package/dist/client/lazy.d.ts +6 -0
  23. package/dist/client/lazy.js +7 -0
  24. package/dist/client/pool.d.ts +29 -0
  25. package/dist/client/pool.js +174 -0
  26. package/dist/client/probe.d.ts +29 -0
  27. package/dist/client/probe.js +131 -0
  28. package/dist/client/stub.js +45 -0
  29. package/dist/client/types.d.ts +95 -0
  30. package/dist/client/types.js +1 -0
  31. package/dist/core/abort.js +4 -0
  32. package/dist/core/analyze.js +87 -0
  33. package/dist/core/index.js +6 -0
  34. package/dist/core/isobmff/boxes.js +76 -0
  35. package/dist/core/isobmff/codecs.js +43 -0
  36. package/dist/core/isobmff/index.js +421 -0
  37. package/dist/core/isobmff/model.js +60 -0
  38. package/dist/core/isobmff/moof.js +232 -0
  39. package/dist/core/isobmff/moov.js +341 -0
  40. package/dist/core/isobmff/prefetch.js +90 -0
  41. package/dist/core/isobmff/track.js +544 -0
  42. package/dist/core/matroska/ebml.js +164 -0
  43. package/dist/core/matroska/header.js +380 -0
  44. package/dist/core/matroska/index.js +466 -0
  45. package/dist/core/matroska/scan.js +419 -0
  46. package/dist/core/matroska/source.js +145 -0
  47. package/dist/core/options.js +38 -0
  48. package/dist/core/rational.js +101 -0
  49. package/dist/core/reader.d.ts +56 -0
  50. package/dist/core/reader.js +217 -0
  51. package/dist/core/result.js +49 -0
  52. package/dist/core/rfps.js +438 -0
  53. package/dist/core/segments.js +234 -0
  54. package/dist/core/sniff.js +99 -0
  55. package/dist/core/stats.js +622 -0
  56. package/dist/core/types.d.ts +135 -0
  57. package/dist/core/types.js +1 -0
  58. package/dist/engines/common.js +124 -0
  59. package/dist/engines/counting.js +78 -0
  60. package/dist/engines/mediabunny.d.ts +9 -0
  61. package/dist/engines/mediabunny.js +241 -0
  62. package/dist/engines/mp4box.d.ts +8 -0
  63. package/dist/engines/mp4box.js +429 -0
  64. package/dist/engines/rvfc.js +216 -0
  65. package/dist/engines/types.d.ts +69 -0
  66. package/dist/engines/types.js +1 -0
  67. package/dist/index.d.ts +12 -0
  68. package/dist/index.js +6 -0
  69. package/dist/lazy.d.ts +6 -0
  70. package/dist/lazy.js +6 -0
  71. package/dist/plugins/mediabunny.d.ts +2 -0
  72. package/dist/plugins/mediabunny.js +9 -0
  73. package/dist/plugins/mp4box.d.ts +2 -0
  74. package/dist/plugins/mp4box.js +9 -0
  75. package/dist/plugins/own.js +6 -0
  76. package/dist/plugins/rvfc.d.ts +5 -0
  77. package/dist/plugins/rvfc.js +3 -0
  78. package/dist/plugins/types.d.ts +17 -0
  79. package/dist/plugins/types.js +1 -0
  80. package/dist/presets/index.d.ts +18 -0
  81. package/dist/presets/index.js +54 -0
  82. package/dist/probe.d.ts +3 -0
  83. package/dist/probe.js +6 -0
  84. package/dist/react/index.d.ts +14 -0
  85. package/dist/react/index.js +30 -0
  86. package/dist/react/store.d.ts +25 -0
  87. package/dist/react/store.js +95 -0
  88. package/dist/standalone/own.worker.js +1 -0
  89. package/dist/worker/mediabunny.worker.js +5 -0
  90. package/dist/worker/mp4box.worker.js +5 -0
  91. package/dist/worker/own.worker.js +2 -0
  92. package/dist/worker/protocol.d.ts +47 -0
  93. package/dist/worker/protocol.js +1 -0
  94. package/dist/worker/runtime.d.ts +4 -0
  95. package/dist/worker/runtime.js +104 -0
  96. package/package.json +201 -4
@@ -0,0 +1,271 @@
1
+ const UNEVEN_SPREAD = 1.075;
2
+ const DECIMATE_FROM = [3n, 2n];
3
+ const SPAN_SLACK_MS = 3n;
4
+ const MAX_EXPONENT = 400;
5
+ const NOMINAL_SLACK_PER_S = 10000000n;
6
+ const NTSC = [24n, 30n, 48n, 60n, 120n];
7
+ const STANDARD = [...[24n, 25n, 30n, 48n, 50n, 60n, 100n, 120n].map((m) => [m, 1n]), ...NTSC.map((m) => [m * 1000n, 1001n])];
8
+ const RELATION = { min: 'below the minimum', max: 'above the maximum', exact: 'not', oneOf: 'not one of' };
9
+ const BOUNDS = Object.keys(RELATION);
10
+ const GROUPS = ['fps', 'duration', 'frameSize', 'aspectRatio', 'pixels', 'fileSize'];
11
+ const POLICIES = ['allow', 'warn', 'reject'];
12
+ const SOURCES = ['avg', 'ffprobe', 'nominal', 'dominant'];
13
+ const AUDIO_ENGINES = ['own', 'mp4box', 'mediabunny'];
14
+ const RANGE = ['min', 'max'];
15
+ const FILE_RULES = ['fileSize', 'containers', 'audio'];
16
+ const GROUP_KEYS = {
17
+ fps: [...RANGE, 'exact', 'oneOf', 'source'],
18
+ duration: RANGE,
19
+ frameSize: ['Width', 'Height', 'ShortSide', 'LongSide'].flatMap((side) => RANGE.map((b) => b + side)),
20
+ aspectRatio: [...RANGE, 'oneOf'],
21
+ pixels: RANGE,
22
+ fileSize: RANGE,
23
+ };
24
+ const RULE_KEYS = [...GROUPS, 'containers', 'codecs', 'audio', 'vfr', 'uneven', 'severity'];
25
+ const invalid = (rule, v, what) => {
26
+ throw new TypeError(`${rule}: ${typeof v === 'number' ? v : JSON.stringify(v)} is not ${what}`);
27
+ };
28
+ function decimal(s) {
29
+ const m = /^(\d+)(?:\.(\d+))?(?:e([+-]?\d+))?$/i.exec(s.trim());
30
+ if (!m || !Number.isFinite(Number(m[0])))
31
+ return null;
32
+ const e = Number(m[3] ?? 0) - (m[2]?.length ?? 0);
33
+ if (Math.abs(e) > MAX_EXPONENT)
34
+ return null;
35
+ const n = BigInt(m[1] + (m[2] ?? ''));
36
+ return e >= 0 ? [n * 10n ** BigInt(e), 1n] : [n, 10n ** BigInt(-e)];
37
+ }
38
+ function ratio(v, rule, amount = false) {
39
+ const parts = typeof v === 'number' || typeof v === 'string' ? String(v).split(/[/:]/) : [];
40
+ const [a, b] = parts.map(decimal);
41
+ const q = a && parts.length === 1 ? a : a && b && parts.length === 2 && !amount ? [a[0] * b[1], a[1] * b[0]] : null;
42
+ const ratios = parts.length > 1 ? "; 'num/den' and 'num:den' ratios are for fps and aspectRatio" : '';
43
+ return q && q[1] !== 0n ? q : invalid(rule, v, amount ? `a non-negative number${ratios}` : "a non-negative number or 'num/den'");
44
+ }
45
+ const cmp = (a, b) => a[0] * b[1] - b[0] * a[1];
46
+ const ntsc = (v, q) => (/[/:]/.test(String(v)) ? [] : NTSC.filter((m) => q[0] * 1000n === q[1] * m * 999n).map((m) => [m * 1000n, 1001n]));
47
+ const readings = (v, rule) => {
48
+ const q = ratio(v, rule);
49
+ return [q, ...ntsc(v, q)];
50
+ };
51
+ function close(n, a, b, slack, perSecond) {
52
+ const gap = n * (a[1] * b[0] - b[1] * a[0]);
53
+ return (gap < 0n ? -gap : gap) * perSecond <= slack * a[0] * b[0];
54
+ }
55
+ const whole = (x) => Number.isSafeInteger(x) && x > 0;
56
+ const record = (x) => !!x && typeof x === 'object' && !Array.isArray(x);
57
+ function primaryTrack(result) {
58
+ const list = result.videoTracks;
59
+ const id = result.primaryTrackId;
60
+ if (!Array.isArray(list))
61
+ return 'the result has no videoTracks list';
62
+ if (list.length === 0)
63
+ return 'no video track';
64
+ const track = list.find((x) => record(x) && typeof id === 'number' && x.trackId === id);
65
+ return track || `no primary track: primaryTrackId ${id === undefined ? 'is missing' : `${JSON.stringify(id)} matches no video track`}`;
66
+ }
67
+ const errorKind = (e) => (typeof e === 'string' ? 'blank' : Array.isArray(e) ? 'a list' : `of type ${typeof e}`);
68
+ const failedWith = (e) => `the analysis failed${typeof e === 'string' && e.trim() ? `: ${e}` : `, and its error is ${errorKind(e)}`}`;
69
+ const fromRational = (r) => (r && whole(r.num) && whole(r.den) ? [BigInt(r.num), BigInt(r.den)] : null);
70
+ const gcd = (a, b) => (b === 0n ? a : gcd(b, a % b));
71
+ const value = (q) => Number(q[0]) / Number(q[1]);
72
+ const round = (x) => +x.toFixed(3);
73
+ function digits(q, against) {
74
+ let d = 3;
75
+ while (d < 12 && against.some((b) => value(b).toFixed(d) === value(q).toFixed(d)))
76
+ d++;
77
+ return +value(q).toFixed(d);
78
+ }
79
+ function fraction(q) {
80
+ const g = gcd(q[0], q[1]) || 1n;
81
+ return `${q[0] / g}/${q[1] / g}`;
82
+ }
83
+ const fpsText = (q, against = []) => (q[0] % q[1] === 0n ? `${q[0] / q[1]}` : `${digits(q, against)} (${fraction(q)})`);
84
+ function steady(t) {
85
+ const avg = fromRational(t.avgFps);
86
+ const nominal = fromRational(t.nominalFps);
87
+ const frames = t.presentedFrames ?? t.frames;
88
+ if (!avg || !nominal || !t.timestampsQuantized || t.isVfr !== false || !whole(frames))
89
+ return null;
90
+ const n = BigInt(frames);
91
+ return close(n, avg, nominal, SPAN_SLACK_MS, 1000n) ? [nominal, [n * nominal[1], nominal[0]]] : null;
92
+ }
93
+ function resolution(t, cfr) {
94
+ const avg = fromRational(t.avgFps);
95
+ const frames = t.presentedFrames ?? t.frames;
96
+ if (!avg || !t.timestampsQuantized || t.isVfr !== false || !whole(frames))
97
+ return undefined;
98
+ return cfr ? (x) => close(1n, cfr[0], x, 1n, NOMINAL_SLACK_PER_S) : (x) => close(BigInt(frames), avg, x, SPAN_SLACK_MS, 1000n);
99
+ }
100
+ function judgedFps(t, source, cfr) {
101
+ const m = /^(\d+)\/(\d+)$/.exec(t.avgFpsFfprobe ?? '');
102
+ if (source === 'ffprobe')
103
+ return m && fromRational({ num: +m[1], den: +m[2] });
104
+ if (source === 'nominal')
105
+ return fromRational(t.nominalFps);
106
+ if (source === 'dominant')
107
+ return fromRational(t.dominantFps?.rate ?? (t.isVfr === false ? t.nominalFps : null));
108
+ return cfr?.[0] ?? fromRational(t.avgFps);
109
+ }
110
+ function unevenOf(t) {
111
+ const p = t.rateProfile;
112
+ if (p != null)
113
+ return record(p) ? p.uneven : null;
114
+ return t.isVfr === false || (t.minFps && t.maxFps && t.maxFps.value <= t.minFps.value * UNEVEN_SPREAD) ? false : null;
115
+ }
116
+ const abs = (x) => (x < 0n ? -x : x);
117
+ function fit(target, rule = {}) {
118
+ const list = (v) => (v == null ? [] : [v].flat().flatMap((x) => readings(x, 'fps')));
119
+ const [lo] = list(rule.min);
120
+ const hi = list(rule.max).reduce((a, q) => (a && cmp(a, q) > 0 ? a : q), undefined);
121
+ const inside = (q) => (!lo || cmp(q, lo) >= 0) && (!hi || cmp(q, hi) <= 0);
122
+ const nearer = (a, b) => abs(cmp(a, target)) * b[1] - abs(cmp(b, target)) * a[1] || cmp(a, b);
123
+ const nearest = (qs) => qs.filter(inside).reduce((b, q) => (b && nearer(b, q) <= 0n ? b : q), null);
124
+ const divided = (keep) => {
125
+ for (let k = 2n; k <= 8n; k++) {
126
+ const q = [target[0], target[1] * k];
127
+ if (keep(q))
128
+ return [q, Number(k)];
129
+ }
130
+ };
131
+ const oneOf = rule.oneOf == null ? null : list(rule.oneOf);
132
+ const named = rule.exact == null ? oneOf : list(rule.exact).filter((q) => !oneOf || oneOf.some((o) => cmp(o, q) === 0n));
133
+ if (lo && hi && cmp(lo, hi) > 0)
134
+ return null;
135
+ if (!named && inside(target))
136
+ return [target, 0];
137
+ const listed = (named ?? STANDARD).filter(inside);
138
+ const range = !named && listed.length === 0;
139
+ const closest = range ? (lo && cmp(target, lo) < 0 ? lo : hi) : nearest(listed);
140
+ if (!closest)
141
+ return null;
142
+ const allowed = range ? inside : (q) => listed.some((s) => cmp(s, q) === 0n);
143
+ const decimates = cmp(target, [DECIMATE_FROM[0] * closest[0], DECIMATE_FROM[1] * closest[1]]) >= 0;
144
+ return (decimates && divided(allowed)) || [closest, 0];
145
+ }
146
+ export function check(result, rules) {
147
+ const known = (path, v, keys) => {
148
+ if (!v || typeof v !== 'object' || Array.isArray(v))
149
+ invalid(path, v, 'an object');
150
+ const prefix = path === 'rules' ? '' : `${path}.`;
151
+ for (const k of Object.keys(v))
152
+ if (!keys.includes(k))
153
+ throw new TypeError(`${prefix}${k}: not a rule; expected one of ${keys.join(', ')}`);
154
+ };
155
+ known('rules', rules, RULE_KEYS);
156
+ for (const g of GROUPS)
157
+ if (rules[g] != null)
158
+ known(g, rules[g], GROUP_KEYS[g]);
159
+ const choices = [
160
+ ['fps.source', rules.fps?.source, SOURCES],
161
+ ['audio', rules.audio, ['required', 'forbidden']],
162
+ ['vfr', rules.vfr, POLICIES],
163
+ ['uneven', rules.uneven, POLICIES],
164
+ ['severity', rules.severity, ['fail', 'warn']],
165
+ ];
166
+ for (const [rule, v, list] of choices)
167
+ if (v != null && !list.includes(v))
168
+ invalid(rule, v, `one of ${list.join(', ')}`);
169
+ const violations = [];
170
+ const severity = rules.severity === 'warn' ? 'warn' : 'fail';
171
+ const failure = result == null ? 'the analysis failed: no result' : result.error ? failedWith(result.error) : null;
172
+ const found = failure ?? primaryTrack(result);
173
+ const t = typeof found === 'string' ? null : found;
174
+ const add = (rule, s, expected, actual, message) => violations.push({ rule, severity: s, expected, actual, message: `${rule}: ${message}` });
175
+ const unknown = (rule, missing) => violations.some((v) => v.rule === rule) ||
176
+ add(rule, 'unknown', null, null, `unknown, ${t || FILE_RULES.includes(rule) ? `no ${missing}${failure ? `; ${failure}` : ''}` : found}`);
177
+ const limits = (group, rule, actual, shown, text, unit, missing, side = '', near) => {
178
+ const bounds = BOUNDS.filter((b) => rule?.[b] != null).map((b) => {
179
+ const path = `${group}.${b}${side}`;
180
+ const values = (b === 'oneOf' ? [rule[b]].flat() : [rule[b]]).map((v) => (group === 'fps' ? readings(v, path) : [ratio(v, path, group !== 'aspectRatio')]));
181
+ return [b, rule[b], values];
182
+ });
183
+ if (bounds.length === 0)
184
+ return;
185
+ if (!actual)
186
+ return void unknown(group, missing);
187
+ for (const [b, expected, values] of bounds) {
188
+ const meets = (q) => near?.(q) || (b === 'min' ? cmp(actual, q) >= 0n : b === 'max' ? cmp(actual, q) <= 0n : cmp(actual, q) === 0n);
189
+ const listed = [expected].flat();
190
+ const named = listed.length ? `${listed.join(', ')}${unit}` : 'an empty list';
191
+ const actualText = typeof text === 'string' ? text : text(values.flat());
192
+ if (!values.flat().some(meets))
193
+ add(`${group}.${b}${side}`, severity, expected, shown, `${actualText}${unit} is ${RELATION[b]} ${named}`);
194
+ }
195
+ };
196
+ const quantity = (x) => (typeof x === 'number' ? decimal(String(x)) : null);
197
+ const allow = (rule, list, actual, missing) => {
198
+ if (list == null)
199
+ return;
200
+ if (!Array.isArray(list) || list.some((x) => typeof x !== 'string'))
201
+ invalid(rule, list, 'a list of strings');
202
+ if (typeof actual !== 'string' || !actual || actual === 'unknown')
203
+ unknown(rule, missing);
204
+ else if (!list.some((x) => x.toLowerCase() === actual.toLowerCase()))
205
+ add(rule, severity, list, actual, `${actual} is not one of ${list.length ? list.join(', ') : 'an empty list'}`);
206
+ };
207
+ const policy = (rule, state, missing, message) => {
208
+ const p = rules[rule];
209
+ if (!p || p === 'allow')
210
+ return false;
211
+ if (typeof state !== 'boolean')
212
+ unknown(rule, missing);
213
+ else if (state)
214
+ add(rule, p === 'warn' ? 'warn' : severity, p, true, message);
215
+ return state === true;
216
+ };
217
+ const cfr = t && steady(t);
218
+ const fps = t && judgedFps(t, rules.fps?.source, cfr);
219
+ const near = t && (rules.fps?.source ?? 'avg') === 'avg' ? resolution(t, cfr) : undefined;
220
+ limits('fps', rules.fps, fps, fps ? fraction(fps) : '', (against) => fpsText(fps, against), ' fps', `${rules.fps?.source ?? 'avg'} frame rate`, '', near);
221
+ const exact = cfr?.[1] ?? quantity(t && (t.durationSec ?? result.formatDurationSec));
222
+ const span = exact && [(exact[0] * 2000000n + exact[1]) / (2n * exact[1]), 1000000n];
223
+ const seconds = span ? value(span) : 0;
224
+ limits('duration', rules.duration, span, seconds, (against) => `${digits(span, against)}`, ' s', 'duration');
225
+ const size = t ? (Math.abs(t.rotation) % 180 === 90 ? [t.height, t.width] : [t.width, t.height]) : [0, 0];
226
+ const sized = size.every(whole);
227
+ const [w, h] = sized ? size : [0, 0];
228
+ const sides = { Width: w, Height: h, ShortSide: Math.min(w, h), LongSide: Math.max(w, h) };
229
+ const d = rules.frameSize;
230
+ for (const [side, x] of Object.entries(sides)) {
231
+ limits('frameSize', { min: d?.[`min${side}`], max: d?.[`max${side}`] }, sized ? [BigInt(x), 1n] : null, x, `${x}`, ' px', 'frame size', side);
232
+ }
233
+ const area = [BigInt(w), BigInt(h)];
234
+ limits('aspectRatio', rules.aspectRatio, sized ? area : null, fraction(area), `${w}x${h} (${round(w / h)})`, '', 'frame size');
235
+ limits('pixels', rules.pixels, sized ? [BigInt(w) * BigInt(h), 1n] : null, w * h, `${w * h}`, ' px', 'frame size');
236
+ limits('fileSize', rules.fileSize, quantity(result?.size), result?.size ?? '', `${result?.size}`, ' bytes', 'file size');
237
+ allow('containers', rules.containers, result?.container, 'container');
238
+ allow('codecs', rules.codecs, t?.codecName, 'codec name');
239
+ if (rules.audio) {
240
+ const engine = result?.engine;
241
+ const counts = AUDIO_ENGINES.includes(engine);
242
+ const n = result?.audioTrackCount;
243
+ const audio = !failure && counts && Number.isSafeInteger(n) && n >= 0 ? n > 0 : null;
244
+ const required = rules.audio === 'required';
245
+ if (audio === null)
246
+ unknown('audio', `audio track count${!counts && typeof engine === 'string' ? ` (the ${engine} engine reports none)` : ''}`);
247
+ else if (audio !== required)
248
+ add('audio', severity, rules.audio, audio, `${rules.audio}, the file has ${required ? 'no' : 'an'} audio track`);
249
+ }
250
+ const vfr = policy('vfr', t?.isVfr, 'VFR flag', 'the frame rate is variable');
251
+ const profile = t?.rateProfile;
252
+ const summary = record(profile) && typeof profile.summary === 'string' ? `: ${profile.summary}` : '';
253
+ const uneven = policy('uneven', t && unevenOf(t), `${profile == null ? '' : 'uneven flag in the '}rate profile`, `the frame rate is uneven${summary}`);
254
+ const report = { verdict: 'pass', violations };
255
+ const fpsOff = violations.some((v) => v.rule.startsWith('fps.'));
256
+ if (t && (fpsOff || vfr || uneven)) {
257
+ const constant = fpsOff ? fps : (fromRational(t.dominantFps?.rate) ?? fromRational(t.nominalFps));
258
+ const avg = Math.round(t.avgFps?.value ?? 0);
259
+ const fitted = fit(constant ?? [whole(avg) ? BigInt(avg) : 0n, 1n], rules.fps);
260
+ const why = fpsOff ? `${fpsText(fps, fitted ? [fitted[0]] : [])} fps is not allowed` : `the frame rate is ${vfr ? 'variable' : 'uneven'}`;
261
+ if (!fitted)
262
+ report.suggestedFps = { value: null, reason: `${why}, and no frame rate meets every bound of the fps rule` };
263
+ else if (fitted[0][0] > 0n) {
264
+ const [q, k] = fitted;
265
+ const how = k > 1 ? `, which keeps 1 of every ${k} frames` : '';
266
+ report.suggestedFps = { value: value(q), reason: `${why}: ${fpsOff ? 'convert to' : 're-encode at a constant'} ${fpsText(q)} fps${how}` };
267
+ }
268
+ }
269
+ report.verdict = ['fail', 'unknown', 'warn'].find((s) => violations.some((v) => v.severity === s)) ?? 'pass';
270
+ return report;
271
+ }
@@ -0,0 +1,57 @@
1
+ import { compareResults } from "../api/agreement.js";
2
+ import { ProbeError } from "../api/errors.js";
3
+ import { isAbortError } from "../api/guards.js";
4
+ import { failureOf } from "./fallback.js";
5
+ import { nameOf } from "./probe.js";
6
+ export async function compare(ctx, file, o = {}) {
7
+ const name = nameOf(file);
8
+ const ids = [...new Set(o.engines ?? ctx.engines.filter((id) => id !== 'rvfc'))];
9
+ const missing = ids.find((id) => !ctx.routes.has(id));
10
+ if (missing)
11
+ throw new ProbeError('engine-not-registered', `engine ${missing} is not registered`, { engine: missing });
12
+ const wire = { ...ctx.defaults };
13
+ const { signal, timeoutMs, onProgress } = o;
14
+ const outcomeOf = async (id) => {
15
+ const t0 = performance.now();
16
+ try {
17
+ const reply = await ctx.run(id, file, name, wire, { signal, timeoutMs, onProgress: onProgress && ((p) => onProgress({ ...p, engine: id })) });
18
+ if (reply.type === 'skipped')
19
+ return { status: 'skipped', reason: reply.reason };
20
+ const ms = performance.now() - t0;
21
+ const result = reply.result;
22
+ const error = failureOf(result);
23
+ return error === null ? { status: 'ok', result, ms } : { status: 'failed', result, error, ms };
24
+ }
25
+ catch (e) {
26
+ if (isAbortError(e))
27
+ throw e;
28
+ const error = e instanceof ProbeError ? e : Object.assign(new ProbeError('worker-failed', String(e), { engine: id, cause: e }), { reason: 'crash' });
29
+ if (error.code === 'unsupported-environment')
30
+ return { status: 'skipped', reason: 'unsupported-environment' };
31
+ return { status: error.code === 'timeout' ? 'timeout' : 'crashed', error };
32
+ }
33
+ };
34
+ const groups = new Map();
35
+ for (const id of ids) {
36
+ const route = ctx.routes.get(id);
37
+ const kind = route && 'kind' in route ? route.kind : id;
38
+ groups.set(kind, [...(groups.get(kind) ?? []), id]);
39
+ }
40
+ const settled = new Map();
41
+ await Promise.all([...groups.values()].map(async (group) => {
42
+ for (const id of group)
43
+ settled.set(id, await outcomeOf(id));
44
+ }));
45
+ const outcomes = {};
46
+ const results = {};
47
+ for (const id of ids) {
48
+ const outcome = settled.get(id);
49
+ if (!outcome)
50
+ continue;
51
+ outcomes[id] = outcome;
52
+ if ('result' in outcome)
53
+ results[id] = outcome.result;
54
+ }
55
+ const agreement = compareResults(results, o.reference);
56
+ return { name, size: file.size, reference: agreement.reference, outcomes, agreement };
57
+ }
@@ -0,0 +1,43 @@
1
+ import { ProbeError } from "../api/errors.js";
2
+ import { isAbortError } from "../api/guards.js";
3
+ import { emptyAnalysis, primaryTrack } from "../core/result.js";
4
+ export const failureOf = (r) => {
5
+ const t = primaryTrack(r);
6
+ return r.error ?? (!t ? 'no video track' : t.avgFps ? null : 'no frame rate');
7
+ };
8
+ const skippedResult = (id, file, name, family) => ({
9
+ name,
10
+ size: file.size,
11
+ ...emptyAnalysis(family === 'isobmff' ? 'mp4' : (family ?? 'unknown'), family ? `unsupported by ${id}` : 'not a recognised container'),
12
+ io: { reads: 0, bytesRead: 0, ms: 0 },
13
+ engine: id,
14
+ });
15
+ const withAttempts = (result, failures) => failures.length > 0 ? { ...result, attempts: failures.map(({ engine, error }) => ({ engine, error })) } : result;
16
+ export async function runChain(run, chain, file, name, wire, call, first) {
17
+ const failures = [];
18
+ let last = null;
19
+ for (const [i, id] of chain.entries()) {
20
+ let result;
21
+ try {
22
+ const reply = i === 0 && first ? first : await run(id, file, name, wire, call);
23
+ result = reply.type === 'result' ? reply.result : skippedResult(id, file, name, reply.family ?? null);
24
+ }
25
+ catch (e) {
26
+ if (isAbortError(e))
27
+ throw e;
28
+ last = e instanceof ProbeError ? e : Object.assign(new ProbeError('worker-failed', String(e), { engine: id, cause: e }), { reason: 'crash' });
29
+ failures.push({ engine: id, error: last.message });
30
+ continue;
31
+ }
32
+ const error = failureOf(result);
33
+ if (error === null || chain.length === 1)
34
+ return withAttempts(result, failures);
35
+ failures.push({ engine: id, error, result });
36
+ }
37
+ for (let i = failures.length - 1; i >= 0; i--) {
38
+ const { result } = failures[i];
39
+ if (result)
40
+ return withAttempts(result, failures.slice(0, i).concat(failures.slice(i + 1)));
41
+ }
42
+ throw last;
43
+ }
@@ -0,0 +1,11 @@
1
+ export { compareResults, type Agreement, type ComparedField, type ComparedFieldName, type ComparedValue } from '../api/agreement.js';
2
+ export { ProbeError, type ProbeErrorCode } from '../api/errors.js';
3
+ export { isAbortError, isTimeoutError } from '../api/guards.js';
4
+ export { summarize, type VideoFpsSummary } from '../api/summary.js';
5
+ export type { BlobLike, BlobSlice } from '../core/reader.js';
6
+ export type * from '../core/types.js';
7
+ export type { MediabunnyNative, MediabunnyTrackNative, Mp4boxNative, Mp4boxTrackNative, RvfcConfidence, RvfcDetails, RvfcPass } from '../engines/types.js';
8
+ export type { ContainerFamily, EngineDescriptor, EngineModule, WorkerEngineId } from '../plugins/types.js';
9
+ export type { WireOptions } from '../worker/protocol.js';
10
+ export { createProbeClient } from './probe.js';
11
+ export type * from './types.js';
@@ -0,0 +1,5 @@
1
+ export { compareResults } from "../api/agreement.js";
2
+ export { ProbeError } from "../api/errors.js";
3
+ export { isAbortError, isTimeoutError } from "../api/guards.js";
4
+ export { summarize } from "../api/summary.js";
5
+ export { createProbeClient } from "./probe.js";
@@ -0,0 +1,19 @@
1
+ import type { AnalyzeProgress, EngineId, FileResult } from '../core/types.js';
2
+ import type { ContainerFamily, EngineModule } from '../plugins/types.js';
3
+ import type { SkipReason, WireOptions } from '../worker/protocol.js';
4
+ export interface InlineRun {
5
+ engine: EngineId;
6
+ signals: readonly (AbortSignal | null | undefined)[];
7
+ timeoutMs?: number;
8
+ onProgress?: (progress: AnalyzeProgress) => void;
9
+ host?: HTMLElement;
10
+ }
11
+ export type InlineReply = {
12
+ type: 'result';
13
+ result: FileResult;
14
+ } | {
15
+ type: 'skipped';
16
+ reason: SkipReason;
17
+ family: ContainerFamily | null;
18
+ };
19
+ export declare function runInline(load: () => Promise<EngineModule>, file: Blob, name: string, options: WireOptions, run: InlineRun): Promise<InlineReply>;
@@ -0,0 +1,43 @@
1
+ import { ProbeError } from "../api/errors.js";
2
+ import { containerFamily } from "../core/sniff.js";
3
+ export function runInline(load, file, name, options, run) {
4
+ const { engine, signals, onProgress, host } = run;
5
+ const timeoutMs = run.timeoutMs ?? 0;
6
+ const controller = new AbortController();
7
+ return new Promise((resolve, reject) => {
8
+ let done = false;
9
+ let timer;
10
+ const finish = (settle) => {
11
+ if (done)
12
+ return;
13
+ done = true;
14
+ clearTimeout(timer);
15
+ for (const s of signals)
16
+ s?.removeEventListener('abort', onAbort);
17
+ settle();
18
+ };
19
+ const fail = (error) => {
20
+ controller.abort();
21
+ finish(() => reject(error));
22
+ };
23
+ const onAbort = () => fail(new ProbeError('aborted', 'aborted', { engine, cause: signals.find((s) => s?.aborted)?.reason }));
24
+ if (signals.some((s) => s?.aborted))
25
+ return onAbort();
26
+ for (const s of signals)
27
+ s?.addEventListener('abort', onAbort);
28
+ if (timeoutMs > 0 && timeoutMs <= 2 ** 31 - 1)
29
+ timer = setTimeout(() => fail(new ProbeError('timeout', `timed out after ${timeoutMs} ms`, { engine })), timeoutMs);
30
+ let loading = true;
31
+ (async () => {
32
+ const module = await load();
33
+ loading = false;
34
+ if (module.containers !== 'any') {
35
+ const family = await containerFamily(file);
36
+ if (!family || !module.containers.includes(family))
37
+ return { type: 'skipped', reason: 'unsupported-container', family };
38
+ }
39
+ const opts = { ...options, signal: controller.signal, onProgress, host };
40
+ return { type: 'result', result: await module.run(file, name, opts) };
41
+ })().then((reply) => finish(() => resolve(reply)), (e) => (e instanceof ProbeError ? finish(() => reject(e)) : fail(Object.assign(new ProbeError('worker-failed', e instanceof Error ? e.message || e.name : String(e), { engine, cause: e }), { reason: loading ? 'load' : 'crash' }))));
42
+ });
43
+ }
@@ -0,0 +1,6 @@
1
+ import type { EngineId } from '../core/types.js';
2
+ import type { EngineDescriptor, WorkerEngineId } from '../plugins/types.js';
3
+ import type { ClientOptions, IdOf, LazyProbe, Probe } from './types.js';
4
+ export type { ClientOptions, LazyProbe, Probe };
5
+ export declare function lazyProbeFrom<Id extends EngineId>(factory: () => Promise<Probe<Id>>): LazyProbe<Id>;
6
+ export declare function lazyProbeClient<const E extends readonly EngineDescriptor[] = [], const W extends WorkerEngineId = never>(options: ClientOptions<E, W>): LazyProbe<W | IdOf<E[number]> | 'own'>;
@@ -0,0 +1,7 @@
1
+ import { knownEngines, lazyStub } from "./stub.js";
2
+ export function lazyProbeFrom(factory) {
3
+ return lazyStub(factory, []);
4
+ }
5
+ export function lazyProbeClient(options) {
6
+ return lazyStub(() => import("./probe.js").then((m) => m.createProbeClient(options)), knownEngines(options.worker, options.engines));
7
+ }
@@ -0,0 +1,29 @@
1
+ import { ProbeError } from '../api/errors.js';
2
+ import type { AnalyzeProgress, EngineId } from '../core/types.js';
3
+ import { type JobRequest, type WorkerResponse } from '../worker/protocol.js';
4
+ export declare const KILL_AFTER_MS = 1500;
5
+ export declare const DEFAULT_IDLE_TIMEOUT_MS = 30000;
6
+ export declare const LOAD_FAILED = "worker script failed to load or parse; the app may have been redeployed";
7
+ export declare const workerFailed: (message: string, reason: NonNullable<ProbeError["reason"]>, engine?: EngineId | null, cause?: unknown) => ProbeError;
8
+ export interface PoolOptions {
9
+ create: () => Worker;
10
+ size?: number;
11
+ idleTimeoutMs?: number;
12
+ }
13
+ export interface RunOptions {
14
+ signal?: AbortSignal | null;
15
+ timeoutMs?: number;
16
+ onProgress?: (progress: AnalyzeProgress) => void;
17
+ engine?: EngineId | null;
18
+ }
19
+ export type PoolReply = Extract<WorkerResponse, {
20
+ type: 'result' | 'skipped' | 'warmed';
21
+ }>;
22
+ export declare class WorkerPool {
23
+ #private;
24
+ constructor(options: PoolOptions);
25
+ get workers(): number;
26
+ run(request: JobRequest, options?: RunOptions): Promise<PoolReply>;
27
+ dispose(): void;
28
+ destroy(): void;
29
+ }