"use client"; // THE ASSEMBLY, not the cards. Each chore card's body — its state, its server // action, its copy — is its own module under ./cards/ (enumerated, with why each // is a chore and not an operation, in ../lib/videoChoreCards.ts). What stays here // is what only the page as a whole knows: the ORDER of the cards and the gating // booleans derived from the file list, the download outcome and the coverage. // The registry-driven operation panels (digest, diarization, attribution) render // after this component, in page.tsx. import type { AudioFormat, ChannelHandling, } from "yt-dlp-transcript-common/lib/channelConfig"; import type { DownloadOutcomeRecord } from "yt-dlp-transcript-common/lib/downloadOutcome"; import type { SavedVideoPointer } from "yt-dlp-transcript-common/lib/savedVideo"; import type { SourceVideoQuality } from "yt-dlp-transcript-common/ytdlp/downloadFormat"; import type { AvailabilityHistoryEntry } from "yt-dlp-transcript-common/lib/availability"; import type { SubtitleProvenance } from "yt-dlp-transcript-common/lib/subtitleProvenance"; import { formatBytes } from "yt-dlp-transcript-common/lib/format"; import { PipelineStageCard } from "../../../components/PipelineStageCard"; import { TranscriptTracksReader } from "./cards/TranscriptTracksReader"; import { VideoNavStrip } from "./VideoNavStrip"; import { PipelineStatusStrip } from "./PipelineStatusStrip"; import { AvailabilityHistoryList } from "./cards/AvailabilityHistoryList"; import { DeleteVideoDirSection } from "./cards/DeleteVideoDirSection"; import { DoNotCleanSection } from "./cards/DoNotCleanSection"; import { DownloadOutcomeBadge } from "./cards/DownloadOutcomeBadge"; import { ExcludeTruncatedCheckSection } from "./cards/ExcludeTruncatedCheckSection"; import { FetchedWindows, type FetchedWindow } from "./cards/FetchedWindows"; import { FilesList } from "./cards/FilesList"; import { IncompleteTranscriptBanner } from "./cards/IncompleteTranscriptBanner"; import { MarkUntranscribableSection } from "./cards/MarkUntranscribableSection"; import { PerFileTranscodeRow } from "./cards/PerFileTranscodeRow"; import { PerFileTranscribeRow } from "./cards/PerFileTranscribeRow"; import { RedownloadSection } from "./cards/RedownloadSection"; import { ShortAudioBanner } from "./cards/ShortAudioBanner"; import { SourceVideoSection } from "./cards/SourceVideoSection"; import { TranscriptSourceSection } from "./cards/TranscriptSourceSection"; import { SubtitleDeferralLine } from "./SubtitleDeferralLine"; import { CANONICAL_VTT, WHISPER_FILENAME, isAudioFile, isTranscodeSource, isTranscriptVttName, type VideoFile, } from "./cards/videoFiles"; // Both pages import these types from here; they are defined beside the cards // that use them. export type { FetchedWindow, VideoFile }; type Props = { slug: string; videoId: string; files: VideoFile[]; // Resolved primary English VTT filename (transcript.en.vtt, or a regional/auto // fallback like transcript.en-US.vtt), or null when no English VTT exists. primaryVtt: string | null; // filename -> where that subtitle track came from (YouTube ASR vs a manual // upload). Computed server-side from a 4 KB head read per VTT. vttProvenance?: Record; handling: ChannelHandling; defaultQueueKey: string; existingQueues: string[]; downloadOutcome: DownloadOutcomeRecord | null; availabilityHistory: AvailabilityHistoryEntry[]; channelAudioFormat?: AudioFormat; doNotClean?: boolean; // When true the user has opted this video out of the truncated/incomplete // check, so the "looks truncated" banner is suppressed and the toggle reads // as on. Drives ExcludeTruncatedCheckSection. excludedFromTruncatedCheck?: boolean; // This video's saved-video pointer when its source container is persisted to // the store, else null. Drives the Source-video persistence card (Phase 5). savedVideo?: SavedVideoPointer | null; // The effective source-video quality for this channel (channel over global, // resolveSourceVideoQuality): where the persist control's select starts. sourceVideoQuality?: SourceVideoQuality; // Windows of this video's source media another tool asked the editor to // fetch, into data//clips/. Empty for almost every video. clipWindows?: FetchedWindow[]; // Transcript coverage vs. video duration. When `incomplete` the transcript // covers only a fraction of the runtime (truncated audio download) — surfaced // as a warning banner with a re-download/re-transcribe action. Absent when // there's no transcript or no duration to judge against. coverage?: { lastCueEnd: number; duration: number; coverage: number | null; incomplete: boolean; } | null; prevHref?: string; nextHref?: string; position?: { index: number; total: number }; // The umtool front door, when UMTOOL_URL is set on the editor. One link: // "open in umtool" scaffolds a report video from this exact video. umtoolUrl?: string | null; }; export function VideoPanel({ slug, videoId, files, primaryVtt, handling, defaultQueueKey, existingQueues, downloadOutcome, availabilityHistory, channelAudioFormat, doNotClean = false, excludedFromTruncatedCheck = false, savedVideo = null, sourceVideoQuality, clipWindows = [], coverage = null, prevHref, nextHref, position, vttProvenance = {}, umtoolUrl = null, }: Props) { const audioFiles = files.filter((f) => isAudioFile(f.name)); const transcodeSources = files.filter((f) => isTranscodeSource(f.name)); const hasTranscriptJson = files.some((f) => f.name === WHISPER_FILENAME); const hasYtVtt = primaryVtt !== null; const hasTranscript = hasTranscriptJson || hasYtVtt; // This video's only transcript is YouTube's speech recognition: the transcribe // controls stay live so it can be replaced with one of our own. A manual — or // unclassifiable — caption track still blocks them. const autoSubsOnly = !hasTranscriptJson && primaryVtt !== null && vttProvenance[primaryVtt] === "asr"; const vttTracks = files .filter((f) => isTranscriptVttName(f.name)) .map((f) => f.name) .sort(); // The transcript the index/viewer will actually use: whisper wins over VTT. const activeTranscript = hasTranscriptJson ? WHISPER_FILENAME : primaryVtt; const noAudio = audioFiles.length === 0; const downloadFailed = downloadOutcome?.status === "failed"; const incompleteTranscript = coverage?.incomplete ?? false; const downloadSummary = noAudio ? handling === "youtube" ? "No audio on disk — run the pipeline (VTT first, whisper if needed)." : "Audio not yet downloaded." : `${audioFiles.length} audio file${audioFiles.length === 1 ? "" : "s"} on disk.`; const transcribeSummary = autoSubsOnly ? "YouTube auto-captions only — can be replaced with an AI transcript." : hasTranscript ? "Transcript present." : noAudio ? "No audio yet — run the download pipeline (or use Audio + Whisper) to produce a transcript." : "Audio ready, no transcript."; const transcodeSummary = transcodeSources.length > 0 ? `Convert ${transcodeSources.length} source file${transcodeSources.length === 1 ? "" : "s"} to another format.` : "Nothing to transcode."; const showNav = !!prevHref || !!nextHref || !!position; return (
{showNav && ( )} {umtoolUrl && (
Open in umtool — start a report video from this recording
)} {downloadOutcome && } {/* 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
 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. */}
      
      
      
        
      
      {doNotClean && (
        
Media archived — cleanup will skip this video.
)} {transcodeSources.length > 0 && (
{transcodeSources.map((f) => ( ))}
)}
{audioFiles.map((f) => ( ))}
{hasTranscript && ( 1 ? "Read it; switch to another track where one says something else." : "Read it." } defaultOpen={false} tone="neutral" > )} {vttTracks.length > 0 && ( 1 || primaryVtt !== CANONICAL_VTT} tone={ primaryVtt && primaryVtt !== CANONICAL_VTT && !hasTranscriptJson ? "attention" : "neutral" } > )} {!hasTranscript && ( )} {clipWindows.length > 0 && ( n + w.bytes, 0), )}) of this video's source media, fetched for another tool.`} defaultOpen={false} tone="neutral" > )}
); }