2026-04-03 12:05:44 -06:00
|
|
|
import { useState } from 'react';
|
|
|
|
|
import { useEditorStore } from '../store/editorStore';
|
2026-04-11 19:13:04 -06:00
|
|
|
import { Loader2, Scissors, Trash2, RotateCcw, PencilLine, Layers } from 'lucide-react';
|
|
|
|
|
import type { SilenceDetectionRange, SilenceTrimSettings } from '../types/project';
|
2026-04-03 12:05:44 -06:00
|
|
|
|
|
|
|
|
export default function SilenceTrimmerPanel() {
|
2026-04-11 19:13:04 -06:00
|
|
|
const {
|
|
|
|
|
videoPath,
|
|
|
|
|
backendUrl,
|
|
|
|
|
silenceTrimGroups,
|
|
|
|
|
cutRanges,
|
|
|
|
|
applySilenceTrimGroup,
|
|
|
|
|
removeSilenceTrimGroup,
|
|
|
|
|
} = useEditorStore();
|
2026-04-03 12:05:44 -06:00
|
|
|
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);
|
2026-04-11 19:13:04 -06:00
|
|
|
const [ranges, setRanges] = useState<SilenceDetectionRange[]>([]);
|
|
|
|
|
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
|
|
|
|
const [status, setStatus] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
const selectedGroup = selectedGroupId
|
|
|
|
|
? silenceTrimGroups.find((group) => group.id === selectedGroupId) ?? null
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
const buildSettings = (): SilenceTrimSettings => ({
|
|
|
|
|
minSilenceMs,
|
|
|
|
|
silenceDb,
|
|
|
|
|
preBufferMs,
|
|
|
|
|
postBufferMs,
|
|
|
|
|
});
|
2026-04-03 12:05:44 -06:00
|
|
|
|
|
|
|
|
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 || []);
|
2026-04-11 19:13:04 -06:00
|
|
|
setStatus(`Detected ${(data.ranges || []).length} pause ranges.`);
|
2026-04-03 12:05:44 -06:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error(err);
|
|
|
|
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
|
|
|
alert(`Silence detection failed: ${message}`);
|
|
|
|
|
} finally {
|
|
|
|
|
setIsDetecting(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-11 19:13:04 -06:00
|
|
|
const applyAsNewGroup = () => {
|
|
|
|
|
if (ranges.length === 0) return;
|
|
|
|
|
const result = applySilenceTrimGroup({
|
|
|
|
|
sourceRanges: ranges,
|
|
|
|
|
settings: buildSettings(),
|
|
|
|
|
});
|
|
|
|
|
setSelectedGroupId(result.groupId);
|
|
|
|
|
setStatus(`Applied ${result.appliedCount} cut ranges as ${result.groupId}. Undo will revert this pass in one step.`);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const loadGroupForEditing = (groupId: string) => {
|
|
|
|
|
const group = silenceTrimGroups.find((entry) => entry.id === groupId);
|
|
|
|
|
if (!group) return;
|
|
|
|
|
setSelectedGroupId(groupId);
|
|
|
|
|
setRanges(group.sourceRanges);
|
|
|
|
|
setMinSilenceMs(group.settings.minSilenceMs);
|
|
|
|
|
setSilenceDb(group.settings.silenceDb);
|
|
|
|
|
setPreBufferMs(group.settings.preBufferMs);
|
|
|
|
|
setPostBufferMs(group.settings.postBufferMs);
|
|
|
|
|
setStatus(`Loaded ${group.id} for editing. Adjust settings and reapply.`);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const reapplySelectedGroup = () => {
|
|
|
|
|
if (!selectedGroupId || ranges.length === 0) return;
|
|
|
|
|
const result = applySilenceTrimGroup({
|
|
|
|
|
groupId: selectedGroupId,
|
|
|
|
|
sourceRanges: ranges,
|
|
|
|
|
settings: buildSettings(),
|
|
|
|
|
});
|
|
|
|
|
setStatus(`Reapplied ${result.groupId} with ${result.appliedCount} cut ranges.`);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const removeGroup = (groupId: string) => {
|
|
|
|
|
removeSilenceTrimGroup(groupId);
|
|
|
|
|
if (selectedGroupId === groupId) {
|
|
|
|
|
setSelectedGroupId(null);
|
2026-04-03 12:05:44 -06:00
|
|
|
}
|
2026-04-11 19:13:04 -06:00
|
|
|
setStatus(`Removed all cut ranges from ${groupId}.`);
|
2026-04-03 12:05:44 -06:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
2026-04-11 19:13:04 -06:00
|
|
|
{status && (
|
|
|
|
|
<div className="text-[11px] text-editor-text-muted bg-editor-surface border border-editor-border rounded px-2.5 py-2">
|
|
|
|
|
{status}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-04-03 12:05:44 -06:00
|
|
|
{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>
|
2026-04-11 19:13:04 -06:00
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
{selectedGroup && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={reapplySelectedGroup}
|
|
|
|
|
className="flex items-center gap-1 px-2 py-1 text-xs bg-editor-warning/20 text-editor-warning rounded hover:bg-editor-warning/30"
|
|
|
|
|
>
|
|
|
|
|
<RotateCcw className="w-3 h-3" />
|
|
|
|
|
Reapply Group
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
<button
|
|
|
|
|
onClick={applyAsNewGroup}
|
|
|
|
|
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 New Group
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-04-03 12:05:44 -06:00
|
|
|
</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>
|
|
|
|
|
)}
|
2026-04-11 19:13:04 -06:00
|
|
|
|
|
|
|
|
{silenceTrimGroups.length > 0 && (
|
|
|
|
|
<div className="space-y-2 pt-1">
|
|
|
|
|
<div className="text-xs font-medium flex items-center gap-1">
|
|
|
|
|
<Layers className="w-3 h-3" />
|
|
|
|
|
Silence Trim Groups
|
|
|
|
|
</div>
|
|
|
|
|
<div className="max-h-48 overflow-y-auto space-y-1 pr-1">
|
|
|
|
|
{silenceTrimGroups.map((group) => {
|
|
|
|
|
const groupCutCount = cutRanges.filter((range) => range.trimGroupId === group.id).length;
|
|
|
|
|
const isActive = selectedGroupId === group.id;
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={group.id}
|
|
|
|
|
className={`rounded border px-2 py-1.5 text-xs ${isActive ? 'border-editor-accent bg-editor-accent/10' : 'border-editor-border bg-editor-surface'}`}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center justify-between gap-2">
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
<div className="font-medium truncate">{group.id}</div>
|
|
|
|
|
<div className="text-[10px] text-editor-text-muted">
|
|
|
|
|
{groupCutCount} cuts · {group.sourceRanges.length} source pauses
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => loadGroupForEditing(group.id)}
|
|
|
|
|
className="px-1.5 py-1 rounded hover:bg-editor-accent/20 text-editor-accent"
|
|
|
|
|
title="Edit and reapply this group"
|
|
|
|
|
>
|
|
|
|
|
<PencilLine className="w-3 h-3" />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => removeGroup(group.id)}
|
|
|
|
|
className="px-1.5 py-1 rounded hover:bg-editor-danger/20 text-editor-danger"
|
|
|
|
|
title="Delete all cuts from this group"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="w-3 h-3" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-04-03 12:05:44 -06:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|