import { readdir, readFile } from "node:fs/promises"; import path from "node:path"; import Link from "next/link"; import BrowseHeader from "@/components/BrowseHeader"; import CopyButton from "@/components/CopyButton"; import CheckSourcesButton from "@/components/dashboard/CheckSourcesButton"; import { fmtAgo, fmtBytes } from "@/lib/format"; import { Markdown } from "@/lib/markdown"; import { correctionsOf, readClipDetail, reviewOf, sourcesOf, unfetchedNeedingJudgement, walkReadiness, } from "@/lib/projects/report.mjs"; import { EXPORT_FORMATS, exportableVariants } from "@/lib/report/export.mjs"; import { diffManifests, formatChange } from "@/lib/report/manifest-diff.mjs"; import { listSnapshots, readSnapshot } from "@/lib/report/snapshots.mjs"; import { listTakes } from "@/lib/report/takes.mjs"; import DeliverSection from "./DeliverSection"; import FetchUnfetchedButton from "./FetchUnfetchedButton"; import OnscreenSection from "./OnscreenSection"; import ReportBuildChain from "./ReportBuildChain"; import SnapshotButton from "./SnapshotButton"; import TagCitedButton from "./TagCitedButton"; import { RowControls, RowNotes, TimelineList } from "./TimelineEditor"; import GeneratedBanner from "@/components/notes/GeneratedBanner"; import { NotesProvider } from "@/components/notes/NotesProvider"; import { decisionsForProject } from "@/lib/projects"; import { badgeVariants, type BadgeVariants } from "@/components/ui/badge"; import { buttonVariants } from "@/components/ui/button"; import type { Severity } from "@/lib/decisions"; import type { ProjectRef } from "@/lib/project-types"; // --------------------------------------------------------------------------- // A report video, as a page. // // The manifest IS the cut -- array order, absolute source seconds, one entry per // clip -- so the page is the timeline, read back. What it adds is the three // things the JSON cannot show you: whether each clip's material is actually on // disk, whether its window ends where a sentence does, and what the widener // would do to it if you ran it. // --------------------------------------------------------------------------- const hms = (t: number) => { const s = Math.max(0, Math.floor(t)); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = s % 60; return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}` : `${m}:${String(sec).padStart(2, "0")}`; }; const TONE: Record = { blocking: "blocking", open: "open", info: "info", }; function Pill({ tone, children }: { tone?: BadgeVariants["variant"]; children: React.ReactNode }) { return {children}; } export default async function ReportProject({ project, search, }: { project: ProjectRef; search: Record; }) { const detail = await readClipDetail(project.dir); const decisions = await decisionsForProject(project); if (!detail) { return (

