per-group folder config: general audio/subtitle/rename pickers via group_config

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 12:46:53 +00:00
co-authored by Claude Opus 4.8
parent a0cfb1c526
commit 372fbf11ab
@@ -545,21 +545,62 @@ const FolderSummary = ({ folder, keepAll = false, onKeepAllChange }: FolderSumma
// ── Per-group track config (one run handles every layout) ──
// Kept-ids csv per group ('none' = drop all). A group with every track kept is a no-op.
const groupRemoves = (csv: string, trackCount: number) => {
if (csv === 'none') return true;
const kept = new Set(csv.split(',').filter(Boolean).map(Number));
return kept.size < trackCount;
// Which per-group pickers a task declares.
type PickerKinds = { audio: boolean; subs: boolean; subEdit: boolean };
// Per-group selection: audio/subs are kept-id csv (or 'none'); subEdit is the keep+label list.
type GroupSel = { audio: string; subs: string; subEdit: SubtitleEditEntry[] };
const parseCsv = (csv: string) => new Set(csv.split(',').filter(Boolean).map(Number).filter((n) => !Number.isNaN(n)));
const pickerKinds = (defs: Record<string, TaskInputDef> | null): PickerKinds => ({
audio: Object.values(defs ?? {}).some((d) => d.type === 'audio_tracks'),
subs: Object.values(defs ?? {}).some((d) => d.type === 'subtitle_tracks'),
subEdit: Object.values(defs ?? {}).some((d) => d.type === 'subtitle_edit'),
});
const defaultGroupSel = (g: FolderTrackGroup): GroupSel => ({
audio: g.audioTracks.map((t) => t.id).join(','), // keep all
subs: g.subtitleTracks.map((t) => t.id).join(','), // keep all
subEdit: g.subtitleTracks.map((t) => ({ id: t.id, keep: true, label: trackLabel(t) })),
});
// Does a group's selection change anything vs keep-all + original labels?
const groupChanges = (g: FolderTrackGroup, s: GroupSel, has: PickerKinds): boolean => {
if (has.audio && (s.audio === 'none' ? g.audioTracks.length > 0 : parseCsv(s.audio).size < g.audioTracks.length)) return true;
if (has.subs && (s.subs === 'none' ? g.subtitleTracks.length > 0 : parseCsv(s.subs).size < g.subtitleTracks.length)) return true;
if (has.subEdit) {
for (const t of g.subtitleTracks) {
const e = s.subEdit.find((x) => x.id === t.id);
if (!e || !e.keep || e.label !== trackLabel(t)) return true;
}
}
return false;
};
type PerGroupAudioConfigProps = {
// Build the JSON group config the scripts consume ($INPUT_GROUP_CONFIG). allFiles=true (convert) keeps
// every group; otherwise only groups that actually change are included.
const buildGroupConfig = (groups: FolderTrackGroup[], sel: GroupSel[], has: PickerKinds, allFiles: boolean) => {
const spec = (csv: string, count: number) => (csv === 'none' ? 'none' : parseCsv(csv).size >= count ? 'all' : [...parseCsv(csv)].sort((a, b) => a - b).join(','));
return groups
.map((g, gi) => ({ g, s: sel[gi] ?? defaultGroupSel(g) }))
.filter(({ g, s }) => allFiles || groupChanges(g, s, has))
.map(({ g, s }) => ({
files: g.files,
...(has.audio ? { audio: spec(s.audio, g.audioTracks.length) } : {}),
...(has.subs ? { subs: spec(s.subs, g.subtitleTracks.length) } : {}),
...(has.subEdit ? { subEdit: g.subtitleTracks.map((t) => s.subEdit.find((x) => x.id === t.id) ?? { id: t.id, keep: true, label: trackLabel(t) }) } : {}),
}));
};
type PerGroupTrackConfigProps = {
groups: FolderTrackGroup[];
values: string[]; // per-group kept-ids csv (or 'none')
onChange: (groupIdx: number, value: string) => void;
has: PickerKinds;
sel: GroupSel[];
onChange: (groupIdx: number, patch: Partial<GroupSel>) => void;
probing?: boolean;
};
const PerGroupAudioConfig = ({ groups, values, onChange, probing }: PerGroupAudioConfigProps) => {
const PerGroupTrackConfig = ({ groups, has, sel, onChange, probing }: PerGroupTrackConfigProps) => {
if (probing) {
return (
<div className="px-5 py-3 border-b border-duck-dark/10 flex items-center gap-1.5 text-xs text-duck-dark/50 dark:text-foreground/50">
@@ -574,42 +615,100 @@ const PerGroupAudioConfig = ({ groups, values, onChange, probing }: PerGroupAudi
</div>
);
}
const label = (kind: string) => <span className="text-[11px] font-medium uppercase tracking-wide text-duck-dark/40 dark:text-foreground/40">{kind}</span>;
return (
<div className="px-5 py-3 border-b border-duck-dark/10 flex flex-col gap-3">
<span className="text-sm font-medium text-duck-dark dark:text-foreground">
{groups.length} track layout{groups.length !== 1 ? 's' : ''} uncheck tracks to remove. All groups run in one pass.
{groups.length} track layout{groups.length !== 1 ? 's' : ''} configure each; all groups run in one pass.
</span>
{groups.map((g, gi) => {
const csv = String(values[gi] ?? '');
const selected = new Set(csv.split(',').filter(Boolean).map(Number).filter((n) => !Number.isNaN(n)));
const toggle = (id: number) => {
const next = new Set(selected);
if (next.has(id)) next.delete(id);
else next.add(id);
const ids = [...next].sort((a, b) => a - b);
onChange(gi, ids.length > 0 ? ids.join(',') : 'none');
const s = sel[gi] ?? defaultGroupSel(g);
const aSel = parseCsv(s.audio);
const sSel = parseCsv(s.subs);
const toggleA = (id: number) => {
const n = new Set(aSel);
n.has(id) ? n.delete(id) : n.add(id);
onChange(gi, { audio: n.size ? [...n].sort((a, b) => a - b).join(',') : 'none' });
};
const removing = g.audioTracks.length - selected.size;
const toggleS = (id: number) => {
const n = new Set(sSel);
n.has(id) ? n.delete(id) : n.add(id);
onChange(gi, { subs: n.size ? [...n].sort((a, b) => a - b).join(',') : 'none' });
};
const entryFor = (t: SubtitleTrack): SubtitleEditEntry => s.subEdit.find((x) => x.id === t.id) ?? { id: t.id, keep: true, label: trackLabel(t) };
const updateEdit = (id: number, patch: Partial<SubtitleEditEntry>) =>
onChange(gi, { subEdit: g.subtitleTracks.map((t) => (t.id === id ? { ...entryFor(t), ...patch } : entryFor(t))) });
return (
<div key={g.signature + gi} className="flex flex-col gap-1.5 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-duck-dark dark:text-foreground">
Group {gi + 1}
<span className="text-duck-dark/50 dark:text-foreground/50 font-normal"> · {g.count} file{g.count !== 1 ? 's' : ''}</span>
</span>
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">{removing > 0 ? `removing ${removing}` : 'no change'}</span>
</div>
<div className="flex flex-col gap-1">
{g.audioTracks.map((t) => (
<label key={t.id} className="flex items-center gap-2 text-sm cursor-pointer text-duck-dark dark:text-foreground">
<input type="checkbox" checked={selected.has(t.id)} onChange={() => toggle(t.id)} className="accent-duck-teal cursor-pointer shrink-0" />
<span className="whitespace-nowrap">
{trackLabel(t)}
<span className="text-duck-dark/40 dark:text-foreground/40"> · {audioMeta(t)}</span>
</span>
</label>
))}
</div>
<div key={g.signature + gi} className="flex flex-col gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
<span className="text-sm font-medium text-duck-dark dark:text-foreground">
Group {gi + 1}
<span className="text-duck-dark/50 dark:text-foreground/50 font-normal"> · {g.count} file{g.count !== 1 ? 's' : ''}</span>
</span>
{has.audio && (
<div className="flex flex-col gap-1">
{label('audio')}
{g.audioTracks.length === 0 ? (
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">none</span>
) : (
g.audioTracks.map((t) => (
<label key={t.id} className="flex items-center gap-2 text-sm cursor-pointer text-duck-dark dark:text-foreground">
<input type="checkbox" checked={aSel.has(t.id)} onChange={() => toggleA(t.id)} className="accent-duck-teal cursor-pointer shrink-0" />
<span className="whitespace-nowrap">
{trackLabel(t)}
<span className="text-duck-dark/40 dark:text-foreground/40"> · {audioMeta(t)}</span>
</span>
</label>
))
)}
</div>
)}
{has.subs && (
<div className="flex flex-col gap-1">
{label('subtitles')}
{g.subtitleTracks.length === 0 ? (
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">none</span>
) : (
g.subtitleTracks.map((t) => (
<label key={t.id} className="flex items-center gap-2 text-sm cursor-pointer text-duck-dark dark:text-foreground">
<input type="checkbox" checked={sSel.has(t.id)} onChange={() => toggleS(t.id)} className="accent-duck-teal cursor-pointer shrink-0" />
<span className="whitespace-nowrap">
{trackLabel(t)}
<span className="text-duck-dark/40 dark:text-foreground/40"> · {t.codec}</span>
</span>
</label>
))
)}
</div>
)}
{has.subEdit && (
<div className="flex flex-col gap-1">
{label('subtitles')}
{g.subtitleTracks.length === 0 ? (
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">none</span>
) : (
g.subtitleTracks.map((t) => {
const e = entryFor(t);
return (
<div key={t.id} className="flex items-center gap-2">
<input type="checkbox" checked={e.keep} onChange={() => updateEdit(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) => updateEdit(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>
)}
<details className="text-xs text-duck-dark/40 dark:text-foreground/40">
<summary className="cursor-pointer select-none">files</summary>
<ul className="mt-1 max-h-24 overflow-y-auto flex flex-col gap-0.5 pl-1">
@@ -659,17 +758,21 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
const [folderKeepAll, setFolderKeepAll] = useState(false);
// Task opts into per-group track config (one run, each layout its own selection) via config.perGroupTracks.
const [perGroupTracks, setPerGroupTracks] = useState(false);
// perGroupAllFiles: process every file even when a group keeps everything (convert — a format change
// is still work). Otherwise only groups that actually change are run (track-only edits).
const [perGroupAllFiles, setPerGroupAllFiles] = useState(false);
const [allGroups, setAllGroups] = useState<FolderTrackGroup[]>([]);
const [groupSel, setGroupSel] = useState<string[]>([]); // per-group kept-ids csv (or 'none')
const [groupSel, setGroupSel] = useState<GroupSel[]>([]);
// Fetch task detail to get input definitions
useEffect(() => {
client
.get<{ inputs?: Record<string, TaskInputDef>; config?: { folderKeepAll?: boolean; perGroupTracks?: boolean } }>(`/tasks/${taskDirName}`)
.get<{ inputs?: Record<string, TaskInputDef>; config?: { folderKeepAll?: boolean; perGroupTracks?: boolean; perGroupAllFiles?: boolean } }>(`/tasks/${taskDirName}`)
.then((task) => {
const defs: Record<string, TaskInputDef> = task.inputs ?? {};
setFolderKeepAll(task.config?.folderKeepAll === true);
setPerGroupTracks(task.config?.perGroupTracks === true);
setPerGroupAllFiles(task.config?.perGroupAllFiles === true);
setInputDefs(defs);
// Initialize from autofill context, then defaults
const initial: Record<string, string> = {};
@@ -731,9 +834,9 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
: probe.groups;
const fileCount = sel ? groups.reduce((n, g) => n + g.files.length, 0) : probe.fileCount;
// Per-group mode uses every group with its own picker, each defaulting to keep-all.
// Per-group mode uses every group with its own pickers, each defaulting to keep-all.
setAllGroups(groups);
setGroupSel(groups.map((g) => g.audioTracks.map((t) => t.id).join(',') || 'none'));
setGroupSel(groups.map(defaultGroupSel));
const majority = groups[0];
if (!majority) {
@@ -793,21 +896,17 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
(d) => d.type === 'audio_tracks' || d.type === 'subtitle_tracks',
);
// Per-group mode: any group that removes at least one track is real work.
// Which per-group pickers this task declares, and whether the current selection is real work.
const has = pickerKinds(inputDefs);
const perGroupHasWork =
perGroupTracks && entryType === 'directory' && allGroups.some((g, gi) => groupRemoves(String(groupSel[gi] ?? ''), g.audioTracks.length));
perGroupTracks && entryType === 'directory' && buildGroupConfig(allGroups, groupSel, has, perGroupAllFiles).length > 0;
const handleRun = () => {
// Per-group config: flatten each group's selection to a per-file "<keep_ids>\t<relpath>" map, so a
// single run handles every layout. No-op groups (all tracks kept) are left out.
// Per-group config: serialize each layout's selection to $INPUT_GROUP_CONFIG (JSON), so a single
// run handles every group. For track-only edits, no-op groups are left out; convert keeps all.
if (perGroupTracks && entryType === 'directory') {
const lines: string[] = [];
allGroups.forEach((g, gi) => {
const csv = String(groupSel[gi] ?? '');
if (!groupRemoves(csv, g.audioTracks.length)) return;
for (const f of g.files) lines.push(`${csv || 'none'}\t${f}`);
});
const allInputs = { ...formValues, ...(lines.length ? { track_map: lines.join('\n') } : {}), ...autoInputs };
const config = buildGroupConfig(allGroups, groupSel, has, perGroupAllFiles);
const allInputs = { ...formValues, ...(config.length ? { group_config: JSON.stringify(config) } : {}), ...autoInputs };
runner.run(taskDirName, allInputs, cwd);
return;
}
@@ -833,10 +932,11 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
</div>
)}
{perGroupTracks && entryType === 'directory' ? (
<PerGroupAudioConfig
<PerGroupTrackConfig
groups={allGroups}
values={groupSel}
onChange={(gi, v) => setGroupSel((prev) => prev.map((s, i) => (i === gi ? v : s)))}
has={has}
sel={groupSel}
onChange={(gi, patch) => setGroupSel((prev) => prev.map((s, i) => (i === gi ? { ...s, ...patch } : s)))}
probing={probing}
/>
) : (