per-group track config: probe bitrate, per-file track map, folder multi-picker
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -88,6 +88,12 @@ function trackName(tags?: { title?: string; handler_name?: string }): string {
|
||||
return tags?.title || (handler && !/Handler$/.test(handler) ? handler : '');
|
||||
}
|
||||
|
||||
// ffprobe bit_rate (bits/s, as a string) → rounded kbps, or null when the container doesn't report it.
|
||||
function kbps(bitRate?: string): number | null {
|
||||
const n = Number(bitRate);
|
||||
return Number.isFinite(n) && n > 0 ? Math.round(n / 1000) : null;
|
||||
}
|
||||
|
||||
// Serve a video with a chosen audio track selected: fast `-c copy` remux (video untouched, other
|
||||
// audio dropped) cached under the user's data dir, so it streams with byte-range seeking like /raw.
|
||||
// In-flight remuxes are shared so concurrent requests for the same track don't race on the temp file.
|
||||
@@ -390,13 +396,13 @@ router.get('/audio-tracks', async (ctx) => {
|
||||
const absPath = resolveUserPath(rootDir, relPath);
|
||||
|
||||
const proc = Bun.spawn(
|
||||
['ffprobe', '-v', 'error', '-select_streams', 'a', '-show_entries', 'stream=channels,codec_name:stream_tags=language,title,handler_name', '-of', 'json', absPath],
|
||||
['ffprobe', '-v', 'error', '-select_streams', 'a', '-show_entries', 'stream=channels,codec_name,bit_rate:stream_tags=language,title,handler_name', '-of', 'json', absPath],
|
||||
{ stdout: 'pipe', stderr: 'ignore' },
|
||||
);
|
||||
const out = await new Response(proc.stdout).text();
|
||||
await proc.exited;
|
||||
|
||||
type ProbeAudio = { channels?: number; codec_name?: string; tags?: { language?: string; title?: string; handler_name?: string } };
|
||||
type ProbeAudio = { channels?: number; codec_name?: string; bit_rate?: string; tags?: { language?: string; title?: string; handler_name?: string } };
|
||||
let streams: ProbeAudio[] = [];
|
||||
try {
|
||||
streams = (JSON.parse(out).streams as ProbeAudio[]) ?? [];
|
||||
@@ -408,6 +414,7 @@ router.get('/audio-tracks', async (ctx) => {
|
||||
id,
|
||||
codec: s.codec_name ?? '',
|
||||
channels: s.channels ?? 0,
|
||||
bitrate: kbps(s.bit_rate),
|
||||
lang: s.tags?.language ?? '',
|
||||
title: trackName(s.tags),
|
||||
}));
|
||||
@@ -424,19 +431,19 @@ const VIDEO_EXTENSIONS = new Set([
|
||||
'ts', 'm2ts', 'mts', '3gp', 'ogv', 'vob', 'divx', 'asf', 'f4v', 'rm', 'rmvb',
|
||||
]);
|
||||
|
||||
type FolderAudioTrack = { id: number; codec: string; channels: number; lang: string; title: string };
|
||||
type FolderAudioTrack = { id: number; codec: string; channels: number; bitrate: number | null; lang: string; title: string };
|
||||
type FolderSubtitleTrack = { id: number; codec: string; lang: string; title: string };
|
||||
type ProbedTracks = { audio: FolderAudioTrack[]; subtitle: FolderSubtitleTrack[] };
|
||||
|
||||
async function probeVideoTracks(absPath: string): Promise<ProbedTracks> {
|
||||
const proc = Bun.spawn(
|
||||
['ffprobe', '-v', 'error', '-show_entries', 'stream=codec_type,codec_name,channels:stream_tags=language,title,handler_name', '-of', 'json', absPath],
|
||||
['ffprobe', '-v', 'error', '-show_entries', 'stream=codec_type,codec_name,channels,bit_rate:stream_tags=language,title,handler_name', '-of', 'json', absPath],
|
||||
{ stdout: 'pipe', stderr: 'ignore' },
|
||||
);
|
||||
const out = await new Response(proc.stdout).text();
|
||||
await proc.exited;
|
||||
|
||||
type ProbeStream = { codec_type?: string; codec_name?: string; channels?: number; tags?: { language?: string; title?: string; handler_name?: string } };
|
||||
type ProbeStream = { codec_type?: string; codec_name?: string; channels?: number; bit_rate?: string; tags?: { language?: string; title?: string; handler_name?: string } };
|
||||
let streams: ProbeStream[] = [];
|
||||
try {
|
||||
streams = (JSON.parse(out).streams as ProbeStream[]) ?? [];
|
||||
@@ -446,7 +453,7 @@ async function probeVideoTracks(absPath: string): Promise<ProbedTracks> {
|
||||
|
||||
const audio = streams
|
||||
.filter((s) => s.codec_type === 'audio')
|
||||
.map((s, id) => ({ id, codec: s.codec_name ?? '', channels: s.channels ?? 0, lang: s.tags?.language ?? '', title: trackName(s.tags) }));
|
||||
.map((s, id) => ({ id, codec: s.codec_name ?? '', channels: s.channels ?? 0, bitrate: kbps(s.bit_rate), lang: s.tags?.language ?? '', title: trackName(s.tags) }));
|
||||
// subtitle `id` is the index among ALL subtitle streams (what `-map 0:s:id` expects), assigned
|
||||
// before filtering out image-based tracks that can't become soft subs.
|
||||
const subtitle = streams
|
||||
|
||||
+147
-20
@@ -12,7 +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, type FolderProbe } from '../../../../hooks/useFilesAPI';
|
||||
import { useFilesAPI, type AudioTrack, type SubtitleTrack, type FolderProbe, type FolderTrackGroup } from '../../../../hooks/useFilesAPI';
|
||||
|
||||
const playDing = () => {
|
||||
const ctx = new AudioContext();
|
||||
@@ -271,6 +271,10 @@ type 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}`;
|
||||
|
||||
// Channel count → friendly layout name; audio meta → "stereo · aac 193k"-style detail for a track row.
|
||||
const chLabel = (n: number) => (n === 1 ? 'mono' : n === 2 ? 'stereo' : n === 6 ? '5.1' : n === 8 ? '7.1' : n ? `${n}ch` : '');
|
||||
const audioMeta = (t: AudioTrack) => [chLabel(t.channels), t.codec].filter(Boolean).join(' ') + (t.bitrate ? ` ${t.bitrate}k` : '');
|
||||
|
||||
// subtitle_edit input: per-subtitle keep flag + editable label, serialized to JSON in the form value.
|
||||
type SubtitleEditEntry = { id: number; keep: boolean; label: string };
|
||||
|
||||
@@ -539,6 +543,90 @@ const FolderSummary = ({ folder, keepAll = false, onKeepAllChange }: FolderSumma
|
||||
);
|
||||
};
|
||||
|
||||
// ── Per-group track config (one run handles every layout) ──
|
||||
|
||||
// Kept-ids csv per group ('none' = drop all). A group with every track kept is a no-op.
|
||||
const groupRemoves = (csv: string, trackCount: number) => {
|
||||
if (csv === 'none') return true;
|
||||
const kept = new Set(csv.split(',').filter(Boolean).map(Number));
|
||||
return kept.size < trackCount;
|
||||
};
|
||||
|
||||
type PerGroupAudioConfigProps = {
|
||||
groups: FolderTrackGroup[];
|
||||
values: string[]; // per-group kept-ids csv (or 'none')
|
||||
onChange: (groupIdx: number, value: string) => void;
|
||||
probing?: boolean;
|
||||
};
|
||||
|
||||
const PerGroupAudioConfig = ({ groups, values, onChange, probing }: PerGroupAudioConfigProps) => {
|
||||
if (probing) {
|
||||
return (
|
||||
<div className="px-5 py-3 border-b border-duck-dark/10 flex items-center gap-1.5 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> Analyzing files…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<div className="px-5 py-3 border-b border-duck-dark/10 flex items-center gap-2 text-sm text-amber-600 dark:text-amber-500">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" /> No videos found in this folder.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="px-5 py-3 border-b border-duck-dark/10 flex flex-col gap-3">
|
||||
<span className="text-sm font-medium text-duck-dark dark:text-foreground">
|
||||
{groups.length} track layout{groups.length !== 1 ? 's' : ''} — uncheck tracks to remove. All groups run in one pass.
|
||||
</span>
|
||||
{groups.map((g, gi) => {
|
||||
const csv = String(values[gi] ?? '');
|
||||
const selected = new Set(csv.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(gi, ids.length > 0 ? ids.join(',') : 'none');
|
||||
};
|
||||
const removing = g.audioTracks.length - selected.size;
|
||||
return (
|
||||
<div key={g.signature + gi} className="flex flex-col gap-1.5 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium text-duck-dark dark:text-foreground">
|
||||
Group {gi + 1}
|
||||
<span className="text-duck-dark/50 dark:text-foreground/50 font-normal"> · {g.count} file{g.count !== 1 ? 's' : ''}</span>
|
||||
</span>
|
||||
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">{removing > 0 ? `removing ${removing}` : 'no change'}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{g.audioTracks.map((t) => (
|
||||
<label key={t.id} className="flex items-center gap-2 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 shrink-0" />
|
||||
<span className="whitespace-nowrap">
|
||||
{trackLabel(t)}
|
||||
<span className="text-duck-dark/40 dark:text-foreground/40"> · {audioMeta(t)}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<details className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
<summary className="cursor-pointer select-none">files</summary>
|
||||
<ul className="mt-1 max-h-24 overflow-y-auto flex flex-col gap-0.5 pl-1">
|
||||
{g.files.map((f) => (
|
||||
<li key={f} className="truncate">
|
||||
• {f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Script-mode runner ──
|
||||
|
||||
type ScriptRunnerProps = {
|
||||
@@ -569,12 +657,19 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
|
||||
const [keepAll, setKeepAll] = useState(false);
|
||||
// Task opts into the folder "keep every track, run over all" escape hatch via config.folderKeepAll.
|
||||
const [folderKeepAll, setFolderKeepAll] = useState(false);
|
||||
// Task opts into per-group track config (one run, each layout its own selection) via config.perGroupTracks.
|
||||
const [perGroupTracks, setPerGroupTracks] = useState(false);
|
||||
const [allGroups, setAllGroups] = useState<FolderTrackGroup[]>([]);
|
||||
const [groupSel, setGroupSel] = useState<string[]>([]); // per-group kept-ids csv (or 'none')
|
||||
|
||||
// Fetch task detail to get input definitions
|
||||
useEffect(() => {
|
||||
client.get<{ inputs?: Record<string, TaskInputDef>; config?: { folderKeepAll?: boolean } }>(`/tasks/${taskDirName}`).then((task) => {
|
||||
client
|
||||
.get<{ inputs?: Record<string, TaskInputDef>; config?: { folderKeepAll?: boolean; perGroupTracks?: boolean } }>(`/tasks/${taskDirName}`)
|
||||
.then((task) => {
|
||||
const defs: Record<string, TaskInputDef> = task.inputs ?? {};
|
||||
setFolderKeepAll(task.config?.folderKeepAll === true);
|
||||
setPerGroupTracks(task.config?.perGroupTracks === true);
|
||||
setInputDefs(defs);
|
||||
// Initialize from autofill context, then defaults
|
||||
const initial: Record<string, string> = {};
|
||||
@@ -636,6 +731,10 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
|
||||
: probe.groups;
|
||||
const fileCount = sel ? groups.reduce((n, g) => n + g.files.length, 0) : probe.fileCount;
|
||||
|
||||
// Per-group mode uses every group with its own picker, each defaulting to keep-all.
|
||||
setAllGroups(groups);
|
||||
setGroupSel(groups.map((g) => g.audioTracks.map((t) => t.id).join(',') || 'none'));
|
||||
|
||||
const majority = groups[0];
|
||||
if (!majority) {
|
||||
setFolder({ fileCount, majorityCount: 0, skipped: [] });
|
||||
@@ -694,7 +793,24 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
|
||||
(d) => d.type === 'audio_tracks' || d.type === 'subtitle_tracks',
|
||||
);
|
||||
|
||||
// Per-group mode: any group that removes at least one track is real work.
|
||||
const perGroupHasWork =
|
||||
perGroupTracks && entryType === 'directory' && allGroups.some((g, gi) => groupRemoves(String(groupSel[gi] ?? ''), g.audioTracks.length));
|
||||
|
||||
const handleRun = () => {
|
||||
// Per-group config: flatten each group's selection to a per-file "<keep_ids>\t<relpath>" map, so a
|
||||
// single run handles every layout. No-op groups (all tracks kept) are left out.
|
||||
if (perGroupTracks && entryType === 'directory') {
|
||||
const lines: string[] = [];
|
||||
allGroups.forEach((g, gi) => {
|
||||
const csv = String(groupSel[gi] ?? '');
|
||||
if (!groupRemoves(csv, g.audioTracks.length)) return;
|
||||
for (const f of g.files) lines.push(`${csv || 'none'}\t${f}`);
|
||||
});
|
||||
const allInputs = { ...formValues, ...(lines.length ? { track_map: lines.join('\n') } : {}), ...autoInputs };
|
||||
runner.run(taskDirName, allInputs, cwd);
|
||||
return;
|
||||
}
|
||||
// "Keep every track" mode: clear track selections (empty = keep all) and convert everything.
|
||||
const overrides: Record<string, string> = {};
|
||||
if (keepAll && inputDefs) {
|
||||
@@ -716,30 +832,41 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
|
||||
Running on <span className="font-medium">{selectedNames.length}</span> selected items
|
||||
</div>
|
||||
)}
|
||||
{entryType === 'directory' && folder && !probing && (
|
||||
<FolderSummary
|
||||
folder={folder}
|
||||
keepAll={keepAll}
|
||||
onKeepAllChange={folderKeepAll && hasSelectablePickers && folder.skipped.length > 0 ? setKeepAll : undefined}
|
||||
/>
|
||||
)}
|
||||
{inputDefs && (
|
||||
<TaskInputForm
|
||||
inputDefs={inputDefs}
|
||||
values={formValues}
|
||||
onChange={handleInputChange}
|
||||
autoFilledKeys={autoFilledKeys}
|
||||
audioTracks={audioTracks}
|
||||
subtitleTracks={subtitleTracks}
|
||||
{perGroupTracks && entryType === 'directory' ? (
|
||||
<PerGroupAudioConfig
|
||||
groups={allGroups}
|
||||
values={groupSel}
|
||||
onChange={(gi, v) => setGroupSel((prev) => prev.map((s, i) => (i === gi ? v : s)))}
|
||||
probing={probing}
|
||||
entryType={entryType}
|
||||
hideTrackPickers={keepAll}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{entryType === 'directory' && folder && !probing && (
|
||||
<FolderSummary
|
||||
folder={folder}
|
||||
keepAll={keepAll}
|
||||
onKeepAllChange={folderKeepAll && hasSelectablePickers && folder.skipped.length > 0 ? setKeepAll : undefined}
|
||||
/>
|
||||
)}
|
||||
{inputDefs && (
|
||||
<TaskInputForm
|
||||
inputDefs={inputDefs}
|
||||
values={formValues}
|
||||
onChange={handleInputChange}
|
||||
autoFilledKeys={autoFilledKeys}
|
||||
audioTracks={audioTracks}
|
||||
subtitleTracks={subtitleTracks}
|
||||
probing={probing}
|
||||
entryType={entryType}
|
||||
hideTrackPickers={keepAll}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={!runner.isConnected || !inputDefs || probing}
|
||||
disabled={!runner.isConnected || !inputDefs || probing || (perGroupTracks && entryType === 'directory' && !perGroupHasWork)}
|
||||
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" />
|
||||
|
||||
@@ -148,7 +148,7 @@ export type DownloadVideoStatus = {
|
||||
filename?: string;
|
||||
};
|
||||
|
||||
export type AudioTrack = { id: number; codec: string; channels: number; lang: string; title: string };
|
||||
export type AudioTrack = { id: number; codec: string; channels: number; bitrate: number | null; lang: string; title: string };
|
||||
export type SubtitleTrack = { id: number; codec: string; lang: string; title: string };
|
||||
|
||||
export type FolderTrackGroup = {
|
||||
|
||||
Reference in New Issue
Block a user