video.manifest.json could not be parsed.

); } const { manifest: m, build, entries, channelsDir, shadowExists } = detail; const corrections = correctionsOf(m) as { id: string; channel: string | null; video: string; at: number; href: string; text: string; verdict: "confirmed" | "incorrect" | "unreviewed"; }[]; const clips = entries.filter((e) => e.kind === "clip"); // How much of the cut has been walked. One definition, shared with the bench // header and `umtool corrections`. const review = reviewOf(m); // And how much of it can be walked TODAY: the walk visits the clips that // still need judgement and have something to play, so the gap between these // two numbers is what is waiting on a download rather than on somebody. const readiness = walkReadiness(clips); // What the walk is waiting on: the same clips the rows below mark "not // fetched yet". const pendingFetch = unfetchedNeedingJudgement(clips) as string[]; const walkStart = (readiness.readyIds[0] as string | undefined) ?? clips[0]?.id; const nonClips = entries.filter((e) => e.kind !== "clip"); const runtime = clips.reduce((n, e) => n + Math.max(0, e.end - e.start), 0); const showAll = search.all === "1"; const p = (m.provenance ?? {}) as Record; // ---- the long-form reads: sources, revisions, notes, exports ------------ const sources = await sourcesOf(project.dir, { manifest: m }); const snapshots = await listSnapshots(project.dir); const diffs = await Promise.all( snapshots.map(async (sn) => { try { return diffManifests(await readSnapshot(project.dir, sn.rel), m); } catch { return null; } }), ); const readme = await readFile(path.join(project.dir, "README.md"), "utf8").catch(() => null); // Sibling notes: every .md that is neither the report nor the README. const names = await readdir(project.dir).catch(() => [] as string[]); const noteFiles = names.filter((n) => /\.md$/i.test(n) && !/^readme\.md$/i.test(n) && n !== "sweep-report.md").sort(); const noteTexts = await Promise.all(noteFiles.map((n) => readFile(path.join(project.dir, n), "utf8").catch(() => ""))); const variants = await exportableVariants(project.dir); const takes = await listTakes(project.dir); // Provenance splits by SHAPE: a scalar or a URL stays in the table; a long // string, or one with a newline, is prose the author wrote and reads as a // paragraph under Notes. No field is moved or rewritten. const isProse = (v: unknown) => typeof v === "string" && (v.length > 120 || v.includes("\n")); const scalars = Object.entries(p).filter(([, v]) => !isProse(v) && (typeof v !== "object" || v === null)); const prose = Object.entries(p).filter(([, v]) => isProse(v)) as [string, string][]; const structured = Object.entries(p).filter(([, v]) => typeof v === "object" && v !== null); const days = (ms: number) => Math.floor((Date.now() - ms) / 86_400_000); // ---- what a clip's "mix" link opens --------------------------------------- // // The widest cached RAW window, not the built segment. Three reasons: it // exists as soon as a clip has been fetched once (segments only exist after a // build), it has NO CHROME burned in -- which is what a mix is looking at -- // and it is the file the bench already has peaks for. The built segment is the // fallback, and the link says which it is. // // start/end are the clip's window MINUS the file's own start, because a mix // spec is relative to the file it names. The arithmetic is exact and known // here; making the client do it would be a second place to get it wrong. const mixHref = (e: { id: string; start: number; end: number; widest: { name: string; path: string; from: number } | null; segment: string | null; }): string | null => { const q = new URLSearchParams(); if (e.widest) { // The window's own path, not a rebuild of it: the editor fetches into // the corpus, so "out/clips-raw/" is a file that is not there. q.set("body", e.widest.path); q.set("start", (e.start - e.widest.from).toFixed(2)); q.set("end", (e.end - e.widest.from).toFixed(2)); } else if (e.segment) { q.set("body", path.join(project.dir, e.segment)); } else { return null; } q.set("from", project.id); q.set("clip", e.id); return `/mix?${q}`; }; return (

{m.title}

{m.subtitle &&

{m.subtitle}

}
{typeof p.channel === "string" && p.channel && {p.channel}} {m.generatedOn && generated {m.generatedOn}} {typeof p.videosCited === "number" && {p.videosCited} videos cited} {typeof p.enumeratedMatches === "number" && ( {p.enumeratedMatches} enumerated matches )}
{/* ---- the way in ---- Every clip row carries a bench link, which is the right control for "go to that one" and the wrong one for "start". Reviewing a cut is watching every clip in order, and the first clip is where that begins -- so it is a button in the title bar rather than the eleventh link on a page whose first screenful is decisions, sources and the build chain. */} {clips.length > 0 && (
{/* Where the walk STARTS is where the walk goes: the first clip that still needs an answer and can be played. A cut whose first clip is already judged (or not fetched) would otherwise open on a clip `n` immediately leaves. */} Walk the cut → start at {walkStart} p / n move between clips · {review.reviewed} of {review.total}{" "} reviewed{review.incorrect > 0 ? ` · ${review.incorrect} incorrect` : ""} {" · "} ready {readiness.ready} of {readiness.needing} needing judgement
)} {/* Alternative renders of part of the cut, made elsewhere and judged on their own page. Only when there is a takes/ to look at. */} {takes.exists && ( Takes — {takes.takes.length} )} {build.built && (
{build.slug}.mp4
{fmtBytes(build.finalSize)} · {fmtAgo(build.finalMtimeMs)}
{/* No window: the whole thing is the point of a deliverable link. */} open in mix
)}
{/* --- the author's own account of the cut, first ------------------ */} {readme && (
)} {/* --- what is wrong, first ------------------------------------- */} {decisions.length > 0 && (

{decisions.filter((d) => d.severity !== "info").length} waiting of {decisions.length}

    {decisions.map((d, i) => (
  • {d.severity} {d.target} {d.why} {d.kind}
  • ))}
)} {/* --- the sources ---------------------------------------------- */}

