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 type { TaskSummary } from '../../useTasks';
|
||||||
import { useTaskRunner } from './useTaskRunner';
|
import { useTaskRunner } from './useTaskRunner';
|
||||||
import { usePipelineRunner } from './usePipelineRunner';
|
import { usePipelineRunner } from './usePipelineRunner';
|
||||||
|
import { useFilesAPI, type AudioTrack, type SubtitleTrack } from '../../../../hooks/useFilesAPI';
|
||||||
|
|
||||||
const playDing = () => {
|
const playDing = () => {
|
||||||
const ctx = new AudioContext();
|
const ctx = new AudioContext();
|
||||||
@@ -61,7 +62,7 @@ const PiMonoInner = ({ taskDirName, defaultInput, cwd, initialModel, taskInfo, s
|
|||||||
// Fetch task detail for inputs and body
|
// Fetch task detail for inputs and body
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
client.get<{ inputs?: Record<string, TaskInputDef>; body?: string }>(`/tasks/${taskDirName}`).then((task) => {
|
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);
|
setInputDefs(defs);
|
||||||
setTaskBody(task.body ?? null);
|
setTaskBody(task.body ?? null);
|
||||||
// Initialize from defaults and autofill
|
// Initialize from defaults and autofill
|
||||||
@@ -260,15 +261,67 @@ type TaskInputFormProps = {
|
|||||||
values: Record<string, string>;
|
values: Record<string, string>;
|
||||||
onChange: (key: string, value: string) => void;
|
onChange: (key: string, value: string) => void;
|
||||||
autoFilledKeys: Set<string>;
|
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));
|
const configurableInputs = Object.entries(inputDefs).filter(([key]) => !autoFilledKeys.has(key));
|
||||||
if (configurableInputs.length === 0) return null;
|
if (configurableInputs.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-5 py-3 border-b border-duck-dark/10 flex flex-col gap-3">
|
<div className="px-5 py-3 border-b border-duck-dark/10 flex flex-col gap-3">
|
||||||
{configurableInputs.map(([key, def]) => {
|
{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') {
|
if (def.type === 'boolean') {
|
||||||
const isTrue = values[key] === 'true';
|
const isTrue = values[key] === 'true';
|
||||||
return (
|
return (
|
||||||
@@ -338,19 +391,25 @@ type ScriptRunnerProps = {
|
|||||||
autoInputs: Record<string, string>;
|
autoInputs: Record<string, string>;
|
||||||
context: Record<string, string>;
|
context: Record<string, string>;
|
||||||
cwd?: 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 runner = useTaskRunner();
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
|
const files = useFilesAPI('home');
|
||||||
const bottomRef = useRef<HTMLDivElement | null>(null);
|
const bottomRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [inputDefs, setInputDefs] = useState<Record<string, TaskInputDef> | null>(null);
|
const [inputDefs, setInputDefs] = useState<Record<string, TaskInputDef> | null>(null);
|
||||||
const [formValues, setFormValues] = useState<Record<string, string>>({});
|
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
|
// Fetch task detail to get input definitions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
client.get<{ inputs?: Record<string, TaskInputDef> }>(`/tasks/${taskDirName}`).then((task) => {
|
client.get<{ inputs?: Record<string, TaskInputDef> }>(`/tasks/${taskDirName}`).then((task) => {
|
||||||
const defs = task.inputs ?? {};
|
const defs: Record<string, TaskInputDef> = task.inputs ?? {};
|
||||||
setInputDefs(defs);
|
setInputDefs(defs);
|
||||||
// Initialize from autofill context, then defaults
|
// Initialize from autofill context, then defaults
|
||||||
const initial: Record<string, string> = {};
|
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;
|
else if (def.default !== undefined) initial[key] = def.default;
|
||||||
}
|
}
|
||||||
setFormValues(initial);
|
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]);
|
}, [taskDirName]);
|
||||||
|
|
||||||
@@ -404,12 +487,16 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd }: ScriptRunnerPro
|
|||||||
values={formValues}
|
values={formValues}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
autoFilledKeys={autoFilledKeys}
|
autoFilledKeys={autoFilledKeys}
|
||||||
|
audioTracks={audioTracks}
|
||||||
|
subtitleTracks={subtitleTracks}
|
||||||
|
probing={probing}
|
||||||
|
entryType={entryType}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<button
|
<button
|
||||||
onClick={handleRun}
|
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"
|
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" />
|
<Play className="h-4 w-4" />
|
||||||
@@ -867,6 +954,8 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFull
|
|||||||
autoInputs={autoInputs}
|
autoInputs={autoInputs}
|
||||||
context={autofillContext}
|
context={autofillContext}
|
||||||
cwd={cwd.path || undefined}
|
cwd={cwd.path || undefined}
|
||||||
|
entryType={entryType}
|
||||||
|
filePath={entryName ? (cwd.path ? `${cwd.path}/${entryName}` : entryName) : undefined}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<PiMonoInner
|
<PiMonoInner
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ export const useFilesAPI = (root: string = 'home') => {
|
|||||||
saveResult: (path: string) =>
|
saveResult: (path: string) =>
|
||||||
client.post<{ savedPath: string }>('/file-browser/save-result', { path }),
|
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) => {
|
getRawUrl: (path: string) => {
|
||||||
const token = getHeaders()['Authorization']?.replace('Bearer ', '') ?? '';
|
const token = getHeaders()['Authorization']?.replace('Bearer ', '') ?? '';
|
||||||
const rp = root !== 'home' ? `&root=${encodeURIComponent(root)}` : '';
|
const rp = root !== 'home' ? `&root=${encodeURIComponent(root)}` : '';
|
||||||
@@ -130,6 +136,9 @@ export type UseFilesAPIType = ReturnType<typeof useFilesAPI>;
|
|||||||
|
|
||||||
// ── Types ──
|
// ── 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 = {
|
export type DirEntry = {
|
||||||
name: string;
|
name: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user