Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit a7a407ac6855df87e68dfad6ace0d1b5b1be680e
parent c3def8b4d8a8fc202b064559239a61f4f5bc1e95
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun,  4 Oct 2026 16:34:04 -0400

Merge fetch-max-height (fetch_clip / fetch-window / fetch-via-editor take maxHeight: ≤720 whole-source fetches save video_720, windows cap at it; cached hits report their height)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	editor/CHANGELOG.md

Diffstat:
MREADME.md | 3++-
Mcommon/lib/clipWindow.test.ts | 8++++++++
Mcommon/lib/clipWindow.ts | 16++++++++++++++++
Mcommon/ytdlp/downloadFormat.test.ts | 10++++++++++
Mcommon/ytdlp/downloadFormat.ts | 11+++++++++++
Acommon/ytdlp/ffprobeDuration.test.ts | 46++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/ytdlp/ffprobeDuration.ts | 34++++++++++++++++++++++++++++++++++
Meditor/CHANGELOG.md | 1+
Meditor/app/api/media/fetch-window/[jobId]/route.ts | 6++++++
Aeditor/app/api/media/fetch-window/route.test.ts | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/api/media/fetch-window/route.ts | 36+++++++++++++++++++++++++++++++++++-
Meditor/app/channels/[slug]/videos/[id]/videoActions.ts | 33++++++++++++++++++++++++++++++++-
Mmcp/README.md | 2+-
Mmcp/src/fetchClip.test.ts | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmcp/src/fetchClip.tool.test.ts | 21++++++++++++++++++++-
Mmcp/src/fetchClip.ts | 85++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mmcp/src/server.ts | 20+++++++++++++++++++-
Mumtool/app/api/report/fetch/route.ts | 2+-
Mumtool/lib/report/driver.mjs | 23++++++++++++++++++++++-
Mumtool/lib/report/driver.test.mjs | 18+++++++++++++++++-
Mumtool/report-to-video/fetch-via-editor.mjs | 41++++++++++++++++++++++++++++++++++++++---
Aumtool/report-to-video/fetch-via-editor.test.mjs | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
22 files changed, 693 insertions(+), 19 deletions(-)

