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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/7347-mcp-read-file-describes-an-mp4-mov-or-m4 → main 4 commits fork 4 threads · 4 unresolved

  • Merged. torque/7347-mcp-read-file-describes-an-mp4-mov-or-m4 was merged into main as 832700e.

Review

Sign in to review this change.
6 files changed +295 −2
docs/MCP.md +1 −1
@@ -135,7 +135,7 @@ for a tunnel and a forge that sleeps.
135135 | `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 | |
136136 | `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` | |
137137 | `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} | |
139139 | `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 | |
140140 | `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` | |
141141 | `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";
44 import { blobForRender, listTree } from "../../../git/read.ts";
55 import { readStl, sayStl, stlSize, TOO_LARGE } from "../../../text/stl.ts";
66 import { checkConfig, configKind, type ConfigKind } from "../../../text/config_check.ts";
7 import { describeMedia } from "../../../text/mp4.ts";
78 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
89
910 export const tool: Tool = {
1011 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}",
1213 description:
1314 "Read a file or list a directory at a ref (default branch); over 5 MB answers tooLarge.",
1415 inputSchema: {
@@ -70,6 +71,8 @@ function aboutFile(path: string, body: Uint8Array, text: string | undefined): Re
7071 const { format, triangles } = stl.value;
7172 return { kind: "stl", format, triangles, size: stlSize(stl.value), says: sayStl(stl.value) };
7273 }
74 const media = describeMedia(path, body);
75 if (media) return media;
7376 if (text === undefined) return undefined;
7477 const kind = configKind(path);
7578 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;
outdated

Nothing outside this file imports this. Drop the export so the module's surface is only what callers use.

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 duration === 0 (fragmented MP4), this returns ok(0) and the answer says 0:00. Read moov → mvex → mehd fragment_duration (v0 u32 at body+4, v1 u64 at body+4) in the same timescale. If there is none, return err("duration unknown").

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";
1010 import { createUser } from "../src/core/users.ts";
1111 import { serve, type Servers } from "../src/http/server.ts";
1212 import { quietLogs } from "./helpers/globals.ts";
13 import { box, concat, ftyp, mvhd0, tkhd } from "./helpers/mp4-boxes.ts";
1314 import { asciiBox, binaryBox } from "./helpers/stl.ts";
1415
1516 quietLogs();
@@ -66,6 +67,8 @@ beforeAll(async () => {
6667 { path: "bad.json", content: '{\n "a": }' },
6768 { path: "good.json", content: "{}" },
6869 ] }));
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 }] }));
6972 ok(
7073 await commitToBranch(app, owner, pub, {
7174 message: "models",
@@ -109,6 +112,13 @@ test("bad arguments are refused", async () => {
109112 expect((await read(t, { repo: "ada/pub", path: "nope.txt" })).error).toContain("not_found");
110113 });
111114
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
112122 test("an STL file is described in words", async () => {
113123 const t = tok["stranger"]!;
114124 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs