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:
+84
-15
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user