diff --git a/README.md b/README.md @@ -362,7 +362,8 @@ seconds. So the loop is: window through its paced, cookie-aware, provenanced job; the file lands in the corpus beside the video (`channels/<slug>/data/<id>/clips/`). Seconds of media, not hours. `full: true` fetches the whole recording into the saved-video store instead - (it needs a video the editor already knows). + (it needs a video the editor already knows). `maxHeight` caps the source height; + at 720 or less a whole recording is saved as the 720p H.264 preset. 4. Optionally, render them into a finished video. You are never downloading a back catalogue to find a quote. You search text, then fetch diff --git a/common/lib/clipWindow.test.ts b/common/lib/clipWindow.test.ts @@ -5,6 +5,7 @@ import { clipWindowFile, clipWindowName, clipWindowSidecar, + isFetchMaxHeight, parseClipProvenance, parseClipWindowName, tightestClipWindow, @@ -142,3 +143,10 @@ test("a saved-video pointer written before `origin` parses unchanged", () => { assert.ok(bad); assert.equal(bad?.origin, undefined); }); + +test("isFetchMaxHeight: a whole number of pixels from 144 to 2160, nothing else", () => { + for (const ok of [144, 360, 720, 1080, 2160]) assert.ok(isFetchMaxHeight(ok), String(ok)); + for (const bad of [143, 2161, 720.5, 0, -720, Number.NaN, Infinity, "720", null, undefined]) { + assert.ok(!isFetchMaxHeight(bad), String(bad)); + } +}); diff --git a/common/lib/clipWindow.ts b/common/lib/clipWindow.ts @@ -52,6 +52,22 @@ export const WIN_EPS = 0.02; // constant there is a build error, not a lint. export const MAX_CLIP_WINDOW_SECONDS = 900; +// The source heights a fetch may cap itself at (`maxHeight` on the fetch-window +// request, fetch_clip and fetch-via-editor). 144 is the lowest rung a source +// offers; 2160 is 4K. A whole number in between, or the request is refused — +// it lands in a yt-dlp `-f` selector, so it is checked, not coerced. +export const MIN_FETCH_MAX_HEIGHT = 144; +export const MAX_FETCH_MAX_HEIGHT = 2160; + +export function isFetchMaxHeight(v: unknown): v is number { + return ( + typeof v === "number" && + Number.isInteger(v) && + v >= MIN_FETCH_MAX_HEIGHT && + v <= MAX_FETCH_MAX_HEIGHT + ); +} + export function clipsDirFor(videoDir: string): string { return path.join(videoDir, CLIPS_DIR_NAME); } diff --git a/common/ytdlp/downloadFormat.test.ts b/common/ytdlp/downloadFormat.test.ts @@ -12,6 +12,7 @@ import { resolveDownloadFormatSelector, resolveSourceVideoQuality, sourceVideoFormatSelector, + sourceVideoQualityForMaxHeight, } from "./downloadFormat"; import { clipFormatSelector as reexported } from "./fetchWindowManaged"; @@ -92,3 +93,12 @@ test("resolveSourceVideoQuality: override beats channel beats global; original w assert.ok(isSourceVideoQuality("video_720")); assert.ok(!isSourceVideoQuality("bestvideo_audio")); }); + +test("sourceVideoQualityForMaxHeight: a cap at or under 720 is video_720, above it original", () => { + assert.equal(sourceVideoQualityForMaxHeight(144), "video_720"); + assert.equal(sourceVideoQualityForMaxHeight(480), "video_720"); + assert.equal(sourceVideoQualityForMaxHeight(720), "video_720"); + assert.equal(sourceVideoQualityForMaxHeight(721), "original"); + assert.equal(sourceVideoQualityForMaxHeight(1080), "original"); + assert.equal(sourceVideoQualityForMaxHeight(2160), "original"); +}); diff --git a/common/ytdlp/downloadFormat.ts b/common/ytdlp/downloadFormat.ts @@ -198,3 +198,14 @@ export function resolveSourceVideoQuality(opts: { } return DEFAULT_SOURCE_VIDEO_QUALITY; } + +// The source-video quality a whole-recording fetch that names a height cap +// asks for. At or under 720 that is "video_720" (≤720p H.264); above it, +// "original" — the caller said in so many words that 720 is not enough, so the +// channel's own "video_720" must not quietly overrule it. No cap at all is not +// handled here: that fetch inherits the channel's, else the global, quality. +export function sourceVideoQualityForMaxHeight( + maxHeight: number, +): SourceVideoQuality { + return maxHeight <= VIDEO_720_MAX_HEIGHT ? VIDEO_720_PRESET : "original"; +} diff --git a/common/ytdlp/ffprobeDuration.test.ts b/common/ytdlp/ffprobeDuration.test.ts @@ -0,0 +1,46 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { probeVideoHeight } from "./ffprobeDuration"; + +// Run with: +// pnpm --filter yt-dlp-transcript-common exec tsx --test ytdlp/ffprobeDuration.test.ts +// +// A stand-in ffprobe: a shell script that prints what the test says, so no +// media file and no real ffprobe are needed. +async function fakeFfprobe(dir: string, body: string): Promise<string> { + const bin = path.join(dir, "ffprobe"); + await writeFile(bin, `#!/bin/sh\n${body}\n`); + await chmod(bin, 0o755); + return bin; +} + +test("probeVideoHeight reads the first video stream's height", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "probe-height-")); + try { + const ok = await fakeFfprobe(dir, 'echo 720'); + assert.equal(await probeVideoHeight({ ffprobeBin: ok, file: "x.mp4" }), 720); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("probeVideoHeight is null when ffprobe fails, prints nothing, or is missing", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "probe-height-")); + try { + const fails = await fakeFfprobe(dir, "exit 1"); + assert.equal(await probeVideoHeight({ ffprobeBin: fails, file: "x.mp4" }), null); + // An audio-only file: no v:0 stream, so ffprobe prints nothing. + await rm(fails); + const empty = await fakeFfprobe(dir, "exit 0"); + assert.equal(await probeVideoHeight({ ffprobeBin: empty, file: "x.m4a" }), null); + assert.equal( + await probeVideoHeight({ ffprobeBin: path.join(dir, "no-such-bin"), file: "x.mp4" }), + null, + ); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/common/ytdlp/ffprobeDuration.ts b/common/ytdlp/ffprobeDuration.ts @@ -46,3 +46,37 @@ export async function probeMediaDurationSec( return null; } } + +// The height, in pixels, of a media file's first video stream, or null when +// ffprobe is missing, errors, times out or the file has no video. Reads the +// container header only, so it is cheap enough to answer "how tall is the file +// already on disk" on a cache hit; `timeoutMs` bounds it for a caller that +// must answer promptly (default 5 s). +export async function probeVideoHeight(opts: { + ffprobeBin: string; + file: string; + timeoutMs?: number; +}): Promise<number | null> { + try { + const result = await execa( + opts.ffprobeBin, + [ + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=height", + "-of", + "default=noprint_wrappers=1:nokey=1", + opts.file, + ], + { reject: false, timeout: opts.timeoutMs ?? 5000 }, + ); + if (result.exitCode !== 0) return null; + const h = Number.parseInt(String(result.stdout).trim(), 10); + return Number.isInteger(h) && h > 0 ? h : null; + } catch { + return null; + } +} diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] - **umtool's report videos can show a highlighted sentence from a saved article.** `node umtool/report-to-video/shoot-page.mjs --page <saved page.html> --quote "<sentence>" --out <shot.png>` opens a web page saved to disk, finds the sentence in its text, highlights it and saves a PNG of the paragraph that holds it, ready to be a report manifest's `image` entry. `--batch <items.json> --out <dir>` does a list of `{ id, page, quote, context? }` at once and writes `<id>.png` for each plus a `results.json` recording each shot's crop, the matched text and the block it shot. The page is opened offline: nothing is fetched except files saved beside it, and its own scripts do not run unless `--js` is given. The sentence is found whether its quotes and apostrophes are curly or straight, across links and emphasis, and through non-breaking spaces, soft hyphens and line breaks in the page's source. A sentence that is not on the page is listed in `results.json` and on the terminal, and the run ends with an error rather than leaving it out. `--color` sets the highlight; `context` picks one occurrence of a sentence that appears more than once. +- **A clip or whole-recording fetch can name the tallest source video it wants.** The MCP's `fetch_clip` takes `maxHeight`, `fetch-via-editor.mjs` takes `--max-height`, and the editor's fetch endpoint takes `maxHeight`: a whole number of pixels from 144 to 2160; anything else is refused before anything is fetched. A window is fetched at or under that height (720 when none is given, as before). A whole recording asked for at 720 or less is saved as the **Video 720p** quality, and above 720 at the original quality; with no height it follows the channel's, else the global, source video quality, as before. umtool's whole-source fetch from the clip bench now asks at the report's `render.maxHeightSource`. A file already on disk is returned as it is and never fetched again for a different height; the answer now gives its height (a window's is read from the file, a whole recording's from what its persist recorded) and says when it is taller than the height asked for. - **Capture specific X posts: a screenshot of each, and its attached media.** `pnpm ops capture-posts --json '{"slug":"<channel>","ids":["<post id>", …]}'` shoots each post as X shows it, through the connected X profile, and downloads its pictures and videos with gallery-dl, into the channel's `posts-media/<post id>/` beside a `capture.json` that records when, from which URLs, and each file's size and SHA-256. Every id must already be in the channel's posts archive; one that is not is refused by name and nothing runs. `"shots": false` or `"media": false` skips that half, and posts already captured are skipped unless `"force": true`. The job runs on the X queue with a post fetch, so the two never run at once, and waits a random 4–10 seconds before each request to X, as fetches do. A deleted post, or one behind its account's wall (protected, suspended, gone), is recorded as such in the channel's deleted-post record; a post behind a sensitive-media warning is opened and shot. If X asks to log in, or answers "Something went wrong", the job stops at that post and leaves the rest for a later run. Captures are never published: the export does not read them. - **A source video can be saved at 720p for clip and editing work.** **Persist source video** on a video's page has a **Quality** select: **Original** (the best video and audio, as every persist has been) or **Video 720p (H.264, for clips/editing)**, which saves an H.264 mp4 at most 720p tall — smaller, and quick to cut. When a source has nothing at or under 720p in H.264 it takes 480p, and when it has neither it takes whatever is best and says so in the job's log, with the height it got. The default is the new **Source video quality** under **Settings**, which a channel can override in its Advanced settings; the whole-recording fetch (`full: true`) and **Persist kept now** follow the channel's, else the global, choice. A persisted video's **Source video** card now shows the format that was saved (height and codec) and the quality asked for; videos persisted before this show nothing new. "Video 720p" is also offered as a download format. - **A report cut can be a fact-check: each claim gets a verdict stamp over the footage, and a tally in the on-screen deck counts them.** Any clip, still or card entry of a report manifest can say which claim it is evidence for and the verdict, `"claim": { "id": "k3", "verdict": "CONTRADICTED" }` (one of CORROBORATED, PARTLY, CONTRADICTED, NOT_FOUND, UNTESTABLE). At the end of the last entry carrying each claim, the verdict slams in over the picture as a stamp in its colour and leaves with the transition, and a row of counts in the deck, one per verdict the cut uses, steps up as each stamp lands. `render.chrome.factcheck` sets each verdict's label and colour, how long the stamp is up (1–10 seconds, 3 by default) and which corner of the footage it sits in, and whether the tally is drawn beside the QR or before the title. A claim is a new key, separate from the clip review's `verdict`; one claim id given two verdicts, a claim on a teaser, or an unknown setting is refused with a sentence before a build fetches anything. umtool's On-screen table has a **claim** column (an id and a verdict per row), saved with the titles, and its preview shows the stamps and the tally before a build. The deck's QR can link each clip's original instead of the archive with `render.chrome.deck.qr.links: "original"` — a YouTube video at the clip's second, a Rumble page or an X post as they are; a clip's `citeUrl` still wins. A manifest post can carry `shot`, a screenshot (PNG, JPEG or WebP, beside the manifest), which its card draws in place of the post's text, its QR kept. A cut with none of these builds exactly as before. diff --git a/editor/app/api/media/fetch-window/[jobId]/route.ts b/editor/app/api/media/fetch-window/[jobId]/route.ts @@ -77,6 +77,12 @@ export async function GET( } else { const pointer = await loadSavedVideo(videoDir); file = pointer ? savedVideoPath(pointer) : null; + // How tall the saved file is, as the persist recorded it: a 720p + // persist can fall through to its last-resort rung, and the caller + // asked with a height in mind. + if (pointer?.format?.height !== undefined) { + out.height = pointer.format.height; + } } if (file) { const st = await stat(file).catch(() => null); diff --git a/editor/app/api/media/fetch-window/route.test.ts b/editor/app/api/media/fetch-window/route.test.ts @@ -0,0 +1,99 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +// Run with: +// pnpm -C editor exec tsx --test "app/api/media/fetch-window/route.test.ts" +// +// The refusals that come before any action runs, and the two cached answers — +// both read the disk only, so a temp corpus and a stand-in ffprobe are the +// whole world: no job is queued and nothing is fetched. + +const ROOT = await mkdtemp(path.join(os.tmpdir(), "fetch-window-route-")); +const SLUG = "demo-channel"; +const VIDEO_DIR = path.join(ROOT, "channels", SLUG, "data", "abc123"); +// Set before the route (and getPaths, which caches) is first imported. +process.env.WORKER_TOKEN = "test-token"; +process.env.TRANSCRIPTS_DIR = ROOT; +process.env.SETTINGS_FILE = path.join(ROOT, "settings.json"); +process.env.FFPROBE_BIN = path.join(ROOT, "ffprobe"); +await writeFile(process.env.FFPROBE_BIN, "#!/bin/sh\necho 1080\n"); +await chmod(process.env.FFPROBE_BIN, 0o755); +await mkdir(path.join(VIDEO_DIR, "clips"), { recursive: true }); +await writeFile( + path.join(ROOT, "channels", SLUG, "config.json"), + JSON.stringify({ name: "Demo", handling: "youtube", url: "https://www.youtube.com/@demo" }), +); +const { POST } = await import("./route"); +test.after(() => rm(ROOT, { recursive: true, force: true })); + +function post(body: Record<string, unknown>): Promise<Response> { + return POST( + new Request("http://localhost/api/media/fetch-window", { + method: "POST", + headers: { + authorization: "Bearer test-token", + "content-type": "application/json", + }, + body: JSON.stringify(body), + }), + ); +} + +const base = { + channelSlug: "demo-channel", + videoId: "abc123", + requestedBy: "test", + from: 10, + to: 20, +}; + +test("maxHeight out of range, fractional or not a number is refused with 400", async () => { + for (const maxHeight of [143, 2161, 720.5, "720", 0, -1]) { + for (const full of [false, true]) { + const res = await post({ ...base, full, maxHeight }); + assert.equal(res.status, 400, `${JSON.stringify(maxHeight)} full=${full}`); + const body = (await res.json()) as { error: string }; + assert.match(body.error, /maxHeight must be a whole number of pixels from 144 to 2160/); + } + } +}); + +test("a cached window answers with its probed height", async () => { + await writeFile(path.join(VIDEO_DIR, "clips", "5.00-25.00.mp4"), "not really an mp4"); + const res = await post({ ...base, maxHeight: 720 }); + assert.equal(res.status, 200); + const body = (await res.json()) as Record<string, unknown>; + assert.equal(body.cached, true); + assert.equal(body.from, 5); + assert.equal(body.to, 25); + // Taller than the cap asked for: the answer says so, it does not refetch. + assert.equal(body.height, 1080); +}); + +test("a cached whole recording answers with the height its persist recorded", async () => { + const store = path.join(ROOT, "saved-videos", SLUG, "abc123"); + await mkdir(store, { recursive: true }); + await writeFile(path.join(store, "abc123.mp4"), "x"); + const pointer = (format?: unknown) => + writeFile( + path.join(VIDEO_DIR, "saved-video.json"), + JSON.stringify({ storedAt: "", dir: store, file: "abc123.mp4", bytes: 1, format }), + ); + + await pointer({ preset: "original", height: 1080, vcodec: "avc1" }); + let res = await post({ ...base, full: true, maxHeight: 720 }); + assert.equal(res.status, 200); + let body = (await res.json()) as Record<string, unknown>; + assert.equal(body.file, path.join(store, "abc123.mp4")); + assert.equal(body.height, 1080); + + // A pointer from before formats were recorded: no height, rather than a guess. + await pointer(undefined); + res = await post({ ...base, full: true }); + body = (await res.json()) as Record<string, unknown>; + assert.equal(res.status, 200); + assert.equal("height" in body, false); +}); diff --git a/editor/app/api/media/fetch-window/route.ts b/editor/app/api/media/fetch-window/route.ts @@ -1,7 +1,13 @@ import { NextResponse } from "next/server"; import { authorizeWorkerRequest } from "yt-dlp-transcript-common/lib/workerToken"; import { isValidChannelSlug } from "yt-dlp-transcript-common/controller/channels"; -import { MAX_CLIP_WINDOW_SECONDS } from "yt-dlp-transcript-common/lib/clipWindow"; +import { + MAX_CLIP_WINDOW_SECONDS, + MAX_FETCH_MAX_HEIGHT, + MIN_FETCH_MAX_HEIGHT, + isFetchMaxHeight, +} from "yt-dlp-transcript-common/lib/clipWindow"; +import { sourceVideoQualityForMaxHeight } from "yt-dlp-transcript-common/ytdlp/downloadFormat"; import { fetchFullSourceAction, fetchWindowAction, @@ -47,6 +53,7 @@ type Body = { reason?: unknown; pad?: unknown; full?: unknown; + maxHeight?: unknown; }; const str = (v: unknown): string | undefined => @@ -77,6 +84,7 @@ function answer(outcome: FetchMediaOutcome): NextResponse { to: outcome.to, bytes: outcome.bytes, provenance: outcome.provenance, + ...(outcome.height !== undefined ? { height: outcome.height } : {}), }, { status: 200 }, ); @@ -140,12 +148,37 @@ export async function POST(request: Request) { requestedAt: new Date().toISOString(), }; + // THE HEIGHT CAP, optional. Absent (or null) keeps every default as it was; + // anything else must be a whole number of pixels in range, because it ends up + // inside a yt-dlp `-f` selector. + const maxHeight = + body.maxHeight === undefined || body.maxHeight === null + ? undefined + : body.maxHeight; + if (maxHeight !== undefined && !isFetchMaxHeight(maxHeight)) { + return NextResponse.json( + { + error: + `maxHeight must be a whole number of pixels from ` + + `${MIN_FETCH_MAX_HEIGHT} to ${MAX_FETCH_MAX_HEIGHT}`, + }, + { status: 400 }, + ); + } + if (body.full === true) { + // A cap at or under 720 asks for "video_720"; one above it asks for + // "original" in so many words. No cap: the channel's, else the global, + // source-video quality, as a persist from the video page would. return answer( await fetchFullSourceAction({ slug: channelSlug, videoId, provenance, + quality: + maxHeight === undefined + ? undefined + : sourceVideoQualityForMaxHeight(maxHeight), }), ); } @@ -184,6 +217,7 @@ export async function POST(request: Request) { from, to, provenance, + maxHeight, }), ); } diff --git a/editor/app/channels/[slug]/videos/[id]/videoActions.ts b/editor/app/channels/[slug]/videos/[id]/videoActions.ts @@ -64,6 +64,7 @@ import { } from "yt-dlp-transcript-common/lib/savedVideo"; import { MAX_CLIP_WINDOW_SECONDS, + isFetchMaxHeight, type ClipProvenance, } from "yt-dlp-transcript-common/lib/clipWindow"; import { @@ -74,6 +75,7 @@ import { fetchWindowManaged, type FetchWindowProvenance, } from "yt-dlp-transcript-common/ytdlp/fetchWindowManaged"; +import { probeVideoHeight } from "yt-dlp-transcript-common/ytdlp/ffprobeDuration"; import { detectPlatform } from "yt-dlp-transcript-common/lib/platform"; import { applyTagAssignmentsAction } from "../../../../tags/actions"; import { @@ -916,7 +918,11 @@ function requesterLine(o: SavedVideoOrigin | FetchWindowProvenance): string { } export type FetchMediaOutcome = - // Already on disk. No job, no bytes, no politeness owed. + // Already on disk. No job, no bytes, no politeness owed. `height` is how + // tall the file on disk is, when that is known (a saved pointer's recorded + // format, a window's probed stream) — a cached file was fetched for an + // earlier ask, so it may be taller than this one's cap, and the caller is + // the one who can tell whether that matters. | { ok: true; cached: true; @@ -925,6 +931,7 @@ export type FetchMediaOutcome = to: number; bytes: number; provenance: ClipProvenance | SavedVideoOrigin | null; + height?: number; } // Queued. `file` is where the window WILL be; for a full source it is not // knowable until the container's real extension is (null until then). @@ -981,6 +988,10 @@ export async function fetchWindowAction(req: { to: number; provenance: FetchWindowProvenance; queueKey?: string; + // The source height to cap the fetch at (clipFormatSelector's), absent for + // the default (DEFAULT_CLIP_MAX_HEIGHT). Checked again here for the same + // reason the window is: a replayed spec is a file on disk. + maxHeight?: number; // Hand back the StreamActionResult instead of detaching it. Set by Retry, // which renders the log; the HTTP route leaves it off and polls. stream?: boolean; @@ -1005,6 +1016,13 @@ export async function fetchWindowAction(req: { `(at most ${MAX_CLIP_WINDOW_SECONDS}s, from < to, from >= 0).`, }; } + if (req.maxHeight !== undefined && !isFetchMaxHeight(req.maxHeight)) { + return { + ok: false, + status: 400, + error: `maxHeight ${req.maxHeight} is not a source height to cap a fetch at.`, + }; + } const r = await loadConfigOrError(slug); if (!r.ok) return { ok: false, status: 404, error: r.error }; const paths = getPaths(); @@ -1015,6 +1033,13 @@ export async function fetchWindowAction(req: { // neighbour it already covers rather than being downloaded again. const hit = await findContainingClipWindow(videoDir, from, to); if (hit) { + // The window's own height, read off its header: a window holds seconds, + // so the probe is quick, and it is the only record of how tall a window + // fetched under an earlier cap is. + const height = await probeVideoHeight({ + ffprobeBin: paths.ffprobeBin, + file: hit.path, + }); return { ok: true, cached: true, @@ -1023,6 +1048,7 @@ export async function fetchWindowAction(req: { to: hit.to, bytes: hit.bytes, provenance: hit.provenance, + ...(height !== null ? { height } : {}), }; } @@ -1086,6 +1112,7 @@ export async function fetchWindowAction(req: { to, webpageUrl: url, queueKey: req.queueKey, + ...(req.maxHeight !== undefined ? { maxHeight: req.maxHeight } : {}), ...req.provenance, }, }, @@ -1103,6 +1130,7 @@ export async function fetchWindowAction(req: { from, to, provenance: req.provenance, + maxHeight: req.maxHeight, cookiePolicy: resolveCookiePolicy(settings, r.config), onLog, signal, @@ -1188,6 +1216,9 @@ export async function fetchFullSourceAction(req: { to: 0, bytes: pointer.bytes, provenance: pointer.origin ?? null, + ...(pointer.format?.height !== undefined + ? { height: pointer.format.height } + : {}), }; } diff --git a/mcp/README.md b/mcp/README.md @@ -21,7 +21,7 @@ clip window; the MCP itself still writes nothing. | `get_transcript` | One video's full transcript as clean markdown (metadata + **linked** timestamped captions). | | `get_post` / `get_thread` | One archived social post, or its whole thread. Posts have no timeline — cite them with no `@ mm:ss`. | | `get_video_metadata` | Everything known about one video without the transcript body: metadata, plus **view/like counts, cue count and transcript coverage** (`stats/`), **other archived copies of the same recording** with an explicit timings-aligned verdict (`duplicates.json`), and **AI chapters/tags** where they exist (`digests/`). | -| `fetch_clip` | The media behind a cited moment, **fetched by the local editor** (`POST /api/media/fetch-window`) through its paced, cookie-aware, provenanced job — never a yt-dlp run by hand. Needs `ARCHILYZER_EDITOR_URL` (default `http://localhost:3001`) and `WORKER_TOKEN` (the editor's own) in this server's env; without them it says so and fetches nothing. The editor must already archive the cited channel (a channel dir under its `transcripts/`), else it answers 404 `Channel "<slug>" not found`: an MCP pointed at a public site with a fresh editor gets that on every clip. A window is the cited span ± `pad` (default 3 s), at most 15 min, and lands at `channels/<slug>/data/<id>/clips/`; `full: true` fetches the whole recording into the saved-video store (needs a video the editor already knows). Waits up to `wait_seconds` (default 90, max 300), then returns the job id to resume with `job`; a client with a 60 s default request timeout must raise it or pass `wait_seconds` ≤ 50 — the fetch continues on the editor either way; resume it with `job`, and once it has finished the same request finds it cached. While it waits it sends one progress notification per poll to a client that asked for progress (a `progressToken`), which keeps a reset-on-progress timeout alive. A Rumble embed id is mapped to the editor's slug id through the record's `webpageUrl`, so pass the citing corpus as `source`; a video not in `source` is passed through as cited (known limitation). The file is a read-only corpus artifact. | +| `fetch_clip` | The media behind a cited moment, **fetched by the local editor** (`POST /api/media/fetch-window`) through its paced, cookie-aware, provenanced job — never a yt-dlp run by hand. Needs `ARCHILYZER_EDITOR_URL` (default `http://localhost:3001`) and `WORKER_TOKEN` (the editor's own) in this server's env; without them it says so and fetches nothing. The editor must already archive the cited channel (a channel dir under its `transcripts/`), else it answers 404 `Channel "<slug>" not found`: an MCP pointed at a public site with a fresh editor gets that on every clip. A window is the cited span ± `pad` (default 3 s), at most 15 min, and lands at `channels/<slug>/data/<id>/clips/`; `full: true` fetches the whole recording into the saved-video store (needs a video the editor already knows). `maxHeight` (144–2160) caps the source height: a window is fetched at or under it (default 720); a whole recording at 720 or less is saved as the editor's 720p H.264 preset and above 720 at the original quality (omitted, the channel's source-video quality applies). A file already on disk is returned as it is, never re-fetched for a different cap, and the answer gives its height and says when it is taller than asked. Waits up to `wait_seconds` (default 90, max 300), then returns the job id to resume with `job`; a client with a 60 s default request timeout must raise it or pass `wait_seconds` ≤ 50 — the fetch continues on the editor either way; resume it with `job`, and once it has finished the same request finds it cached. While it waits it sends one progress notification per poll to a client that asked for progress (a `progressToken`), which keeps a reset-on-progress timeout alive. A Rumble embed id is mapped to the editor's slug id through the record's `webpageUrl`, so pass the citing corpus as `source`; a video not in `source` is passed through as cited (known limitation). The file is a read-only corpus artifact. | | `open_link` | Paste an archilyzer viewer **share link** to re-run that exact search here (query tree + every filter, at full fidelity) — plan, results and corpus handle in **one** call. `dry_run:true` for the plan alone. | | `list_sources` | Show the **default** corpus and, with a hub, its member sites as ready-to-paste handles. | | `resolve_source` | Turn a URL or site name into the canonical `source` handle and check it can be read. Changes nothing. | diff --git a/mcp/src/fetchClip.test.ts b/mcp/src/fetchClip.test.ts @@ -744,3 +744,82 @@ test("onPoll is told after every poll that finds the job still waiting, and cann assert.deepEqual(seen, ["1 j1 queued 1s", "2 j1 running 2s"]); assert.equal(outcome.kind, "fetched"); }); + +// ─── maxHeight ─── + +test("maxHeight: a whole number from 144 to 2160 rides on the target; anything else is refused", () => { + const base = { channel: "chan", video: "vid", start: 10, end: 20, reason: "why" }; + for (const bad of [143, 2161, 720.5, "720", 0]) { + const v = validateFetchClipArgs({ ...base, maxHeight: bad }); + assert.equal(v.ok, false); + assert.equal( + v.ok ? "" : v.error, + `fetch_clip: maxHeight "${String(bad)}" must be a whole number of pixels from 144 to 2160`, + ); + } + const win = validateFetchClipArgs({ ...base, maxHeight: 480 }); + assert.ok(win.ok && "target" in win.request); + assert.equal(win.ok && "target" in win.request ? win.request.target.maxHeight : null, 480); + const full = validateFetchClipArgs({ ...base, full: true, maxHeight: 1080 }); + assert.ok(full.ok && "target" in full.request); + assert.deepEqual(full.ok && "target" in full.request ? full.request.target : null, { + kind: "full", + channel: "chan", + video: "vid", + maxHeight: 1080, + }); + // null is "not given", as JSON clients often send it. + const none = validateFetchClipArgs({ ...base, maxHeight: null }); + assert.ok(none.ok && "target" in none.request); + assert.equal(none.ok && "target" in none.request ? "maxHeight" in none.request.target : true, false); +}); + +test("maxHeight is sent in the POST for a window and for a whole recording", async () => { + for (const req of [windowRequest({ maxHeight: 480 }), fullRequest({ maxHeight: 720 })]) { + const { deps, calls } = editor( + seq({ status: 202, body: { cached: false, jobId: "j3", file: null, from: 0, to: 0 } }), + ); + await fetchClip({ ...req, waitSeconds: 0 } as FetchClipRequest, deps); + const body = calls[0].body as Record<string, unknown>; + assert.equal(body.maxHeight, "target" in req && req.target.kind === "full" ? 720 : 480); + } +}); + +test("a cached window's height is shown, and one taller than maxHeight is said to be", async () => { + const cached = (height: number) => + editor( + seq({ + status: 200, + body: { cached: true, file: CLIP_FILE, from: 7, to: 23, bytes: 10, provenance: null, height }, + }), + ).deps; + const fits = renderFetchClip(await fetchClip(windowRequest({ maxHeight: 720 }), cached(720)), CTX); + assert.match(fits.text, /\nheight: 720p\n/); + const tall = renderFetchClip(await fetchClip(windowRequest({ maxHeight: 480 }), cached(1080)), CTX); + assert.equal(tall.isError, false); + assert.match( + tall.text, + /\nheight: 1080p — taller than the maxHeight 480 asked for; this file was fetched earlier and is served as it is\n/, + ); + // No cap asked: the height is still given, with no comparison. + const plain = renderFetchClip(await fetchClip(windowRequest(), cached(1080)), CTX); + assert.match(plain.text, /\nheight: 1080p\n/); + // An editor that reports no height: no line at all. + const { deps } = editor( + seq({ status: 200, body: { cached: true, file: CLIP_FILE, from: 7, to: 23, bytes: 10, provenance: null } }), + ); + assert.ok(!/height:/.test(renderFetchClip(await fetchClip(windowRequest(), deps), CTX).text)); +}); + +test("full: a finished recording taller than maxHeight says why", async () => { + const saved = "/corpus/saved-videos/chan/vid/source.mp4"; + const { deps } = editor( + seq( + { status: 202, body: { cached: false, jobId: "j4", file: null, from: 0, to: 0 } }, + { status: 200, body: { status: "done", jobId: "j4", file: saved, bytes: 42, height: 1080 } }, + ), + ); + const r = renderFetchClip(await fetchClip(fullRequest({ maxHeight: 720 }), deps), CTX); + assert.equal(r.isError, false); + assert.match(r.text, /\nheight: 1080p — taller than the maxHeight 720 asked for; a whole recording is saved at 720p/); +}); diff --git a/mcp/src/fetchClip.tool.test.ts b/mcp/src/fetchClip.tool.test.ts @@ -265,7 +265,7 @@ test("the tool is advertised with job-only calls allowed", async () => { assert.ok(tool, "fetch_clip is listed"); assert.deepEqual(tool.inputSchema.required ?? [], []); const props = Object.keys(tool.inputSchema.properties ?? {}); - for (const p of ["source", "channel", "video", "start", "end", "pad", "full", "reason", "report", "wait_seconds", "job"]) { + for (const p of ["source", "channel", "video", "start", "end", "pad", "full", "maxHeight", "reason", "report", "wait_seconds", "job"]) { assert.ok(props.includes(p), `has ${p}`); } assert.match(tool.description ?? "", /NEVER run yt-dlp/); @@ -297,3 +297,22 @@ test("a client that asks for progress gets one notification per poll", async () { progress: 2, message: "editor job j6: running, 2s waited" }, ]); }); + +test("maxHeight goes to the editor as given, and a bad one is refused before any HTTP", async () => { + const { deps, calls } = fakeEditor(); + const client = await connect(deps); + await client.callTool({ + name: "fetch_clip", + arguments: { channel: RUMBLE, video: "vxe1ae", full: true, maxHeight: 720, reason: "summary" }, + }); + assert.equal((calls[0].body as Record<string, unknown>).maxHeight, 720); + + const bad = fakeEditor(); + const res = await (await connect(bad.deps)).callTool({ + name: "fetch_clip", + arguments: { channel: RUMBLE, video: "vxe1ae", start: 1, end: 5, maxHeight: 4320, reason: "why" }, + }); + assert.equal(isError(res), true); + assert.match(textOf(res), /^fetch_clip: maxHeight "4320" must be a whole number of pixels from 144 to 2160/); + assert.equal(bad.calls.length, 0); +}); diff --git a/mcp/src/fetchClip.ts b/mcp/src/fetchClip.ts @@ -1,4 +1,9 @@ -import { MAX_CLIP_WINDOW_SECONDS } from "yt-dlp-transcript-common/lib/clipWindow"; +import { + MAX_CLIP_WINDOW_SECONDS, + MAX_FETCH_MAX_HEIGHT, + MIN_FETCH_MAX_HEIGHT, + isFetchMaxHeight, +} from "yt-dlp-transcript-common/lib/clipWindow"; // ─── fetch_clip: ask the local Archilyzer editor for a clip's media ─── // @@ -142,6 +147,9 @@ export function planWindow(a: { // ─── Arguments ─── +// `maxHeight`, when the caller gave one: the source height to cap the fetch +// at. Absent, the editor's own default applies (720 for a window; the +// channel's, else the global, source-video quality for a whole recording). export type ClipTarget = | { kind: "window"; @@ -151,8 +159,9 @@ export type ClipTarget = from: number; to: number; pad: number; + maxHeight?: number; } - | { kind: "full"; channel: string; video: string }; + | { kind: "full"; channel: string; video: string; maxHeight?: number }; export type FetchClipRequest = | { job: string; waitSeconds: number } @@ -199,9 +208,24 @@ export function validateFetchClipArgs( return { ok: false, error: `fetch_clip: video "${video}" must match /^[\\w.-]+$/` }; } + // Checked here rather than passed through: it ends up inside a yt-dlp + // format selector on the editor, which refuses it too, but a refusal before + // any HTTP says what to fix in the tool's own words. + const rawHeight = args.maxHeight; + if (rawHeight !== undefined && rawHeight !== null && !isFetchMaxHeight(rawHeight)) { + return { + ok: false, + error: + `fetch_clip: maxHeight "${String(rawHeight)}" must be a whole number ` + + `of pixels from ${MIN_FETCH_MAX_HEIGHT} to ${MAX_FETCH_MAX_HEIGHT}`, + }; + } + const maxHeight = isFetchMaxHeight(rawHeight) ? rawHeight : undefined; + const capped = maxHeight !== undefined ? { maxHeight } : {}; + let target: ClipTarget; if (args.full === true) { - target = { kind: "full", channel, video }; + target = { kind: "full", channel, video, ...capped }; } else { const times: Record<"start" | "end", number> = { start: 0, end: 0 }; for (const key of ["start", "end"] as const) { @@ -234,7 +258,7 @@ export function validateFetchClipArgs( } const w = planWindow({ video, start: times.start, end: times.end, pad }); if ("error" in w) return { ok: false, error: w.error }; - target = { kind: "window", channel, video, from: w.from, to: w.to, pad }; + target = { kind: "window", channel, video, from: w.from, to: w.to, pad, ...capped }; } const reason = trimmed(args.reason); @@ -265,6 +289,10 @@ export type FetchClipOutcome = to: number; bytes: number; requestedBy?: string; + // How tall the file on disk is, when the editor knows; and the cap this + // call asked for, so the answer can say when a cached file is taller. + height?: number; + maxHeight?: number; } | { kind: "fetched"; @@ -277,6 +305,8 @@ export type FetchClipOutcome = to?: number; bytes?: number; waited: number; + height?: number; + maxHeight?: number; } | { kind: "queued"; jobId: string; status: string; waited: number } | { kind: "cooldown"; platform: string; cooldownMs: number; error: string } @@ -304,6 +334,18 @@ export type FetchClipOutcome = type Json = Record<string, unknown>; +// The two height fields of an outcome, each only when there is one — so an +// answer from an editor that reports no height reads exactly as it did. +function heightFields( + height: number | undefined, + maxHeight: number | undefined, +): { height?: number; maxHeight?: number } { + return { + ...(height !== undefined ? { height } : {}), + ...(maxHeight !== undefined ? { maxHeight } : {}), + }; +} + function isTimeout(e: unknown): boolean { return typeof e === "object" && e !== null && (e as { name?: unknown }).name === "TimeoutError"; } @@ -386,6 +428,7 @@ export async function fetchClip( jobId: string, mode: "window" | "full" | "unknown", pollFirst: boolean, + maxHeight?: number, ): Promise<FetchClipOutcome> { let status = "queued"; let skipSleep = pollFirst; @@ -433,6 +476,7 @@ export async function fetchClip( to, bytes: num(body.bytes), waited: waited(), + ...heightFields(num(body.height), maxHeight), }; } if (status === "failed" || status === "cancelled") { @@ -463,12 +507,14 @@ export async function fetchClip( // `full === true` before it validates from/to, and the saved-video path // resolves the URL itself, so a webpageUrl would describe a request the // editor does not have. + const capped = target.maxHeight !== undefined ? { maxHeight: target.maxHeight } : {}; const body = target.kind === "full" ? { channelSlug: target.channel, videoId: target.video, full: true, + ...capped, ...provenance, } : { @@ -478,6 +524,7 @@ export async function fetchClip( from: target.from, to: target.to, pad: target.pad, + ...capped, ...provenance, }; const res = await call(`${editor.url}/api/media/fetch-window`, { @@ -509,10 +556,11 @@ export async function fetchClip( to: num(answer.to) ?? reqTo, bytes: num(answer.bytes) ?? 0, requestedBy: prov && typeof prov === "object" ? str(prov.requestedBy) : undefined, + ...heightFields(num(answer.height), target.maxHeight), }; } if (res.status === 202 && str(answer.jobId)) { - return waitFor(str(answer.jobId)!, target.kind, false); + return waitFor(str(answer.jobId)!, target.kind, false, target.maxHeight); } if (res.status === 409) { return { @@ -539,7 +587,16 @@ const READ_ONLY_NOTE = function footer( mode: "window" | "full", - f: { file: string; from?: number; to?: number; bytes?: number; requestedBy?: string }, + f: { + file: string; + from?: number; + to?: number; + bytes?: number; + requestedBy?: string; + height?: number; + maxHeight?: number; + }, + cached = false, ): string { const lines = [`file: ${f.file}`]; if (mode === "window" && f.from !== undefined && f.to !== undefined) { @@ -547,6 +604,20 @@ function footer( `window: ${fmtSeconds(f.from)}–${fmtSeconds(f.to)} (${fmtSpan(f.to - f.from)})`, ); } + if (f.height !== undefined) { + // TALLER THAN ASKED is said, not hidden. A cached file was fetched for an + // earlier ask and is served as it is; a whole recording is saved at one of + // two qualities, not at the exact height asked for. + const over = + f.maxHeight !== undefined && f.height > f.maxHeight + ? ` — taller than the maxHeight ${f.maxHeight} asked for` + + (cached + ? "; this file was fetched earlier and is served as it is" + : "; a whole recording is saved at 720p (falling back to what " + + "the source has) or at its original quality, not at an exact height") + : ""; + lines.push(`height: ${f.height}p${over}`); + } if (f.bytes !== undefined) lines.push(`bytes: ${f.bytes}`); if (f.requestedBy) lines.push(`requested by ${f.requestedBy}`); if (mode === "window") { @@ -599,7 +670,7 @@ export function renderFetchClip( `${fmtSeconds(outcome.reqFrom)}–${fmtSeconds(outcome.reqTo)}: ` + `${fmtSeconds(outcome.from)}–${fmtSeconds(outcome.to)}.`; } - return { text: `${head}\n\n${footer(outcome.mode, outcome)}`, isError: false }; + return { text: `${head}\n\n${footer(outcome.mode, outcome, true)}`, isError: false }; } case "fetched": { if (!outcome.file) { diff --git a/mcp/src/server.ts b/mcp/src/server.ts @@ -724,7 +724,11 @@ export const TOOLS: Tool[] = [ "yourself instead. Give the citation's channel slug, video id, start " + "and end, and a one-line reason; the window is padded (pad, default 3 " + "s) and may be at most 15 min. full: true fetches the whole recording " + - "instead, into the editor's saved-video store. The answer names the " + + "instead, into the editor's saved-video store. maxHeight caps the " + + "source height (a window defaults to 720; a whole recording at or under " + + "720 is saved as 720p H.264, above it at the original quality); a " + + "cached file is served as it is, and the answer gives its height. The " + + "answer names the " + "file on disk: a read-only corpus artifact to play or copy, never to " + "move, edit or delete. The call waits up to wait_seconds; if the fetch " + "is still running it returns the job id — call again with job to keep " + @@ -773,6 +777,20 @@ export const TOOLS: Tool[] = [ "and needs a video the editor already knows (its metadata or " + "playlist entry). start, end and pad are ignored.", }, + maxHeight: { + type: "integer", + minimum: 144, + maximum: 2160, + description: + "Optional: the tallest source video to fetch, in pixels (144–2160). " + + "A window is fetched at or under it (default 720, H.264 preferred). " + + "With full: true, 720 or less saves the 720p H.264 preset (480p, " + + "then whatever the source has, when it has no 720p H.264) and more " + + "than 720 saves the original; omitted, the channel's own " + + "source-video quality applies. Nothing is re-fetched for it: a " + + "file already on disk is returned as it is, and the answer gives " + + "its height, so a taller one can be seen.", + }, reason: { type: "string", description: diff --git a/umtool/app/api/report/fetch/route.ts b/umtool/app/api/report/fetch/route.ts @@ -122,7 +122,7 @@ export async function POST(request: Request) { // yt-dlp from here has none of that, which is why it is now the opt-out // (UMTOOL_LOCAL_FETCH=1) rather than the default. const steps = wantFull - ? editorFullSourceSteps(r.project, clipId) + ? editorFullSourceSteps(r.project, clipId, { manifest: r.manifest }) : localFetch() ? fetchSteps(r.project, clipId, { padBefore, padAfter }) : editorFetchSteps(r.project, clipId, { padBefore, padAfter }); diff --git a/umtool/lib/report/driver.mjs b/umtool/lib/report/driver.mjs @@ -277,11 +277,20 @@ export function editorFetchSteps(project, clipId, pad) { * media, and the job keeps running on the editor if we give up — nothing is * lost, and the next ask finds it cached. * + * THE HEIGHT. `--max-height` is the caller's `maxHeight`, else the manifest's + * `render.maxHeightSource` — the tallest source the report renders from, so a + * whole recording is not fetched taller than the cut will use. At 720 or less + * the editor saves its 720p H.264 preset, above it the original. A value that + * is not a whole number from 144 to 2160 falls through to the next, and with + * neither the flag is left off and the channel's own quality applies. + * * @param {{ dir: string }} project * @param {string} clipId + * @param {{ maxHeight?: unknown, manifest?: { render?: { maxHeightSource?: unknown } } | null }} [opts] * @returns {import("../trim").Step[]} */ -export function editorFullSourceSteps(project, clipId) { +export function editorFullSourceSteps(project, clipId, opts = {}) { + const maxHeight = [opts.maxHeight, opts.manifest?.render?.maxHeightSource].find(isFetchMaxHeight); return [ { cwd: PIPELINE_DIR, @@ -296,6 +305,7 @@ export function editorFullSourceSteps(project, clipId) { "--fetch-only", clipId, "--full", + ...(maxHeight !== undefined ? ["--max-height", String(maxHeight)] : []), "--progress", "ndjson", ], @@ -305,6 +315,17 @@ export function editorFullSourceSteps(project, clipId) { ]; } +/** + * A source height the editor accepts as a fetch cap: a whole number of pixels + * from 144 to 2160 (common/lib/clipWindow.ts isFetchMaxHeight, whose bounds + * fetch-via-editor.mjs checks too). + * @param {unknown} v + * @returns {v is number} + */ +function isFetchMaxHeight(v) { + return typeof v === "number" && Number.isInteger(v) && v >= 144 && v <= 2160; +} + /** Whether this instance fetches locally with yt-dlp instead of asking. */ export const localFetch = () => process.env.UMTOOL_LOCAL_FETCH === "1"; diff --git a/umtool/lib/report/driver.test.mjs b/umtool/lib/report/driver.test.mjs @@ -4,7 +4,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { buildSteps } from "./driver.mjs"; +import { buildSteps, editorFullSourceSteps } from "./driver.mjs"; const project = { id: "p", dir: "/r/p" }; @@ -33,3 +33,19 @@ test("the verify is told when the build joined without crossfades", () => { assert.ok(verifyOf(buildSteps(project, { preset: "final", options: { xfade: false } })).includes("--no-xfade")); assert.ok(!verifyOf(buildSteps(project, { preset: "final" })).includes("--no-xfade")); }); + +test("a whole-source fetch asks at the manifest's maxHeightSource, unless told a height", () => { + const argvOf = (opts) => editorFullSourceSteps(project, "c01", opts)[0].argv; + const heightOf = (argv) => (argv.includes("--max-height") ? argv[argv.indexOf("--max-height") + 1] : null); + const manifest = { render: { maxHeightSource: 720 } }; + assert.equal(heightOf(argvOf({ manifest })), "720"); + assert.ok(argvOf({ manifest }).includes("--full")); + // An explicit height beats the manifest's. + assert.equal(heightOf(argvOf({ manifest, maxHeight: 1080 })), "1080"); + // Neither, or neither valid: no flag, so the channel's own quality applies. + assert.equal(heightOf(argvOf()), null); + assert.equal(heightOf(argvOf({ manifest: { render: {} } })), null); + assert.equal(heightOf(argvOf({ manifest: { render: { maxHeightSource: 4320 } } })), null); + // A bad explicit height falls through to the manifest's. + assert.equal(heightOf(argvOf({ manifest, maxHeight: "tall" })), "720"); +}); diff --git a/umtool/report-to-video/fetch-via-editor.mjs b/umtool/report-to-video/fetch-via-editor.mjs @@ -57,7 +57,7 @@ function die(message) { const manifestPath = argv.find((a) => !a.startsWith("-") && a.endsWith(".json")); const clipId = flag("--fetch-only") ?? flag("--clip"); if (!manifestPath || !clipId) { - die("usage: fetch-via-editor.mjs <manifest.json> --fetch-only <clipId> [--full] [--pad-before N] [--pad-after N] [--progress ndjson]"); + die("usage: fetch-via-editor.mjs <manifest.json> --fetch-only <clipId> [--full] [--max-height N] [--pad-before N] [--pad-after N] [--progress ndjson]"); } // THE WHOLE RECORDING INSTEAD OF A WINDOW. For a clip whose windows would tile @@ -67,6 +67,26 @@ if (!manifestPath || !clipId) { // the pointer it writes beside the video is what `clipWindowDirs` reads back. const wantFull = argv.includes("--full"); +// THE TALLEST SOURCE TO FETCH, in pixels, when the caller names one. A window +// is fetched at or under it; a whole recording at 720 or less is saved as the +// editor's 720p H.264 preset, above it at the original quality. Absent, the +// editor's own default applies (720 for a window, the channel's source-video +// quality for a whole recording). Checked here with the editor's bounds, so a +// typo is refused before the request rather than as a 400 after it. +const MIN_MAX_HEIGHT = 144; +const MAX_MAX_HEIGHT = 2160; +const maxHeightArg = flag("--max-height"); +const maxHeight = maxHeightArg === undefined ? undefined : Number(maxHeightArg); +if ( + maxHeight !== undefined && + !(Number.isInteger(maxHeight) && maxHeight >= MIN_MAX_HEIGHT && maxHeight <= MAX_MAX_HEIGHT) +) { + die( + `--max-height ${maxHeightArg} must be a whole number of pixels from ` + + `${MIN_MAX_HEIGHT} to ${MAX_MAX_HEIGHT}`, + ); +} + const editorUrl = (process.env.ARCHILYZER_EDITOR_URL ?? DEFAULT_EDITOR).replace(/\/+$/, ""); const token = process.env.WORKER_TOKEN ?? ""; if (!token) { @@ -165,6 +185,7 @@ const res = await ask(`${editorUrl}/api/media/fetch-window`, { videoId: entry.video, webpageUrl: entry.webpageUrl ?? undefined, ...(wantFull ? { full: true } : { from, to, pad: Math.max(padBefore, padAfter) }), + ...(maxHeight !== undefined ? { maxHeight } : {}), requestedBy: "umtool", manifest: manifestId, clipId: entry.id, @@ -174,6 +195,20 @@ const res = await ask(`${editorUrl}/api/media/fetch-window`, { const body = await res.json().catch(() => ({})); +// The file's height, when the editor reports it: carried on `done`, and said +// out loud when it is taller than --max-height (a file already on disk is +// served as it is, never re-fetched for a different cap). +function heightOf(answer) { + const h = Number(answer.height); + if (!Number.isInteger(h) || h <= 0) return {}; + if (maxHeight !== undefined && h > maxHeight) { + EMIT("note", { + message: ` ${path.basename(String(answer.file ?? ""))} is ${h}p, taller than --max-height ${maxHeight}`, + }); + } + return { height: h }; +} + if (res.status === 200) { // Already on disk — possibly WIDER than asked for, which is the point of // containing-window reuse. `fetchStart` is the file's own start, because @@ -186,7 +221,7 @@ if (res.status === 200) { cached: true, reuse: path.basename(String(body.file ?? "")), }); - EMIT("done", { out: body.file, fetchStart: body.from, cached: true }); + EMIT("done", { out: body.file, fetchStart: body.from, cached: true, ...heightOf(body) }); process.exit(0); } @@ -229,7 +264,7 @@ for (;;) { } if (j.status === "done") { EMIT("fetch", { id: entry.id, video: entry.video, from, to, cached: true }); - EMIT("done", { out: j.file ?? null, fetchStart: j.from ?? from, cached: false }); + EMIT("done", { out: j.file ?? null, fetchStart: j.from ?? from, cached: false, ...heightOf(j) }); process.exit(0); } if (j.status === "failed" || j.status === "cancelled") { diff --git a/umtool/report-to-video/fetch-via-editor.test.mjs b/umtool/report-to-video/fetch-via-editor.test.mjs @@ -0,0 +1,118 @@ +// fetch-via-editor.mjs's --max-height: checked before any request, sent with a +// window and with --full, and a reported height comes back on `done`. +// +// The editor is a scripted HTTP server on a loopback port: no corpus, no +// yt-dlp, nothing fetched. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const SCRIPT = path.join(path.dirname(fileURLToPath(import.meta.url)), "fetch-via-editor.mjs"); + +const MANIFEST = { + provenance: { channelSlug: "demo-channel", manifestId: "demo" }, + timeline: [{ id: "c01", type: "clip", video: "abc123", start: 10, end: 20, note: "why" }], +}; + +/** An editor that answers every POST with `answer` and records the bodies. */ +async function stubEditor(answer) { + const posts = []; + const server = http.createServer((req, res) => { + let raw = ""; + req.on("data", (c) => (raw += c)); + req.on("end", () => { + posts.push(JSON.parse(raw || "{}")); + res.writeHead(answer.status, { "content-type": "application/json" }); + res.end(JSON.stringify(answer.body)); + }); + }); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + const { port } = /** @type {import("node:net").AddressInfo} */ (server.address()); + return { posts, url: `http://127.0.0.1:${port}`, close: () => new Promise((r) => server.close(r)) }; +} + +/** Run the script; resolves with its exit code and NDJSON events. */ +async function run(editorUrl, args) { + const dir = await mkdtemp(path.join(os.tmpdir(), "fetch-via-editor-")); + try { + const manifest = path.join(dir, "video.manifest.json"); + await writeFile(manifest, JSON.stringify(MANIFEST)); + return await new Promise((resolve) => { + execFile( + process.execPath, + [SCRIPT, manifest, "--fetch-only", "c01", "--progress", "ndjson", ...args], + { env: { ...process.env, ARCHILYZER_EDITOR_URL: editorUrl, WORKER_TOKEN: "tok" } }, + (err, stdout) => { + const events = stdout + .split("\n") + .filter(Boolean) + .map((l) => JSON.parse(l)); + resolve({ code: err ? err.code : 0, events }); + }, + ); + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +const CACHED = (height) => ({ + status: 200, + body: { cached: true, file: "/corpus/clips/7.00-23.00.mp4", from: 7, to: 23, bytes: 1, height }, +}); + +test("--max-height rides on a window request; a taller cached file is noted and its height carried", async () => { + const ed = await stubEditor(CACHED(1080)); + try { + const { code, events } = await run(ed.url, ["--max-height", "480"]); + assert.equal(code, 0); + assert.equal(ed.posts.length, 1); + assert.equal(ed.posts[0].maxHeight, 480); + assert.equal(ed.posts[0].from, 7); + const done = events.find((e) => e.ev === "done"); + assert.equal(done.height, 1080); + assert.ok( + events.some((e) => e.ev === "note" && /is 1080p, taller than --max-height 480/.test(e.message)), + JSON.stringify(events), + ); + } finally { + await ed.close(); + } +}); + +test("--max-height rides on --full, which sends no span", async () => { + const ed = await stubEditor(CACHED(720)); + try { + const { code, events } = await run(ed.url, ["--full", "--max-height", "720"]); + assert.equal(code, 0); + assert.equal(ed.posts[0].full, true); + assert.equal(ed.posts[0].maxHeight, 720); + assert.equal("from" in ed.posts[0], false); + assert.ok(!events.some((e) => e.ev === "note" && /taller/.test(e.message))); + } finally { + await ed.close(); + } +}); + +test("no --max-height sends none; a bad one is refused before any request", async () => { + const ed = await stubEditor(CACHED(720)); + try { + assert.equal((await run(ed.url, [])).code, 0); + assert.equal("maxHeight" in ed.posts[0], false); + for (const bad of ["4320", "720.5", "tall"]) { + const { code, events } = await run(ed.url, ["--max-height", bad]); + assert.equal(code, 1, bad); + assert.ok(events.some((e) => e.ev === "note" && /must be a whole number of pixels from 144 to 2160/.test(e.message))); + } + assert.equal(ed.posts.length, 1); + } finally { + await ed.close(); + } +});