sources — {sources.rows.length}

{sources.checkedAtMs ? `availability checked ${days(sources.checkedAtMs)} d ago` : "availability never checked"}
{/* Beside the sources, because these ARE the sources: tagging them in the archive is a statement about the recordings this table lists, and the count in the control is the count in its heading. */}
cues from {channelsDir} {shadowExists && ( (this project’s own shadow tree) )} {" · "}QR codes resolve to{" "} {String(p.siteOrigin ?? "") || "(nothing — siteOrigin is unset)"}
{sources.rows.length > 0 && (
{sources.rows.map((r) => { const a = r.availability; return ( ); })}
source clips cues coverage availability cite target
{r.key} {r.title &&
{r.title}
}
{r.clips.map((c, i) => ( {i > 0 && " "} {c} ))} {r.cues === "missing" ? ( missing ) : r.cues === "no-punctuation" ? ( no punctuation ) : ( present )} {r.coverageGap ? ( cues end {r.coverageGap.cuesEnd.toFixed(0)} s · {r.coverageGap.clip} needs {r.coverageGap.needs.toFixed(0)} s ) : r.cuesEnd != null ? ( cues to {r.cuesEnd.toFixed(0)} s ) : ( — )} {a ? ( {a.state} · {days(a.checkedAtMs)} d ago ) : ( never checked )} {r.cite.differs ? ( citeUrl{" "} override ) : ( derived )}
)}
{/* --- building it ---------------------------------------------- */} ({ id: e.id, kind: e.kind }))} /> {/* --- what is drawn over it --------------------------------------- */} {/* The on-screen panel: its settings, its words and a live preview of both. After the build chain, because "re-render on-screen" is a run of that chain over segments it already built; before Deliver, because the panel is part of the video that gets delivered. */} ({ id: e.id, kind: e.kind, segment: !!e.segment }))} built={!!build.built} /> {/* --- delivering it --------------------------------------------- */} {/* After the build chain, because that is the order the work happens in: the video is one deliverable and the written report with its own clip files is the other, and both wait on the same walk. */} {/* --- the timeline --------------------------------------------- */}

the cut — {entries.length} entries, in array order

