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.