import { notFound } from "next/navigation"; import BrowseHeader from "@/components/BrowseHeader"; import ClipBench, { type ClipBenchData } from "./ClipBench"; import { manifestToken } from "@/lib/report/manifest.mjs"; import { clipVerdict, cuesInWindow, readClipDetail, readManifest, siblingsOf, walkReadiness, } from "@/lib/projects/report.mjs"; import { projectCache, segmentFor, windowsFor } from "@/lib/report/serve.mjs"; import { FETCH_MAX_PAD } from "@/lib/report/driver.mjs"; import { deckOn } from "umtool-report-to-video/deck"; import type { ProjectRef } from "@/lib/project-types"; // The server half of the bench: resolve the clip, read what it needs, and hand // it over. The clip id is validated as a MEMBER of the timeline, never as a // path -- the same rule every other name that crosses the wire here follows. export default async function ClipBenchPage({ project, clipId, }: { project: ProjectRef; clipId: string; }) { const manifest = await readManifest(project.dir); if (!manifest) notFound(); const detail = await readClipDetail(project.dir, { manifest }); if (!detail) notFound(); const entry = detail.entries.find( (e: { id: string; kind: string }) => e.id === clipId && e.kind === "clip", ); if (!entry) notFound(); const clips = detail.entries.filter((e: { kind: string }) => e.kind === "clip"); const i = clips.findIndex((e: { id: string }) => e.id === clipId); // ---- where `p` and `n` go ------------------------------------------------ // // The walk visits the clips that still NEED JUDGEMENT and are FETCHED, in // timeline order. A clip nobody can play is a dead end -- there is nothing to // judge and the only move is to press `n` again -- and one already answered is // the round trip a walk exists to remove. A URL still opens ANY clip: this // page renders it, says what it has (or has not) got, and points its links at // the fetched, unjudged neighbours on either side. const readiness = walkReadiness(clips); const inWalk = new Set(readiness.readyIds as string[]); const onWalk = (e: { id: string }) => e.id !== clipId && inWalk.has(e.id); const prev = [...clips.slice(0, Math.max(0, i))].reverse().find(onWalk)?.id ?? null; const next = clips.slice(i + 1).find(onWalk)?.id ?? null; // One listing of clips-raw for the page: readClipDetail already built one to // answer `fetched` for every clip, and this is the same question for this one. const windows = await windowsFor(project, entry, await projectCache(project)); // The mtime rides along so a re-render busts the browser's cache: the segment // is written back to the SAME path, and without it the player would keep // showing the cut from before the edit. const seg = await segmentFor(project, clipId); const widest = windows[0] ?? null; const view = widest ? { from: widest.from, to: widest.to } : { from: Math.max(0, entry.start - 20), to: entry.end + 20 }; const cues = await cuesInWindow(project.dir, clipId, view.from, view.to); const data: ClipBenchData = { project: project.id, clip: { id: entry.id, video: entry.video, channel: entry.channel ?? null, start: entry.start, end: entry.end, cite: entry.cite ?? null, quote: entry.quote ?? null, note: entry.note ?? null, correction: entry.correction ?? null, title: entry.title ?? null, date: entry.date ?? null, citeUrl: entry.citeUrl ?? null, lock: !!entry.lock, lockStart: !!entry.lockStart, lockEnd: !!entry.lockEnd, cutStart: entry.cutStart ?? null, cutEnd: entry.cutEnd ?? null, lockCut: !!entry.lockCut, // The mute mark, from the manifest's own entry: readClipDetail's rows // carry the fields it reads, and this is not one of them. muteFrom: ((manifest.timeline ?? []) as { id: string; muteFrom?: number }[]).find((e) => e.id === clipId) ?.muteFrom ?? null, verdict: entry.verdict === "confirmed" || entry.verdict === "incorrect" ? entry.verdict : null, // From the manifest's own entry: what the on-screen panel says over this // clip, when somebody has written it. onscreen: ((manifest.timeline ?? []) as { id: string; onscreen?: { title?: string; subtitle?: string } }[]).find( (e) => e.id === clipId, )?.onscreen ?? null, }, // Whether the cut is built with the on-screen panel. Decides whether the // bench composes a preview of it; the fields are there either way. deckOn: deckOn(manifest.render ?? {}), generatedBy: typeof manifest.generatedBy === "string" ? manifest.generatedBy : null, view, windows: windows.map((w: { name: string; from: number; to: number }) => ({ name: w.name, from: w.from, to: w.to, })), proposed: entry.proposed ?? null, endsSentence: entry.endsSentence ?? null, noPunctuation: !!entry.noPunctuation, sourceDuration: entry.duration ?? null, segment: seg?.rel ?? entry.segment ?? null, segmentMtime: seg?.mtimeMs ?? null, // What the header would say with no overrides. Shown beside the fields that // replace it, because "the archive says 2019" is the whole reason `date` // exists. sourceTitle: entry.sourceTitleClean ?? null, uploadDate: entry.uploadDate ?? null, sourceChannel: entry.sourceChannel ?? null, prev: prev, next: next, index: i + 1, total: clips.length, // The OTHERS, so the bench can add this clip's own answer to the count // without a reload and without knowing anything about the rest of the cut. /** The walk's own reading: fetched clips that still need an answer. */ ready: readiness.ready, needing: readiness.needing, /** Is there a cached file holding THIS clip's window, end to end? */ fetched: !!entry.fetched, reviewedOthers: clips.filter( (e: { id: string }) => e.id !== clipId && clipVerdict(e) !== "unreviewed", ).length, cues: cues?.cues ?? [], siblings: siblingsOf(manifest, clipId), 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 // spec is relative to the file it names. mixHref: widest ? `/mix?${new URLSearchParams({ // The window's own path; see ReportProject — a window may live in // the corpus rather than under out/clips-raw. body: widest.path, start: (entry.start - widest.from).toFixed(2), end: (entry.end - widest.from).toFixed(2), from: project.id, clip: clipId, })}` : null, }; return (
); }