commit 09088c9fbaa41df654ec15b57c3579f977385fc4
parent ba12c240b94c2118fc69d5975a4743e04437352a
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 8 Sep 2026 21:57:25 -0400
editor: the three video-panel run logs outlive the state their own run lands
`plans/FACTS.md` names these three as the pre-existing instances of the rule
`a6e6d46` established: a component carrying a StreamActionLog must not be
UNMOUNTED when the record its own run produced lands. StreamActionLog keeps the
streamed log in useState and calls router.refresh() ITSELF the moment a run
ends; unmount the panel in that render and the log is not stale, it is GONE —
with `log` empty and `running` false the component renders no <pre role="log">
at all. Here the unmount came from the other side of a6e6d46's: not a moved
unkeyed child, but a PARENT (or an early return) that stops rendering the whole
subtree once the run has cleared the condition that drew it.
SourceVideoSection early-returned the persisted view as soon as `savedVideo`
was truthy, so the "Persist source video" log was destroyed by the very run
that wrote the pointer. It is one return now: the persisted details
(PersistedSourceVideo, extracted verbatim) and the run panel are siblings in
two fixed slots, and the panel — keyed — stays mounted once a run has started
from it. With no run it is still absent whenever the video is already
persisted, so the rendered DOM is unchanged.
IncompleteTranscriptBanner and ShortAudioBanner were dropped by the parent
instead: both offer the operation that CLEARS their own condition. The parent
no longer branches on the condition — it renders both banners unconditionally
and passes the condition down (`coverage` now nullable, `flagged` new), and
each banner returns null until it has something to show. A `ranHere` flag, set
when a run starts from that banner, is what keeps it (and its log) on screen
after the refresh; before any run the banner still renders nothing when its
condition is false, so again the DOM is unchanged.
StreamActionLog itself is untouched, as is every server path, lane and
operation. The three e2e cases from the previous commit are red at ba12c24
and green here.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
1 file changed, 209 insertions(+), 134 deletions(-)
diff --git a/editor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx b/editor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx
@@ -234,25 +234,36 @@ export function VideoPanel({
downloadFailed={downloadFailed}
/>
{downloadOutcome && <DownloadOutcomeBadge outcome={downloadOutcome} />}
- {incompleteTranscript && coverage && (
- <IncompleteTranscriptBanner
- slug={slug}
- videoId={videoId}
- coverage={coverage}
- defaultQueueKey={defaultQueueKey}
- existingQueues={existingQueues}
- />
- )}
- {downloadOutcome?.status === "failed-short-audio" &&
- !excludedFromTruncatedCheck && (
- <ShortAudioBanner
- slug={slug}
- videoId={videoId}
- shortAudio={downloadOutcome.shortAudio}
- defaultQueueKey={defaultQueueKey}
- existingQueues={existingQueues}
- />
- )}
+ {/* BOTH BANNERS ARE RENDERED UNCONDITIONALLY, and each decides for
+ itself whether to draw anything. THE RULE (plans/FACTS.md, fae99f1):
+ a component carrying a StreamActionLog must not be UNMOUNTED when the
+ record its own run produced lands. Each of these banners runs the very
+ operation that CLEARS the condition that drew it, and StreamActionLog
+ fires router.refresh() itself the moment its run ends — so a parent
+ that stops rendering the banner on that refresh destroys the log the
+ operator is reading, the <pre role="log"> and all, at the instant the
+ run's last line arrives. Passing the condition down instead of
+ branching on it here keeps the banner's fiber (and its log) alive
+ across that refresh; with no run and the condition false the banner
+ renders null, so the DOM is unchanged. */}
+ <IncompleteTranscriptBanner
+ slug={slug}
+ videoId={videoId}
+ coverage={incompleteTranscript ? coverage : null}
+ defaultQueueKey={defaultQueueKey}
+ existingQueues={existingQueues}
+ />
+ <ShortAudioBanner
+ slug={slug}
+ videoId={videoId}
+ flagged={
+ downloadOutcome?.status === "failed-short-audio" &&
+ !excludedFromTruncatedCheck
+ }
+ shortAudio={downloadOutcome?.shortAudio}
+ defaultQueueKey={defaultQueueKey}
+ existingQueues={existingQueues}
+ />
<PipelineStageCard
id="availability-history"
title="Availability history"
@@ -1046,8 +1057,14 @@ function SourceVideoSection({
savedVideo: SavedVideoPointer | null;
}) {
const [queueKey, setQueueKey] = useState(defaultQueueKey);
- const [pending, startTransition] = useTransition();
- const [error, setError] = useState<string | null>(null);
+ // Set the moment a persist run STARTS from this surface. That run's own
+ // router.refresh() lands the saved-video pointer, and the persisted view used
+ // to REPLACE this whole subtree — destroying the StreamActionLog below and the
+ // log the operator was reading (plans/FACTS.md, "A run log lives in the
+ // panel's React state"). One return, two fixed slots, and the run panel stays
+ // mounted once it has been used; with no run it is still absent whenever the
+ // video is already persisted, so the DOM is unchanged.
+ const [ranHere, setRanHere] = useState(false);
if (handling !== "transcribe") {
return (
@@ -1057,96 +1074,122 @@ function SourceVideoSection({
);
}
- if (savedVideo) {
- return (
- <div className="flex flex-col gap-3">
+ return (
+ <div className="flex flex-col gap-3">
+ {savedVideo ? (
+ <PersistedSourceVideo
+ slug={slug}
+ videoId={videoId}
+ savedVideo={savedVideo}
+ />
+ ) : (
<p className="text-sm text-muted-foreground">
- The source container for this video is persisted to the saved-video
- store, so the Clean-audio sweep can reclaim its <code>audio.*</code>{" "}
- without losing the original. Transcription falls back to it when no
- audio is on disk.
+ Re-fetch this video's full source container and move it into the
+ saved-video store, without disturbing the existing transcript. Use
+ this to archive a video that was downloaded audio-only. (Videos in a
+ channel's keep-latest window persist automatically on download.)
</p>
- <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
- <dt className="text-muted-foreground">File</dt>
- <dd className="font-mono break-all">{savedVideo.file}</dd>
- <dt className="text-muted-foreground">Size</dt>
- <dd className="tabular-nums">{formatBytes(savedVideo.bytes)}</dd>
- <dt className="text-muted-foreground">Stored</dt>
- <dd>{new Date(savedVideo.storedAt).toLocaleString()}</dd>
- {savedVideo.keepReason && (
- <>
- <dt className="text-muted-foreground">Reason</dt>
- <dd>{savedVideo.keepReason}</dd>
- </>
- )}
- {savedVideo.sha256 && (
- <>
- <dt className="text-muted-foreground">sha256</dt>
- <dd className="font-mono text-xs break-all">
- {savedVideo.sha256}
- </dd>
- </>
- )}
- <dt className="text-muted-foreground">Location</dt>
- <dd className="font-mono text-xs break-all">{savedVideo.dir}</dd>
- </dl>
- <p className="text-xs text-muted-foreground">
- Unpersist moves the container back into this video's data dir
- (where it appears under Files and plays inline) and removes the
- pointer.
- </p>
- <button
- type="button"
- disabled={pending}
- aria-label={`unpersist source video ${videoId}`}
- onClick={() => {
- setError(null);
- startTransition(async () => {
- const res = await unpersistVideoAction(slug, videoId);
- if (!res.ok) setError(res.error);
- });
+ )}
+ {savedVideo && !ranHere ? null : (
+ <StreamActionLog
+ key="persist-run"
+ trigger={() => {
+ setRanHere(true);
+ return redownloadToArchiveAction(slug, videoId, queueKey);
}}
- className="self-start px-3 py-2 rounded-md border border-border text-sm font-medium hover:bg-muted disabled:opacity-50"
- >
- {pending ? "Restoring…" : "Unpersist (move back to data dir)"}
- </button>
- {error && (
- <span
- className="text-sm text-destructive"
- aria-label="unpersist error"
- >
- {error}
- </span>
- )}
- </div>
- );
- }
+ cancelAction={cancelJobAction}
+ buttonLabel="Persist source video"
+ runningLabel="Archiving…"
+ label={`Persist source video for ${videoId}`}
+ extraControls={
+ <QueueControl
+ value={queueKey}
+ onChange={setQueueKey}
+ defaultQueueKey={defaultQueueKey}
+ existingQueues={existingQueues}
+ actionLabel={`Persist source video for ${videoId}`}
+ />
+ }
+ />
+ )}
+ </div>
+ );
+}
+// The persisted-details view: a sibling of the run panel above, never a
+// replacement for it.
+function PersistedSourceVideo({
+ slug,
+ videoId,
+ savedVideo,
+}: {
+ slug: string;
+ videoId: string;
+ savedVideo: SavedVideoPointer;
+}) {
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState<string | null>(null);
return (
- <div className="flex flex-col gap-3">
+ <>
<p className="text-sm text-muted-foreground">
- Re-fetch this video's full source container and move it into the
- saved-video store, without disturbing the existing transcript. Use this
- to archive a video that was downloaded audio-only. (Videos in a
- channel's keep-latest window persist automatically on download.)
+ The source container for this video is persisted to the saved-video
+ store, so the Clean-audio sweep can reclaim its <code>audio.*</code>{" "}
+ without losing the original. Transcription falls back to it when no
+ audio is on disk.
</p>
- <StreamActionLog
- trigger={() => redownloadToArchiveAction(slug, videoId, queueKey)}
- cancelAction={cancelJobAction}
- buttonLabel="Persist source video"
- runningLabel="Archiving…"
- label={`Persist source video for ${videoId}`}
- extraControls={
- <QueueControl
- value={queueKey}
- onChange={setQueueKey}
- defaultQueueKey={defaultQueueKey}
- existingQueues={existingQueues}
- actionLabel={`Persist source video for ${videoId}`}
- />
- }
- />
- </div>
+ <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
+ <dt className="text-muted-foreground">File</dt>
+ <dd className="font-mono break-all">{savedVideo.file}</dd>
+ <dt className="text-muted-foreground">Size</dt>
+ <dd className="tabular-nums">{formatBytes(savedVideo.bytes)}</dd>
+ <dt className="text-muted-foreground">Stored</dt>
+ <dd>{new Date(savedVideo.storedAt).toLocaleString()}</dd>
+ {savedVideo.keepReason && (
+ <>
+ <dt className="text-muted-foreground">Reason</dt>
+ <dd>{savedVideo.keepReason}</dd>
+ </>
+ )}
+ {savedVideo.sha256 && (
+ <>
+ <dt className="text-muted-foreground">sha256</dt>
+ <dd className="font-mono text-xs break-all">
+ {savedVideo.sha256}
+ </dd>
+ </>
+ )}
+ <dt className="text-muted-foreground">Location</dt>
+ <dd className="font-mono text-xs break-all">{savedVideo.dir}</dd>
+ </dl>
+ <p className="text-xs text-muted-foreground">
+ Unpersist moves the container back into this video's data dir
+ (where it appears under Files and plays inline) and removes the
+ pointer.
+ </p>
+ <button
+ type="button"
+ disabled={pending}
+ aria-label={`unpersist source video ${videoId}`}
+ onClick={() => {
+ setError(null);
+ startTransition(async () => {
+ const res = await unpersistVideoAction(slug, videoId);
+ if (!res.ok) setError(res.error);
+ });
+ }}
+ className="self-start px-3 py-2 rounded-md border border-border text-sm font-medium hover:bg-muted disabled:opacity-50"
+ >
+ {pending ? "Restoring…" : "Unpersist (move back to data dir)"}
+ </button>
+ {error && (
+ <span
+ className="text-sm text-destructive"
+ aria-label="unpersist error"
+ >
+ {error}
+ </span>
+ )}
+ </>
);
}
@@ -1456,6 +1499,14 @@ function DownloadOutcomeBadge({
);
}
+// `coverage` is null when the transcript is NOT flagged as truncated — the
+// banner is always rendered and draws nothing in that case, rather than the
+// parent branching on the condition. The run offered here is the one that
+// CLEARS the flag, so a parent branch would unmount this component (and the
+// run's log with it) on the router.refresh() StreamActionLog fires when the run
+// ends. `ranHere` is what keeps it: once a run has started from this surface
+// the banner stays on screen with its log, whatever the condition now says.
+// See plans/FACTS.md, "A run log lives in the panel's React state".
function IncompleteTranscriptBanner({
slug,
videoId,
@@ -1465,13 +1516,19 @@ function IncompleteTranscriptBanner({
}: {
slug: string;
videoId: string;
- coverage: { lastCueEnd: number; duration: number; coverage: number | null };
+ coverage: {
+ lastCueEnd: number;
+ duration: number;
+ coverage: number | null;
+ } | null;
defaultQueueKey: string;
existingQueues: string[];
}) {
const [queueKey, setQueueKey] = useState(defaultQueueKey);
+ const [ranHere, setRanHere] = useState(false);
+ if (!coverage && !ranHere) return null;
const pct =
- coverage.coverage === null
+ !coverage || coverage.coverage === null
? null
: Math.round(coverage.coverage * 1000) / 10;
const actionLabel = `Re-download & re-transcribe ${videoId}`;
@@ -1481,20 +1538,24 @@ function IncompleteTranscriptBanner({
aria-label="incomplete transcript"
className="flex flex-col gap-3 rounded border px-3 py-2 text-sm border-warning/30 bg-warning-soft text-warning"
>
- <div className="flex flex-col gap-1">
- <span className="font-medium">Transcript looks truncated</span>
- <span>
- The transcript covers only {formatDuration(coverage.lastCueEnd)} of{" "}
- {formatDuration(coverage.duration)}
- {pct !== null ? ` (${pct}%)` : ""}. The audio download likely stopped
- early. Re-download the audio and re-transcribe to fix it — this deletes
- the truncated audio first so the full file is fetched.
- </span>
- </div>
+ {coverage ? (
+ <div className="flex flex-col gap-1">
+ <span className="font-medium">Transcript looks truncated</span>
+ <span>
+ The transcript covers only {formatDuration(coverage.lastCueEnd)} of{" "}
+ {formatDuration(coverage.duration)}
+ {pct !== null ? ` (${pct}%)` : ""}. The audio download likely stopped
+ early. Re-download the audio and re-transcribe to fix it — this
+ deletes the truncated audio first so the full file is fetched.
+ </span>
+ </div>
+ ) : null}
<StreamActionLog
- trigger={() =>
- redownloadIncompleteTranscriptAction(slug, videoId, queueKey)
- }
+ key="run"
+ trigger={() => {
+ setRanHere(true);
+ return redownloadIncompleteTranscriptAction(slug, videoId, queueKey);
+ }}
cancelAction={cancelJobAction}
buttonLabel="Re-download & re-transcribe"
runningLabel="Re-downloading…"
@@ -1517,15 +1578,21 @@ function IncompleteTranscriptBanner({
// source served a truncated stream (e.g. a CDN-truncated HLS rung). The stub is
// kept on disk; re-downloading with a different format (Original) fetches the
// full audio. Offers a one-click re-download forcing the Original format.
+// `flagged` is the parent's condition, passed down rather than branched on for
+// the same reason as IncompleteTranscriptBanner above: the re-download offered
+// here is what clears the flag, and unmounting this component on the refresh
+// that lands the new download outcome would take the run's log with it.
function ShortAudioBanner({
slug,
videoId,
+ flagged,
shortAudio,
defaultQueueKey,
existingQueues,
}: {
slug: string;
videoId: string;
+ flagged: boolean;
shortAudio?: {
audioDurationSec: number;
expectedDurationSec: number;
@@ -1535,6 +1602,8 @@ function ShortAudioBanner({
existingQueues: string[];
}) {
const [queueKey, setQueueKey] = useState(defaultQueueKey);
+ const [ranHere, setRanHere] = useState(false);
+ if (!flagged && !ranHere) return null;
const actionLabel = `Re-download ${videoId} as Original`;
return (
<div
@@ -1542,22 +1611,28 @@ function ShortAudioBanner({
aria-label="short audio"
className="flex flex-col gap-3 rounded border px-3 py-2 text-sm border-warning/30 bg-warning-soft text-warning"
>
- <div className="flex flex-col gap-1">
- <span className="font-medium">Download was truncated at the source</span>
- <span>
- {shortAudio
- ? `The downloaded audio is only ${formatDuration(shortAudio.audioDurationSec)} of ${formatDuration(shortAudio.expectedDurationSec)} (${Math.round(shortAudio.coverage * 100)}%). `
- : "The downloaded audio is far shorter than the video. "}
- The source served a truncated stream for the selected format, so it was
- flagged before transcription (the file is kept, not transcribed).
- Re-download with the <strong>Original</strong> format to fetch the full
- audio.
- </span>
- </div>
+ {flagged ? (
+ <div className="flex flex-col gap-1">
+ <span className="font-medium">
+ Download was truncated at the source
+ </span>
+ <span>
+ {shortAudio
+ ? `The downloaded audio is only ${formatDuration(shortAudio.audioDurationSec)} of ${formatDuration(shortAudio.expectedDurationSec)} (${Math.round(shortAudio.coverage * 100)}%). `
+ : "The downloaded audio is far shorter than the video. "}
+ The source served a truncated stream for the selected format, so it
+ was flagged before transcription (the file is kept, not
+ transcribed). Re-download with the <strong>Original</strong> format
+ to fetch the full audio.
+ </span>
+ </div>
+ ) : null}
<StreamActionLog
- trigger={() =>
- downloadVideoPipelineAction(slug, videoId, queueKey, "original")
- }
+ key="run"
+ trigger={() => {
+ setRanHere(true);
+ return downloadVideoPipelineAction(slug, videoId, queueKey, "original");
+ }}
cancelAction={cancelJobAction}
buttonLabel="Re-download as Original"
runningLabel="Re-downloading…"