commit 161912b93d84f9077b435b2f5e8a130e1f7344bf
parent b56621327931d93e958b90df8a457195d3f97777
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 6 Oct 2026 09:55:24 -0400
editor: the video page reads its transcript, with the track switcher
A "Transcript" card reads the video's tracks on demand (readVideoTracks:
the primary by the index's rules, then every English track whose words
differ) and shows the cues, with a "Track:" switcher when there is more than
one. Switching changes nothing on disk; "Set as transcript" stays the way the
primary changes.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
4 files changed, 167 insertions(+), 0 deletions(-)
diff --git a/editor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx b/editor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx
@@ -19,6 +19,7 @@ import type { AvailabilityHistoryEntry } from "yt-dlp-transcript-common/lib/avai
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";
@@ -322,6 +323,22 @@ export function VideoPanel({
<SubtitleDeferralLine slug={slug} videoId={videoId} />
+ {hasTranscript && (
+ <PipelineStageCard
+ id="transcript-read"
+ title="Transcript"
+ summary={
+ vttTracks.length + (hasTranscriptJson ? 1 : 0) > 1
+ ? "Read it; switch to another track where one says something else."
+ : "Read it."
+ }
+ defaultOpen={false}
+ tone="neutral"
+ >
+ <TranscriptTracksReader slug={slug} videoId={videoId} />
+ </PipelineStageCard>
+ )}
+
{vttTracks.length > 0 && (
<PipelineStageCard
id="transcript-source"
diff --git a/editor/app/channels/[slug]/videos/[id]/components/cards/TranscriptTracksReader.tsx b/editor/app/channels/[slug]/videos/[id]/components/cards/TranscriptTracksReader.tsx
@@ -0,0 +1,100 @@
+"use client";
+
+// The video's transcript, read on demand, with the small track switcher when it
+// has more than one English track whose words differ (lib/captionTracks.ts).
+// Switching is a reader's choice and changes nothing on disk — "Set as
+// transcript" in the Transcript source card is how the primary changes.
+
+import { useState, useTransition } from "react";
+import type { AltTrack } from "yt-dlp-transcript-common/lib/captionTracks";
+import { trackLabel } from "yt-dlp-transcript-common/lib/captionTracks";
+import { formatTimestamp } from "yt-dlp-transcript-common/lib/vtt";
+import { readTranscriptTracksAction } from "../../videoActions";
+
+export function TranscriptTracksReader({
+ slug,
+ videoId,
+}: {
+ slug: string;
+ videoId: string;
+}) {
+ const [pending, startTransition] = useTransition();
+ const [tracks, setTracks] = useState<AltTrack[] | null>(null);
+ const [shown, setShown] = useState<string | null>(null);
+ const [error, setError] = useState<string | null>(null);
+
+ const load = () => {
+ setError(null);
+ startTransition(async () => {
+ const res = await readTranscriptTracksAction(slug, videoId);
+ if (!res.ok) {
+ setError(res.error);
+ return;
+ }
+ setTracks(res.tracks);
+ setShown(res.tracks[0]?.track ?? null);
+ });
+ };
+
+ if (!tracks) {
+ return (
+ <div className="flex flex-col gap-2">
+ <button
+ type="button"
+ onClick={load}
+ disabled={pending}
+ aria-label="read transcript"
+ className="self-start px-2 py-1 rounded border border-border text-xs hover:bg-muted disabled:opacity-50"
+ >
+ {pending ? "Reading…" : "Read transcript"}
+ </button>
+ {error && (
+ <span className="text-sm text-destructive" aria-label="read transcript error">
+ {error}
+ </span>
+ )}
+ </div>
+ );
+ }
+
+ const current = tracks.find((t) => t.track === shown) ?? tracks[0];
+ return (
+ <div className="flex flex-col gap-2">
+ {tracks.length > 1 && (
+ <label className="inline-flex items-center gap-1.5 self-start text-xs text-muted-foreground">
+ <span>Track:</span>
+ <select
+ aria-label="transcript track"
+ value={current.track}
+ onChange={(e) => setShown(e.target.value)}
+ className="rounded border border-border bg-background px-1 py-0.5 text-xs text-foreground"
+ >
+ {tracks.map((t, i) => (
+ <option key={t.track} value={t.track}>
+ {trackLabel(t.track)}
+ {i === 0 ? " (default)" : ""}
+ </option>
+ ))}
+ </select>
+ </label>
+ )}
+ {current.cues.length === 0 ? (
+ <p className="text-sm text-muted-foreground">No cues in this track.</p>
+ ) : (
+ <ol
+ aria-label="transcript cues"
+ className="max-h-96 overflow-y-auto rounded border border-border divide-y divide-border text-sm"
+ >
+ {current.cues.map((c, i) => (
+ <li key={i} className="flex gap-3 px-3 py-1">
+ <span className="shrink-0 w-16 font-mono text-xs text-muted-foreground pt-0.5">
+ {formatTimestamp(c.start)}
+ </span>
+ <span className="min-w-0">{c.text}</span>
+ </li>
+ ))}
+ </ol>
+ )}
+ </div>
+ );
+}
diff --git a/editor/app/channels/[slug]/videos/[id]/videoActions.ts b/editor/app/channels/[slug]/videos/[id]/videoActions.ts
@@ -47,6 +47,8 @@ import { onDrive } from "yt-dlp-transcript-common/lib/storageHealth";
import { isTierable } from "yt-dlp-transcript-common/lib/mediaTier";
import { setExcludedFromTruncatedCheck } from "yt-dlp-transcript-common/lib/excludeTruncatedCheck-server";
import { pinTranscript } from "yt-dlp-transcript-common/lib/transcriptPin-server";
+import { readVideoTracks } from "yt-dlp-transcript-common/lib/captionTracks-server";
+import type { AltTrack } from "yt-dlp-transcript-common/lib/captionTracks";
import { pruneFailedTranscriptions } from "yt-dlp-transcript-common/controller/failedTranscriptions";
import { transcodeAudio } from "yt-dlp-transcript-common/controller/transcode";
import {
@@ -623,6 +625,20 @@ export async function setPrimaryTranscriptAction(
return { ok: true };
}
+// The video's transcript tracks, primary first (lib/captionTracks.ts): the
+// primary and every other English track whose words differ from it. Read-only —
+// what the page's transcript reader shows and switches between. Choosing a
+// track there changes nothing on disk; "Set as transcript" above is how the
+// primary changes.
+export async function readTranscriptTracksAction(
+ slug: string,
+ videoId: string,
+): Promise<{ ok: true; tracks: AltTrack[] } | { ok: false; error: string }> {
+ const read = await readVideoTracks(videoDirOf(slug, videoId));
+ if (!read) return { ok: false, error: "This video has no transcript to read." };
+ return { ok: true, tracks: read.tracks };
+}
+
// A refusal carries what was submitted (lib/formState.ts).
export type DeleteDirActionResult = FormErrorState;
diff --git a/editor/e2e/transcript-source.spec.ts b/editor/e2e/transcript-source.spec.ts
@@ -72,6 +72,40 @@ test("switching the transcript source promotes a track to transcript.en.vtt", as
).toBeVisible();
});
+// The page's transcript reader shows the primary and switches to another
+// English track only where its words differ — a reader's choice, nothing on
+// disk changes.
+test("the transcript reader switches to a track whose words differ", async ({
+ page,
+}) => {
+ await resetData("one-youtube-channel-with-data");
+ await writeFile(
+ resolvePath(`${DATA}/transcript.en-orig.vtt`),
+ "WEBVTT\n\n00:00:00.000 --> 00:00:05.000\nwords as spoken\n",
+ );
+ await writeFile(
+ resolvePath(`${DATA}/transcript.en.vtt`),
+ "WEBVTT\n\n00:00:00.000 --> 00:00:05.000\nwords as uploaded\n",
+ );
+
+ await page.goto(VIDEO_URL);
+ await page.getByLabel("Transcript stage summary", { exact: true }).click();
+ await page.getByRole("button", { name: "read transcript" }).click();
+
+ const cuesList = page.getByLabel("transcript cues", { exact: true });
+ await expect(cuesList).toContainText("words as spoken");
+ const switcher = page.getByLabel("transcript track", { exact: true });
+ await expect(switcher).toHaveValue("en-orig");
+ await expect(switcher.locator("option")).toHaveText([
+ "original audio captions (default)",
+ "uploaded captions",
+ ]);
+ await switcher.selectOption("en");
+ await expect(cuesList).toContainText("words as uploaded");
+ // Nothing was pinned.
+ expect(await pathExists(`${DATA}/transcript-pin.json`)).toBe(false);
+});
+
// The channel diagnostics surface a "non-standard transcript VTT name" bucket
// for videos whose transcript rides on a non-canonical VTT name.
test("diagnostics list a video with only transcript.en-US.vtt", async ({