able to modify trim zones
This commit is contained in:
@ -1,21 +1,36 @@
|
||||
import { useState } from 'react';
|
||||
import { useEditorStore } from '../store/editorStore';
|
||||
import { Loader2, Scissors } from 'lucide-react';
|
||||
|
||||
type SilenceRange = {
|
||||
start: number;
|
||||
end: number;
|
||||
duration: number;
|
||||
};
|
||||
import { Loader2, Scissors, Trash2, RotateCcw, PencilLine, Layers } from 'lucide-react';
|
||||
import type { SilenceDetectionRange, SilenceTrimSettings } from '../types/project';
|
||||
|
||||
export default function SilenceTrimmerPanel() {
|
||||
const { videoPath, backendUrl, addCutRange, duration } = useEditorStore();
|
||||
const {
|
||||
videoPath,
|
||||
backendUrl,
|
||||
silenceTrimGroups,
|
||||
cutRanges,
|
||||
applySilenceTrimGroup,
|
||||
removeSilenceTrimGroup,
|
||||
} = 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 [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,
|
||||
});
|
||||
|
||||
const detectSilence = async () => {
|
||||
if (!videoPath) return;
|
||||
@ -48,6 +63,7 @@ export default function SilenceTrimmerPanel() {
|
||||
|
||||
const data = await res.json();
|
||||
setRanges(data.ranges || []);
|
||||
setStatus(`Detected ${(data.ranges || []).length} pause ranges.`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
@ -57,19 +73,44 @@ export default function SilenceTrimmerPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
const applyAsCuts = () => {
|
||||
const preBufferSeconds = preBufferMs / 1000;
|
||||
const postBufferSeconds = postBufferMs / 1000;
|
||||
const maxEnd = duration > 0 ? duration : Number.POSITIVE_INFINITY;
|
||||
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.`);
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
setStatus(`Removed all cut ranges from ${groupId}.`);
|
||||
};
|
||||
|
||||
return (
|
||||
@ -158,17 +199,34 @@ export default function SilenceTrimmerPanel() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status && (
|
||||
<div className="text-[11px] text-editor-text-muted bg-editor-surface border border-editor-border rounded px-2.5 py-2">
|
||||
{status}
|
||||
</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 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>
|
||||
</div>
|
||||
<div className="max-h-56 overflow-y-auto space-y-1 pr-1">
|
||||
{ranges.slice(0, 50).map((r, i) => (
|
||||
@ -179,6 +237,52 @@ export default function SilenceTrimmerPanel() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,17 @@
|
||||
import { create } from 'zustand';
|
||||
import { temporal } from 'zundo';
|
||||
import type { Word, Segment, DeletedRange, CutRange, MuteRange, TranscriptionResult, ProjectFile } from '../types/project';
|
||||
import type {
|
||||
Word,
|
||||
Segment,
|
||||
DeletedRange,
|
||||
CutRange,
|
||||
MuteRange,
|
||||
TranscriptionResult,
|
||||
ProjectFile,
|
||||
SilenceDetectionRange,
|
||||
SilenceTrimSettings,
|
||||
SilenceTrimGroup,
|
||||
} from '../types/project';
|
||||
|
||||
interface EditorState {
|
||||
videoPath: string | null;
|
||||
@ -11,6 +22,7 @@ interface EditorState {
|
||||
deletedRanges: DeletedRange[];
|
||||
cutRanges: CutRange[];
|
||||
muteRanges: MuteRange[];
|
||||
silenceTrimGroups: SilenceTrimGroup[];
|
||||
language: string;
|
||||
|
||||
currentTime: number;
|
||||
@ -43,12 +55,18 @@ interface EditorActions {
|
||||
deleteSelectedWords: () => void;
|
||||
deleteWordRange: (startIndex: number, endIndex: number) => void;
|
||||
restoreRange: (rangeId: string) => void;
|
||||
addCutRange: (start: number, end: number) => void;
|
||||
addCutRange: (start: number, end: number, trimGroupId?: string) => void;
|
||||
addMuteRange: (start: number, end: number) => void;
|
||||
updateCutRange: (id: string, start: number, end: number) => void;
|
||||
updateMuteRange: (id: string, start: number, end: number) => void;
|
||||
removeCutRange: (id: string) => void;
|
||||
removeMuteRange: (id: string) => void;
|
||||
applySilenceTrimGroup: (args: {
|
||||
groupId?: string;
|
||||
sourceRanges: SilenceDetectionRange[];
|
||||
settings: SilenceTrimSettings;
|
||||
}) => { groupId: string; appliedCount: number };
|
||||
removeSilenceTrimGroup: (groupId: string) => void;
|
||||
setTranscribing: (active: boolean, progress?: number, status?: string) => void;
|
||||
setExporting: (active: boolean, progress?: number) => void;
|
||||
getKeepSegments: () => Array<{ start: number; end: number }>;
|
||||
@ -66,6 +84,7 @@ const initialState: EditorState = {
|
||||
deletedRanges: [],
|
||||
cutRanges: [],
|
||||
muteRanges: [],
|
||||
silenceTrimGroups: [],
|
||||
language: '',
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
@ -81,6 +100,32 @@ const initialState: EditorState = {
|
||||
};
|
||||
|
||||
let nextRangeId = 1;
|
||||
let nextTrimGroupId = 1;
|
||||
|
||||
function buildTrimCutRanges(
|
||||
sourceRanges: SilenceDetectionRange[],
|
||||
settings: SilenceTrimSettings,
|
||||
maxEnd: number,
|
||||
trimGroupId: string,
|
||||
): CutRange[] {
|
||||
const preBufferSeconds = settings.preBufferMs / 1000;
|
||||
const postBufferSeconds = settings.postBufferMs / 1000;
|
||||
const capEnd = maxEnd > 0 ? maxEnd : Number.POSITIVE_INFINITY;
|
||||
|
||||
const built: CutRange[] = [];
|
||||
for (const range of sourceRanges) {
|
||||
const start = Math.max(0, range.start + preBufferSeconds);
|
||||
const end = Math.min(capEnd, range.end - postBufferSeconds);
|
||||
if (end - start < 0.01) continue;
|
||||
built.push({
|
||||
id: `cut_${nextRangeId++}`,
|
||||
start,
|
||||
end,
|
||||
trimGroupId,
|
||||
});
|
||||
}
|
||||
return built;
|
||||
}
|
||||
|
||||
export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
temporal(
|
||||
@ -92,7 +137,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
setExportedAudioPath: (path) => set({ exportedAudioPath: path }),
|
||||
|
||||
saveProject: (): ProjectFile => {
|
||||
const { videoPath, words, segments, deletedRanges, cutRanges, muteRanges, language, exportedAudioPath } = get();
|
||||
const { videoPath, words, segments, deletedRanges, cutRanges, muteRanges, silenceTrimGroups, language, exportedAudioPath } = get();
|
||||
if (!videoPath) throw new Error('No video loaded');
|
||||
const now = new Date().toISOString();
|
||||
// Strip globalStartIndex (runtime-only field) before persisting
|
||||
@ -106,6 +151,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
deletedRanges,
|
||||
cutRanges,
|
||||
muteRanges,
|
||||
silenceTrimGroups,
|
||||
language,
|
||||
createdAt: now, // will be overwritten if we track original creation time later
|
||||
modifiedAt: now,
|
||||
@ -186,12 +232,13 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
set({ deletedRanges: deletedRanges.filter((r) => r.id !== rangeId) });
|
||||
},
|
||||
|
||||
addCutRange: (start, end) => {
|
||||
addCutRange: (start, end, trimGroupId) => {
|
||||
const { cutRanges } = get();
|
||||
const newRange: CutRange = {
|
||||
id: `cut_${nextRangeId++}`,
|
||||
start,
|
||||
end,
|
||||
trimGroupId,
|
||||
};
|
||||
set({ cutRanges: [...cutRanges, newRange] });
|
||||
},
|
||||
@ -234,6 +281,47 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
set({ muteRanges: muteRanges.filter((r) => r.id !== id) });
|
||||
},
|
||||
|
||||
applySilenceTrimGroup: ({ groupId, sourceRanges, settings }) => {
|
||||
const { duration, cutRanges, silenceTrimGroups } = get();
|
||||
const now = new Date().toISOString();
|
||||
const existingGroup = groupId
|
||||
? silenceTrimGroups.find((group) => group.id === groupId)
|
||||
: null;
|
||||
const resolvedGroupId = existingGroup?.id ?? `trimgrp_${nextTrimGroupId++}`;
|
||||
|
||||
const nextGroup: SilenceTrimGroup = {
|
||||
id: resolvedGroupId,
|
||||
sourceRanges,
|
||||
settings,
|
||||
createdAt: existingGroup?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const withoutGroupCuts = cutRanges.filter((range) => range.trimGroupId !== resolvedGroupId);
|
||||
const generatedCuts = buildTrimCutRanges(sourceRanges, settings, duration, resolvedGroupId);
|
||||
const nextGroups = existingGroup
|
||||
? silenceTrimGroups.map((group) => (group.id === resolvedGroupId ? nextGroup : group))
|
||||
: [...silenceTrimGroups, nextGroup];
|
||||
|
||||
set({
|
||||
cutRanges: [...withoutGroupCuts, ...generatedCuts],
|
||||
silenceTrimGroups: nextGroups,
|
||||
});
|
||||
|
||||
return {
|
||||
groupId: resolvedGroupId,
|
||||
appliedCount: generatedCuts.length,
|
||||
};
|
||||
},
|
||||
|
||||
removeSilenceTrimGroup: (groupId) => {
|
||||
const { cutRanges, silenceTrimGroups } = get();
|
||||
set({
|
||||
cutRanges: cutRanges.filter((range) => range.trimGroupId !== groupId),
|
||||
silenceTrimGroups: silenceTrimGroups.filter((group) => group.id !== groupId),
|
||||
});
|
||||
},
|
||||
|
||||
setTranscribing: (active, progress, status) =>
|
||||
set({
|
||||
isTranscribing: active,
|
||||
@ -321,6 +409,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
deletedRanges: data.deletedRanges || [],
|
||||
cutRanges: data.cutRanges || [],
|
||||
muteRanges: data.muteRanges || [],
|
||||
silenceTrimGroups: data.silenceTrimGroups || [],
|
||||
language: data.language || '',
|
||||
exportedAudioPath: data.exportedAudioPath ?? null,
|
||||
});
|
||||
|
||||
@ -28,12 +28,32 @@ export interface DeletedRange extends TimeRange {
|
||||
|
||||
export interface CutRange extends TimeRange {
|
||||
id: string;
|
||||
trimGroupId?: string;
|
||||
}
|
||||
|
||||
export interface MuteRange extends TimeRange {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface SilenceDetectionRange extends TimeRange {
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface SilenceTrimSettings {
|
||||
minSilenceMs: number;
|
||||
silenceDb: number;
|
||||
preBufferMs: number;
|
||||
postBufferMs: number;
|
||||
}
|
||||
|
||||
export interface SilenceTrimGroup {
|
||||
id: string;
|
||||
settings: SilenceTrimSettings;
|
||||
sourceRanges: SilenceDetectionRange[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ProjectFile {
|
||||
version: 1;
|
||||
videoPath: string;
|
||||
@ -43,6 +63,7 @@ export interface ProjectFile {
|
||||
deletedRanges: DeletedRange[];
|
||||
cutRanges: CutRange[];
|
||||
muteRanges: MuteRange[];
|
||||
silenceTrimGroups?: SilenceTrimGroup[];
|
||||
language: string;
|
||||
createdAt: string;
|
||||
modifiedAt: string;
|
||||
|
||||
Reference in New Issue
Block a user