PageSourceSearch

https://video-to-notes.com/lib/media-probe.mjs

js video-to-notes.com collected 2026-10-02 01:48:01 UTC 18,801 bytes, 487 lines download raw bytes

1// media-probe.mjs — read a video file's real technical specs in the browser.
2//
3// Two clearly separated halves:
4//
5//   * PURE (Node-testable, no browser API): parseFfmpegProbeLogs / mergeProbeLayers
6//     / applyRotation / containerFromFileName. `parseFfmpegProbeLogs` is where the
7//     bugs live — ffmpeg's stderr is a human-readable format, not an API — so it is
8//     covered by captured real-stderr fixtures for every shape we expect.
9//
10//   * BROWSER: probeMediaFile(), layered so a result ALWAYS appears:
11//       1. Fast layer (~instant): an <video> element gives width/height/duration;
12//          File gives size and container. Aspect, orientation and overall bitrate
13//          derive from those. The result panel renders from this immediately.
14//       2. Deep layer (ffmpeg.wasm, lazy): mounts the File via WORKERFS — never
15//          readAsArrayBuffer, so a multi-GB file costs nothing — and dumps the
16//          input header. Fills in codec, profile, pixel format, frame rate and
17//          audio detail. Failure here is NON-FATAL: the fast layer's values stay
18//          and the ffmpeg-only fields report as unknown, never as a guess.
19//
20// Nothing here uploads anything or talks to a backend.
21
22import {
23  ensureFfmpeg,
24  mountSource,
25  unmountSource,
26  buildMountedInputName,
27  getInputExtension,
28  isOomError,
29  AudioExtractionError,
30} from "./ffmpeg-extractor.mjs";
31import { normalizeProbe } from "./platform-video-specs.mjs";
32
33// Probing is not transcoding: WORKERFS + a header-only run reads the front of the
34// file and nothing else, so the 500 MB ceiling that protects the encode widgets
35// does not apply here. 8 GB is a defensive cap, not a technical one.
36export const MAX_PROBE_FILE_BYTES = 8 * 1024 * 1024 * 1024;
37
38// ─── pure: file-name → container ──────────────────────────────────────────────
39
40// The file extension is the most reliable container signal we have. ffmpeg's
41// demuxer name is a GROUP ("mov,mp4,m4a,3gp,3g2,mj2"), which genuinely cannot
42// tell MP4 from MOV, so the extension wins whenever there is one.
43export function containerFromFileName(name) {
44  if (typeof name !== "string") return null;
45  const dot = name.lastIndexOf(".");
46  if (dot < 0 || dot === name.length - 1) return null;
47  return name.slice(dot + 1).toLowerCase() || null;
48}
49
50// ─── pure: rotation ───────────────────────────────────────────────────────────
51
52// A phone clip is stored landscape with a 90° rotation flag; it DISPLAYS
53// portrait. Every platform check is about the displayed shape, so the coded
54// dimensions have to be swapped before anything is evaluated.
55export function applyRotation(width, height, rotation) {
56  const rotated = ((Number(rotation) || 0) % 360 + 360) % 360;
57  if (rotated === 90 || rotated === 270) return { width: height, height: width };
58  return { width, height };
59}
60
61// ─── pure: the ffmpeg stderr parser ───────────────────────────────────────────
62
63const PIXEL_FORMAT_RE =
64  /\b(yuvj?[0-9]{3}p(?:[0-9]{1,2}(?:le|be))?|gbrp[0-9a-z]*|rgb[0-9a-z]*|bgr[0-9a-z]*|gray[0-9a-z]*|nv[0-9]{2}|p0[0-9]{2}[a-z]*|yuva[0-9]{3}p[0-9a-z]*)\b/;
65
66const CHANNEL_LAYOUTS = {
67  mono: 1,
68  stereo: 2,
69  "2.1": 3,
70  quad: 4,
71  "4.0": 4,
72  "5.0": 5,
73  "5.1": 6,
74  "6.1": 7,
75  "7.1": 8,
76  downmix: 2,
77};
78
79function toNumber(value) {
80  const num = Number(value);
81  return Number.isFinite(num) ? num : null;
82}
83
84// "00:01:23.45" → 83.45. "N/A" (or anything unparseable) → null.
85function parseClock(hours, minutes, seconds) {
86  const h = toNumber(hours);
87  const m = toNumber(minutes);
88  const s = toNumber(seconds);
89  if (h === null || m === null || s === null) return null;
90  return h * 3600 + m * 60 + s;
91}
92
93// ffmpeg prints "1500 kb/s" but also "1.5 Mb/s" for some demuxers.
94function parseBitrateKbps(text) {
95  const match = /([0-9]+(?:\.[0-9]+)?)\s*(k|m)b\/s/i.exec(text);
96  if (!match) return null;
97  const value = toNumber(match[1]);
98  if (value === null) return null;
99  return match[2].toLowerCase() === "m" ? value * 1000 : value;
100}
101
102// The highest frame rate we will report. Anything above this is a timebase
103// artifact ("1k tbr"), not a frame rate, and reporting it would flip the
104// LinkedIn/X frame-rate checks to a confidently wrong verdict. Unknown beats
105// wrong, so an implausible value is discarded rather than shown.
106const MAX_PLAUSIBLE_FPS = 300;
107
108// Prefer "fps"; fall back to "tbr". A "k" suffix means thousands ("1k tbr").
109// Some muxers print tbr in hundredths with no separator ("2997 tbr" = 29.97),
110// so a large bare tbr is rescaled before the plausibility check.
111function parseFrameRate(text) {
112  const fps = /([0-9]+(?:\.[0-9]+)?)\s*fps/.exec(text);
113  if (fps) {
114    const value = toNumber(fps[1]);
115    if (value !== null && value > 0 && value <= MAX_PLAUSIBLE_FPS) return value;
116  }
117  const tbr = /([0-9]+(?:\.[0-9]+)?)(k?)\s*tbr/.exec(text);
118  if (!tbr) return null;
119  let value = toNumber(tbr[1]);
120  if (value === null || value <= 0) return null;
121  if (tbr[2] === "k") {
122    // An explicit "k" is a rounded timebase ("1k tbr"), never a hundredths
123    // frame rate — so it is scaled up and then falls out as implausible.
124    value *= 1000;
125  } else if (value > MAX_PLAUSIBLE_FPS && value / 100 <= MAX_PLAUSIBLE_FPS) {
126    value /= 100;
127  }
128  return value > 0 && value <= MAX_PLAUSIBLE_FPS ? value : null;
129}
130
131function parseChannels(text) {
132  for (const [layout, count] of Object.entries(CHANNEL_LAYOUTS)) {
133    if (new RegExp(`(?:^|,\\s*)${layout.replace(".", "\\.")}(?:\\(|,|\\s|$)`).test(text)) {
134      return count;
135    }
136  }
137  const explicit = /(\d+)\s*channels?/i.exec(text);
138  return explicit ? toNumber(explicit[1]) : null;
139}
140
141// "h264 (High) (avc1 / 0x31637661)" → { codec: "h264", profile: "High" }.
142// The fourcc parenthesis is skipped: it contains "/" or "0x", a profile never does.
143function parseCodecAndProfile(tail) {
144  const head = /^\s*([A-Za-z0-9_.\-]+)\s*(.*)$/.exec(tail);
145  if (!head) return { codec: null, profile: null };
146  const codec = head[1].toLowerCase();
147  let profile = null;
148  const groups = head[2].match(/^(?:\s*\([^)]*\))+/);
149  if (groups) {
150    for (const group of groups[0].match(/\(([^)]*)\)/g) || []) {
151      const inner = group.slice(1, -1).trim();
152      if (!inner || inner.includes("/") || inner.includes("0x")) continue;
153      profile = inner;
154      break;
155    }
156  }
157  return { codec, profile };
158}
159
160/**
161 * Parse the input-header lines ffmpeg writes to stderr.
162 *
163 * Only the FIRST video and FIRST audio stream of the FIRST INPUT are read: a
164 * `-f null` run also prints an "Output #0" section whose wrapped_avframe stream
165 * would otherwise overwrite the real codec.
166 *
167 * Every field is null when the logs do not contain it. Nothing is guessed.
168 *
169 * @param {string[]|string} lines ffmpeg stderr, as captured by ffmpeg.on("log")
170 */
171export function parseFfmpegProbeLogs(lines) {
172  const rows = Array.isArray(lines) ? lines : String(lines ?? "").split(/\r?\n/);
173
174  const result = {
175    container: null,
176    containerFormats: [],
177    durationSeconds: null,
178    overallBitrateKbps: null,
179    width: null,
180    height: null,
181    videoCodec: null,
182    profile: null,
183    pixelFormat: null,
184    frameRate: null,
185    videoBitrateKbps: null,
186    audioCodec: null,
187    audioSampleRate: null,
188    audioChannels: null,
189    audioBitrateKbps: null,
190    hasAudio: null,
191    rotation: null,
192  };
193
194  let inInput = false;
195  let seenVideo = false;
196  let seenAudio = false;
197  // Rotation metadata trails its stream, so it is only trusted while we are
198  // still inside the video stream's own Metadata / Side data block.
199  let inVideoStreamBlock = false;
200
201  for (const raw of rows) {
202    const line = String(raw ?? "");
203    const trimmed = line.trim();
204
205    const input = /^Input #(\d+),\s*(.+?),\s*from\s/.exec(trimmed);
206    if (input) {
207      inInput = input[1] === "0";
208      inVideoStreamBlock = false;
209      if (inInput) {
210        result.containerFormats = input[2]
211          .split(",")
212          .map((token) => token.trim().toLowerCase())
213          .filter(Boolean);
214        result.container = result.containerFormats[0] || null;
215      }
216      continue;
217    }
218
219    // Everything after the output/mapping header belongs to the encode, not the
220    // source file.
221    if (/^Output #/.test(trimmed) || /^Stream mapping:/.test(trimmed)) {
222      inInput = false;
223      inVideoStreamBlock = false;
224      continue;
225    }
226    if (!inInput) continue;
227
228    const duration = /^Duration:\s*(?:(\d+):(\d{2}):(\d{2}(?:\.\d+)?)|N\/A)/.exec(trimmed);
229    if (duration) {
230      result.durationSeconds = parseClock(duration[1], duration[2], duration[3]);
231      const bitratePart = /bitrate:\s*(.+)$/.exec(trimmed);
232      result.overallBitrateKbps = bitratePart ? parseBitrateKbps(bitratePart[1]) : null;
233      inVideoStreamBlock = false;
234      continue;
235    }
236
237    const stream = /^Stream #\d+:\d+[^:]*:\s*(Video|Audio|Subtitle|Data):\s*(.+)$/.exec(trimmed);
238    if (stream) {
239      const kind = stream[1];
240      const tail = stream[2];
241      inVideoStreamBlock = false;
242
243      if (kind === "Video" && !seenVideo) {
244        seenVideo = true;
245        inVideoStreamBlock = true;
246        const { codec, profile } = parseCodecAndProfile(tail);
247        result.videoCodec = codec;
248        result.profile = profile;
249        const pixelFormat = PIXEL_FORMAT_RE.exec(tail);
250        result.pixelFormat = pixelFormat ? pixelFormat[1] : null;
251        const size = /\b(\d{2,5})x(\d{2,5})\b/.exec(tail);
252        if (size) {
253          result.width = toNumber(size[1]);
254          result.height = toNumber(size[2]);
255        }
256        result.frameRate = parseFrameRate(tail);
257        result.videoBitrateKbps = parseBitrateKbps(tail);
258      } else if (kind === "Audio" && !seenAudio) {
259        seenAudio = true;
260        const { codec } = parseCodecAndProfile(tail);
261        result.audioCodec = codec;
262        const sampleRate = /(\d+)\s*Hz/.exec(tail);
263        result.audioSampleRate = sampleRate ? toNumber(sampleRate[1]) : null;
264        result.audioChannels = parseChannels(tail);
265        result.audioBitrateKbps = parseBitrateKbps(tail);
266      }
267      continue;
268    }
269
270    if (inVideoStreamBlock) {
271      const rotate = /^rotate\s*:\s*(-?\d+(?:\.\d+)?)/.exec(trimmed);
272      if (rotate) {
273        result.rotation = normalizeRotation(toNumber(rotate[1]));
274        continue;
275      }
276      // ffmpeg 5+ drops the `rotate` tag and prints a display matrix instead,
277      // whose sign is inverted relative to the old tag.
278      const matrix = /displaymatrix:\s*rotation of\s*(-?\d+(?:\.\d+)?)\s*degrees/i.exec(trimmed);
279      if (matrix) {
280        result.rotation = normalizeRotation(-toNumber(matrix[1]));
281      }
282    }
283  }
284
285  // A parse that found a video stream but no audio stream is positive evidence
286  // the file is silent; a parse that found nothing at all stays unknown.
287  if (seenVideo) result.hasAudio = seenAudio;
288
289  return result;
290}
291
292function normalizeRotation(degrees) {
293  if (degrees === null) return null;
294  return ((Math.round(degrees) % 360) + 360) % 360;
295}
296
297// ─── pure: layer merge ────────────────────────────────────────────────────────
298
299// Fields only ffmpeg can know. Everything else the fast layer already measured
300// from the actual decoded/displayed video, which is the more trustworthy source.
301const DEEP_ONLY_FIELDS = [
302  "videoCodec",
303  "profile",
304  "pixelFormat",
305  "frameRate",
306  "videoBitrateKbps",
307  "audioCodec",
308  "audioSampleRate",
309  "audioChannels",
310  "audioBitrateKbps",
311  "rotation",
312  "containerFormats",
313];
314
315/**
316 * Merge the deep (ffmpeg) layer over the fast (<video> element) layer.
317 *
318 * The fast layer keeps width/height/duration: the browser reports the DISPLAY
319 * dimensions of a rotated clip, while ffmpeg reports the coded ones. When the
320 * fast layer could not read them, the deep values are used with the rotation
321 * flag applied so a portrait phone clip is not reported as landscape.
322 */
323export function mergeProbeLayers(fast, deep) {
324  const merged = { ...(fast || {}) };
325  if (!deep) return merged;
326
327  for (const field of DEEP_ONLY_FIELDS) {
328    if (deep[field] !== null && deep[field] !== undefined) merged[field] = deep[field];
329  }
330
331  if (!merged.width || !merged.height) {
332    const rotated = applyRotation(deep.width, deep.height, deep.rotation);
333    if (rotated.width && rotated.height) {
334      merged.width = rotated.width;
335      merged.height = rotated.height;
336    }
337  }
338  if (!merged.durationSeconds && deep.durationSeconds) {
339    merged.durationSeconds = deep.durationSeconds;
340  }
341  if (deep.overallBitrateKbps) merged.overallBitrateKbps = deep.overallBitrateKbps;
342  if (typeof deep.hasAudio === "boolean") merged.hasAudio = deep.hasAudio;
343  // Only fall back to ffmpeg's demuxer group when the file had no extension.
344  if (!merged.container && deep.container) merged.container = deep.container;
345
346  return merged;
347}
348
349// ─── browser: the fast layer ──────────────────────────────────────────────────
350
351// Read width/height/duration from a throwaway <video>. Never rejects — an
352// unreadable file simply yields nulls and the deep layer fills what it can.
353export function readMediaMetadata(file) {
354  return new Promise((resolve) => {
355    if (typeof document === "undefined") {
356      resolve({ width: null, height: null, durationSeconds: null });
357      return;
358    }
359    const el = document.createElement("video");
360    el.preload = "metadata";
361    el.muted = true;
362    const url = URL.createObjectURL(file);
363    let settled = false;
364    const finish = () => {
365      if (settled) return;
366      settled = true;
367      const width = Number(el.videoWidth) || null;
368      const height = Number(el.videoHeight) || null;
369      const duration = Number(el.duration);
370      URL.revokeObjectURL(url);
371      el.removeAttribute("src");
372      resolve({
373        width,
374        height,
375        durationSeconds: Number.isFinite(duration) && duration > 0 ? duration : null,
376      });
377    };
378    el.addEventListener("loadedmetadata", finish);
379    el.addEventListener("error", finish);
380    // Safety net: a stream whose metadata never arrives must not hang the page.
381    setTimeout(finish, 8000);
382    el.src = url;
383  });
384}
385
386async function probeFastLayer(file) {
387  const { width, height, durationSeconds } = await readMediaMetadata(file);
388  return normalizeProbe({
389    fileName: file.name || "",
390    fileSizeBytes: file.size || null,
391    mimeType: file.type || "",
392    container: containerFromFileName(file.name) || null,
393    width,
394    height,
395    durationSeconds,
396  });
397}
398
399// ─── browser: the deep layer ──────────────────────────────────────────────────
400
401// `-t 1 -f null -` asks ffmpeg to decode one second into the null muxer, which
402// touches only the header region. If that run produces no usable stream line
403// (some builds refuse the null muxer), fall back to a bare `-i`: real ffmpeg
404// prints the full input header and then exits non-zero for "no output file",
405// which is exactly the header dump we want and decodes nothing at all.
406const PROBE_COMMANDS = [
407  (inputName) => ["-hide_banner", "-i", inputName, "-t", "1", "-f", "null", "-"],
408  (inputName) => ["-hide_banner", "-i", inputName],
409];
410
411async function probeDeepLayer(file) {
412  const ffmpeg = await ensureFfmpeg();
413  const { blobName, inputName } = buildMountedInputName(getInputExtension(file.name));
414
415  // WORKERFS mount, never readAsArrayBuffer: ffmpeg reads the byte ranges it
416  // needs straight off the File handle, so a 4 GB source costs no wasm heap.
417  await mountSource(ffmpeg, blobName, file);
418  try {
419    let parsed = null;
420    for (const buildArgs of PROBE_COMMANDS) {
421      const logLines = [];
422      const logHandler = ({ message }) => logLines.push(message);
423      ffmpeg.on("log", logHandler);
424      try {
425        // The exit code is deliberately ignored: the header is printed before
426        // ffmpeg decides whether it has an output file to write.
427        await ffmpeg.exec(buildArgs(inputName));
428      } finally {
429        ffmpeg.off("log", logHandler);
430      }
431      parsed = parseFfmpegProbeLogs(logLines);
432      if (parsed.videoCodec || parsed.audioCodec) return parsed;
433    }
434    return parsed;
435  } finally {
436    await unmountSource(ffmpeg);
437  }
438}
439
440// ─── browser: the public entry point ──────────────────────────────────────────
441
442/**
443 * Probe a video file and return a normalized, plain-JSON spec object — the exact
444 * shape evaluateAgainstPlatforms() consumes and the exact shape the manual-entry
445 * form produces, so both input modes share one evaluation path.
446 *
447 * @param {File} file
448 * @param {{onFastLayer?: (probe) => void}} [options] called the moment the fast
449 *        layer resolves, so the spec sheet paints before ffmpeg is even fetched
450 * @returns {Promise<{probe, deepAvailable: boolean, deepErrorMessage: string|null}>}
451 */
452export async function probeMediaFile(file, { onFastLayer } = {}) {
453  if (!file) {
454    throw new AudioExtractionError("NO_FILE", "Choose a video file to check.", { retryable: false });
455  }
456  if (file.size > MAX_PROBE_FILE_BYTES) {
457    throw new AudioExtractionError(
458      "FILE_TOO_LARGE",
459      "That file is over 8 GB, which is larger than this checker will open.",
460      { retryable: false },
461    );
462  }
463
464  const fast = await probeFastLayer(file);
465  if (typeof onFastLayer === "function") onFastLayer(fast);
466
467  let deep = null;
468  let deepErrorMessage = null;
469  try {
470    deep = await probeDeepLayer(file);
471  } catch (err) {
472    // Non-fatal by design: the spec sheet keeps every fast-layer value and the
473    // ffmpeg-only fields report unknown rather than a plausible-looking guess.
474    console.warn("[media-probe] deep probe unavailable", err);
475    deepErrorMessage = isOomError(err)
476      ? "This file was too large for the deep probe to read in your browser."
477      : "The deep probe couldn't read this file's codec details.";
478  }
479
480  const merged = normalizeProbe(mergeProbeLayers(fast, deep));
481  const deepAvailable = Boolean(deep && (deep.videoCodec || deep.audioCodec));
482  if (!deepAvailable && !deepErrorMessage) {
483    deepErrorMessage = "The deep probe couldn't read this file's codec details.";
484  }
485
486  return { probe: merged, deepAvailable, deepErrorMessage: deepAvailable ? null : deepErrorMessage };
487}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.