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.
This commit is contained in:
Alexandre Brandizzi 2026-09-24 22:14:42 -03:00
parent c99e81d0d7
commit cfe83fc489
2 changed files with 163 additions and 32 deletions

View file

@ -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<number | null> {
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<DataView | null> {
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<Box | null> {
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<number | null> {
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<number | null> {
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;
}
}

View file

@ -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();
});
});