task runner: probe video files and let you pick which audio/subtitle tracks to keep

convert video's task modal now probes a single file on open, lists its audio
and subtitle tracks as inline checkboxes, and passes the selection to the
script. audio defaults to all kept, subtitles to none; a "none" sentinel keeps
empty (folder mode) meaning keep-all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 04:47:09 +00:00
co-authored by Claude Opus 4.8
parent 8c9bb9a68e
commit 0f2b71562a
2 changed files with 103 additions and 5 deletions
@@ -12,6 +12,7 @@ import { useClient } from 'hooks/useClient';
import type { TaskSummary } from '../../useTasks';
import { useTaskRunner } from './useTaskRunner';
import { usePipelineRunner } from './usePipelineRunner';
import { useFilesAPI, type AudioTrack, type SubtitleTrack } from '../../../../hooks/useFilesAPI';
const playDing = () => {
const ctx = new AudioContext();
@@ -61,7 +62,7 @@ const PiMonoInner = ({ taskDirName, defaultInput, cwd, initialModel, taskInfo, s
// Fetch task detail for inputs and body
useEffect(() => {
client.get<{ inputs?: Record<string, TaskInputDef>; body?: string }>(`/tasks/${taskDirName}`).then((task) => {
const defs = task.inputs ?? {};
const defs: Record<string, TaskInputDef> = task.inputs ?? {};
setInputDefs(defs);
setTaskBody(task.body ?? null);
// Initialize from defaults and autofill
@@ -260,15 +261,67 @@ type TaskInputFormProps = {
values: Record<string, string>;
onChange: (key: string, value: string) => void;
autoFilledKeys: Set<string>;
audioTracks?: AudioTrack[];
subtitleTracks?: SubtitleTrack[];
probing?: boolean;
entryType?: 'file' | 'directory';
};
const TaskInputForm = ({ inputDefs, values, onChange, autoFilledKeys }: 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}`;
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;
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 === 'audio_tracks' || def.type === 'subtitle_tracks') {
const isAudio = def.type === 'audio_tracks';
const tracks = (isAudio ? audioTracks : subtitleTracks) ?? [];
const selected = new Set(
(values[key] ?? '')
.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(key, ids.length > 0 ? ids.join(',') : 'none');
};
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>
{entryType !== 'file' ? (
<span className="text-xs text-duck-dark/50 dark:text-foreground/50">All tracks kept (folder mode)</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" /> Probing
</span>
) : tracks.length === 0 ? (
<span className="text-xs text-duck-dark/50 dark:text-foreground/50">None</span>
) : (
<div className="flex flex-wrap gap-x-3 gap-y-1.5">
{tracks.map((t) => (
<label key={t.id} className="flex items-center gap-1.5 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" />
<span className="whitespace-nowrap">
{trackLabel(t)}
{isAudio && <span className="text-duck-dark/40 dark:text-foreground/40"> · {(t as AudioTrack).channels}ch</span>}
</span>
</label>
))}
</div>
)}
</div>
);
}
if (def.type === 'boolean') {
const isTrue = values[key] === 'true';
return (
@@ -338,19 +391,25 @@ type ScriptRunnerProps = {
autoInputs: Record<string, string>;
context: Record<string, string>;
cwd?: string;
entryType?: 'file' | 'directory';
filePath?: string;
};
const ScriptRunner = ({ taskDirName, autoInputs, context, cwd }: ScriptRunnerProps) => {
const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePath }: ScriptRunnerProps) => {
const runner = useTaskRunner();
const client = useClient();
const files = useFilesAPI('home');
const bottomRef = useRef<HTMLDivElement | null>(null);
const [inputDefs, setInputDefs] = useState<Record<string, TaskInputDef> | null>(null);
const [formValues, setFormValues] = useState<Record<string, string>>({});
const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([]);
const [subtitleTracks, setSubtitleTracks] = useState<SubtitleTrack[]>([]);
const [probing, setProbing] = useState(false);
// Fetch task detail to get input definitions
useEffect(() => {
client.get<{ inputs?: Record<string, TaskInputDef> }>(`/tasks/${taskDirName}`).then((task) => {
const defs = task.inputs ?? {};
const defs: Record<string, TaskInputDef> = task.inputs ?? {};
setInputDefs(defs);
// Initialize from autofill context, then defaults
const initial: Record<string, string> = {};
@@ -359,6 +418,30 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd }: ScriptRunnerPro
else if (def.default !== undefined) initial[key] = def.default;
}
setFormValues(initial);
// If the task has track pickers and we're on a single file, probe it and seed keep-all.
const hasAudio = Object.values(defs).some((d) => d.type === 'audio_tracks');
const hasSubs = Object.values(defs).some((d) => d.type === 'subtitle_tracks');
if ((hasAudio || hasSubs) && entryType === 'file' && filePath) {
setProbing(true);
Promise.all([
hasAudio ? files.audioTracks(filePath).catch(() => [] as AudioTrack[]) : Promise.resolve([] as AudioTrack[]),
hasSubs ? files.subtitles(filePath).catch(() => [] as SubtitleTrack[]) : Promise.resolve([] as SubtitleTrack[]),
])
.then(([aud, sub]: [AudioTrack[], SubtitleTrack[]]) => {
setAudioTracks(aud);
setSubtitleTracks(sub);
setFormValues((prev) => {
const next = { ...prev };
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';
}
return next;
});
})
.finally(() => setProbing(false));
}
});
}, [taskDirName]);
@@ -404,12 +487,16 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd }: ScriptRunnerPro
values={formValues}
onChange={handleInputChange}
autoFilledKeys={autoFilledKeys}
audioTracks={audioTracks}
subtitleTracks={subtitleTracks}
probing={probing}
entryType={entryType}
/>
)}
<div className="flex-1 flex items-center justify-center">
<button
onClick={handleRun}
disabled={!runner.isConnected || !inputDefs}
disabled={!runner.isConnected || !inputDefs || probing}
className="flex items-center gap-2 px-6 py-2.5 rounded-lg bg-duck-teal text-white font-medium text-sm hover:bg-duck-teal/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
<Play className="h-4 w-4" />
@@ -867,6 +954,8 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFull
autoInputs={autoInputs}
context={autofillContext}
cwd={cwd.path || undefined}
entryType={entryType}
filePath={entryName ? (cwd.path ? `${cwd.path}/${entryName}` : entryName) : undefined}
/>
) : (
<PiMonoInner
@@ -69,6 +69,12 @@ export const useFilesAPI = (root: string = 'home') => {
saveResult: (path: string) =>
client.post<{ savedPath: string }>('/file-browser/save-result', { path }),
audioTracks: (path: string) =>
client.get<AudioTrack[]>(withRoot(`/file-browser/audio-tracks?path=${encodeURIComponent(path)}`)),
subtitles: (path: string) =>
client.get<SubtitleTrack[]>(withRoot(`/file-browser/subtitles?path=${encodeURIComponent(path)}`)),
getRawUrl: (path: string) => {
const token = getHeaders()['Authorization']?.replace('Bearer ', '') ?? '';
const rp = root !== 'home' ? `&root=${encodeURIComponent(root)}` : '';
@@ -130,6 +136,9 @@ export type UseFilesAPIType = ReturnType<typeof useFilesAPI>;
// ── Types ──
export type AudioTrack = { id: number; codec: string; channels: number; lang: string; title: string };
export type SubtitleTrack = { id: number; codec: string; lang: string; title: string };
export type DirEntry = {
name: string;
path?: string;