diff --git a/src/servers/api/file-browser/router.ts b/src/servers/api/file-browser/router.ts index 1e937c2a..4309ca0a 100644 --- a/src/servers/api/file-browser/router.ts +++ b/src/servers/api/file-browser/router.ts @@ -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 { 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 { 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 diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx index 5268b8f4..d587492c 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx @@ -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 ( +
+ Analyzing files… +
+ ); + } + if (groups.length === 0) { + return ( +
+ No videos found in this folder. +
+ ); + } + return ( +
+ + {groups.length} track layout{groups.length !== 1 ? 's' : ''} — uncheck tracks to remove. All groups run in one pass. + + {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 ( +
+
+ + Group {gi + 1} + · {g.count} file{g.count !== 1 ? 's' : ''} + + {removing > 0 ? `removing ${removing}` : 'no change'} +
+
+ {g.audioTracks.map((t) => ( + + ))} +
+
+ files +
    + {g.files.map((f) => ( +
  • + • {f} +
  • + ))} +
+
+
+ ); + })} +
+ ); +}; + // ── 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([]); + const [groupSel, setGroupSel] = useState([]); // per-group kept-ids csv (or 'none') // Fetch task detail to get input definitions useEffect(() => { - client.get<{ inputs?: Record; config?: { folderKeepAll?: boolean } }>(`/tasks/${taskDirName}`).then((task) => { + client + .get<{ inputs?: Record; config?: { folderKeepAll?: boolean; perGroupTracks?: boolean } }>(`/tasks/${taskDirName}`) + .then((task) => { const defs: Record = task.inputs ?? {}; setFolderKeepAll(task.config?.folderKeepAll === true); + setPerGroupTracks(task.config?.perGroupTracks === true); setInputDefs(defs); // Initialize from autofill context, then defaults const initial: Record = {}; @@ -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 "\t" 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 = {}; if (keepAll && inputDefs) { @@ -716,30 +832,41 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa Running on {selectedNames.length} selected items )} - {entryType === 'directory' && folder && !probing && ( - 0 ? setKeepAll : undefined} - /> - )} - {inputDefs && ( - setGroupSel((prev) => prev.map((s, i) => (i === gi ? v : s)))} probing={probing} - entryType={entryType} - hideTrackPickers={keepAll} /> + ) : ( + <> + {entryType === 'directory' && folder && !probing && ( + 0 ? setKeepAll : undefined} + /> + )} + {inputDefs && ( + + )} + )}