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:
+156
-56
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user