Archilyzer · Source

archilyzer

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

commit a65bf532578cceba940bdeebeab1a08d19b395bb
parent 76b20f6d1ca68bdf9509dd3b2cf83aecf2e23fe3
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat, 19 Sep 2026 02:35:33 -0400

clip bench: fetch what is not cached yet, and read ahead before paying for it

"fetch 20s more" asked for pad 20 every time. The pad is measured from the
CLIP, not from the cache, and the pipeline reuses any file that already
contains the window -- so on a cache fetched at pad 20, or at the pad 10 an
earlier CLI run used, the second press downloaded nothing, exited 0, and the
bench reported "fetched" over a cache that had not moved.

Three changes, because any one of them alone still lies. The button computes
its pad from what is on disk right now and NAMES it ("fetch to ±40 s"), or says
the window is at the maximum instead of offering a press that cannot help. The
route compares the wanted window against the cached ones BEFORE starting a job
and answers "already cached to ±N s — ask for more". And a job that did run
still has to show a wider file before the bench claims anything arrived --
refresh() returns what it read for exactly that.

Reading ahead is the other half of the same problem: the rail stopped where the
cache stopped, so "should I fetch more" could only be answered by fetching
more. Cues are text and cost nothing beside media, so the rail now runs ±60 s
past the cached file, dims what is not on disk, marks both cache edges and
shades the stretch the waveform is showing. Clicking a dimmed cue fetches
exactly far enough to reach it plus a breath; "peek further" widens the reading
by another 60 s and downloads nothing.

Also restores the date/cite/citeUrl validation in updateClip, which the
previous commit's verdict rewrite swallowed -- the e2e caught it: a PUT of
`2025-02-31` came back 200.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mumtool/app/api/report/fetch/route.ts | 36+++++++++++++++++++++++++++++++++---
Mumtool/components/projects/ClipBench.tsx | 282+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mumtool/components/projects/ClipBenchPage.tsx | 5+++++
Mumtool/docs/clip-bench.md | 20++++++++++++++++++++
Mumtool/lib/report/driver.mjs | 9+++++++++
Mumtool/lib/report/manifest.mjs | 44++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 336 insertions(+), 60 deletions(-)

