commit e7f116699507834f88ed505ed71618a397400fd5
parent 3b741a45b76fc61f2c24ed57f337e2f2400fe189
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 28 Aug 2026 15:47:55 -0400
video page: one panel per operation
The video page had exactly one derived-data surface, and it was the digest's.
Diarization and both attribution operations wrote records that no per-video
page could show — for diarization that record is often the only surviving
evidence of audio the cleanup sweep has since deleted.
DigestPanel becomes DigestBody: the section, the heading and the freshness pill
move out to OperationPanel, which draws them once for every entry the registry
has. Everything else about the digest body is unchanged, labels included.
SpeakerBodies draws a diarization record's engine, models, threshold and window
seam, and an attribution record's method, model, chunk survival and named
speakers. Both attribution operations read the SAME attribution.json — one
read, memoized per request — so the pill differs between them and the body does
not; provenance.method is how a reader tells which lane wrote it.
operationState.ts is the one table from state to words, and stateCopy's
present / stale / missing rows are digest.spec.ts's contract verbatim.
`partial` deliberately contains "stale". `deferred` prints the kind's own hint,
which is a plural fragment kept as one copy rather than given a singular twin.
An operation whose feature is switched off but whose sidecar is on disk is
still shown, marked "switched off" — hiding it is how a record that cost audio
nobody has any more becomes invisible.
The page no longer re-derives the digest's freshness: no second target
resolution, no second per-section fold. And the no-block arm of the body switch
is the vision test made real — a fifth registry entry gets a panel, a state and
its outputs with nobody editing these files.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat:
8 files changed, 1189 insertions(+), 560 deletions(-)
diff --git a/editor/app/channels/[slug]/videos/[id]/components/DigestBody.tsx b/editor/app/channels/[slug]/videos/[id]/components/DigestBody.tsx
@@ -0,0 +1,488 @@
+"use client";
+
+// The per-video digest review surface — the thing that makes a validation run
+// readable.
+//
+// Until this existed the digest layer had nowhere for a human to LOOK at what it
+// produced: the channel Digest stage is a queue-and-count card by its own
+// admission, and the per-video page had no digest reference at all. A ~100-video
+// validation run cannot mean anything while its output is invisible, and
+// STATE.md's own lesson from this layer is that "the digest layer's failure
+// modes live at the wiring, not in the pure functions."
+//
+// So this panel leads with the things that are wrong rather than the things that
+// are right: warnings first, then provenance (WHICH configuration produced
+// this), then the items. During a bake-off-driven validation the provenance line
+// is the entire point — two videos can carry visually similar chapters from
+// completely different engines, models or prompt versions.
+//
+// THE FRAME IS NOW OperationPanel'S: the section, the heading and the freshness
+// pill are drawn once for every operation. This is the digest's BODY, and the
+// only thing about it that is digest-specific.
+
+import { useState, useTransition } from "react";
+import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
+import {
+ digestBucketAction,
+ saveDigestOverridesAction,
+ type DigestLaneChoice,
+} from "../../../digestActions";
+import type {
+ DigestChapter,
+ DigestSectionState,
+ DigestWarning,
+ EffectiveDigest,
+} from "yt-dlp-transcript-common/lib/digest";
+import { cancelJobAction } from "../../../../../jobs/actions";
+
+export type DigestBodyData = {
+ digest: EffectiveDigest;
+ hasMachineDigest: boolean;
+ // One row per section KIND the current settings ask for, so a section that
+ // was never generated is as visible as one that is stale. Computed on the
+ // server by digestSectionStates — the SAME fold the digest operation's
+ // state() counts, so this panel and the channel's work list cannot disagree.
+ sections: DigestSectionState[];
+ // What a regeneration would produce right now, for the "stale because…" line.
+ target: {
+ appId: string;
+ model: string;
+ promptVersion: number;
+ promptVariant?: string;
+ contextHash: string;
+ };
+ hasOverrides: boolean;
+ note: string;
+ remoteEnabled: boolean;
+};
+
+export function DigestBody({
+ slug,
+ videoId,
+ data,
+}: {
+ slug: string;
+ videoId: string;
+ data: DigestBodyData;
+}) {
+ const [lane, setLane] = useState<DigestLaneChoice>("local");
+ const { digest, sections, target } = data;
+
+ return (
+ <>
+ {data.digest.derivedFrom && (
+ <SharedFrom derivedFrom={data.digest.derivedFrom} />
+ )}
+
+ {!data.hasMachineDigest ? (
+ <p aria-label="digest empty" className="text-sm text-muted-foreground">
+ No digest has been generated for this video yet. Run one below, or
+ sweep the whole channel from the channel page's Digest stage.
+ </p>
+ ) : (
+ <>
+ <Warnings warnings={digest.warnings} />
+ <SectionProvenance sections={sections} target={target} />
+ <Chapters chapters={digest.chapters} />
+ <Tags tags={digest.tags} />
+ </>
+ )}
+
+ <OverridesEditor
+ slug={slug}
+ videoId={videoId}
+ chapters={digest.chapters}
+ note={data.note}
+ hasOverrides={data.hasOverrides}
+ />
+
+ <div className="flex flex-col gap-2 border-t border-border pt-3">
+ <StreamActionLog
+ trigger={() => digestBucketAction(slug, [videoId], lane)}
+ cancelAction={cancelJobAction}
+ buttonLabel="Regenerate digest"
+ runningLabel="Generating…"
+ label="Regenerate digest"
+ extraControls={
+ <label className="flex items-center gap-1 text-xs text-muted-foreground">
+ Lane
+ <select
+ value={lane}
+ onChange={(e) => setLane(e.target.value as DigestLaneChoice)}
+ aria-label="lane for Regenerate digest"
+ className="rounded border border-border bg-card px-1 py-0.5"
+ >
+ <option value="local">local (ollama)</option>
+ <option value="remote" disabled={!data.remoteEnabled}>
+ metered
+ {data.remoteEnabled ? "" : " — disabled on the Digest page"}
+ </option>
+ </select>
+ </label>
+ }
+ />
+ <p className="text-xs text-muted-foreground">
+ Regeneration only ever rewrites <code>ai-digest.json</code>. Hand
+ corrections live in <code>ai-digest.overrides.json</code> and are never
+ touched by it — which is what lets a correction survive a re-sweep of a
+ corpus too large to generate twice.
+ </p>
+ </div>
+ </>
+ );
+}
+
+// A borrowed digest must NEVER be presented as native. Sharing only happens at
+// near-zero measured offset, so the offset is shown as the reason it was
+// considered safe rather than left implicit.
+function SharedFrom({
+ derivedFrom,
+}: {
+ derivedFrom: NonNullable<EffectiveDigest["derivedFrom"]>;
+}) {
+ const [channelSlug, id] = derivedFrom.slug.split("/");
+ return (
+ <div
+ aria-label="digest shared from"
+ className="rounded border border-info/30 bg-info-soft p-2 text-sm"
+ >
+ <p>
+ <strong>Shared from a duplicate.</strong> This digest was generated for{" "}
+ <a
+ href={`/channels/${channelSlug}/videos/${id}`}
+ className="underline font-mono"
+ >
+ {derivedFrom.slug}
+ </a>
+ , the canonical member of its cluster, and copied here.
+ </p>
+ <p className="text-xs text-muted-foreground mt-1">
+ Cue timings were measured{" "}
+ <strong>{derivedFrom.offsetSeconds.toFixed(2)}s</strong> apart at the
+ sampled anchors, which is why placing it here was considered safe.
+ Regenerating the canonical member re-applies the share.
+ </p>
+ </div>
+ );
+}
+
+// Warnings lead. This is the array Phase 11a's review queue is built on, and
+// showing it per-video first is what makes a validation run readable: a section
+// generated from 3 of 5 chunks looks perfectly healthy until you read these.
+function Warnings({ warnings }: { warnings: DigestWarning[] }) {
+ if (warnings.length === 0) {
+ return (
+ <p aria-label="digest warnings empty" className="text-xs text-success">
+ No warnings — every chunk parsed and every item passed the guards.
+ </p>
+ );
+ }
+ const byCode = new Map<string, DigestWarning[]>();
+ for (const w of warnings) {
+ const list = byCode.get(w.code) ?? [];
+ list.push(w);
+ byCode.set(w.code, list);
+ }
+ return (
+ <div
+ aria-label="digest warnings"
+ className="rounded border border-warning/30 bg-warning-soft p-2"
+ >
+ <p className="text-sm font-medium">
+ {warnings.length} warning{warnings.length === 1 ? "" : "s"}
+ </p>
+ <ul className="mt-1 flex flex-col gap-1 text-xs">
+ {Array.from(byCode.entries()).map(([code, list]) => (
+ <li key={code} aria-label={`digest warning ${code}`}>
+ <code className="font-medium">{code}</code> × {list.length}
+ <span className="text-muted-foreground">
+ {" "}
+ ({list[0].section}
+ {list[0].chunk !== undefined ? `, chunk ${list[0].chunk}` : ""})
+ </span>
+ {list[0].value && (
+ <span className="text-muted-foreground">
+ {" "}
+ — first: <code>{list[0].value}</code>
+ </span>
+ )}
+ {list[0].detail && (
+ <span className="text-muted-foreground"> — {list[0].detail}</span>
+ )}
+ </li>
+ ))}
+ </ul>
+ </div>
+ );
+}
+
+// WHICH configuration produced this. During a bake-off-driven validation, two
+// videos can carry similar-looking chapters from entirely different engines.
+function SectionProvenance({
+ sections,
+ target,
+}: {
+ sections: DigestSectionState[];
+ target: DigestBodyData["target"];
+}) {
+ return (
+ <div aria-label="digest provenance" className="flex flex-col gap-2">
+ {sections.map((s) => (
+ <div
+ key={s.kind}
+ aria-label={`digest section ${s.kind}`}
+ className="rounded border border-border p-2 text-xs"
+ >
+ <div className="flex flex-wrap items-baseline gap-2">
+ <span className="text-sm font-medium">{s.kind}</span>
+ {!s.present ? (
+ <span className="text-muted-foreground">never generated</span>
+ ) : s.fresh ? (
+ <span className="text-success">current</span>
+ ) : (
+ <span className="text-warning">stale</span>
+ )}
+ </div>
+ {s.provenance ? (
+ <dl className="mt-1 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 font-mono">
+ <Row label="engine" value={s.provenance.appId} />
+ <Row
+ label="model"
+ value={
+ s.provenance.modelRequested &&
+ s.provenance.modelRequested !== s.provenance.model
+ ? `${s.provenance.modelRequested} → ${s.provenance.model}`
+ : s.provenance.model
+ }
+ />
+ <Row label="lane" value={s.provenance.lane} />
+ <Row
+ label="promptVersion"
+ value={String(s.provenance.promptVersion)}
+ />
+ <Row
+ label="promptVariant"
+ value={s.provenance.promptVariant ?? "(default)"}
+ />
+ <Row label="contextHash" value={s.provenance.contextHash} />
+ {s.provenance.chunks !== undefined && (
+ <Row
+ label="chunks"
+ value={`${s.provenance.chunksOk ?? "?"} of ${s.provenance.chunks} usable`}
+ />
+ )}
+ <Row label="generated" value={s.provenance.generatedAt} />
+ {s.provenance.costUsd !== undefined && (
+ <Row label="cost" value={`$${s.provenance.costUsd.toFixed(4)}`} />
+ )}
+ </dl>
+ ) : null}
+ {s.present && !s.fresh && (
+ <p className="mt-1 text-muted-foreground">
+ A regeneration would produce{" "}
+ <code>
+ {target.appId}/{target.model} v{target.promptVersion}
+ {target.promptVariant ? ` (${target.promptVariant})` : ""}
+ </code>
+ .
+ </p>
+ )}
+ </div>
+ ))}
+ </div>
+ );
+}
+
+function Row({ label, value }: { label: string; value: string }) {
+ return (
+ <>
+ <dt className="text-muted-foreground">{label}</dt>
+ <dd className="break-all">{value}</dd>
+ </>
+ );
+}
+
+function Chapters({ chapters }: { chapters: DigestChapter[] }) {
+ if (chapters.length === 0) {
+ return (
+ <p aria-label="digest chapters empty" className="text-sm text-muted-foreground">
+ No chapters.
+ </p>
+ );
+ }
+ return (
+ <div>
+ <h3 className="text-sm font-medium">Chapters ({chapters.length})</h3>
+ <ol aria-label="digest chapters" className="mt-1 flex flex-col gap-0.5">
+ {chapters.map((c) => (
+ <li
+ key={c.id}
+ aria-label={`digest chapter ${c.id}`}
+ className="flex gap-2 text-sm"
+ >
+ <span className="font-mono text-muted-foreground">{c.clock}</span>
+ <span>{c.title}</span>
+ {c.decidedBy === "human" && (
+ <span className="text-xs text-info">edited</span>
+ )}
+ </li>
+ ))}
+ </ol>
+ </div>
+ );
+}
+
+function Tags({ tags }: { tags: EffectiveDigest["tags"] }) {
+ if (tags.length === 0) return null;
+ return (
+ <div>
+ <h3 className="text-sm font-medium">Tags ({tags.length})</h3>
+ <ul aria-label="digest tags" className="mt-1 flex flex-wrap gap-1">
+ {tags.map((t) => (
+ <li
+ key={t.id}
+ className="rounded bg-muted px-1.5 py-0.5 text-xs"
+ >
+ {t.tag}
+ {t.decidedBy === "human" && <span className="text-info"> ✎</span>}
+ </li>
+ ))}
+ </ul>
+ </div>
+ );
+}
+
+// Corrections. Deliberately minimal: retitle a chapter, or suppress it. Those
+// are the two things a reviewer actually does, and both are id-keyed so a
+// regeneration that re-emits the same segmentation keeps honoring them.
+function OverridesEditor({
+ slug,
+ videoId,
+ chapters,
+ note,
+ hasOverrides,
+}: {
+ slug: string;
+ videoId: string;
+ chapters: DigestChapter[];
+ note: string;
+ hasOverrides: boolean;
+}) {
+ const [rows, setRows] = useState(() =>
+ chapters.map((c) => ({ id: c.id, title: c.title, enabled: true })),
+ );
+ const [noteText, setNoteText] = useState(note);
+ const [pending, startTransition] = useTransition();
+ const [result, setResult] = useState<string | null>(null);
+
+ // The panel re-renders from the server after a save (revalidatePath), so the
+ // row list can go stale against a regeneration that happened in between.
+ const chapterIds = chapters.map((c) => c.id).join(",");
+ const rowIds = rows.map((r) => r.id).join(",");
+ if (chapterIds !== rowIds) {
+ setRows(chapters.map((c) => ({ id: c.id, title: c.title, enabled: true })));
+ }
+
+ if (chapters.length === 0 && !hasOverrides) return null;
+
+ const save = (): void => {
+ setResult(null);
+ startTransition(async () => {
+ const edited = rows.filter((row, i) => {
+ const original = chapters[i];
+ return (
+ !original || row.title !== original.title || row.enabled === false
+ );
+ });
+ const res = await saveDigestOverridesAction(slug, videoId, {
+ chapters: edited.map((row) => ({
+ id: row.id,
+ // start/clock are patched in from the machine item by mergeItems — an
+ // override authored as (id + title) must not blank them.
+ start: chapters.find((c) => c.id === row.id)?.start ?? 0,
+ clock: chapters.find((c) => c.id === row.id)?.clock ?? "00:00:00",
+ title: row.title,
+ decidedBy: "human" as const,
+ ...(row.enabled ? {} : { enabled: false }),
+ })),
+ ...(noteText.trim() ? { note: noteText.trim() } : {}),
+ });
+ setResult(res.ok ? "Saved." : res.error);
+ });
+ };
+
+ return (
+ <details className="text-sm">
+ <summary className="cursor-pointer font-medium">
+ Correct this digest{hasOverrides ? " (edited)" : ""}
+ </summary>
+ <div className="mt-2 flex flex-col gap-2">
+ {rows.map((row, i) => (
+ <div key={row.id} className="flex items-center gap-2">
+ <input
+ type="checkbox"
+ checked={row.enabled}
+ aria-label={`keep chapter ${row.id}`}
+ onChange={(e) =>
+ setRows((prev) =>
+ prev.map((r, j) =>
+ j === i ? { ...r, enabled: e.target.checked } : r,
+ ),
+ )
+ }
+ />
+ <span className="font-mono text-xs text-muted-foreground">
+ {chapters[i]?.clock}
+ </span>
+ <input
+ type="text"
+ value={row.title}
+ aria-label={`title for chapter ${row.id}`}
+ onChange={(e) =>
+ setRows((prev) =>
+ prev.map((r, j) =>
+ j === i ? { ...r, title: e.target.value } : r,
+ ),
+ )
+ }
+ className="flex-1 rounded border border-border bg-card px-2 py-1 text-sm"
+ />
+ </div>
+ ))}
+ <label className="flex flex-col gap-1">
+ <span className="text-xs font-medium">Note (why)</span>
+ <input
+ type="text"
+ value={noteText}
+ aria-label="digest override note"
+ onChange={(e) => setNoteText(e.target.value)}
+ className="rounded border border-border bg-card px-2 py-1 text-sm"
+ />
+ </label>
+ <div className="flex items-center gap-3">
+ <button
+ type="button"
+ onClick={save}
+ disabled={pending}
+ className="px-3 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50"
+ >
+ {pending ? "Saving…" : "Save corrections"}
+ </button>
+ {result && (
+ <span
+ role="status"
+ className="text-sm text-muted-foreground"
+ >
+ {result}
+ </span>
+ )}
+ </div>
+ <p className="text-xs text-muted-foreground">
+ Unchecking a chapter suppresses it without deleting it, so a
+ regeneration that re-emits the same item does not resurrect something
+ you rejected.
+ </p>
+ </div>
+ </details>
+ );
+}
diff --git a/editor/app/channels/[slug]/videos/[id]/components/DigestPanel.tsx b/editor/app/channels/[slug]/videos/[id]/components/DigestPanel.tsx
@@ -1,515 +0,0 @@
-"use client";
-
-// The per-video digest review surface — the thing that makes a validation run
-// readable.
-//
-// Until this existed the digest layer had nowhere for a human to LOOK at what it
-// produced: the channel Digest stage is a queue-and-count card by its own
-// admission, and the per-video page had no digest reference at all. A ~100-video
-// validation run cannot mean anything while its output is invisible, and
-// STATE.md's own lesson from this layer is that "the digest layer's failure
-// modes live at the wiring, not in the pure functions."
-//
-// So this panel leads with the things that are wrong rather than the things that
-// are right: warnings first, then provenance (WHICH configuration produced
-// this), then the items. During a bake-off-driven validation the provenance line
-// is the entire point — two videos can carry visually similar chapters from
-// completely different engines, models or prompt versions.
-
-import { useState, useTransition } from "react";
-import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
-import {
- digestBucketAction,
- saveDigestOverridesAction,
- type DigestLaneChoice,
-} from "../../../digestActions";
-import type {
- DigestChapter,
- DigestProvenance,
- DigestSectionKind,
- DigestWarning,
- EffectiveDigest,
-} from "yt-dlp-transcript-common/lib/digest";
-import { cancelJobAction } from "../../../../../jobs/actions";
-
-// One row per section KIND the current settings ask for, so a section that was
-// never generated is as visible as one that is stale. Computed on the server —
-// freshness needs the resolved target, which reaches settings and disk.
-export type DigestSectionState = {
- kind: DigestSectionKind;
- present: boolean;
- fresh: boolean;
- provenance?: DigestProvenance;
-};
-
-export type DigestPanelData = {
- digest: EffectiveDigest;
- hasMachineDigest: boolean;
- sections: DigestSectionState[];
- // What a regeneration would produce right now, for the "stale because…" line.
- target: {
- appId: string;
- model: string;
- promptVersion: number;
- promptVariant?: string;
- contextHash: string;
- };
- hasOverrides: boolean;
- note: string;
- remoteEnabled: boolean;
-};
-
-export function DigestPanel({
- slug,
- videoId,
- data,
-}: {
- slug: string;
- videoId: string;
- data: DigestPanelData;
-}) {
- const [lane, setLane] = useState<DigestLaneChoice>("local");
- const { digest, sections, target } = data;
- const anyStale = sections.some((s) => !s.fresh);
-
- return (
- <section
- aria-label="digest panel"
- className="flex flex-col gap-4 rounded border border-border p-3"
- >
- <div className="flex flex-wrap items-baseline justify-between gap-2">
- <h2 className="text-lg font-semibold">AI digest</h2>
- <span
- aria-label="digest freshness"
- className={
- !data.hasMachineDigest
- ? "text-xs text-muted-foreground"
- : anyStale
- ? "text-xs font-medium text-warning"
- : "text-xs font-medium text-success"
- }
- >
- {!data.hasMachineDigest
- ? "not generated"
- : anyStale
- ? "stale — regenerating would replace this"
- : "current"}
- </span>
- </div>
-
- {data.digest.derivedFrom && (
- <SharedFrom derivedFrom={data.digest.derivedFrom} />
- )}
-
- {!data.hasMachineDigest ? (
- <p aria-label="digest empty" className="text-sm text-muted-foreground">
- No digest has been generated for this video yet. Run one below, or
- sweep the whole channel from the channel page's Digest stage.
- </p>
- ) : (
- <>
- <Warnings warnings={digest.warnings} />
- <SectionProvenance sections={sections} target={target} />
- <Chapters chapters={digest.chapters} />
- <Tags tags={digest.tags} />
- </>
- )}
-
- <OverridesEditor
- slug={slug}
- videoId={videoId}
- chapters={digest.chapters}
- note={data.note}
- hasOverrides={data.hasOverrides}
- />
-
- <div className="flex flex-col gap-2 border-t border-border pt-3">
- <StreamActionLog
- trigger={() => digestBucketAction(slug, [videoId], lane)}
- cancelAction={cancelJobAction}
- buttonLabel="Regenerate digest"
- runningLabel="Generating…"
- label="Regenerate digest"
- extraControls={
- <label className="flex items-center gap-1 text-xs text-muted-foreground">
- Lane
- <select
- value={lane}
- onChange={(e) => setLane(e.target.value as DigestLaneChoice)}
- aria-label="lane for Regenerate digest"
- className="rounded border border-border bg-card px-1 py-0.5"
- >
- <option value="local">local (ollama)</option>
- <option value="remote" disabled={!data.remoteEnabled}>
- metered
- {data.remoteEnabled ? "" : " — disabled on the Digest page"}
- </option>
- </select>
- </label>
- }
- />
- <p className="text-xs text-muted-foreground">
- Regeneration only ever rewrites <code>ai-digest.json</code>. Hand
- corrections live in <code>ai-digest.overrides.json</code> and are never
- touched by it — which is what lets a correction survive a re-sweep of a
- corpus too large to generate twice.
- </p>
- </div>
- </section>
- );
-}
-
-// A borrowed digest must NEVER be presented as native. Sharing only happens at
-// near-zero measured offset, so the offset is shown as the reason it was
-// considered safe rather than left implicit.
-function SharedFrom({
- derivedFrom,
-}: {
- derivedFrom: NonNullable<EffectiveDigest["derivedFrom"]>;
-}) {
- const [channelSlug, id] = derivedFrom.slug.split("/");
- return (
- <div
- aria-label="digest shared from"
- className="rounded border border-info/30 bg-info-soft p-2 text-sm"
- >
- <p>
- <strong>Shared from a duplicate.</strong> This digest was generated for{" "}
- <a
- href={`/channels/${channelSlug}/videos/${id}`}
- className="underline font-mono"
- >
- {derivedFrom.slug}
- </a>
- , the canonical member of its cluster, and copied here.
- </p>
- <p className="text-xs text-muted-foreground mt-1">
- Cue timings were measured{" "}
- <strong>{derivedFrom.offsetSeconds.toFixed(2)}s</strong> apart at the
- sampled anchors, which is why placing it here was considered safe.
- Regenerating the canonical member re-applies the share.
- </p>
- </div>
- );
-}
-
-// Warnings lead. This is the array Phase 11a's review queue is built on, and
-// showing it per-video first is what makes a validation run readable: a section
-// generated from 3 of 5 chunks looks perfectly healthy until you read these.
-function Warnings({ warnings }: { warnings: DigestWarning[] }) {
- if (warnings.length === 0) {
- return (
- <p aria-label="digest warnings empty" className="text-xs text-success">
- No warnings — every chunk parsed and every item passed the guards.
- </p>
- );
- }
- const byCode = new Map<string, DigestWarning[]>();
- for (const w of warnings) {
- const list = byCode.get(w.code) ?? [];
- list.push(w);
- byCode.set(w.code, list);
- }
- return (
- <div
- aria-label="digest warnings"
- className="rounded border border-warning/30 bg-warning-soft p-2"
- >
- <p className="text-sm font-medium">
- {warnings.length} warning{warnings.length === 1 ? "" : "s"}
- </p>
- <ul className="mt-1 flex flex-col gap-1 text-xs">
- {Array.from(byCode.entries()).map(([code, list]) => (
- <li key={code} aria-label={`digest warning ${code}`}>
- <code className="font-medium">{code}</code> × {list.length}
- <span className="text-muted-foreground">
- {" "}
- ({list[0].section}
- {list[0].chunk !== undefined ? `, chunk ${list[0].chunk}` : ""})
- </span>
- {list[0].value && (
- <span className="text-muted-foreground">
- {" "}
- — first: <code>{list[0].value}</code>
- </span>
- )}
- {list[0].detail && (
- <span className="text-muted-foreground"> — {list[0].detail}</span>
- )}
- </li>
- ))}
- </ul>
- </div>
- );
-}
-
-// WHICH configuration produced this. During a bake-off-driven validation, two
-// videos can carry similar-looking chapters from entirely different engines.
-function SectionProvenance({
- sections,
- target,
-}: {
- sections: DigestSectionState[];
- target: DigestPanelData["target"];
-}) {
- return (
- <div aria-label="digest provenance" className="flex flex-col gap-2">
- {sections.map((s) => (
- <div
- key={s.kind}
- aria-label={`digest section ${s.kind}`}
- className="rounded border border-border p-2 text-xs"
- >
- <div className="flex flex-wrap items-baseline gap-2">
- <span className="text-sm font-medium">{s.kind}</span>
- {!s.present ? (
- <span className="text-muted-foreground">never generated</span>
- ) : s.fresh ? (
- <span className="text-success">current</span>
- ) : (
- <span className="text-warning">stale</span>
- )}
- </div>
- {s.provenance ? (
- <dl className="mt-1 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 font-mono">
- <Row label="engine" value={s.provenance.appId} />
- <Row
- label="model"
- value={
- s.provenance.modelRequested &&
- s.provenance.modelRequested !== s.provenance.model
- ? `${s.provenance.modelRequested} → ${s.provenance.model}`
- : s.provenance.model
- }
- />
- <Row label="lane" value={s.provenance.lane} />
- <Row
- label="promptVersion"
- value={String(s.provenance.promptVersion)}
- />
- <Row
- label="promptVariant"
- value={s.provenance.promptVariant ?? "(default)"}
- />
- <Row label="contextHash" value={s.provenance.contextHash} />
- {s.provenance.chunks !== undefined && (
- <Row
- label="chunks"
- value={`${s.provenance.chunksOk ?? "?"} of ${s.provenance.chunks} usable`}
- />
- )}
- <Row label="generated" value={s.provenance.generatedAt} />
- {s.provenance.costUsd !== undefined && (
- <Row label="cost" value={`$${s.provenance.costUsd.toFixed(4)}`} />
- )}
- </dl>
- ) : null}
- {s.present && !s.fresh && (
- <p className="mt-1 text-muted-foreground">
- A regeneration would produce{" "}
- <code>
- {target.appId}/{target.model} v{target.promptVersion}
- {target.promptVariant ? ` (${target.promptVariant})` : ""}
- </code>
- .
- </p>
- )}
- </div>
- ))}
- </div>
- );
-}
-
-function Row({ label, value }: { label: string; value: string }) {
- return (
- <>
- <dt className="text-muted-foreground">{label}</dt>
- <dd className="break-all">{value}</dd>
- </>
- );
-}
-
-function Chapters({ chapters }: { chapters: DigestChapter[] }) {
- if (chapters.length === 0) {
- return (
- <p aria-label="digest chapters empty" className="text-sm text-muted-foreground">
- No chapters.
- </p>
- );
- }
- return (
- <div>
- <h3 className="text-sm font-medium">Chapters ({chapters.length})</h3>
- <ol aria-label="digest chapters" className="mt-1 flex flex-col gap-0.5">
- {chapters.map((c) => (
- <li
- key={c.id}
- aria-label={`digest chapter ${c.id}`}
- className="flex gap-2 text-sm"
- >
- <span className="font-mono text-muted-foreground">{c.clock}</span>
- <span>{c.title}</span>
- {c.decidedBy === "human" && (
- <span className="text-xs text-info">edited</span>
- )}
- </li>
- ))}
- </ol>
- </div>
- );
-}
-
-function Tags({ tags }: { tags: EffectiveDigest["tags"] }) {
- if (tags.length === 0) return null;
- return (
- <div>
- <h3 className="text-sm font-medium">Tags ({tags.length})</h3>
- <ul aria-label="digest tags" className="mt-1 flex flex-wrap gap-1">
- {tags.map((t) => (
- <li
- key={t.id}
- className="rounded bg-muted px-1.5 py-0.5 text-xs"
- >
- {t.tag}
- {t.decidedBy === "human" && <span className="text-info"> ✎</span>}
- </li>
- ))}
- </ul>
- </div>
- );
-}
-
-// Corrections. Deliberately minimal: retitle a chapter, or suppress it. Those
-// are the two things a reviewer actually does, and both are id-keyed so a
-// regeneration that re-emits the same segmentation keeps honoring them.
-function OverridesEditor({
- slug,
- videoId,
- chapters,
- note,
- hasOverrides,
-}: {
- slug: string;
- videoId: string;
- chapters: DigestChapter[];
- note: string;
- hasOverrides: boolean;
-}) {
- const [rows, setRows] = useState(() =>
- chapters.map((c) => ({ id: c.id, title: c.title, enabled: true })),
- );
- const [noteText, setNoteText] = useState(note);
- const [pending, startTransition] = useTransition();
- const [result, setResult] = useState<string | null>(null);
-
- // The panel re-renders from the server after a save (revalidatePath), so the
- // row list can go stale against a regeneration that happened in between.
- const chapterIds = chapters.map((c) => c.id).join(",");
- const rowIds = rows.map((r) => r.id).join(",");
- if (chapterIds !== rowIds) {
- setRows(chapters.map((c) => ({ id: c.id, title: c.title, enabled: true })));
- }
-
- if (chapters.length === 0 && !hasOverrides) return null;
-
- const save = (): void => {
- setResult(null);
- startTransition(async () => {
- const edited = rows.filter((row, i) => {
- const original = chapters[i];
- return (
- !original || row.title !== original.title || row.enabled === false
- );
- });
- const res = await saveDigestOverridesAction(slug, videoId, {
- chapters: edited.map((row) => ({
- id: row.id,
- // start/clock are patched in from the machine item by mergeItems — an
- // override authored as (id + title) must not blank them.
- start: chapters.find((c) => c.id === row.id)?.start ?? 0,
- clock: chapters.find((c) => c.id === row.id)?.clock ?? "00:00:00",
- title: row.title,
- decidedBy: "human" as const,
- ...(row.enabled ? {} : { enabled: false }),
- })),
- ...(noteText.trim() ? { note: noteText.trim() } : {}),
- });
- setResult(res.ok ? "Saved." : res.error);
- });
- };
-
- return (
- <details className="text-sm">
- <summary className="cursor-pointer font-medium">
- Correct this digest{hasOverrides ? " (edited)" : ""}
- </summary>
- <div className="mt-2 flex flex-col gap-2">
- {rows.map((row, i) => (
- <div key={row.id} className="flex items-center gap-2">
- <input
- type="checkbox"
- checked={row.enabled}
- aria-label={`keep chapter ${row.id}`}
- onChange={(e) =>
- setRows((prev) =>
- prev.map((r, j) =>
- j === i ? { ...r, enabled: e.target.checked } : r,
- ),
- )
- }
- />
- <span className="font-mono text-xs text-muted-foreground">
- {chapters[i]?.clock}
- </span>
- <input
- type="text"
- value={row.title}
- aria-label={`title for chapter ${row.id}`}
- onChange={(e) =>
- setRows((prev) =>
- prev.map((r, j) =>
- j === i ? { ...r, title: e.target.value } : r,
- ),
- )
- }
- className="flex-1 rounded border border-border bg-card px-2 py-1 text-sm"
- />
- </div>
- ))}
- <label className="flex flex-col gap-1">
- <span className="text-xs font-medium">Note (why)</span>
- <input
- type="text"
- value={noteText}
- aria-label="digest override note"
- onChange={(e) => setNoteText(e.target.value)}
- className="rounded border border-border bg-card px-2 py-1 text-sm"
- />
- </label>
- <div className="flex items-center gap-3">
- <button
- type="button"
- onClick={save}
- disabled={pending}
- className="px-3 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50"
- >
- {pending ? "Saving…" : "Save corrections"}
- </button>
- {result && (
- <span
- role="status"
- className="text-sm text-muted-foreground"
- >
- {result}
- </span>
- )}
- </div>
- <p className="text-xs text-muted-foreground">
- Unchecking a chapter suppresses it without deleting it, so a
- regeneration that re-emits the same item does not resurrect something
- you rejected.
- </p>
- </div>
- </details>
- );
-}
diff --git a/editor/app/channels/[slug]/videos/[id]/components/OperationPanel.tsx b/editor/app/channels/[slug]/videos/[id]/components/OperationPanel.tsx
@@ -0,0 +1,123 @@
+"use client";
+
+// ONE FRAME FOR EVERY OPERATION on the video page: the section, the heading,
+// the freshness pill, and — when the feature is switched off — the word that
+// says so. The body inside it is chosen by the operation's `settingsBlock`.
+//
+// THE VISION TEST. The IA's claim for this registry is that a new operation is
+// an entry in a table and nothing else: it should light every surface without a
+// new page. The `undefined` arm below is what makes that true here — an
+// operation registered with no settings block still gets a panel, a state and
+// its output list, with nobody editing this file.
+//
+// TYPE-ONLY from lib/operations, always: that module imports the digest,
+// diarization and attribution servers, all of which reach node:fs. A value
+// import would pull the filesystem into this client bundle — the same lesson
+// pauseControl.tsx paid for. Labels and hints reach here as PROPS.
+
+import { DigestBody, type DigestBodyData } from "./DigestBody";
+import {
+ AttributionBody,
+ DiarizationBody,
+ type AttributionBodyData,
+ type DiarizationBodyData,
+} from "./SpeakerBodies";
+import {
+ stateCopy,
+ stateTone,
+ type OperationPanelView,
+} from "./operationState";
+
+export type OperationPanelBody =
+ | { block: "digest"; data: DigestBodyData }
+ | { block: "diarization"; data: DiarizationBodyData }
+ | { block: "attribution"; data: AttributionBodyData }
+ // An operation with no settings block: the outputs list alone.
+ | { block: undefined };
+
+const TONE_CLASS = {
+ success: "text-xs font-medium text-success",
+ warning: "text-xs font-medium text-warning",
+ muted: "text-xs text-muted-foreground",
+} as const;
+
+export function OperationPanel({
+ slug,
+ videoId,
+ view,
+ body,
+}: {
+ slug: string;
+ videoId: string;
+ view: OperationPanelView;
+ body: OperationPanelBody;
+}) {
+ return (
+ <section
+ aria-label={`${view.id} panel`}
+ className="flex flex-col gap-4 rounded border border-border p-3"
+ >
+ <div className="flex flex-wrap items-baseline justify-between gap-2">
+ <h2 className="text-lg font-semibold">{view.label}</h2>
+ <div className="flex flex-wrap items-baseline gap-2">
+ {/* A SHOWN-BUT-DISABLED operation is a real case: a sidecar captured
+ before the feature was switched off still has to be visible, and
+ without this word its pill would read "stale — regenerating would
+ replace this" beside nothing that can regenerate it. */}
+ {!view.enabled && (
+ <span
+ aria-label={`${view.id} off`}
+ className="text-xs text-muted-foreground"
+ >
+ switched off
+ </span>
+ )}
+ <span
+ aria-label={`${view.id} freshness`}
+ className={TONE_CLASS[stateTone(view.state)]}
+ >
+ {stateCopy(view)}
+ </span>
+ </div>
+ </div>
+ <Body slug={slug} videoId={videoId} view={view} body={body} />
+ </section>
+ );
+}
+
+// EXHAUSTIVE, the settingsFormFor precedent: a new block has to be named in
+// OperationSettingsBlock and then handled here, rather than silently rendering
+// nothing.
+function Body({
+ slug,
+ videoId,
+ view,
+ body,
+}: {
+ slug: string;
+ videoId: string;
+ view: OperationPanelView;
+ body: OperationPanelBody;
+}) {
+ switch (body.block) {
+ case "digest":
+ return <DigestBody slug={slug} videoId={videoId} data={body.data} />;
+ case "diarization":
+ return <DiarizationBody data={body.data} />;
+ case "attribution":
+ return <AttributionBody data={body.data} />;
+ case undefined:
+ return (
+ <ul
+ aria-label={`${view.id} outputs`}
+ className="flex flex-col gap-0.5 text-sm"
+ >
+ {view.outputs.map((o) => (
+ <li key={o.name}>
+ <code>{o.name}</code> — {o.present ? "present" : "absent"}
+ </li>
+ ))}
+ </ul>
+ );
+ }
+}
diff --git a/editor/app/channels/[slug]/videos/[id]/components/SpeakerBodies.tsx b/editor/app/channels/[slug]/videos/[id]/components/SpeakerBodies.tsx
@@ -0,0 +1,205 @@
+"use client";
+
+// The bodies for the three speaker operations — what a diarization or an
+// attribution record actually says about this video.
+//
+// Until this existed the speaker layer had the problem the digest layer had
+// before its panel: sidecars on disk with nowhere for a human to look at them.
+// diarization.json is produced from audio that the cleanup sweep then deletes,
+// so the record is frequently the ONLY surviving evidence of what was heard —
+// and it was invisible per video.
+//
+// BOTH ATTRIBUTION OPERATIONS SHARE attribution.json (lib/operations.ts's
+// two-kinds-one-file rule). So `attribution-diarized` and `attribution-text`
+// draw the SAME record with their own state(): the pill differs, the body does
+// not, and `provenance.method` is how a reader tells which lane wrote it.
+//
+// TYPE-ONLY imports from the common lib modules named here: lib/diarization.ts
+// and lib/attribution.ts have no imports at all and are safe for values, but
+// nothing in this file needs one.
+
+import type { DiarizationEngine } from "yt-dlp-transcript-common/lib/diarization";
+import type { AttributionProvenance } from "yt-dlp-transcript-common/lib/attribution";
+
+// Projected on the server from DiarizationRecord — `turns` as a COUNT, because
+// a long recording has thousands of them and none is worth a row here.
+export type DiarizationBodyData = {
+ record: {
+ generatedAt: string;
+ audioSeconds?: number;
+ durationMs?: number;
+ speakers: number;
+ turns: number;
+ engine: DiarizationEngine;
+ windowing?: {
+ windows: number;
+ windowSeconds: number;
+ overlapSeconds: number;
+ };
+ } | null;
+};
+
+// Projected from AttributionRecord. `segments` and `warnings` are counts for
+// the same reason; the speakers ARE the content and are listed.
+export type AttributionBodyData = {
+ record: {
+ generatedAt: string;
+ speakers: {
+ index: number;
+ label: string;
+ cluster?: number;
+ confidence?: number;
+ seconds?: number;
+ }[];
+ segments: number;
+ warnings: number;
+ provenance: AttributionProvenance;
+ } | null;
+};
+
+export function DiarizationBody({ data }: { data: DiarizationBodyData }) {
+ const record = data.record;
+ if (!record) {
+ return (
+ <p aria-label="diarization empty" className="text-sm text-muted-foreground">
+ No speaker turns have been captured for this video.
+ </p>
+ );
+ }
+ const e = record.engine;
+ return (
+ <>
+ {/* WHICH configuration produced this, for the same reason the digest's
+ provenance block exists: swapping the engine, a model or the
+ clustering threshold changes the answer, and after the audio is gone
+ this record is the only thing that can say which one ran. */}
+ <dl
+ aria-label="diarization provenance"
+ className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs font-mono"
+ >
+ <Row label="engine" value={e.engine} />
+ {e.segmentationModel && (
+ <Row label="segmentationModel" value={e.segmentationModel} />
+ )}
+ {e.embeddingModel && (
+ <Row label="embeddingModel" value={e.embeddingModel} />
+ )}
+ {e.model && <Row label="model" value={e.model} />}
+ {e.version && <Row label="version" value={e.version} />}
+ {e.threshold !== undefined && (
+ <Row label="threshold" value={String(e.threshold)} />
+ )}
+ <Row label="generated" value={record.generatedAt} />
+ {record.audioSeconds !== undefined && (
+ <Row label="audio" value={clock(record.audioSeconds)} />
+ )}
+ {record.durationMs !== undefined && (
+ <Row label="took" value={`${Math.round(record.durationMs / 1000)}s`} />
+ )}
+ {record.windowing && (
+ <Row
+ label="windows"
+ value={`${record.windowing.windows} × ${record.windowing.windowSeconds}s, ${record.windowing.overlapSeconds}s overlap`}
+ />
+ )}
+ </dl>
+ <p aria-label="diarization summary" className="text-sm">
+ {record.speakers} speakers · {record.turns} turns
+ </p>
+ </>
+ );
+}
+
+export function AttributionBody({ data }: { data: AttributionBodyData }) {
+ const record = data.record;
+ if (!record) {
+ return (
+ <p aria-label="attribution empty" className="text-sm text-muted-foreground">
+ No speaker names for this video yet.
+ </p>
+ );
+ }
+ const p = record.provenance;
+ return (
+ <>
+ <dl
+ aria-label="attribution provenance"
+ className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs font-mono"
+ >
+ {/* The row that says which of the two lanes wrote this file. */}
+ <Row label="method" value={p.method} />
+ <Row label="engine" value={p.appId} />
+ <Row
+ label="model"
+ value={
+ p.modelRequested && p.modelRequested !== p.model
+ ? `${p.modelRequested} → ${p.model}`
+ : p.model
+ }
+ />
+ <Row label="promptVersion" value={String(p.promptVersion)} />
+ {p.transcriptSource && (
+ <Row label="transcriptSource" value={p.transcriptSource} />
+ )}
+ {p.diarizationGeneratedAt && (
+ <Row
+ label="diarizationGeneratedAt"
+ value={p.diarizationGeneratedAt}
+ />
+ )}
+ {p.chunks !== undefined && (
+ <Row
+ label="chunks"
+ value={`${p.chunksOk ?? "?"} of ${p.chunks} usable`}
+ />
+ )}
+ <Row label="generated" value={p.generatedAt} />
+ {p.costUsd !== undefined && (
+ <Row label="cost" value={`$${p.costUsd.toFixed(4)}`} />
+ )}
+ </dl>
+ <ul
+ aria-label="attribution speakers"
+ className="flex flex-col gap-0.5 text-sm"
+ >
+ {record.speakers.map((s) => (
+ <li key={s.index} aria-label={`attribution speaker ${s.index}`}>
+ <span className="font-medium">{s.label}</span>
+ {s.cluster !== undefined && (
+ <span className="text-muted-foreground"> · cluster {s.cluster}</span>
+ )}
+ {s.confidence !== undefined && (
+ <span className="text-muted-foreground">
+ {" "}
+ · {(s.confidence * 100).toFixed(0)}% confident
+ </span>
+ )}
+ {s.seconds !== undefined && (
+ <span className="text-muted-foreground">
+ {" "}
+ · {clock(s.seconds)}
+ </span>
+ )}
+ </li>
+ ))}
+ </ul>
+ <p aria-label="attribution summary" className="text-sm">
+ {record.segments} segments · {record.warnings} warnings
+ </p>
+ </>
+ );
+}
+
+function Row({ label, value }: { label: string; value: string }) {
+ return (
+ <>
+ <dt className="text-muted-foreground">{label}</dt>
+ <dd className="break-all">{value}</dd>
+ </>
+ );
+}
+
+function clock(seconds: number): string {
+ const s = Math.max(0, Math.round(seconds));
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
+}
diff --git a/editor/app/channels/[slug]/videos/[id]/components/operationState.test.ts b/editor/app/channels/[slug]/videos/[id]/components/operationState.test.ts
@@ -0,0 +1,95 @@
+// Run with: node_modules/.bin/tsx --test "app/**/*.test.ts" (from editor/).
+
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ stateCopy,
+ stateTone,
+ type OperationPanelView,
+} from "./operationState";
+
+function view(over: Partial<OperationPanelView> = {}): OperationPanelView {
+ return {
+ id: "digest",
+ label: "Digest",
+ group: "digest",
+ state: "present",
+ enabled: true,
+ outputs: [],
+ dependsOn: [],
+ ...over,
+ };
+}
+
+test("every state has a word, and the three digest.spec.ts pins are literal", () => {
+ // These three are e2e contract: digest.spec.ts asserts "not generated" and
+ // "current" as exact text and toContainText("stale") for the third.
+ assert.equal(stateCopy(view({ state: "missing" })), "not generated");
+ assert.equal(stateCopy(view({ state: "present" })), "current");
+ assert.equal(
+ stateCopy(view({ state: "stale" })),
+ "stale — regenerating would replace this",
+ );
+ // `partial` contains "stale" too, so digest.spec.ts's toContainText passes
+ // whether a config change leaves the video wholly stale or part-done.
+ const partial = stateCopy(view({ state: "partial" }));
+ assert.equal(
+ partial,
+ "partly stale — regenerating would replace the stale sections",
+ );
+ assert.ok(partial.includes("stale"));
+
+ assert.equal(
+ stateCopy(view({ state: "missing-input" })),
+ "input gone — needs media",
+ );
+ assert.equal(stateCopy(view({ state: "not-applicable" })), "not applicable");
+});
+
+test("deferred carries the kind's own hint, verbatim, and stands alone without one", () => {
+ assert.equal(
+ stateCopy(
+ view({
+ state: "deferred",
+ deferredHint: "waiting on a normalized transcript",
+ }),
+ ),
+ "held — waiting on a normalized transcript",
+ );
+ assert.equal(stateCopy(view({ state: "deferred" })), "held");
+});
+
+test("blocked names what it is waiting for, one or several", () => {
+ assert.equal(
+ stateCopy(
+ view({
+ state: "blocked",
+ dependsOn: [{ id: "diarization", label: "Speaker diarization" }],
+ }),
+ ),
+ "waiting on Speaker diarization",
+ );
+ assert.equal(
+ stateCopy(
+ view({
+ state: "blocked",
+ dependsOn: [
+ { id: "diarization", label: "Speaker diarization" },
+ { id: "transcription", label: "Transcription" },
+ ],
+ }),
+ ),
+ "waiting on Speaker diarization and Transcription",
+ );
+});
+
+test("tone: only a present record is success, and only a state an operator can act on is warning", () => {
+ assert.equal(stateTone("present"), "success");
+ assert.equal(stateTone("stale"), "warning");
+ assert.equal(stateTone("partial"), "warning");
+ assert.equal(stateTone("missing-input"), "warning");
+ assert.equal(stateTone("missing"), "muted");
+ assert.equal(stateTone("deferred"), "muted");
+ assert.equal(stateTone("blocked"), "muted");
+ assert.equal(stateTone("not-applicable"), "muted");
+});
diff --git a/editor/app/channels/[slug]/videos/[id]/components/operationState.ts b/editor/app/channels/[slug]/videos/[id]/components/operationState.ts
@@ -0,0 +1,88 @@
+// THE ONE TABLE that turns an operation's state into words on the video page.
+//
+// No directive: pure, and imported by both the server component that builds a
+// panel and the client component that draws it. It reaches lib/operations only
+// for TYPES — that module imports node:fs through the digest, diarization and
+// attribution servers, so a value import here would drag the whole filesystem
+// graph into a "use client" bundle.
+//
+// The present / stale / missing rows are digest.spec.ts's contract ("not
+// generated", "current", and a stale string that must contain "stale"), so
+// those three strings do not move without that spec moving with them.
+
+import type {
+ OperationClassification,
+ OperationGroup,
+ OperationSettingsBlock,
+} from "yt-dlp-transcript-common/lib/operations";
+
+// The serializable half of VideoOperationView — what crosses to the client.
+// No `target`: it is an erased `unknown` the registry narrows per entry, and
+// the only consumer that narrows it is the digest body, on the server.
+export type OperationPanelView = {
+ id: string;
+ label: string;
+ group: OperationGroup;
+ settingsBlock?: OperationSettingsBlock;
+ state: OperationClassification;
+ enabled: boolean;
+ outputs: { name: string; present: boolean }[];
+ // The kind's own words for what a deferred video is waiting for. Reaches the
+ // client as a PROP, never by reading the registry.
+ deferredHint?: string;
+ dependsOn: { id: string; label: string }[];
+};
+
+export function stateCopy(view: OperationPanelView): string {
+ switch (view.state) {
+ case "present":
+ return "current";
+ case "stale":
+ return "stale — regenerating would replace this";
+ // `partial` deliberately CONTAINS "stale": a digest with some fresh
+ // sections and some superseded ones is a fraction of the cost of a full
+ // regeneration, and saying so is why the state exists — but a reader (and
+ // digest.spec.ts's toContainText("stale")) must still see the same word.
+ case "partial":
+ return "partly stale — regenerating would replace the stale sections";
+ case "missing":
+ return "not generated";
+ case "missing-input":
+ return "input gone — needs media";
+ // "held — " plus the kind's OWN hint, verbatim. The hints are plural
+ // fragments completing "N videos are …" and are kept that way on purpose:
+ // one copy of a sentence cannot drift from itself, and operations.test.ts
+ // pins the digest one. A singular twin would be a second copy.
+ case "deferred":
+ return view.deferredHint ? `held — ${view.deferredHint}` : "held";
+ case "blocked":
+ return `waiting on ${joinLabels(view.dependsOn.map((d) => d.label))}`;
+ case "not-applicable":
+ return "not applicable";
+ }
+}
+
+export function stateTone(
+ state: OperationClassification,
+): "success" | "warning" | "muted" {
+ switch (state) {
+ case "present":
+ return "success";
+ case "stale":
+ case "partial":
+ case "missing-input":
+ return "warning";
+ case "missing":
+ case "deferred":
+ case "blocked":
+ case "not-applicable":
+ return "muted";
+ }
+}
+
+// "A", "A and B", "A, B and C". The same join OperationDetail.tsx uses for the
+// same sentence, so a blocked video reads the same on both surfaces.
+function joinLabels(labels: string[]): string {
+ if (labels.length <= 1) return labels[0] ?? "";
+ return `${labels.slice(0, -1).join(", ")} and ${labels[labels.length - 1]}`;
+}
diff --git a/editor/app/channels/[slug]/videos/[id]/lib/videoOperationPanels.ts b/editor/app/channels/[slug]/videos/[id]/lib/videoOperationPanels.ts
@@ -0,0 +1,164 @@
+// The server half of the video page's operation panels: the registry reader,
+// plus the ONE record each panel's body needs, projected down to what crosses
+// to the client.
+//
+// SERVER-ONLY, no directive — it is imported by the page, which is a server
+// component. It reaches the sidecar loaders (node:fs) and it strips `target`,
+// the erased `unknown` the registry narrows per entry, before anything is
+// handed to a client component.
+//
+// ONE loadAttribution FOR BOTH ATTRIBUTION PANELS: the two operations share
+// attribution.json (lib/operations.ts's two-kinds-one-file rule), so reading it
+// twice would be two reads of one file that must not be allowed to disagree.
+
+import path from "node:path";
+import type { Paths } from "yt-dlp-transcript-common/lib/paths";
+import type { SiteSettings } from "yt-dlp-transcript-common/lib/settings";
+import {
+ inspectVideoOperations,
+ orderForVideoPage,
+ shownOnVideoPage,
+ type VideoOperationView,
+} from "yt-dlp-transcript-common/controller/videoOperations";
+import {
+ loadDigest,
+ loadDigestOverrides,
+} from "yt-dlp-transcript-common/lib/digest-server";
+import {
+ digestSectionStates,
+ effectiveDigest,
+ type DigestFreshnessTarget,
+ type DigestSectionKind,
+} from "yt-dlp-transcript-common/lib/digest";
+import { loadDiarization } from "yt-dlp-transcript-common/lib/diarization-server";
+import { loadAttribution } from "yt-dlp-transcript-common/lib/attribution-server";
+import type { AttributionRecord } from "yt-dlp-transcript-common/lib/attribution";
+import type { OperationPanelBody } from "../components/OperationPanel";
+import type { OperationPanelView } from "../components/operationState";
+
+export type VideoOperationPanel = {
+ view: OperationPanelView;
+ body: OperationPanelBody;
+};
+
+export async function loadVideoOperationPanels(opts: {
+ paths: Paths;
+ channelSlug: string;
+ videoId: string;
+ settings: SiteSettings;
+}): Promise<VideoOperationPanel[]> {
+ const views = orderForVideoPage(
+ (await inspectVideoOperations(opts)).filter(shownOnVideoPage),
+ );
+ const videoDir = path.join(
+ opts.paths.channelsDir,
+ opts.channelSlug,
+ "data",
+ opts.videoId,
+ );
+ // Read once, lazily, and shared by both attribution panels.
+ let attribution: AttributionRecord | null | undefined;
+ const readAttribution = async (): Promise<AttributionRecord | null> => {
+ if (attribution === undefined) attribution = await loadAttribution(videoDir);
+ return attribution;
+ };
+
+ const panels: VideoOperationPanel[] = [];
+ for (const view of views) {
+ panels.push({
+ view: toPanelView(view),
+ body: await loadBody(view, videoDir, opts.settings, readAttribution),
+ });
+ }
+ return panels;
+}
+
+// Drops `target`. Everything else on VideoOperationView is already plain data.
+function toPanelView(view: VideoOperationView): OperationPanelView {
+ const { target: _target, ...rest } = view;
+ return rest;
+}
+
+async function loadBody(
+ view: VideoOperationView,
+ videoDir: string,
+ settings: SiteSettings,
+ // Memoized PER CALL of loadVideoOperationPanels, never at module scope: a
+ // module-level cache would outlive the request and hand a re-render the
+ // record from before the run that just rewrote it.
+ readAttribution: () => Promise<AttributionRecord | null>,
+): Promise<OperationPanelBody> {
+ switch (view.settingsBlock) {
+ case "digest": {
+ const [record, overrides] = await Promise.all([
+ loadDigest(videoDir),
+ loadDigestOverrides(videoDir),
+ ]);
+ // The digest is the one body that narrows the erased target: its
+ // per-section rows need the identity a regeneration would produce and
+ // the section list the settings ask for. Same shape the registry entry's
+ // own resolveTarget returns.
+ const { target, sections } = view.target as {
+ target: DigestFreshnessTarget;
+ sections: DigestSectionKind[];
+ };
+ return {
+ block: "digest",
+ data: {
+ digest: effectiveDigest(record, overrides),
+ hasMachineDigest: record !== null,
+ // THE SAME FOLD state() counts. The page used to compute its own.
+ sections: digestSectionStates(record, sections, target),
+ target,
+ hasOverrides: overrides !== null,
+ note: overrides?.note ?? "",
+ remoteEnabled: settings.digest.remoteEnabled,
+ },
+ };
+ }
+ case "diarization": {
+ const record = await loadDiarization(videoDir);
+ return {
+ block: "diarization",
+ data: {
+ record: record
+ ? {
+ generatedAt: record.generatedAt,
+ ...(record.audioSeconds !== undefined
+ ? { audioSeconds: record.audioSeconds }
+ : {}),
+ ...(record.durationMs !== undefined
+ ? { durationMs: record.durationMs }
+ : {}),
+ speakers: record.speakers,
+ // A count: a long recording has thousands of turns and none of
+ // them is a row on this page.
+ turns: record.turns.length,
+ engine: record.engine,
+ ...(record.windowing ? { windowing: record.windowing } : {}),
+ }
+ : null,
+ },
+ };
+ }
+ case "attribution": {
+ const record = await readAttribution();
+ return {
+ block: "attribution",
+ data: {
+ record: record
+ ? {
+ generatedAt: record.generatedAt,
+ speakers: record.speakers,
+ segments: record.segments.length,
+ warnings: record.warnings?.length ?? 0,
+ provenance: record.provenance,
+ }
+ : null,
+ },
+ };
+ }
+ case undefined:
+ return { block: undefined };
+ }
+}
diff --git a/editor/app/channels/[slug]/videos/[id]/page.tsx b/editor/app/channels/[slug]/videos/[id]/page.tsx
@@ -27,22 +27,10 @@ import {
} from "yt-dlp-transcript-common/lib/platform";
import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
import { getSettings } from "yt-dlp-transcript-common/lib/settings";
-import {
- loadDigest,
- loadDigestOverrides,
-} from "yt-dlp-transcript-common/lib/digest-server";
-import {
- effectiveDigest,
- isSectionFresh,
-} from "yt-dlp-transcript-common/lib/digest";
-import { resolveDigestTarget } from "yt-dlp-transcript-common/controller/digestTarget";
import { RunningJobsList } from "../../../../jobs/components/RunningJobsList";
import { VideoPanel, type VideoFile } from "./components/VideoPanel";
-import {
- DigestPanel,
- type DigestPanelData,
- type DigestSectionState,
-} from "./components/DigestPanel";
+import { OperationPanel } from "./components/OperationPanel";
+import { loadVideoOperationPanels } from "./lib/videoOperationPanels";
export const dynamic = "force-dynamic";
@@ -152,36 +140,17 @@ export default async function VideoDetailPage({
}
: null;
- // The digest artifact, its human shadow, and whether each section still
- // matches what a regeneration would produce. Freshness is computed HERE
- // rather than in the panel because it needs the resolved target, which reads
- // settings, the engine registry and the channel's context note.
- const [digestRecord, digestOverrides, digestTarget] = await Promise.all([
- loadDigest(videoDir),
- loadDigestOverrides(videoDir),
- resolveDigestTarget({ paths: getPaths(), channelSlug: slug, lane: "local" }),
- ]);
- const digestData: DigestPanelData = {
- digest: effectiveDigest(digestRecord, digestOverrides),
- hasMachineDigest: digestRecord !== null,
- sections: digestTarget.sections.map(
- (kind): DigestSectionState => ({
- kind,
- present: digestRecord?.sections[kind] !== undefined,
- // A shared digest is fresh for the receiving video as long as it still
- // points at its canonical member — that member's own freshness is what
- // drives regeneration (isSharedFrom's contract).
- fresh:
- digestRecord?.derivedFrom != null ||
- isSectionFresh(digestRecord, kind, digestTarget.target),
- provenance: digestRecord?.sections[kind]?.provenance,
- }),
- ),
- target: digestTarget.target,
- hasOverrides: digestOverrides !== null,
- note: digestOverrides?.note ?? "",
- remoteEnabled: getSettings().digest.remoteEnabled,
- };
+ // ONE PANEL PER REGISTRY OPERATION, each carrying the state that entry's own
+ // state() reports. The page used to re-derive the digest's freshness here —
+ // its own target resolution and its own per-section fold beside the
+ // registry's — which is how a page and a work list end up describing the same
+ // disk differently.
+ const panels = await loadVideoOperationPanels({
+ paths: getPaths(),
+ channelSlug: slug,
+ videoId: id,
+ settings: getSettings(),
+ });
const registry = getRegistry();
const existingQueues = registry.activeQueueNames();
@@ -272,7 +241,19 @@ export default async function VideoDetailPage({
coverage={coverage}
/>
- <DigestPanel slug={slug} videoId={id} data={digestData} />
+ {/* The external operations' per-video surface is the pipeline stage
+ cards INSIDE VideoPanel above (download, transcode, transcription).
+ These are the registry's — one panel per entry, and a fifth entry
+ would appear here without a new page. */}
+ {panels.map((p) => (
+ <OperationPanel
+ key={p.view.id}
+ slug={slug}
+ videoId={id}
+ view={p.view}
+ body={p.body}
+ />
+ ))}
</div>
);
}