task runner: subtitle_edit input type for the subtitle editor

renders each subtitle as a keep checkbox + editable label field, seeded from the
file's current tracks. reuses the existing probe / folder-grouping / include
plumbing, so it works on a single file or a whole season.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 06:04:20 +00:00
co-authored by Claude Opus 4.8
parent ae1ccdec33
commit a2ca5b0fcd
@@ -270,6 +270,19 @@ type TaskInputFormProps = {
const trackLabel = (t: { title: string; lang: string; id: number }) =>
t.title || (t.lang && t.lang !== 'und' && t.lang !== 'unknown' ? t.lang.toUpperCase() : '') || `Track ${t.id + 1}`;
// subtitle_edit input: per-subtitle keep flag + editable label, serialized to JSON in the form value.
type SubtitleEditEntry = { id: number; keep: boolean; label: string };
const parseSubtitleSpec = (raw?: string): SubtitleEditEntry[] => {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
const TaskInputForm = ({ inputDefs, values, onChange, autoFilledKeys, audioTracks, subtitleTracks, probing, entryType }: TaskInputFormProps) => {
const configurableInputs = Object.entries(inputDefs).filter(([key]) => !autoFilledKeys.has(key));
if (configurableInputs.length === 0) return null;
@@ -277,6 +290,58 @@ const TaskInputForm = ({ inputDefs, values, onChange, autoFilledKeys, audioTrack
return (
<div className="px-5 py-3 border-b border-duck-dark/10 flex flex-col gap-3">
{configurableInputs.map(([key, def]) => {
if (def.type === 'subtitle_edit') {
const tracks = subtitleTracks ?? [];
const byId = new Map(parseSubtitleSpec(values[key]).map((e) => [e.id, e]));
const entryFor = (t: SubtitleTrack): SubtitleEditEntry => byId.get(t.id) ?? { id: t.id, keep: true, label: trackLabel(t) };
const update = (id: number, patch: Partial<SubtitleEditEntry>) =>
onChange(key, JSON.stringify(tracks.map((t) => (t.id === id ? { ...entryFor(t), ...patch } : entryFor(t)))));
const keptCount = tracks.filter((t) => entryFor(t).keep).length;
return (
<div key={key} className="flex flex-col gap-1.5">
<span className="text-sm font-medium text-duck-dark dark:text-foreground">{def.description ?? key}</span>
{probing ? (
<span className="flex items-center gap-1.5 text-xs text-duck-dark/50 dark:text-foreground/50">
<Loader2 className="h-3 w-3 animate-spin" /> {entryType === 'directory' ? 'Analyzing files…' : 'Probing…'}
</span>
) : tracks.length === 0 ? (
<span className="text-xs text-duck-dark/50 dark:text-foreground/50">No subtitles</span>
) : (
<>
<div className="flex flex-col gap-1 max-h-72 overflow-y-auto pr-1">
{tracks.map((t) => {
const e = entryFor(t);
return (
<div key={t.id} className="flex items-center gap-2">
<input
type="checkbox"
checked={e.keep}
onChange={() => update(t.id, { keep: !e.keep })}
className="accent-duck-teal cursor-pointer shrink-0"
/>
<span className="text-[10px] font-mono uppercase w-9 shrink-0 text-duck-dark/40 dark:text-foreground/40">
{t.lang || 'und'}
</span>
<input
type="text"
value={e.label}
disabled={!e.keep}
onChange={(ev) => update(t.id, { label: ev.target.value })}
className="flex-1 min-w-0 px-2 py-1 text-sm rounded-md border border-duck-dark/15 dark:border-foreground/15 bg-background focus:outline-none focus:ring-1 focus:ring-duck-teal disabled:opacity-40"
/>
</div>
);
})}
</div>
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">
Keeping {keptCount} of {tracks.length}
</span>
</>
)}
</div>
);
}
if (def.type === 'audio_tracks' || def.type === 'subtitle_tracks') {
const isAudio = def.type === 'audio_tracks';
const tracks = (isAudio ? audioTracks : subtitleTracks) ?? [];
@@ -470,7 +535,7 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
// Track pickers: audio defaults to keep-all, subtitles to keep-none. A folder probes every
// video and drives the pickers off the largest matching group (the rest convert separately).
const hasTrackPickers = Object.values(defs).some(
(d) => d.type === 'audio_tracks' || d.type === 'subtitle_tracks',
(d) => d.type === 'audio_tracks' || d.type === 'subtitle_tracks' || d.type === 'subtitle_edit',
);
if (!hasTrackPickers || !filePath) return;
@@ -482,6 +547,8 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
for (const [key, def] of Object.entries(defs)) {
if (def.type === 'audio_tracks') next[key] = aud.map((t) => t.id).join(',') || 'none';
if (def.type === 'subtitle_tracks') next[key] = 'none';
if (def.type === 'subtitle_edit')
next[key] = JSON.stringify(sub.map((t) => ({ id: t.id, keep: true, label: trackLabel(t) })));
}
return next;
});