diff --git a/umtool/app/api/report/fetch/route.ts b/umtool/app/api/report/fetch/route.ts @@ -1,6 +1,6 @@ import { getJob, jobView, runningJob, startJob } from "@/lib/jobs"; -import { fetchSteps } from "@/lib/report/driver.mjs"; -import { resolveClip } from "@/lib/report/serve.mjs"; +import { FETCH_MAX_PAD, fetchSteps } from "@/lib/report/driver.mjs"; +import { resolveClip, windowsFor } from "@/lib/report/serve.mjs"; export const dynamic = "force-dynamic"; @@ -14,7 +14,9 @@ export const dynamic = "force-dynamic"; // and containing-window reuse then makes this generous fetch BE the build's // cache rather than a second one. -const MAX_PAD = 120; +const MAX_PAD = FETCH_MAX_PAD; +/** Same tolerance the build uses for "this file holds that window". */ +const WIN_EPS = 0.02; export async function POST(request: Request) { const body = (await request.json().catch(() => ({}))) as Record<string, unknown>; @@ -25,6 +27,34 @@ export async function POST(request: Request) { const r = await resolveClip(projectId, clipId); if ("error" in r) return Response.json({ error: r.error }, { status: r.status }); + // ASK THE CACHE FIRST. + // + // `--fetch-only --pad P` is [start-P, end+P] measured from the CLIP, not + // from what is already on disk, and the pipeline reuses any file that + // contains it. So a second "fetch 20s more" on a window fetched at pad 20 -- + // or at the pad 10 a prior CLI run used -- downloads nothing, exits 0, and + // the bench reported "fetched" over a cache that had not moved. A no-op is + // not a success, and saying so here is cheaper than a job that proves it. + const windows = (await windowsFor(r.project, r.clip)) as { from: number; to: number }[]; + const want = { from: Number(r.clip.start) - pad, to: Number(r.clip.end) + pad }; + const holds = windows.find((w) => w.from <= want.from + WIN_EPS && w.to >= want.to - WIN_EPS); + if (holds) { + // What is already there, as the pad the bench speaks in. + const have = Math.floor( + Math.min(Number(r.clip.start) - holds.from, holds.to - Number(r.clip.end)), + ); + return Response.json( + { + error: + `already cached to ±${have} s — ask for more` + + (pad >= MAX_PAD ? ` (±${MAX_PAD} s is the widest this asks for)` : ""), + cachedPad: have, + maxPad: MAX_PAD, + }, + { status: 409 }, + ); + } + const running = runningJob(); if (running) { return Response.json( diff --git a/umtool/components/projects/ClipBench.tsx b/umtool/components/projects/ClipBench.tsx @@ -161,6 +161,8 @@ export type ClipBenchData = { cues: Cue[]; token: string | null; fetchPad: number; + /** The widest pad `/api/report/fetch` will accept. */ + maxPad: number; /** A /mix deep link for this clip's cached window, or null. Server-built. */ mixHref: string | null; }; @@ -208,6 +210,9 @@ const readPlayback = (): Playback => { /** How much of a moved edge to play: enough to hear the join, not the clip. */ const EDGE_AUDITION = 4; +/** How far past the cached window the cue rail reads ahead, per step. */ +const PEEK_STEP = 60; + export default function ClipBench({ data }: { data: ClipBenchData }) { const [clip, setClip] = useState<Clip>(data.clip); const [windows, setWindows] = useState<Win[]>(data.windows); @@ -216,6 +221,10 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { const [view, setView] = useState(data.view); const [sel, setSel] = useState({ from: data.clip.start, to: data.clip.end }); const [peaks, setPeaks] = useState<Peaks | null>(null); + // The cues AROUND the cached window: what is coming, read before paying for + // the media. One request, widened only when somebody asks. + const [peek, setPeek] = useState<Cue[]>([]); + const [peekPad, setPeekPad] = useState(PEEK_STEP); const [playhead, setPlayhead] = useState<number | null>(null); const [note, setNote] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null); @@ -271,6 +280,30 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { }; }, [cached, data.project, clip.id]); + // ---- reading ahead -------------------------------------------------------- + // + // The rail used to stop where the CACHE stops, so "should I fetch more" could + // only be answered by fetching more. Cues are text from the archive and cost + // nothing next to media, so the rail reads past both edges and marks what is + // not on disk. Deciding to spend a download is then a decision about words + // you have already read. + useEffect(() => { + let live = true; + void (async () => { + const r = await fetch( + `/api/report/cues?project=${encodeURIComponent(data.project)}&clip=${encodeURIComponent(clip.id)}` + + `&from=${(view.from - peekPad).toFixed(2)}&to=${(view.to + peekPad).toFixed(2)}`, + { cache: "no-store" }, + ); + if (!r.ok || !live) return; + const j = (await r.json()) as { cues: Cue[] }; + setPeek(j.cues ?? []); + })(); + return () => { + live = false; + }; + }, [data.project, clip.id, view.from, view.to, peekPad]); + // ---- playback preferences ------------------------------------------------ useEffect(() => setPlayback(readPlayback()), []); @@ -282,9 +315,24 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { } // BOTH players. Listening to the cut at 1.5x and then to the rendered // segment at 1x is two different clips as far as your ear is concerned. - if (video.current) video.current.playbackRate = playback.rate; - if (segVideo.current) segVideo.current.playbackRate = playback.rate; - }, [playback, segment]); + // + // defaultPlaybackRate as well as playbackRate, and that is the whole bug: + // the media load algorithm resets playbackRate TO defaultPlaybackRate, so + // a rate set before the element finished loading its source was silently + // back at 1x by the time anything played. + const apply = (el: HTMLVideoElement | null) => { + if (!el) return; + el.defaultPlaybackRate = playback.rate; + el.playbackRate = playback.rate; + }; + apply(video.current); + apply(segVideo.current); + const el = video.current; + if (!el) return; + const again = () => apply(el); + el.addEventListener("loadedmetadata", again); + return () => el.removeEventListener("loadedmetadata", again); + }, [playback, segment, cached]); // ---- audition ----------------------------------------------------------- const play = useCallback( @@ -294,7 +342,11 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { el.currentTime = Math.max(0, from - fetchStart); el.playbackRate = playback.rate; stopAt.current = to; - void el.play(); + // A rejected play() is normal, not a bug: Chrome refuses unmuted audio on + // a document nobody has interacted with (a typed URL, a fresh tab), and + // an unhandled rejection in that case would be noise. The seek has + // already happened either way. + void el.play().catch(() => {}); }, [cached, fetchStart, playback.rate], ); @@ -313,15 +365,20 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { autoPlayed.current = clip.id; play(clip.start, clip.end); }; - if (el.readyState >= 3) { + // METADATA is the right gate, not `canplay`. `preload="metadata"` tells the + // browser to stop after the headers, so `canplay` may never fire until + // something asks to play -- waiting for it would mean waiting forever. + // Once the duration is known the seek is meaningful and play() buffers the + // rest itself. + if (el.readyState >= 1) { go(); return; } + el.addEventListener("loadedmetadata", go); el.addEventListener("canplay", go); - el.addEventListener("loadeddata", go); return () => { + el.removeEventListener("loadedmetadata", go); el.removeEventListener("canplay", go); - el.removeEventListener("loadeddata", go); }; }, [playback.auto, cached, clip.id, clip.start, clip.end, play]); @@ -575,12 +632,12 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { rejectClip, ]); - const refresh = useCallback(async () => { + const refresh = useCallback(async (): Promise<ClipBenchData | null> => { const r = await fetch( `/api/report/clip?project=${encodeURIComponent(data.project)}&clip=${encodeURIComponent(clip.id)}`, { cache: "no-store" }, ); - if (!r.ok) return; + if (!r.ok) return null; const j = (await r.json()) as ClipBenchData; setWindows(j.windows); setCues(j.cues); @@ -589,41 +646,62 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { setSegment(j.segment); setSegmentMtime(j.segmentMtime); if (j.windows[0]) setView({ from: j.windows[0].from, to: j.windows[0].to }); + // Returned, not just stored: "did anything actually arrive" is a question + // the caller has to answer before it claims a fetch worked. + return j; }, [data.project, clip.id]); // ---- fetching more -------------------------------------------------------- - const fetchMore = useCallback(async () => { - setBusy("fetching a wider window…"); - setNote(null); - const r = await fetch("/api/report/fetch", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ project: data.project, clip: clip.id, pad: 20 }), - }); - if (!r.ok) { - const j = (await r.json()) as { error?: string }; - setBusy(null); - setNote(j.error ?? "could not start the fetch"); - return; - } - const { job } = (await r.json()) as { job: { id: string } }; - // Poll rather than stream: one 12-second job, and a dead poll is harmless. - for (let i = 0; i < 240; i += 1) { - await new Promise((res) => setTimeout(res, 500)); - const s = await fetch(`/api/report/fetch?job=${job.id}`, { cache: "no-store" }); - const sj = (await s.json()) as { job: { state: string; error: string | null } | null }; - if (!sj.job || sj.job.state === "running") continue; - setBusy(null); - if (sj.job.state === "failed") setNote(`the fetch failed: ${sj.job.error ?? "unknown"}`); - else { - setNote("fetched — the build will reuse this file, not download it again"); - await refresh(); + // + // THE PAD IS MEASURED FROM THE CLIP, NOT FROM THE CACHE. `--fetch-only --pad + // P` means [start-P, end+P], and the pipeline reuses any file that already + // contains it -- so a fixed `pad: 20` asks for the same window on the second + // press as on the first, downloads nothing, exits 0, and used to report + // "fetched" over a cache that had not moved. The button therefore computes + // its pad from what is on disk RIGHT NOW and says which number it will ask + // for; the route refuses a window it can already serve; and a job that ran + // still has to show a wider file before this claims anything arrived. + const fetchMore = useCallback( + async (pad: number) => { + const before = windows[0] ? windows[0].to - windows[0].from : 0; + setBusy("fetching a wider window…"); + setNote(null); + const r = await fetch("/api/report/fetch", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ project: data.project, clip: clip.id, pad: round2(pad) }), + }); + if (!r.ok) { + const j = (await r.json()) as { error?: string }; + setBusy(null); + setNote(j.error ?? "could not start the fetch"); + return; } - return; - } - setBusy(null); - setNote("the fetch is still running; reload to pick it up"); - }, [data.project, clip.id, refresh]); + const { job } = (await r.json()) as { job: { id: string } }; + // Poll rather than stream: one 12-second job, and a dead poll is harmless. + for (let i = 0; i < 240; i += 1) { + await new Promise((res) => setTimeout(res, 500)); + const s = await fetch(`/api/report/fetch?job=${job.id}`, { cache: "no-store" }); + const sj = (await s.json()) as { job: { state: string; error: string | null } | null }; + if (!sj.job || sj.job.state === "running") continue; + setBusy(null); + if (sj.job.state === "failed") setNote(`the fetch failed: ${sj.job.error ?? "unknown"}`); + else { + const j = await refresh(); + const after = j?.windows?.[0] ? j.windows[0].to - j.windows[0].from : 0; + setNote( + after > before + 0.01 + ? "fetched — the build will reuse this file, not download it again" + : "nothing new arrived — the cache already covered that window", + ); + } + return; + } + setBusy(null); + setNote("the fetch is still running; reload to pick it up"); + }, + [data.project, clip.id, refresh, windows], + ); // ---- re-rendering this one clip ------------------------------------------- // @@ -699,6 +777,32 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { { title: data.sourceTitle, uploadDate: data.uploadDate }, ); + // The rail spans further than the waveform when there is something to read + // out there. Both edges of the cache are MARKED, so the two scales cannot be + // confused for one. + const railCues = peek.length ? peek : cues; + const rail = peek.length + ? { + from: Math.min(view.from - peekPad, railCues[0]?.start ?? view.from), + to: Math.max(view.to + peekPad, railCues[railCues.length - 1]?.end ?? view.to), + } + : view; + const railSpan = Math.max(1e-6, rail.to - rail.from); + const rpct = (t: number) => `${((t - rail.from) / railSpan) * 100}%`; + + // How far the cached file reaches past the clip on its wider side, which is + // the pad the LAST fetch effectively bought. The next one asks for 20 more. + const reach = cached ? Math.max(clip.start - cached.from, cached.to - clip.end) : 0; + const nextPad = Math.min(data.maxPad, Math.round(reach) + 20); + const atMaxPad = reach >= data.maxPad - 0.5; + + /** The pad that would put this cue (plus a breath) inside the cache. */ + const padForCue = (c: Cue) => + Math.min( + data.maxPad, + Math.ceil(Math.max(clip.start - (c.start - 2), c.end + 2 - clip.end, reach + 1)), + ); + const segSrc = segment ? `/api/report/segment?project=${encodeURIComponent(data.project)}&clip=${encodeURIComponent(clip.id)}&v=${segmentMtime ?? 0}` : null; @@ -900,44 +1004,88 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { /> {(atStartEdge || atEndEdge) && ( - <div className="flex items-center gap-2 text-[12px]"> + <div className="flex flex-wrap items-center gap-2 text-[12px]"> <span className="text-[var(--color-dirty)]"> that is the edge of what is cached{cached ? ` (${cached.name})` : ""} </span> - <button - type="button" - className={buttonVariants({ variant: "primary", size: "sm" })} - disabled={!!busy} - onClick={() => void fetchMore()} - > - fetch 20s more - </button> + {atMaxPad ? ( + <span data-fetch-at-max="" className="text-[var(--color-dim)]"> + the cached window is at the maximum (±{data.maxPad} s) + </span> + ) : ( + <button + type="button" + data-fetch-more={nextPad} + className={buttonVariants({ variant: "primary", size: "sm" })} + disabled={!!busy} + onClick={() => void fetchMore(nextPad)} + > + fetch to ±{nextPad} s + </button> + )} </div> )} - {/* ---- the cue rail ---- */} + {/* ---- the cue rail, and what is past the cache ---- */} <div className="relative h-14 w-full shrink-0 overflow-hidden rounded border border-[var(--color-line)] bg-[var(--color-panel-2)]"> - {cues.map((c) => { + {/* The cached span, shaded. The waveform above is exactly this + stretch; everything outside it is text and nothing else. */} + {peek.length > 0 && ( + <> + <div + data-cache-span="" + className="absolute top-0 h-full bg-[color-mix(in_srgb,var(--color-meter)_7%,transparent)]" + style={{ left: rpct(view.from), width: `calc(${rpct(view.to)} - ${rpct(view.from)})` }} + /> + {[view.from, view.to].map((t) => ( + <div + key={t} + data-cache-edge={t} + className="absolute top-0 h-full border-l border-dashed border-[var(--color-meter)]" + style={{ left: rpct(t) }} + /> + ))} + </> + )} + {railCues.map((c) => { const inSel = c.end > sel.from && c.start < sel.to; + // Outside the cached file: readable, dimmed, and a click offers + // to go and get it rather than pretending it is already here. + const outside = c.start < view.from - 0.02 || c.end > view.to + 0.02; return ( <button type="button" key={`${c.start}-${c.end}`} data-cue={c.start} data-ends-sentence={c.endsSentence ? "1" : "0"} - title={c.text} + data-peek={outside ? "1" : "0"} + title={ + outside + ? `${c.text}\n\nnot fetched — click to fetch to ±${padForCue(c)} s` + : c.text + } onClick={() => { + if (outside) { + if (!atMaxPad) void fetchMore(padForCue(c)); + return; + } // Snap the NEARER edge to this cue's nearer boundary. const dStart = Math.abs(sel.from - c.start); const dEnd = Math.abs(sel.to - c.end); - onSel(dStart <= dEnd ? { ...sel, from: c.start } : { ...sel, to: c.end }, false); + onSel( + dStart <= dEnd ? { ...sel, from: c.start } : { ...sel, to: c.end }, + false, + dStart <= dEnd ? "start" : "end", + ); }} className={`absolute top-0 h-full overflow-hidden border-l px-1 text-left text-[10px] leading-tight ${ - inSel - ? "border-[var(--color-sel)] text-[var(--color-text)]" - : "border-[var(--color-line)] text-[var(--color-dim)] opacity-60" + outside + ? "border-dashed border-[var(--color-line)] text-[var(--color-dim)] opacity-40 hover:opacity-70" + : inSel + ? "border-[var(--color-sel)] text-[var(--color-text)]" + : "border-[var(--color-line)] text-[var(--color-dim)] opacity-60" }`} - style={{ left: pct(c.start), width: `calc(${pct(c.end)} - ${pct(c.start)})` }} + style={{ left: rpct(c.start), width: `calc(${rpct(c.end)} - ${rpct(c.start)})` }} > {c.endsSentence && ( <span className="mr-1 text-[var(--color-meter)]" aria-label="closes a sentence"> @@ -949,6 +1097,26 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { ); })} </div> + <div className="micro flex flex-wrap items-center gap-2"> + <span> + what is being said — a marked cue closes a sentence; click one to snap an edge + </span> + {peek.length > 0 && ( + <> + <span className="text-[var(--color-dim)]"> + · dimmed cues are past the cached file — click one to fetch to it + </span> + <button + type="button" + data-peek-further="" + className="text-[var(--color-sel)] hover:underline" + onClick={() => setPeekPad((n) => n + PEEK_STEP)} + > + peek further (±{peekPad + PEEK_STEP} s) + </button> + </> + )} + </div> {/* ---- the warnings ---- Still unfolded, and deliberately: a warning behind a summary is a diff --git a/umtool/components/projects/ClipBenchPage.tsx b/umtool/components/projects/ClipBenchPage.tsx @@ -5,6 +5,7 @@ import ClipBench, { type ClipBenchData } from "./ClipBench"; import { manifestToken } from "@/lib/report/manifest.mjs"; import { clipVerdict, cuesInWindow, readClipDetail, readManifest } from "@/lib/projects/report.mjs"; import { segmentFor, windowsFor } from "@/lib/report/serve.mjs"; +import { FETCH_MAX_PAD } from "@/lib/report/driver.mjs"; import type { ProjectRef } from "@/lib/project-types"; // The server half of the bench: resolve the clip, read what it needs, and hand @@ -93,6 +94,10 @@ export default async function ClipBenchPage({ cues: cues?.cues ?? [], token: await manifestToken(project.dir), fetchPad: manifest.render?.fetchPad ?? 3, + // The widest the fetch route will go. The BUTTON has to know it: it names + // the pad it will ask for, and "that is as wide as it goes" is a different + // sentence from a number the server quietly clamps. + maxPad: FETCH_MAX_PAD, // The widest cached RAW window: it has no chrome burned in, which is what a // mix is looking at, and it exists as soon as the clip has been fetched // once. start/end are the window minus the file's own start, because a mix diff --git a/umtool/docs/clip-bench.md b/umtool/docs/clip-bench.md @@ -66,6 +66,26 @@ cache rather than a second one. Past the source's own duration the handle stops for good. +**The pad is measured from the CLIP, not from the cache**, and that cost one +confusing session: `--fetch-only --pad P` means `[start-P, end+P]`, the pipeline +reuses any file that already contains it, and the button used to ask for a flat +20 every time. On a window already fetched at pad 20 — or at the pad 10 an +earlier CLI run used — the second press downloaded nothing, exited 0, and the +bench said *"fetched"* over a cache that had not moved. So the button computes +its pad from what is on disk right now and **says the number** ("fetch to +±40 s"), the route refuses a window it can already serve (*"already cached to +±N s"*) rather than running a job to prove it, and a job that did run still has +to produce a wider file before the bench claims anything arrived. At +`FETCH_MAX_PAD` it says so instead of offering a press that cannot help. + +**Read ahead before you pay for it.** The cue rail runs ±60 s past the cached +file (one request to `/api/report/cues`, text from the archive and cheap beside +media). Everything outside the cache is dimmed, both cache edges are marked, and +the cached stretch — the part the waveform above is showing — is shaded, so the +two scales cannot be mistaken for one. Clicking a dimmed cue fetches exactly far +enough to reach it plus two seconds; "peek further" widens the reading by +another 60 s without downloading anything. + ## Three things the JSON cannot show you **Ends mid-sentence**, quoting the cue the cut lands inside — because "ends diff --git a/umtool/lib/report/driver.mjs b/umtool/lib/report/driver.mjs @@ -151,6 +151,15 @@ export function buildSteps(project, { preset = "fast", only = null, skipFetch = } /** + * The widest pad the bench may ask for. + * + * Here rather than in the route, because the BENCH has to know it too: the + * button reads "fetch to ±N s" and has to say "that is as wide as it goes" + * rather than offering a number the server will quietly clamp. + */ +export const FETCH_MAX_PAD = 120; + +/** * Fetch ONE clip's window, wide. What the bench's "fetch more" runs. * @param {{ dir: string }} project * @param {string} clipId diff --git a/umtool/lib/report/manifest.mjs b/umtool/lib/report/manifest.mjs @@ -190,6 +190,50 @@ export async function updateClip(dir, clipId, patch, { token = null } = {}) { else delete entry[k]; } + if (patch.date !== undefined) { + const v = String(patch.date ?? "").trim(); + if (!v) delete entry.date; + else if (!isCalendarDate(v)) { + throw new Error( + `date must be a real calendar date written YYYY-MM-DD (got \`${v}\`)`, + ); + } else entry.date = v; + } + + if (patch.cite !== undefined) { + // null or empty means "no cite", and the header then falls back to the + // clip's own start -- which is what `entry.cite ?? entry.start` has always + // done. Rounded like a window, for the same reason. + if (patch.cite === null || patch.cite === "") delete entry.cite; + else { + const v = Number(patch.cite); + if (!Number.isFinite(v) || v < 0) { + throw new Error("cite must be a number of seconds ≥ 0, or empty to use the clip's start"); + } + entry.cite = round2(v); + } + } + + if (patch.citeUrl !== undefined) { + const v = String(patch.citeUrl ?? "").trim(); + if (!v) delete entry.citeUrl; + else { + // The QR target. A value that is not an http(s) URL encodes to something + // a phone camera opens and nothing answers -- the same class of defect + // as the 19 codes that shipped reading `undefined/?v=…`. + let u = null; + try { + u = new URL(v); + } catch { + /* handled below */ + } + if (!u || (u.protocol !== "http:" && u.protocol !== "https:")) { + throw new Error(`citeUrl must be an http:// or https:// URL (got \`${v}\`)`); + } + entry.citeUrl = v; + } + } + // ---- the walk's verdict ------------------------------------------------- // // Whether somebody has LOOKED at this clip and said the description is what