task runner: "convert all, keep every track" option for mixed-layout folders

when a folder has mixed audio/subtitle layouts, the mismatch banner now offers a
toggle: convert the matching group (pick tracks, skip the rest) OR convert every
video keeping all audio + all subtitles (no picking, nothing skipped). the second
mode hides the pickers and clears the track-selection inputs so run.sh keeps
everything. only shown for tasks with track pickers (Convert Video).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 13:16:11 +00:00
co-authored by Claude Opus 4.8
parent 068cbfe32b
commit e68c8edc23
@@ -265,6 +265,7 @@ type TaskInputFormProps = {
subtitleTracks?: SubtitleTrack[]; subtitleTracks?: SubtitleTrack[];
probing?: boolean; probing?: boolean;
entryType?: 'file' | 'directory'; entryType?: 'file' | 'directory';
hideTrackPickers?: boolean;
}; };
const trackLabel = (t: { title: string; lang: string; id: number }) => const trackLabel = (t: { title: string; lang: string; id: number }) =>
@@ -283,7 +284,7 @@ const parseSubtitleSpec = (raw?: string): SubtitleEditEntry[] => {
} }
}; };
const TaskInputForm = ({ inputDefs, values, onChange, autoFilledKeys, audioTracks, subtitleTracks, probing, entryType }: TaskInputFormProps) => { const TaskInputForm = ({ inputDefs, values, onChange, autoFilledKeys, audioTracks, subtitleTracks, probing, entryType, hideTrackPickers }: TaskInputFormProps) => {
const configurableInputs = Object.entries(inputDefs).filter(([key]) => !autoFilledKeys.has(key)); const configurableInputs = Object.entries(inputDefs).filter(([key]) => !autoFilledKeys.has(key));
if (configurableInputs.length === 0) return null; if (configurableInputs.length === 0) return null;
@@ -343,6 +344,7 @@ const TaskInputForm = ({ inputDefs, values, onChange, autoFilledKeys, audioTrack
} }
if (def.type === 'audio_tracks' || def.type === 'subtitle_tracks') { if (def.type === 'audio_tracks' || def.type === 'subtitle_tracks') {
if (hideTrackPickers) return null; // "convert all, keep every track" mode — selection doesn't apply
const isAudio = def.type === 'audio_tracks'; const isAudio = def.type === 'audio_tracks';
const tracks = (isAudio ? audioTracks : subtitleTracks) ?? []; const tracks = (isAudio ? audioTracks : subtitleTracks) ?? [];
const selected = new Set( const selected = new Set(
@@ -451,9 +453,13 @@ const TaskInputForm = ({ inputDefs, values, onChange, autoFilledKeys, audioTrack
type FolderSummaryProps = { type FolderSummaryProps = {
folder: { fileCount: number; majorityCount: number; skipped: string[] }; folder: { fileCount: number; majorityCount: number; skipped: string[] };
keepAll?: boolean;
// When provided (mixed-layout folder with track pickers), offers the "convert all, keep every
// track" escape hatch instead of only converting the matching group.
onKeepAllChange?: (v: boolean) => void;
}; };
const FolderSummary = ({ folder }: FolderSummaryProps) => { const FolderSummary = ({ folder, keepAll = false, onKeepAllChange }: FolderSummaryProps) => {
const { fileCount, majorityCount, skipped } = folder; const { fileCount, majorityCount, skipped } = folder;
if (fileCount === 0) { if (fileCount === 0) {
@@ -473,22 +479,62 @@ const FolderSummary = ({ folder }: FolderSummaryProps) => {
); );
} }
// Mixed layouts — no toggle available: just report the majority run + skipped list.
if (!onKeepAllChange) {
return (
<div className="px-5 py-3 border-b border-duck-dark/10 flex flex-col gap-1.5">
<span className="flex items-center gap-2 text-sm text-amber-600 dark:text-amber-500">
<AlertCircle className="h-4 w-4 shrink-0" />
{majorityCount} of {fileCount} videos share this layout and will be converted.
</span>
<div className="text-xs text-duck-dark/50 dark:text-foreground/50">
<span>Different layout convert these separately:</span>
<ul className="mt-1 max-h-24 overflow-y-auto flex flex-col gap-0.5 pl-1">
{skipped.map((f) => (
<li key={f} className="truncate">
{f}
</li>
))}
</ul>
</div>
</div>
);
}
return ( return (
<div className="px-5 py-3 border-b border-duck-dark/10 flex flex-col gap-1.5"> <div className="px-5 py-3 border-b border-duck-dark/10 flex flex-col gap-2">
<span className="flex items-center gap-2 text-sm text-amber-600 dark:text-amber-500"> <span className="flex items-center gap-2 text-sm text-amber-600 dark:text-amber-500">
<AlertCircle className="h-4 w-4 shrink-0" /> <AlertCircle className="h-4 w-4 shrink-0" />
{majorityCount} of {fileCount} videos share this layout and will be converted. {majorityCount} of {fileCount} videos share a track layout.
</span> </span>
<div className="text-xs text-duck-dark/50 dark:text-foreground/50"> <div className="flex flex-col gap-1.5 text-sm text-duck-dark dark:text-foreground">
<span>Different layout convert these separately:</span> <label className="flex items-start gap-2 cursor-pointer">
<ul className="mt-1 max-h-24 overflow-y-auto flex flex-col gap-0.5 pl-1"> <input type="radio" checked={!keepAll} onChange={() => onKeepAllChange(false)} className="accent-duck-teal cursor-pointer mt-0.5" />
{skipped.map((f) => ( <span>
<li key={f} className="truncate"> Convert the {majorityCount} matching
{f} <span className="text-xs text-duck-dark/50 dark:text-foreground/50"> pick tracks below; the other {skipped.length} are skipped</span>
</li> </span>
))} </label>
</ul> <label className="flex items-start gap-2 cursor-pointer">
<input type="radio" checked={keepAll} onChange={() => onKeepAllChange(true)} className="accent-duck-teal cursor-pointer mt-0.5" />
<span>
Convert all {fileCount} keep every track
<span className="text-xs text-duck-dark/50 dark:text-foreground/50"> nothing skipped</span>
</span>
</label>
</div> </div>
{!keepAll && (
<div className="text-xs text-duck-dark/50 dark:text-foreground/50">
<span>Skipped (convert separately):</span>
<ul className="mt-1 max-h-20 overflow-y-auto flex flex-col gap-0.5 pl-1">
{skipped.map((f) => (
<li key={f} className="truncate">
{f}
</li>
))}
</ul>
</div>
)}
</div> </div>
); );
}; };
@@ -519,6 +565,8 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
const [folder, setFolder] = useState<{ fileCount: number; majorityCount: number; skipped: string[] } | null>(null); const [folder, setFolder] = useState<{ fileCount: number; majorityCount: number; skipped: string[] } | null>(null);
// Newline-separated folder-relative paths to restrict a batch to the majority group (empty = all). // Newline-separated folder-relative paths to restrict a batch to the majority group (empty = all).
const [includeFiles, setIncludeFiles] = useState(''); const [includeFiles, setIncludeFiles] = useState('');
// Mixed-layout escape hatch: convert every video keeping all tracks (no picking, nothing skipped).
const [keepAll, setKeepAll] = useState(false);
// Fetch task detail to get input definitions // Fetch task detail to get input definitions
useEffect(() => { useEffect(() => {
@@ -638,8 +686,22 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
}; };
// Folder has a track-selection picker (convert-video style) → the "keep every track" escape hatch applies.
const hasSelectablePickers = Object.values(inputDefs ?? {}).some(
(d) => d.type === 'audio_tracks' || d.type === 'subtitle_tracks',
);
const handleRun = () => { const handleRun = () => {
const allInputs = { ...formValues, ...(includeFiles ? { include: includeFiles } : {}), ...autoInputs }; // "Keep every track" mode: clear track selections (empty = keep all) and convert everything.
const overrides: Record<string, string> = {};
if (keepAll && inputDefs) {
for (const [key, def] of Object.entries(inputDefs)) {
if (def.type === 'audio_tracks' || def.type === 'subtitle_tracks') overrides[key] = '';
}
}
// Include list: keepAll on a whole folder = all files (none); on a multi-selection = all selected.
const inc = keepAll ? (selectedNames && selectedNames.length > 1 ? selectedNames.join('\n') : '') : includeFiles;
const allInputs = { ...formValues, ...overrides, ...(inc ? { include: inc } : {}), ...autoInputs };
runner.run(taskDirName, allInputs, cwd); runner.run(taskDirName, allInputs, cwd);
}; };
@@ -651,7 +713,13 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
Running on <span className="font-medium">{selectedNames.length}</span> selected items Running on <span className="font-medium">{selectedNames.length}</span> selected items
</div> </div>
)} )}
{entryType === 'directory' && folder && !probing && <FolderSummary folder={folder} />} {entryType === 'directory' && folder && !probing && (
<FolderSummary
folder={folder}
keepAll={keepAll}
onKeepAllChange={hasSelectablePickers && folder.skipped.length > 0 ? setKeepAll : undefined}
/>
)}
{inputDefs && ( {inputDefs && (
<TaskInputForm <TaskInputForm
inputDefs={inputDefs} inputDefs={inputDefs}
@@ -662,6 +730,7 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
subtitleTracks={subtitleTracks} subtitleTracks={subtitleTracks}
probing={probing} probing={probing}
entryType={entryType} entryType={entryType}
hideTrackPickers={keepAll}
/> />
)} )}
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center">