{/* The gap between "ready" and "needing", closed in one press. Every row below it that reads "not fetched yet" is one of these, and closing them one bench page at a time was the loop this replaces. The default pad is the fetch route's own (±20 s), so a clip fetched here and a clip fetched from the bench land in the same file. */} {pendingFetch.length > 0 && (
({ id, padBefore: 20, padAfter: 20 }))} />
)} {entries.map((e, at) => { // Anything that is not a CLIP renders generically. The timeline's // vocabulary is open -- one real manifest carries `scroll` and // `chart` beside its cards -- and a page that only knows two words // either crashes on the third or silently drops it. if (e.kind !== "clip") { return (
  • {e.id} {e.style ? `${e.kind} · ${e.style}` : e.kind} {e.heading ?? e.title ?? e.label ?? ""} {e.seconds != null && {e.seconds}s}
  • ); } // `endsSentence === null` means the source has no punctuation to // read, which is a different thing to say than "this cut is fine". const midSentence = e.endsSentence === false && !e.lockEnd && !e.lock; return (
  • {e.id} {e.video} {hms(e.start)}–{hms(e.end)} ({(e.end - e.start).toFixed(1)}s) {e.lock && locked} {!e.lock && e.lockStart && start pinned} {!e.lock && e.lockEnd && end pinned} {/* The PLAYER's question, and the walk's: is this clip watchable end to end right now. `data-cached` keeps the BUILD's -- the padded window it would fetch -- because the build chain reads it and they are different. */} {e.fetched ? ( cached ) : ( not fetched yet )} {e.segment && segment built} {!e.hasCues && ( no cues )} {midSentence && ends mid-sentence} {e.noPunctuation && source unpunctuated} {e.proposed && ( widener would move it )} {mixHref(e) ? ( mix ) : ( // Never a dead link that 400s: a clip with nothing // fetched has nothing to open. fetch it first )} bench → §{e.section ?? 0}
    {(showAll || midSentence || e.proposed) && e.quote && (

    “{String(e.quote).slice(0, 240)} {String(e.quote).length > 240 ? "…" : ""}”

    )} {midSentence && e.endCueText && (

    cut lands inside: “…{String(e.endCueText).trim().slice(-64)}”

    )} {e.proposed && (

    resolve-windows would make it {hms(e.proposed.start)}–{hms(e.proposed.end)} — set lock if this window is deliberate

    )}
  • ); })}
    {showAll ? "hide quotes" : "show every quote"}
    {/* --- corrections ------------------------------------------------ */} {/* Not a decision and not a defect in the CUT: a correction says the REPORT got something wrong -- wrong speaker, wrong addressee, wrong date -- and the fix belongs to the next sweep, not to this manifest. So it is collected here rather than filed in the inbox, in the same shape `umtool corrections` prints, because the list's real destination is somebody's next prompt. */} {corrections.length > 0 && (

    corrections for the next pass —{" "} {corrections.filter((c) => c.verdict === "incorrect").length} {corrections.some((c) => c.verdict !== "incorrect") && ` · ${corrections.filter((c) => c.verdict !== "incorrect").length} notes on confirmed clips`}

      {corrections.map((c) => (
    • {/* A note on a CONFIRMED clip is not a defect, and a reader who cannot tell the two apart goes off to fix a clip nobody complained about. */} {c.verdict !== "incorrect" && note · confirmed}{" "} {c.id} {" "} {c.channel}/{c.video} @ {hms(c.at)} {" "} the moment
      {c.text}
    • ))}

    umtool corrections {project.id} prints the same list as markdown, with the moment links, ready to paste into the next sweep.

    )} {/* --- revisions ------------------------------------------------- */}

    revisions — {snapshots.length}

    {snapshots.length === 0 ? (

    no snapshots. One copies video.manifest.json into{" "} revisions/; a build that stamps a deliverable aside takes one of the manifest that made it.

    ) : (
      {snapshots.map((sn, i) => { const d = diffs[i]; const summary = !d ? "unreadable" : d.same ? "same timeline as now" : Object.entries(d.counts).map(([k, n]) => `${n} ${k}`).join(" · "); return (
    • {sn.rel} {sn.legacy && legacy} {sn.label && !sn.legacy && {sn.label}} {fmtAgo(sn.mtimeMs)} {summary} {d && !d.same && (
                                {d.changes.map((c) => formatChange(c)).join("\n")}
                              
      )}
    • ); })}
    )}
    {/* --- exports --------------------------------------------------- */}

    export

    {variants.length === 0 ? (

    nothing built to export from — a TOC needs the build’s chapter offsets, and there is no chapters.ffmeta and no segments under{" "} out/

    ) : ( variants.map((v) => (
    {v} {EXPORT_FORMATS.map((f) => ( ))}
    )) )}
    {/* --- provenance, as written; prose under notes ------------------- */}

    provenance

    {scalars.map(([k, v]) => (
    {k}
    {typeof v === "string" && /^https?:\/\//.test(v) ? ( {v} ) : ( String(v) )}
    ))} {structured.map(([k, v]) => (
    {k}
    {JSON.stringify(v).slice(0, 200)}
    ))}
    {(prose.length > 0 || noteFiles.length > 0) && (

    notes

    {prose.map(([k, v]) => (
    {k}

    {v}

    ))} {noteFiles.map((n, i) => (
    {n}
    ))}
    )}
    ); }