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:
+68
-1
@@ -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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user