diff --git a/src/components/media/QuickToolsDialog.tsx b/src/components/media/QuickToolsDialog.tsx new file mode 100644 index 0000000..20e67c4 --- /dev/null +++ b/src/components/media/QuickToolsDialog.tsx @@ -0,0 +1,306 @@ +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Loader2, Download, Upload, Play } from 'lucide-react' +import { toast } from 'sonner' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { Input } from '@/components/ui/input' +import { getFFmpeg, fetchFile, inferMime, run } from '@/lib/ffmpeg' +import { + buildCompressArgs, + buildResizeArgs, + buildMuteArgs, + buildTransformArgs, + buildThumbnailArgs, + buildExtractAudioArgs, + buildLoudnormArgs, + buildConvertArgs, + buildGifPalettePassArgs, + buildGifRenderArgs, + type Transform, +} from '@/lib/media-ops' + +/** + * Quick single-file operations — the former "operation tabs", folded into a + * dialog so the timeline editor stays the primary view. Operates on ONE file + * end-to-end (pick → run → download); never touches the timeline project. + * Reuses the unit-tested media-ops builders. + */ + +type Op = + | 'compress' + | 'convert' + | 'gif' + | 'audio' + | 'loudnorm' + | 'resize' + | 'mute' + | 'rotate' + | 'frame' + +const OPS: Op[] = ['compress', 'convert', 'gif', 'audio', 'loudnorm', 'resize', 'mute', 'rotate', 'frame'] + +type AudioCodec = 'libmp3lame' | 'aac' | 'flac' | 'pcm_s16le' +const AUDIO_EXT: Record = { libmp3lame: 'mp3', aac: 'aac', flac: 'flac', pcm_s16le: 'wav' } + +const stripExt = (name: string) => name.replace(/\.[^./]+$/, '') +const getExt = (name: string) => name.split('.').pop()?.toLowerCase() ?? 'bin' + +type Result = { url: string; filename: string; size: number } | null +type Status = { kind: 'idle' } | { kind: 'loading' } | { kind: 'running'; progress: number } + +export function QuickToolsDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) { + const { t } = useTranslation() + const [file, setFile] = useState(null) + const [op, setOp] = useState('compress') + const [status, setStatus] = useState({ kind: 'idle' }) + const [result, setResult] = useState(null) + const inputRef = useRef(null) + + // Option state. + const [crf, setCrf] = useState(28) + const [convertTarget, setConvertTarget] = useState<'mp4' | 'webm' | 'mov' | 'mkv'>('mp4') + const [resizeWidth, setResizeWidth] = useState(1280) + const [audioCodec, setAudioCodec] = useState('libmp3lame') + const [transform, setTransform] = useState('rotate90') + const [thumbSec, setThumbSec] = useState(0) + + useEffect(() => { + return () => { + if (result) URL.revokeObjectURL(result.url) + } + }, [result]) + + const clearResult = () => { + setResult((r) => { + if (r) URL.revokeObjectURL(r.url) + return null + }) + } + + const busy = status.kind !== 'idle' + + const runOp = async (fn: () => Promise<{ blob: Blob; filename: string }>) => { + if (!file) { + toast.error(t('media.quick.errPickFile')) + return + } + if (busy) return + clearResult() + setStatus({ kind: 'loading' }) + try { + await getFFmpeg() + setStatus({ kind: 'running', progress: 0 }) + const { blob, filename } = await fn() + setResult({ url: URL.createObjectURL(blob), filename, size: blob.size }) + toast.success(t('media.processingDone')) + } catch (e) { + toast.error(e instanceof Error ? e.message : String(e), { duration: 8000 }) + } finally { + setStatus({ kind: 'idle' }) + } + } + + const onProgress = (r: number) => setStatus({ kind: 'running', progress: r }) + + // Single-input/output op via the shared `run` helper. + const single = (build: (io: { input: string; output: string }) => string[], outExt: string, suffix: string) => + runOp(async () => { + const input = `input.${getExt(file!.name)}` + const output = `out.${outExt}` + const { blob } = await run({ + inputs: [{ name: input, data: file! }], + command: build({ input, output }), + outputName: output, + outputMime: inferMime(output), + onProgress, + }) + return { blob, filename: `${stripExt(file!.name)}${suffix}.${outExt}` } + }) + + const handlers: Record void> = { + compress: () => single(({ input, output }) => buildCompressArgs({ input, output, crf, preset: 'medium' }), 'mp4', '_compressed'), + convert: () => single(({ input, output }) => buildConvertArgs({ input, output, target: convertTarget }), convertTarget, ''), + audio: () => single(({ input, output }) => buildExtractAudioArgs({ input, output, codec: audioCodec }), AUDIO_EXT[audioCodec], ''), + loudnorm: () => single(({ input, output }) => buildLoudnormArgs({ input, output }), 'm4a', '_normalized'), + resize: () => single(({ input, output }) => buildResizeArgs({ input, output, width: resizeWidth }), 'mp4', `_${resizeWidth}w`), + mute: () => single(({ input, output }) => buildMuteArgs({ input, output }), 'mp4', '_muted'), + rotate: () => single(({ input, output }) => buildTransformArgs({ input, output, transform }), 'mp4', `_${transform}`), + frame: () => single(({ input, output }) => buildThumbnailArgs({ input, output, atSec: thumbSec, width: 0 }), 'png', '_frame'), + gif: () => + runOp(async () => { + const ff = await getFFmpeg() + const input = `input.${getExt(file!.name)}` + const palette = 'palette.png' + const output = 'out.gif' + try { + await ff.writeFile(input, await fetchFile(file!)) + if ((await ff.exec(buildGifPalettePassArgs({ input, palette, fps: 12, width: 480 }))) !== 0) + throw new Error(t('media.errGif')) + if ((await ff.exec(buildGifRenderArgs({ input, palette, output, fps: 12, width: 480 }))) !== 0) + throw new Error(t('media.errGif')) + const data = await ff.readFile(output) + if (typeof data === 'string') throw new Error(t('media.errUnexpectedString')) + return { blob: new Blob([new Uint8Array(data)], { type: 'image/gif' }), filename: `${stripExt(file!.name)}.gif` } + } finally { + for (const n of [input, palette, output]) { + try { + await ff.deleteFile(n) + } catch { + /* ignore */ + } + } + } + }), + } + + return ( + + + + {t('media.quick.title')} + {t('media.quick.description')} + + + {/* File picker */} + + { + if (e.target.files?.[0]) { + setFile(e.target.files[0]) + clearResult() + } + e.target.value = '' + }} + /> + + {/* Op selector */} +
+ {OPS.map((o) => ( + + ))} +
+ + {/* Per-op options */} +
+ {op === 'compress' && ( + + setCrf(Number(e.target.value))} className="w-40 accent-primary" /> + {crf} + + )} + {op === 'convert' && ( + + + + )} + {op === 'resize' && ( + + setResizeWidth(Number(v))} options={[['640', '640'], ['1280', '1280'], ['1920', '1920']]} /> + + )} + {op === 'audio' && ( + + setAudioCodec(v as AudioCodec)} options={[['libmp3lame', 'MP3'], ['aac', 'AAC'], ['flac', 'FLAC'], ['pcm_s16le', 'WAV']]} /> + + )} + {op === 'rotate' && ( + + setTransform(v as Transform)} + options={[['rotate90', '⟳90°'], ['rotate180', '180°'], ['rotate270', '⟲90°'], ['flipH', t('media.flipH')], ['flipV', t('media.flipV')]]} + /> + + )} + {op === 'frame' && ( + + setThumbSec(Math.max(0, Number(e.target.value)))} className="h-8 w-24 font-mono text-sm" /> + + )} + {op === 'gif' &&

{t('media.gifDescription')}

} + {op === 'mute' &&

{t('media.quick.muteHint')}

} + {op === 'loudnorm' &&

{t('media.quick.loudnormHint')}

} +
+ +
+ + {status.kind === 'running' && ( +
+
+
+ )} + {status.kind === 'loading' && {t('media.loadingCore')}} +
+ + {result && ( + + + {result.filename} + {(result.size / 1024 / 1024).toFixed(2)} MB + + )} + +
+ ) +} + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ) +} + +function Seg({ value, onChange, options }: { value: T; onChange: (v: T) => void; options: [T, string][] }) { + return ( +
+ {options.map(([v, label]) => ( + + ))} +
+ ) +} diff --git a/src/i18n/en.json b/src/i18n/en.json index 0924347..6c55ddc 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -2026,6 +2026,26 @@ "exporting": "Exporting… {{percent}}%", "errNotMedia": "{{name}} is not an audio/video file.", "errEmpty": "Add a clip to the timeline first." + }, + "quick": { + "title": "Quick tools", + "description": "One-shot operations on a single file — no timeline needed. Everything runs locally.", + "pickFile": "Choose an audio / video file…", + "run": "Run", + "errPickFile": "Choose a file first.", + "muteHint": "Remove the audio track (video copied, no re-encode where possible).", + "loudnormHint": "Normalize loudness to EBU R128 (−16 LUFS).", + "op": { + "compress": "Compress", + "convert": "Convert", + "gif": "GIF", + "audio": "Extract audio", + "loudnorm": "Loudnorm", + "resize": "Resize", + "mute": "Mute", + "rotate": "Rotate / Flip", + "frame": "Frame grab" + } } } } diff --git a/src/i18n/zh-CN.json b/src/i18n/zh-CN.json index 2b951c7..51cfd57 100644 --- a/src/i18n/zh-CN.json +++ b/src/i18n/zh-CN.json @@ -2026,6 +2026,26 @@ "exporting": "导出中… {{percent}}%", "errNotMedia": "{{name}} 不是音视频文件。", "errEmpty": "请先往时间线添加一个片段。" + }, + "quick": { + "title": "快速工具", + "description": "对单个文件的一次性操作 —— 不需要时间线。全部在本地完成。", + "pickFile": "选择一个音视频文件…", + "run": "运行", + "errPickFile": "请先选择一个文件。", + "muteHint": "去掉音轨(尽量直接复制视频、不重编码)。", + "loudnormHint": "把响度标准化到 EBU R128(−16 LUFS)。", + "op": { + "compress": "压缩", + "convert": "格式转换", + "gif": "转 GIF", + "audio": "提取音轨", + "loudnorm": "响度标准化", + "resize": "缩放", + "mute": "静音", + "rotate": "旋转 / 翻转", + "frame": "抽帧" + } } } } diff --git a/src/pages/Media.tsx b/src/pages/Media.tsx index 2674398..abc79f4 100644 --- a/src/pages/Media.tsx +++ b/src/pages/Media.tsx @@ -1,11 +1,12 @@ import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Loader2, Play, Pause, Download, Trash2, Plus, Film, Music, ZoomIn, ZoomOut } from 'lucide-react' +import { Loader2, Play, Pause, Download, Trash2, Plus, Film, Music, ZoomIn, ZoomOut, Wand2 } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { Label } from '@/components/ui/label' import { DropZone } from '@/components/media/DropZone' import { OutputView, type OutputResult } from '@/components/media/OutputView' +import { QuickToolsDialog } from '@/components/media/QuickToolsDialog' import { useTimeline } from '@/components/media/timeline/useTimeline' import { useTimelinePlayer } from '@/components/media/timeline/useTimelinePlayer' import { Timeline } from '@/components/media/timeline/Timeline' @@ -29,6 +30,7 @@ export function MediaPage() { const [exp, setExp] = useState({ kind: 'idle' }) const [output, setOutput] = useState(null) const [crf, setCrf] = useState(23) + const [quickOpen, setQuickOpen] = useState(false) useEffect(() => { return () => { @@ -96,11 +98,19 @@ export function MediaPage() { return (
-
-

{t('tools.media.name')}

-

{t('media.description')}

+
+
+

{t('tools.media.name')}

+

{t('media.description')}

+
+
+ + {!hasContent ? ( ) : (