Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Meditor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx | 17+++++++++++++++++
Aeditor/app/channels/[slug]/videos/[id]/components/cards/TranscriptTracksReader.tsx | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/videos/[id]/videoActions.ts | 16++++++++++++++++
Meditor/e2e/transcript-source.spec.ts | 34++++++++++++++++++++++++++++++++++
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 ({