Public: anyone can read this; the crew can change it.
MCP `read_file` describes an MP4, MOV or M4A file in words: duration, and picture size for video #7365
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7347-mcp-read-file-describes-an-mp4-mov-or-m4 → main 4 commits fork 4 threads · 4 unresolved
docs/MCP.md +1 −1
| @@ -135,7 +135,7 @@ for a tunnel and a forge that sleeps. | ||
| 135 | 135 | | `pulls` | `{ repo, state?, limit? }`: a repository's pulls, newest first, one brief line each: `number`, `title`, `author`, `head`, `check` (state), `approvals`, `changesRequested`, `mergeable` (null once closed); `state` open (default) or closed, `limit` 1-50 (default 20); no bodies, no diffs | | |
| 136 | 136 | | `queue_gates` | `{ repo, limit? }`: the merge queue's recent gates, newest first (`limit` 1–100, default 20), as `GET …/queue/gates` answers: each gate's pulls, state, reason, counts and failing tests by name with their first error lines, a test red in two gates that share no pull flagged `flaky` | | |
| 137 | 137 | | `quotas` | `{ key?, value?, dryRun? }` (forge admin): the per-account quotas `repositories`, `apps`, `storage` (MB), `previews`, `runs`, `checks`, each a number or null for none. `repositories` bounds repos on create, fork and copy; `apps` the apps made; `storage` the MB of repos plus app data, asked on push, create and fork; `previews` live previews; `runs` concurrent agent runs; `checks` concurrent checks. Each is asked before the thing is made or started and a forge admin is never limited; with `key` and `value` (`""` clears), planned first (`dryRun` is true unless `false`); applying needs an admin-scope token | yes | |
| 138 | | `read_file` | a file (utf-8, or base64 for binary) or a directory listing at a ref, as GET …/contents | | | |
| 138 | | `read_file` | a file (utf-8, or base64 for binary) or a directory listing at a ref, as GET …/contents; MP4/MOV/M4A files also carry about: {kind, seconds, width?, height?, says} | | | |
| 139 | 139 | | `releases` | `{}`: the forge's kept releases newest first: short sha, version, the pull that landed it, when, and for the live one `live: true` and `since`; for whoever may read /releases, others get its refusal; no rollback, a person does that on the page | | |
| 140 | 140 | | `repo` | `{ repo }`: one repository's description, visibility, autonomy, `audience { level, sentence }` and `provenance` (where a migrated one came from, `null` when hidden), the same as `GET /api/v1/repos/:owner/:name` | | |
| 141 | 141 | | `repo_labels` | `{ repo, action?, name?, color?, id? }`: list (`list`, the default, paged by `limit` 1–200 and `cursor`, as `{ items, next_cursor }`; a read-only token may), create (`create`, `name`, `color` six hex digits) or remove (`remove`, `id`) the repository's labels, as `GET`/`POST …/labels` and `DELETE …/labels/:id`; create and remove need an admin token and a repository admin | yes | |
src/http/mcp/tools/read_file.ts +4 −1
| @@ -4,11 +4,12 @@ import { isSafeRef, isSafeTreePath } from "../../../git/guard.ts"; | ||
| 4 | 4 | import { blobForRender, listTree } from "../../../git/read.ts"; |
| 5 | 5 | import { readStl, sayStl, stlSize, TOO_LARGE } from "../../../text/stl.ts"; |
| 6 | 6 | import { checkConfig, configKind, type ConfigKind } from "../../../text/config_check.ts"; |
| 7 | import { describeMedia } from "../../../text/mp4.ts"; | |
| 7 | 8 | import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 8 | 9 | |
| 9 | 10 | export const tool: Tool = { |
| 10 | 11 | name: "read_file", |
| 11 | doc: "a file (utf-8, or base64 for binary) or a directory listing at a ref, as GET …/contents", | |
| 12 | doc: "a file (utf-8, or base64 for binary) or a directory listing at a ref, as GET …/contents; MP4/MOV/M4A files also carry about: {kind, seconds, width?, height?, says}", | |
| 12 | 13 | description: |
| 13 | 14 | "Read a file or list a directory at a ref (default branch); over 5 MB answers tooLarge.", |
| 14 | 15 | inputSchema: { |
| @@ -70,6 +71,8 @@ function aboutFile(path: string, body: Uint8Array, text: string | undefined): Re | ||
| 70 | 71 | const { format, triangles } = stl.value; |
| 71 | 72 | return { kind: "stl", format, triangles, size: stlSize(stl.value), says: sayStl(stl.value) }; |
| 72 | 73 | } |
| 74 | const media = describeMedia(path, body); | |
| 75 | if (media) return media; | |
| 73 | 76 | if (text === undefined) return undefined; |
| 74 | 77 | const kind = configKind(path); |
| 75 | 78 | return kind === null ? undefined : aboutConfig(kind, text); |
src/text/mp4.ts added +147
| @@ -0,0 +1,147 @@ | ||
| 1 | /** | |
| 2 | * What an MP4, MOV or M4A file is, read from its ISO-BMFF boxes (ISO/IEC 14496-12): | |
| 3 | * `moov` → `mvhd` for timescale and duration, `moov` → `trak` → `tkhd` for the | |
| 4 | * picture size. Bounds-checked against the bytes given; never throws. | |
| 5 | */ | |
| 6 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 7 | ||
| 8 | export type Mp4Facts = { seconds: number; width?: number; height?: number }; | |
| 9 | export type MediaAbout = { | |
| 10 | kind: "video" | "audio"; | |
| 11 | seconds?: number; | |
| 12 | width?: number; | |
| 13 | height?: number; | |
| 14 | says: string; | |
| 15 | }; | |
| 16 | ||
| 17 | /** The extensions `read_file` describes. */ | |
| 18 | const MEDIA_EXTENSIONS = [".mp4", ".m4v", ".mov", ".m4a"] as const; | |
| 19 | ||
| 20 | type Box = { type: string; body: number; end: number }; | |
| 21 | ||
| 22 | /** The boxes laid end to end in bytes[from, to); a box running past `to` ends the walk. */ | |
| 23 | function boxes(view: DataView, from: number, to: number): Box[] { | |
| 24 | const out: Box[] = []; | |
| 25 | let at = from; | |
| 26 | while (at + 8 <= to) { | |
| 27 | let size = view.getUint32(at); | |
| 28 | const type = String.fromCharCode( | |
| 29 | view.getUint8(at + 4), view.getUint8(at + 5), view.getUint8(at + 6), view.getUint8(at + 7), | |
| 30 | ); | |
| 31 | let header = 8; | |
| 32 | if (size === 1) { | |
| 33 | // 64-bit largesize follows the type (§4.2). | |
| 34 | if (at + 16 > to) break; | |
| 35 | const big = view.getBigUint64(at + 8); | |
| 36 | if (big > BigInt(to - at)) break; | |
| 37 | size = Number(big); | |
| 38 | header = 16; | |
| 39 | } else if (size === 0) { | |
| 40 | size = to - at; // "box extends to the end of the file" | |
| 41 | } | |
| 42 | if (size < header || at + size > to) break; | |
| 43 | out.push({ type, body: at + header, end: at + size }); | |
| 44 | at += size; | |
| 45 | } | |
| 46 | return out; | |
| 47 | } | |
| 48 | ||
| 49 | function child(view: DataView, box: Box, type: string): Box | undefined { | |
| 50 | return boxes(view, box.body, box.end).find((b) => b.type === type); | |
| 51 | } | |
| 52 | ||
| 53 | /** mvhd (§8.2.2): v0 has 32-bit times, v1 64-bit; timescale is units per second. | |
| 54 | * A fragmented file writes duration 0 here and the length in `mvex` → `mehd` (§8.8.2). */ | |
| 55 | function movieHeader(view: DataView, box: Box, moov: Box): Result<number, string> { | |
| 56 | const v = box.body + 4 <= box.end ? view.getUint8(box.body) : -1; | |
| 57 | const need = v === 1 ? 4 + 8 + 8 + 4 + 8 : 4 + 4 + 4 + 4 + 4; | |
| 58 | if ((v !== 0 && v !== 1) || box.body + need > box.end) return err("mvhd is truncated"); | |
| 59 | const timescale = view.getUint32(box.body + (v === 1 ? 20 : 12)); | |
| 60 | const duration = | |
| 61 | v === 1 ? view.getBigUint64(box.body + 24) : BigInt(view.getUint32(box.body + 16)); | |
| 62 | // All ones means "duration unknown" (§8.2.2.3). | |
| 63 | const unknown = v === 1 ? 0xffffffffffffffffn : 0xffffffffn; | |
| 64 | if (timescale === 0 || duration === unknown) return err("duration unknown"); | |
outdated
When |
||
| 65 | if (duration > 0n) return ok(Number(duration) / timescale); | |
| 66 | const fragments = fragmentDuration(view, moov); | |
| 67 | return fragments ? ok(Number(fragments) / timescale) : err("duration unknown"); | |
| 68 | } | |
| 69 | ||
| 70 | /** mehd (§8.8.2): fragment_duration right after version and flags, u32 in v0, u64 in v1, in the mvhd timescale. */ | |
| 71 | function fragmentDuration(view: DataView, moov: Box): bigint | undefined { | |
| 72 | const mvex = child(view, moov, "mvex"); | |
| 73 | const mehd = mvex && child(view, mvex, "mehd"); | |
outdated
This is the stored size, before the track matrix is applied. A rotated (portrait) phone clip reads as landscape here. Swap width and height when the matrix is a 90°/270° rotation, or say "stored size" in the comment above. |
||
| 74 | if (!mehd || mehd.body + 4 > mehd.end) return undefined; | |
| 75 | const v = view.getUint8(mehd.body); | |
| 76 | if (v === 1 && mehd.body + 12 <= mehd.end) return view.getBigUint64(mehd.body + 4) || undefined; | |
| 77 | if (v === 0 && mehd.body + 8 <= mehd.end) return BigInt(view.getUint32(mehd.body + 4)) || undefined; | |
| 78 | return undefined; | |
| 79 | } | |
| 80 | ||
| 81 | /** tkhd (§8.3.2): width and height are 16.16 fixed point after the matrix {a b u c d v x y w}. | |
| 82 | * The size answered is the displayed one: a 90° or 270° matrix (a = d = 0, |b| = |c| = 1.0, | |
| 83 | * as a portrait phone clip writes) swaps the stored width and height. */ | |
| 84 | function trackSize(view: DataView, box: Box): { width: number; height: number } | undefined { | |
| 85 | const v = box.body + 4 <= box.end ? view.getUint8(box.body) : -1; | |
| 86 | if (v !== 0 && v !== 1) return undefined; | |
| 87 | const matrix = box.body + (v === 1 ? 52 : 40); | |
| 88 | const at = matrix + 36; | |
| 89 | if (at + 8 > box.end) return undefined; | |
| 90 | const width = Math.round(view.getUint32(at) / 65536); | |
| 91 | const height = Math.round(view.getUint32(at + 4) / 65536); | |
| 92 | if (width === 0 || height === 0) return undefined; | |
| 93 | const [a, b, c, d] = [0, 4, 12, 16].map((o) => view.getInt32(matrix + o)); | |
| 94 | const quarterTurn = a === 0 && d === 0 && Math.abs(b!) === 65536 && Math.abs(c!) === 65536; | |
| 95 | return quarterTurn ? { width: height, height: width } : { width, height }; | |
| 96 | } | |
| 97 | ||
| 98 | /** Duration in seconds and, when a track has a picture, the largest picture size. */ | |
| 99 | export function readMp4(bytes: Uint8Array): Result<Mp4Facts, string> { | |
| 100 | const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); | |
| 101 | const moov = boxes(view, 0, bytes.byteLength).find((b) => b.type === "moov"); | |
| 102 | if (!moov) return err("no moov box"); | |
| 103 | const mvhd = child(view, moov, "mvhd"); | |
| 104 | if (!mvhd) return err("no mvhd box"); | |
| 105 | const seconds = movieHeader(view, mvhd, moov); | |
| 106 | if (!seconds.ok) return seconds; | |
| 107 | let size: { width: number; height: number } | undefined; | |
| 108 | for (const trak of boxes(view, moov.body, moov.end)) { | |
| 109 | if (trak.type !== "trak") continue; | |
| 110 | const tkhd = child(view, trak, "tkhd"); | |
| 111 | const s = tkhd && trackSize(view, tkhd); | |
| 112 | if (s && (!size || s.width * s.height > size.width * size.height)) size = s; | |
| 113 | } | |
| 114 | return ok(size ? { seconds: seconds.value, ...size } : { seconds: seconds.value }); | |
| 115 | } | |
| 116 | ||
| 117 | /** 92.4 → "1:32", 3725 → "1:02:05"; whole seconds, rounded down. */ | |
| 118 | export function clock(seconds: number): string { | |
| 119 | const s = Math.floor(seconds); | |
| 120 | const h = Math.floor(s / 3600); | |
| 121 | const m = Math.floor((s % 3600) / 60); | |
| 122 | const ss = String(s % 60).padStart(2, "0"); | |
| 123 | return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${ss}` : `${m}:${ss}`; | |
| 124 | } | |
| 125 | ||
| 126 | /** The `about` for a media file at `path`, or undefined when the extension is not one we describe. */ | |
| 127 | export function describeMedia(path: string, bytes: Uint8Array): MediaAbout | undefined { | |
| 128 | const lower = path.toLowerCase(); | |
| 129 | if (!MEDIA_EXTENSIONS.some((e) => lower.endsWith(e))) return undefined; | |
| 130 | const facts = readMp4(bytes); | |
| 131 | const audioOnly = lower.endsWith(".m4a"); | |
| 132 | if (!facts.ok) { | |
| 133 | const kind = audioOnly ? "audio" : "video"; | |
| 134 | return { kind, says: `${kind} · duration unknown` }; | |
| 135 | } | |
| 136 | const { seconds, width, height } = facts.value; | |
| 137 | const seconds3 = Math.round(seconds * 1000) / 1000; | |
| 138 | if (width === undefined || height === undefined || audioOnly) | |
| 139 | return { kind: "audio", seconds: seconds3, says: `audio · ${clock(seconds)}` }; | |
| 140 | return { | |
| 141 | kind: "video", | |
| 142 | seconds: seconds3, | |
| 143 | width, | |
| 144 | height, | |
| 145 | says: `video · ${clock(seconds)} · ${width}×${height}`, | |
| 146 | }; | |
| 147 | } |
test/helpers/mp4-boxes.ts added +58
| @@ -0,0 +1,58 @@ | ||
| 1 | /** Hand-built ISO-BMFF boxes for tests: big-endian, sizes counted by hand. */ | |
| 2 | export function box(type: string, ...parts: Uint8Array[]): Uint8Array { | |
| 3 | const len = 8 + parts.reduce((n, p) => n + p.length, 0); | |
| 4 | const out = new Uint8Array(len); | |
| 5 | new DataView(out.buffer).setUint32(0, len); | |
| 6 | out.set(new TextEncoder().encode(type), 4); | |
| 7 | let at = 8; | |
| 8 | for (const p of parts) (out.set(p, at), (at += p.length)); | |
| 9 | return out; | |
| 10 | } | |
| 11 | ||
| 12 | export function u32(...ns: number[]): Uint8Array { | |
| 13 | const out = new Uint8Array(ns.length * 4); | |
| 14 | ns.forEach((n, i) => new DataView(out.buffer).setUint32(i * 4, n)); | |
| 15 | return out; | |
| 16 | } | |
| 17 | ||
| 18 | export function u64(n: bigint): Uint8Array { | |
| 19 | const out = new Uint8Array(8); | |
| 20 | new DataView(out.buffer).setBigUint64(0, n); | |
| 21 | return out; | |
| 22 | } | |
| 23 | ||
| 24 | /** mvhd v0: version/flags, creation, modification, timescale, duration, then 80 bytes of rest. */ | |
| 25 | export const mvhd0 = (timescale: number, duration: number) => | |
| 26 | box("mvhd", u32(0, 0, 0, timescale, duration), new Uint8Array(80)); | |
| 27 | ||
| 28 | /** mvhd v1: 64-bit creation, modification and duration. */ | |
| 29 | export const mvhd1 = (timescale: number, duration: bigint) => | |
| 30 | box("mvhd", u32(0x01000000), u64(0n), u64(0n), u32(timescale), u64(duration), new Uint8Array(80)); | |
| 31 | ||
| 32 | /** tkhd: v0 has 20 bytes of times/ids, v1 32; then 52 of reserved/layer/volume/matrix; then 16.16 width and height. */ | |
| 33 | export const tkhd = (version: 0 | 1, width: number, height: number, matrix = IDENTITY) => | |
| 34 | box( | |
| 35 | "tkhd", | |
| 36 | u32(version << 24), | |
| 37 | new Uint8Array(version === 1 ? 32 : 20), | |
| 38 | new Uint8Array(16), | |
| 39 | u32(...matrix.map((n) => n >>> 0)), | |
| 40 | u32(width * 65536, height * 65536), | |
| 41 | ); | |
| 42 | ||
| 43 | /** The tkhd matrix {a b u c d v x y w}: a, b, c, d, x, y in 16.16, u, v, w in 2.30. */ | |
| 44 | export const IDENTITY = [0x10000, 0, 0, 0, 0x10000, 0, 0, 0, 0x40000000]; | |
| 45 | export const ROTATE_90 = [0, 0x10000, 0, -0x10000, 0, 0, 0, 0, 0x40000000]; | |
| 46 | ||
| 47 | /** mvex → mehd fragment_duration, v0 32-bit or v1 64-bit. */ | |
| 48 | export const mvex = (version: 0 | 1, duration: number) => | |
| 49 | box("mvex", box("mehd", u32(version << 24), version === 1 ? u64(BigInt(duration)) : u32(duration))); | |
| 50 | ||
| 51 | export const ftyp = () => box("ftyp", new TextEncoder().encode("isom"), u32(0x200)); | |
| 52 | ||
| 53 | export function concat(...parts: Uint8Array[]): Uint8Array { | |
| 54 | const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); | |
| 55 | let at = 0; | |
| 56 | for (const p of parts) (out.set(p, at), (at += p.length)); | |
| 57 | return out; | |
| 58 | } |
test/mcp-read-file.test.ts +10
| @@ -10,6 +10,7 @@ import { createToken } from "../src/core/tokens.ts"; | ||
| 10 | 10 | import { createUser } from "../src/core/users.ts"; |
| 11 | 11 | import { serve, type Servers } from "../src/http/server.ts"; |
| 12 | 12 | import { quietLogs } from "./helpers/globals.ts"; |
| 13 | import { box, concat, ftyp, mvhd0, tkhd } from "./helpers/mp4-boxes.ts"; | |
| 13 | 14 | import { asciiBox, binaryBox } from "./helpers/stl.ts"; |
| 14 | 15 | |
| 15 | 16 | quietLogs(); |
| @@ -66,6 +67,8 @@ beforeAll(async () => { | ||
| 66 | 67 | { path: "bad.json", content: '{\n "a": }' }, |
| 67 | 68 | { path: "good.json", content: "{}" }, |
| 68 | 69 | ] })); |
| 70 | const movie = concat(ftyp(), box("moov", mvhd0(600, 600 * 92), box("trak", tkhd(0, 1920, 1080)))); | |
| 71 | ok(await commitToBranch(app, owner, pub, { message: "movie", files: [{ path: "clip.mp4", content: movie }] })); | |
| 69 | 72 | ok( |
| 70 | 73 | await commitToBranch(app, owner, pub, { |
| 71 | 74 | message: "models", |
| @@ -109,6 +112,13 @@ test("bad arguments are refused", async () => { | ||
| 109 | 112 | expect((await read(t, { repo: "ada/pub", path: "nope.txt" })).error).toContain("not_found"); |
| 110 | 113 | }); |
| 111 | 114 | |
| 115 | test("an MP4 file is described in words beside its base64; a text file has no about", async () => { | |
| 116 | const file = (await read(tok["stranger"]!, { repo: "ada/pub", path: "clip.mp4" })).value; | |
| 117 | expect(file.encoding).toBe("base64"); | |
| 118 | expect(file.about).toEqual({ kind: "video", seconds: 92, width: 1920, height: 1080, says: "video · 1:32 · 1920×1080" }); | |
| 119 | expect((await read(tok["stranger"]!, { repo: "ada/pub", path: "src/a.txt" })).value.about).toBeUndefined(); | |
| 120 | }); | |
| 121 | ||
| 112 | 122 | test("an STL file is described in words", async () => { |
| 113 | 123 | const t = tok["stranger"]!; |
| 114 | 124 | const bin = (await read(t, { repo: "ada/pub", path: "cube.stl" })).value; |
test/text-mp4.test.ts added +75
| @@ -0,0 +1,75 @@ | ||
| 1 | /** src/text/mp4.ts: duration from mvhd (v0 and v1), picture size from tkhd, and a truncated or moov-less file answered without a throw. */ | |
| 2 | import { expect, test } from "bun:test"; | |
| 3 | import { clock, describeMedia, readMp4 } from "../src/text/mp4.ts"; | |
| 4 | import { ROTATE_90, box, concat, ftyp, mvex, mvhd0, mvhd1, tkhd, u32, u64 } from "./helpers/mp4-boxes.ts"; | |
| 5 | ||
| 6 | test("v0: 92.5 s at timescale 1000, a 1920×1080 track beside an audio track", () => { | |
| 7 | const file = concat( | |
| 8 | ftyp(), | |
| 9 | box("moov", mvhd0(1000, 92_500), box("trak", tkhd(0, 0, 0)), box("trak", tkhd(0, 1920, 1080))), | |
| 10 | ); | |
| 11 | expect(readMp4(file)).toEqual({ ok: true, value: { seconds: 92.5, width: 1920, height: 1080 } }); | |
| 12 | expect(describeMedia("clip.MP4", file)).toEqual({ | |
| 13 | kind: "video", | |
| 14 | seconds: 92.5, | |
| 15 | width: 1920, | |
| 16 | height: 1080, | |
| 17 | says: "video · 1:32 · 1920×1080", | |
| 18 | }); | |
| 19 | }); | |
| 20 | ||
| 21 | test("v1: 64-bit duration 3725 s at 90 kHz, and a 64-bit largesize moov after mdat", () => { | |
| 22 | const inner = concat(mvhd1(90_000, 3725n * 90_000n), box("trak", tkhd(1, 1280, 720))); | |
| 23 | const moov = concat(u32(1), new TextEncoder().encode("moov"), u64(BigInt(16 + inner.length)), inner); | |
| 24 | const file = concat(ftyp(), box("mdat", new Uint8Array(32)), moov); | |
| 25 | expect(readMp4(file)).toEqual({ ok: true, value: { seconds: 3725, width: 1280, height: 720 } }); | |
| 26 | expect(describeMedia("a.mov", file)?.says).toBe("video · 1:02:05 · 1280×720"); | |
| 27 | }); | |
| 28 | ||
| 29 | test("an audio-only file says audio; an .m4a is audio even with a picture track", () => { | |
| 30 | const audio = concat(ftyp(), box("moov", mvhd0(44_100, 44_100 * 61))); | |
| 31 | expect(describeMedia("song.mp4", audio)).toEqual({ kind: "audio", seconds: 61, says: "audio · 1:01" }); | |
| 32 | const cover = concat(ftyp(), box("moov", mvhd0(1, 5), box("trak", tkhd(0, 600, 600)))); | |
| 33 | expect(describeMedia("song.m4a", cover)?.kind).toBe("audio"); | |
| 34 | }); | |
| 35 | ||
| 36 | test("truncated, moov-less or unknown-duration files are errors, never throws", () => { | |
| 37 | const whole = concat(ftyp(), box("moov", mvhd0(1000, 5000), box("trak", tkhd(0, 640, 480)))); | |
| 38 | for (let n = 0; n < whole.length; n++) expect(readMp4(whole.subarray(0, n)).ok).toBe(false); | |
| 39 | expect(readMp4(whole).ok).toBe(true); | |
| 40 | expect(readMp4(concat(ftyp(), box("mdat", new Uint8Array(4)))).ok).toBe(false); | |
| 41 | expect(readMp4(concat(box("moov", mvhd0(1000, 0xffffffff))))).toEqual({ ok: false, error: "duration unknown" }); | |
| 42 | expect(readMp4(concat(box("moov", mvhd0(0, 10))))).toEqual({ ok: false, error: "duration unknown" }); | |
| 43 | // A box claiming more bytes than exist, and one claiming fewer than its header. | |
| 44 | expect(readMp4(concat(u32(1 << 30), new TextEncoder().encode("moov"))).ok).toBe(false); | |
| 45 | expect(readMp4(concat(u32(3), new TextEncoder().encode("moov"))).ok).toBe(false); | |
| 46 | expect(describeMedia("x.mp4", new Uint8Array(3))).toEqual({ kind: "video", says: "video · duration unknown" }); | |
| 47 | }); | |
| 48 | ||
| 49 | test("other extensions are not described; the clock rounds down", () => { | |
| 50 | expect(describeMedia("a.mp3", new Uint8Array(0))).toBeUndefined(); | |
| 51 | expect(describeMedia("mp4.txt", new Uint8Array(0))).toBeUndefined(); | |
| 52 | expect(clock(59.9)).toBe("0:59"); | |
| 53 | expect(clock(3600)).toBe("1:00:00"); | |
| 54 | }); | |
| 55 | ||
| 56 | test("a fragmented file (mvhd duration 0) takes its length from mvex → mehd, v0 and v1", () => { | |
| 57 | const v0 = concat(ftyp(), box("moov", mvhd0(1000, 0), box("trak", tkhd(0, 640, 480)), mvex(0, 92_500))); | |
| 58 | expect(describeMedia("rec.mp4", v0)?.says).toBe("video · 1:32 · 640×480"); | |
| 59 | const v1 = concat(ftyp(), box("moov", mvhd1(90_000, 0n), mvex(1, 90_000 * 61))); | |
| 60 | expect(readMp4(v1)).toEqual({ ok: true, value: { seconds: 61 } }); | |
| 61 | }); | |
| 62 | ||
| 63 | test("mvhd duration 0 with no mvex, or an mehd of 0, is duration unknown, not 0:00", () => { | |
| 64 | const bare = concat(ftyp(), box("moov", mvhd0(1000, 0), box("trak", tkhd(0, 640, 480)))); | |
| 65 | expect(readMp4(bare)).toEqual({ ok: false, error: "duration unknown" }); | |
| 66 | expect(describeMedia("rec.mp4", bare)?.says).toBe("video · duration unknown"); | |
| 67 | expect(readMp4(concat(box("moov", mvhd0(1000, 0), mvex(0, 0)))).ok).toBe(false); | |
| 68 | }); | |
| 69 | ||
| 70 | test("a portrait clip whose matrix turns 90° reports the displayed size, height over width", () => { | |
| 71 | const v0 = concat(ftyp(), box("moov", mvhd0(1, 10), box("trak", tkhd(0, 1920, 1080, ROTATE_90)))); | |
| 72 | expect(describeMedia("phone.mov", v0)?.says).toBe("video · 0:10 · 1080×1920"); | |
| 73 | const v1 = concat(ftyp(), box("moov", mvhd0(1, 10), box("trak", tkhd(1, 1920, 1080, ROTATE_90.map((n) => -n))))); | |
| 74 | expect(readMp4(v1)).toEqual({ ok: true, value: { seconds: 10, width: 1080, height: 1920 } }); | |
| 75 | }); |
Nothing outside this file imports this. Drop the
exportso the module's surface is only what callers use.