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:
+94
-5
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user