From cfe83fc4890978c6adf3cfd4ff82e0953fde3e06 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 22:14:42 -0300 Subject: [PATCH] fix(media): read video duration from the MP4/MOV header in the browser The browser pre-check decoded the picked file through a video element and an object URL, which CodeQL flags as DOM text reinterpreted as HTML. Parse the moov/mvhd movie header from file slices instead, the same way the server enforces the 90-second limit, so both sides read one duration. Unreadable headers still never block an upload. --- src/lib/probe-video-duration.ts | 102 +++++++++++++++------- src/test/lib/probe-video-duration.test.ts | 93 ++++++++++++++++++++ 2 files changed, 163 insertions(+), 32 deletions(-) create mode 100644 src/test/lib/probe-video-duration.test.ts diff --git a/src/lib/probe-video-duration.ts b/src/lib/probe-video-duration.ts index d09bec1c..29ae83cc 100644 --- a/src/lib/probe-video-duration.ts +++ b/src/lib/probe-video-duration.ts @@ -1,37 +1,75 @@ /** - * Best-effort client-side video duration probe. Resolves null when the browser - * cannot decode the container's metadata — callers must not block the upload in - * that case (SH-116: the server enforces the limit from the MP4/MOV header and - * also lets unreadable metadata through). + * Best-effort video duration probe for MP4/MOV. Reads the movie header + * (`moov` → `mvhd`) the same way the server does, seeking over box headers so a + * phone file with `moov` after a large `mdat` costs only a few small reads. + * Resolves null when the header cannot be read — callers must not block the + * upload in that case (SH-116: the server enforces the limit from the same + * header and also lets unreadable metadata through). */ -export const VIDEO_DURATION_PROBE_TIMEOUT_MS = 10_000; +const MAX_BOXES_PER_LEVEL = 1024; -export function probeVideoDurationSeconds(file: File): Promise { - return new Promise((resolve) => { - const url = URL.createObjectURL(file); - const video = document.createElement("video"); - let settled = false; +type Box = { bodyStart: number; end: number }; - const done = (value: number | null) => { - if (settled) return; - settled = true; - video.removeAttribute("src"); - video.load(); - URL.revokeObjectURL(url); - resolve(value); - }; - - const timeout = window.setTimeout(() => done(null), VIDEO_DURATION_PROBE_TIMEOUT_MS); - - video.preload = "metadata"; - video.onloadedmetadata = () => { - window.clearTimeout(timeout); - done(Number.isFinite(video.duration) ? video.duration : null); - }; - video.onerror = () => { - window.clearTimeout(timeout); - done(null); - }; - video.src = url; - }); +async function readView(file: Blob, start: number, length: number): Promise { + if (start < 0 || length <= 0 || start + length > file.size) return null; + const buffer = await file.slice(start, start + length).arrayBuffer(); + return buffer.byteLength === length ? new DataView(buffer) : null; +} + +function boxType(view: DataView): string { + return String.fromCharCode( + view.getUint8(4), + view.getUint8(5), + view.getUint8(6), + view.getUint8(7), + ); +} + +async function findBox(file: Blob, start: number, end: number, type: string): Promise { + let offset = start; + for (let index = 0; index < MAX_BOXES_PER_LEVEL && offset + 8 <= end; index += 1) { + const header = await readView(file, offset, Math.min(16, end - offset)); + if (!header) return null; + let size = header.getUint32(0); + let headerLength = 8; + if (size === 1) { + if (header.byteLength < 16) return null; + const largeSize = header.getBigUint64(8); + if (largeSize > BigInt(Number.MAX_SAFE_INTEGER)) return null; + size = Number(largeSize); + headerLength = 16; + } else if (size === 0) { + size = end - offset; + } + if (size < headerLength || offset + size > end) return null; + if (boxType(header) === type) { + return { bodyStart: offset + headerLength, end: offset + size }; + } + offset += size; + } + return null; +} + +async function readMovieHeaderSeconds(file: Blob, mvhd: Box): Promise { + const version = await readView(file, mvhd.bodyStart, 1); + if (!version) return null; + const isVersion1 = version.getUint8(0) === 1; + const fields = await readView(file, mvhd.bodyStart, isVersion1 ? 32 : 20); + if (!fields || mvhd.bodyStart + fields.byteLength > mvhd.end) return null; + const timescale = fields.getUint32(isVersion1 ? 20 : 12); + const duration = isVersion1 ? fields.getBigUint64(24) : BigInt(fields.getUint32(16)); + const unknown = isVersion1 ? 0xffff_ffff_ffff_ffffn : 0xffff_ffffn; + if (timescale === 0 || duration === unknown) return null; + return Number(duration) / timescale; +} + +export async function probeVideoDurationSeconds(file: Blob): Promise { + try { + const moov = await findBox(file, 0, file.size, "moov"); + if (!moov) return null; + const mvhd = await findBox(file, moov.bodyStart, moov.end, "mvhd"); + return mvhd ? await readMovieHeaderSeconds(file, mvhd) : null; + } catch { + return null; + } } diff --git a/src/test/lib/probe-video-duration.test.ts b/src/test/lib/probe-video-duration.test.ts new file mode 100644 index 00000000..0e45a171 --- /dev/null +++ b/src/test/lib/probe-video-duration.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { probeVideoDurationSeconds } from "@/lib/probe-video-duration"; + +function concat(...parts: Uint8Array[]): Uint8Array { + const out = new Uint8Array(parts.reduce((total, part) => total + part.length, 0)); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +} + +function ascii(text: string): Uint8Array { + return Uint8Array.from(text, (char) => char.charCodeAt(0)); +} + +function box(type: string, ...children: Uint8Array[]): Uint8Array { + const body = concat(...children); + const out = new Uint8Array(8 + body.length); + new DataView(out.buffer).setUint32(0, out.length); + out.set(ascii(type), 4); + out.set(body, 8); + return out; +} + +function movieHeader(timescale: number, duration: bigint, version1 = false): Uint8Array { + const body = new Uint8Array(version1 ? 32 : 20); + const view = new DataView(body.buffer); + body[0] = version1 ? 1 : 0; + if (version1) { + view.setUint32(20, timescale); + view.setBigUint64(24, duration); + } else { + view.setUint32(12, timescale); + view.setUint32(16, Number(duration)); + } + return box("mvhd", body); +} + +/** A phone-style file: ftyp, a large mdat, then moov at the end (not fast-start). */ +function mp4(timescale: number, duration: bigint, version1 = false): Uint8Array { + return concat( + box("ftyp", ascii("isom"), new Uint8Array(4)), + box("mdat", new Uint8Array(4096)), + box("moov", movieHeader(timescale, duration, version1)), + ); +} + +function asFile(bytes: Uint8Array, name = "IMG_0001.MOV"): File { + return new File([new Uint8Array(bytes)], name, { type: "video/quicktime" }); +} + +describe("probeVideoDurationSeconds", () => { + it("reads the duration from a moov box after the media data", async () => { + await expect(probeVideoDurationSeconds(asFile(mp4(600, 57_000n)))).resolves.toBe(95); + }); + + it("reads a version 1 movie header", async () => { + await expect(probeVideoDurationSeconds(asFile(mp4(1000, 30_500n, true)))).resolves.toBe(30.5); + }); + + it("follows 64-bit box sizes", async () => { + const mdat = new Uint8Array(16 + 512); + const view = new DataView(mdat.buffer); + view.setUint32(0, 1); + mdat.set(ascii("mdat"), 4); + view.setBigUint64(8, BigInt(mdat.length)); + const file = concat( + box("ftyp", ascii("qt "), new Uint8Array(4)), + mdat, + box("moov", movieHeader(90_000, 90_000n * 120n)), + ); + + await expect(probeVideoDurationSeconds(asFile(file))).resolves.toBe(120); + }); + + it.each([ + [ + "no moov", + concat(box("ftyp", ascii("isom"), new Uint8Array(4)), box("mdat", new Uint8Array(64))), + ], + ["truncated", mp4(600, 57_000n).slice(0, -10)], + ["zero timescale", mp4(0, 57_000n)], + [ + "oversized box", + concat(box("ftyp", new Uint8Array(8)), Uint8Array.of(0x7f, 0xff, 0xff, 0xff), ascii("moov")), + ], + ["not a video", ascii("%PDF-1.7 not a movie")], + ])("returns null for an unreadable file (%s)", async (_shape, bytes) => { + await expect(probeVideoDurationSeconds(asFile(bytes))).resolves.toBeNull(); + }); +});