silence trimmer
This commit is contained in:
@ -7,6 +7,7 @@ import AIPanel from './components/AIPanel';
|
||||
import ExportDialog from './components/ExportDialog';
|
||||
import SettingsPanel from './components/SettingsPanel';
|
||||
import DevPanel from './components/DevPanel';
|
||||
import SilenceTrimmerPanel from './components/SilenceTrimmerPanel';
|
||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
|
||||
import {
|
||||
Film,
|
||||
@ -23,7 +24,7 @@ import {
|
||||
|
||||
const IS_ELECTRON = !!window.electronAPI;
|
||||
|
||||
type Panel = 'ai' | 'settings' | 'export' | null;
|
||||
type Panel = 'ai' | 'settings' | 'export' | 'silence' | null;
|
||||
|
||||
export default function App() {
|
||||
const {
|
||||
@ -166,6 +167,10 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const togglePanel = (panel: Panel) => {
|
||||
setActivePanel((prev) => (prev === panel ? null : panel));
|
||||
};
|
||||
|
||||
const handleCut = () => {
|
||||
if (selectedWordIndices.length > 0) {
|
||||
// If words are selected, apply cut immediately
|
||||
@ -337,6 +342,13 @@ export default function App() {
|
||||
onClick={handleMute}
|
||||
active={muteMode}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<span className="text-[10px] font-semibold">PA</span>}
|
||||
label="Pause Trim"
|
||||
active={activePanel === 'silence'}
|
||||
onClick={() => togglePanel('silence')}
|
||||
disabled={!videoPath}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Sparkles className="w-4 h-4" />}
|
||||
label="AI"
|
||||
@ -411,6 +423,7 @@ export default function App() {
|
||||
{/* Right panel (AI / Export / Settings) */}
|
||||
{activePanel && (
|
||||
<div className="w-80 border-l border-editor-border overflow-y-auto shrink-0">
|
||||
{activePanel === 'silence' && <SilenceTrimmerPanel />}
|
||||
{activePanel === 'ai' && <AIPanel />}
|
||||
{activePanel === 'export' && <ExportDialog />}
|
||||
{activePanel === 'settings' && <SettingsPanel />}
|
||||
|
||||
184
frontend/src/components/SilenceTrimmerPanel.tsx
Normal file
184
frontend/src/components/SilenceTrimmerPanel.tsx
Normal file
@ -0,0 +1,184 @@
|
||||
import { useState } from 'react';
|
||||
import { useEditorStore } from '../store/editorStore';
|
||||
import { Loader2, Scissors } from 'lucide-react';
|
||||
|
||||
type SilenceRange = {
|
||||
start: number;
|
||||
end: number;
|
||||
duration: number;
|
||||
};
|
||||
|
||||
export default function SilenceTrimmerPanel() {
|
||||
const { videoPath, backendUrl, addCutRange, duration } = useEditorStore();
|
||||
const [minSilenceMs, setMinSilenceMs] = useState(500);
|
||||
const [silenceDb, setSilenceDb] = useState(-35);
|
||||
const [preBufferMs, setPreBufferMs] = useState(80);
|
||||
const [postBufferMs, setPostBufferMs] = useState(120);
|
||||
const [isDetecting, setIsDetecting] = useState(false);
|
||||
const [ranges, setRanges] = useState<SilenceRange[]>([]);
|
||||
|
||||
const detectSilence = async () => {
|
||||
if (!videoPath) return;
|
||||
setIsDetecting(true);
|
||||
setRanges([]);
|
||||
try {
|
||||
const res = await fetch(`${backendUrl}/audio/detect-silence`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
input_path: videoPath,
|
||||
min_silence_ms: minSilenceMs,
|
||||
silence_db: silenceDb,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let detail = `HTTP ${res.status} ${res.statusText}`;
|
||||
try {
|
||||
const err = await res.json();
|
||||
if (err?.detail) detail += ` - ${String(err.detail)}`;
|
||||
} catch {
|
||||
// ignore JSON parse errors for non-JSON error responses
|
||||
}
|
||||
if (res.status === 404) {
|
||||
detail += ' (endpoint missing: restart backend to load /audio/detect-silence)';
|
||||
}
|
||||
throw new Error(detail);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setRanges(data.ranges || []);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
alert(`Silence detection failed: ${message}`);
|
||||
} finally {
|
||||
setIsDetecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const applyAsCuts = () => {
|
||||
const preBufferSeconds = preBufferMs / 1000;
|
||||
const postBufferSeconds = postBufferMs / 1000;
|
||||
const maxEnd = duration > 0 ? duration : Number.POSITIVE_INFINITY;
|
||||
|
||||
for (const r of ranges) {
|
||||
// Positive buffers shrink the cut, negative buffers expand it.
|
||||
const start = Math.max(0, r.start + preBufferSeconds);
|
||||
const end = Math.min(maxEnd, r.end - postBufferSeconds);
|
||||
if (end - start >= 0.01) {
|
||||
addCutRange(start, end);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">Silence / Pause Trimmer</h3>
|
||||
<p className="text-xs text-editor-text-muted">
|
||||
Detect pauses and convert them into cut ranges.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] text-editor-text-muted font-medium">
|
||||
Minimum pause length (ms)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={100}
|
||||
step={50}
|
||||
value={minSilenceMs}
|
||||
onChange={(e) => setMinSilenceMs(Number(e.target.value) || 500)}
|
||||
className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] text-editor-text-muted font-medium">
|
||||
Silence threshold (dB)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={-80}
|
||||
max={0}
|
||||
step={1}
|
||||
value={silenceDb}
|
||||
onChange={(e) => setSilenceDb(Number(e.target.value) || -35)}
|
||||
className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] text-editor-text-muted font-medium">
|
||||
Buffer before (ms, +shrink / -expand)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={-5000}
|
||||
max={5000}
|
||||
step={10}
|
||||
value={preBufferMs}
|
||||
onChange={(e) => setPreBufferMs(Number(e.target.value) || 0)}
|
||||
className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] text-editor-text-muted font-medium">
|
||||
Buffer after (ms, +shrink / -expand)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={-5000}
|
||||
max={5000}
|
||||
step={10}
|
||||
value={postBufferMs}
|
||||
onChange={(e) => setPostBufferMs(Number(e.target.value) || 0)}
|
||||
className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={detectSilence}
|
||||
disabled={isDetecting || !videoPath}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-50 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
{isDetecting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Detecting pauses...
|
||||
</>
|
||||
) : (
|
||||
'Detect Pauses'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{ranges.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium">Detected {ranges.length} pause ranges</span>
|
||||
<button
|
||||
onClick={applyAsCuts}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs bg-editor-accent/20 text-editor-accent rounded hover:bg-editor-accent/30"
|
||||
>
|
||||
<Scissors className="w-3 h-3" />
|
||||
Apply As Cuts
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-56 overflow-y-auto space-y-1 pr-1">
|
||||
{ranges.slice(0, 50).map((r, i) => (
|
||||
<div key={`${r.start}-${r.end}-${i}`} className="px-2 py-1.5 rounded bg-editor-surface border border-editor-border text-xs">
|
||||
{r.start.toFixed(2)}s - {r.end.toFixed(2)}s ({r.duration.toFixed(2)}s)
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user