feat(api): extract an embedded track on demand
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:
co-authored by
Claude Opus 5
parent
dfa4656509
commit
73f521ee17
+126
-19
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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) -------------------------- */
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user