feat(api): extract an embedded track on demand
ci / web (pull_request) Successful in 51s
ci / rust (pull_request) Successful in 3m19s
e2e / e2e (pull_request) Failing after 3m36s

The reconcile loop extracts only while it is already translating a gap,
so on a file where nothing is missing no sidecar is ever written and the
panel's translate lane reports "nothing to translate from" about a file
carrying an English text track. §15 treats a manual action as bypassing
the wanted set, not as depending on it.

The status endpoint now joins the codec in from `media_files.probed` and
lists the extractable text tracks; the panel offers them in the source
picker and writes one out when it is picked.

Refs #260

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-08-26 10:35:11 +01:00
co-authored by Claude Opus 5
parent dfa4656509
commit 73f521ee17
9 changed files with 666 additions and 28 deletions
+126 -19
View File
@@ -126,7 +126,11 @@ import "./style.css";
import {
cachedSubtitleOptions,
deleteSubtitle,
type EmbeddedTrack,
type EpisodeSubtitleStatus,
embeddedTrackLabel,
extractableTracks,
extractTrack,
formatCandidateFlags,
formatDownloads,
formatHashMatch,
@@ -1316,6 +1320,10 @@ function subtitleSection(status: SubtitleStatus, refresh: () => void): SubtitleS
const mediaFileId = status.media_file_id;
let subtitles = status.subtitles;
let missing = status.missing;
let tracks = status.embedded_tracks;
/** Set by an extraction so the sidecar it just wrote stays picked across
* the repaint that follows. */
let pickAfterRepaint: number | null = null;
/** Null until the settings request lands; see `wantedSubtitles`. */
let wanted: string[] | null = null;
@@ -1351,7 +1359,10 @@ function subtitleSection(status: SubtitleStatus, refresh: () => void): SubtitleS
* with the delete affordance issue #223 put on each.
*/
function paintChips() {
const summary = subtitleSummary({ media_file_id: mediaFileId, subtitles, missing }, wanted);
const summary = subtitleSummary(
{ media_file_id: mediaFileId, subtitles, missing, embedded_tracks: tracks },
wanted,
);
// the codes read as one string — the chip's own flex gap would otherwise
// open a space in front of every comma
const langs = document.createElement("span");
@@ -1476,41 +1487,133 @@ function subtitleSection(status: SubtitleStatus, refresh: () => void): SubtitleS
panel.append(presentLane, fetchLane, translateLane);
/** Keeps the target picker off the source's own language — a same-language
* translation is refused by the API and is never what was meant. */
/**
* The source picker carries two kinds: the subtitles already written next
* to the video, and the text tracks still inside it (§15, issue #260).
* Picking one of the second kind extracts it — the reconcile loop only
* ever extracts while closing a gap, so on a file where nothing is
* missing this is the only way a sidecar is ever written.
*/
function paintSources() {
const sources = translationSources(subtitles);
source.select.replaceChildren(
...sources.map((subtitle) => {
const offered = extractableTracks(tracks, subtitles);
source.select.replaceChildren();
if (sources.length === 0 && offered.length > 0) {
// nothing on disk to fall back on, and a browser selects the first
// option on sight — which would leave a track reading as picked
// without having been written out. The operator picks it themselves.
const option = document.createElement("option");
option.value = "";
option.textContent = "pick a track to write out";
source.select.append(option);
}
if (sources.length > 0) {
const group = document.createElement("optgroup");
group.label = "on this file";
for (const subtitle of sources) {
const option = document.createElement("option");
option.value = String(subtitle.id);
option.value = `subtitle:${subtitle.id}`;
option.textContent = subtitleChipLabel(subtitle);
return option;
}),
);
const empty = sources.length === 0;
group.append(option);
}
source.select.append(group);
}
if (offered.length > 0) {
const group = document.createElement("optgroup");
group.label = "inside the video — written out when picked";
for (const track of offered) {
const option = document.createElement("option");
option.value = `track:${track.index}`;
option.textContent = embeddedTrackLabel(track);
group.append(option);
}
source.select.append(group);
}
const empty = sources.length === 0 && offered.length === 0;
source.select.disabled = empty;
translateButton.disabled = empty || engine.select.disabled;
if (empty) {
const option = document.createElement("option");
option.textContent = "nothing to translate from";
source.select.append(option);
say(
translateHint,
"no subtitle with text on this file yet — fetch one above, or wait for an embedded text track to be extracted. An image-based track carries bitmaps, not text, and is never a source.",
"no subtitle with text on this file, and no text track inside it either — fetch one above. An image-based track carries bitmaps, not text, and is never a source.",
);
} else if (translateHint.dataset.tone === undefined) {
say(
translateHint,
"any subtitle already on the file is a legal source — a fetched one, an extracted embedded track, even another machine translation.",
offered.length > 0
? "any subtitle already on the file is a legal source, and so is a text track inside the video — picking one writes it out beside the video first."
: "any subtitle already on the file is a legal source — a fetched one, an extracted embedded track, even another machine translation.",
);
}
targetLanguages.bar(
sources.find((s) => String(s.id) === source.select.value)?.language ?? null,
);
if (pickAfterRepaint !== null) {
if (sources.some((subtitle) => subtitle.id === pickAfterRepaint)) {
source.select.value = `subtitle:${pickAfterRepaint}`;
}
pickAfterRepaint = null;
}
// the target picker never offers the source's own language, whichever
// kind of source it is — the API refuses it and it is never what was
// meant
const picked = pickedSource();
targetLanguages.bar(picked?.language ?? null);
// a track is a source only once it has been written out, so until then
// the button has nothing to translate from
translateButton.disabled = engine.select.disabled || picked === null || "index" in picked;
}
source.select.addEventListener("change", () => paintSources());
/**
* What the picker currently names, and which of the two kinds it is: a
* subtitle already on disk, or a track still inside the video. Only the
* second carries an `index`.
*/
function pickedSource(): Subtitle | EmbeddedTrack | null {
const [kind, rest] = source.select.value.split(":");
if (kind === "subtitle") {
return translationSources(subtitles).find((subtitle) => String(subtitle.id) === rest) ?? null;
}
if (kind === "track") {
return (
extractableTracks(tracks, subtitles).find((track) => String(track.index) === rest) ?? null
);
}
return null;
}
/**
* §15's extraction, on demand: the sidecar lands beside the video and
* becomes an ordinary source. The row is repainted from the answer before
* the refresh returns, so the lane is usable immediately.
*/
async function extractPicked(track: EmbeddedTrack) {
source.select.disabled = true;
translateButton.disabled = true;
translateNote.hidden = false;
say(translateNote, `writing the ${track.language} ${track.codec} track out…`);
const outcome = await extractTrack(mediaFileId, track.index);
if (outcome.kind === "error") {
say(translateNote, `extraction failed — ${outcome.detail}`, "fault");
paintSources();
return;
}
say(translateNote, `written beside the video — ${outcome.subtitle.language} is now a source`);
subtitles = [...subtitles, outcome.subtitle];
pickAfterRepaint = outcome.subtitle.id;
paintChips();
paintSources();
refresh();
}
source.select.addEventListener("change", () => {
const picked = pickedSource();
if (picked !== null && "index" in picked) {
void extractPicked(picked);
return;
}
paintSources();
});
let optionsLoaded = false;
@@ -1651,10 +1754,13 @@ function subtitleSection(status: SubtitleStatus, refresh: () => void): SubtitleS
translateButton.addEventListener("click", () => {
const target = targetLanguages.selected();
const sourceId = Number(source.select.value);
if (target === null || !Number.isFinite(sourceId) || source.select.disabled) {
const picked = pickedSource();
// a track that has not been written out carries no text; the change
// handler extracts on pick, so this only guards the moment in between
if (target === null || picked === null || "index" in picked || source.select.disabled) {
return;
}
const sourceId = picked.id;
translateButton.disabled = true;
translateNote.hidden = false;
say(translateNote, `translating into ${target}…`);
@@ -1696,6 +1802,7 @@ function subtitleSection(status: SubtitleStatus, refresh: () => void): SubtitleS
update: (next: SubtitleStatus) => {
subtitles = next.subtitles;
missing = next.missing;
tracks = next.embedded_tracks;
paintChips();
paintSources();
},
+64
View File
@@ -32,11 +32,27 @@ export interface MissingSubtitle {
detail: string | null;
}
/**
* A text track inside the video that an extract could turn into a sidecar
* (§15, issue #260). Image-format and forced tracks are never listed: arr
* does not OCR, and a language has exactly one sidecar to spend.
*/
export interface EmbeddedTrack {
/** Position among the file's subtitle streams — what an extract names. */
index: number;
language: string;
/** `ffprobe`'s own codec name: `subrip`, `ass` or `mov_text`. */
codec: string;
sdh: boolean;
}
/** One media file's subtitles and the wanted languages it still lacks. */
export interface SubtitleStatus {
media_file_id: number;
subtitles: Subtitle[];
missing: MissingSubtitle[];
/** Empty for a file probed before #189, which recorded no codec. */
embedded_tracks: EmbeddedTrack[];
}
/** The series-wide bulk sibling of `SubtitleStatus` (one call, not one per episode). */
@@ -259,6 +275,33 @@ export async function translateSubtitle(
}
}
/**
* Write one embedded text track out as a sidecar (§15, issue #260).
*
* The reconcile loop extracts only while it is already translating a gap,
* so on a file where nothing is missing no sidecar is ever written. This is
* the operator's own way in, and the sidecar it leaves is a legal
* translation source the moment it lands.
*/
export async function extractTrack(
mediaFileId: number,
trackIndex: number,
): Promise<SubtitleWriteOutcome> {
try {
const response = await fetch(`/api/media-files/${mediaFileId}/subtitles/extract`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ track_index: trackIndex }),
});
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "done", subtitle: (await response.json()) as Subtitle };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/* ---- delete (§15, issue #223) ------------------------------------------ */
export type SubtitleDeleteOutcome = { kind: "done" } | { kind: "error"; detail: string };
@@ -337,6 +380,27 @@ export function translationSources(subtitles: Subtitle[]): Subtitle[] {
return subtitles.filter((subtitle) => subtitle.path !== null);
}
/**
* The embedded tracks still worth offering: §15 gives a language exactly one
* sidecar, so a track whose language already has one on disk has nowhere to
* be written and is dropped rather than offered and then refused.
*/
export function extractableTracks(tracks: EmbeddedTrack[], subtitles: Subtitle[]): EmbeddedTrack[] {
const taken = new Set(
subtitles.filter((subtitle) => subtitle.path !== null).map((subtitle) => subtitle.language),
);
return tracks.filter((track) => !taken.has(track.language));
}
/** An embedded track's own label: language, then the format it is in. */
export function embeddedTrackLabel(track: EmbeddedTrack): string {
const parts = [track.language, track.codec];
if (track.sdh) {
parts.push("SDH");
}
return parts.join(" · ");
}
/* ---- candidate chip formatting (§9.3 columns) -------------------------- */
/**