mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 05:43:12 +00:00
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:
parent
c99e81d0d7
commit
cfe83fc489
2 changed files with 163 additions and 32 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
93
src/test/lib/probe-video-duration.test.ts
Normal file
93
src/test/lib/probe-video-duration.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue