diff --git a/src/components/SilenceTimeline.tsx b/src/components/SilenceTimeline.tsx new file mode 100644 index 00000000..2fa55787 --- /dev/null +++ b/src/components/SilenceTimeline.tsx @@ -0,0 +1,40 @@ +'use client'; + +import React from 'react'; +import type { SilenceSegment } from '@/lib/types'; + +interface SilenceTimelineProps { + segments: SilenceSegment[]; + duration: number; + onToggleSegment: (id: string) => void; +} + +export function SilenceTimeline({ + segments, + duration, + onToggleSegment, +}: SilenceTimelineProps) { + if (!segments.length || !duration) return null; + + return ( +
+ {segments.map((seg) => { + const left = (seg.start / duration) * 100; + const width = ((seg.end - seg.start) / duration) * 100; + return ( +
+ ); +} \ No newline at end of file diff --git a/src/components/SmartTrimControl.tsx b/src/components/SmartTrimControl.tsx new file mode 100644 index 00000000..f48c86d6 --- /dev/null +++ b/src/components/SmartTrimControl.tsx @@ -0,0 +1,197 @@ +'use client'; + +import React, { useState } from 'react'; +import { Scissors, Loader2, AlertCircle, CheckCheck, Square } from 'lucide-react'; +import type { SilenceSegment, SmartTrimStatus } from '@/lib/types'; + +interface SmartTrimControlProps { + status: SmartTrimStatus; + segments: SilenceSegment[]; + onDetect: (noiseDb: number, minDuration: number) => void; + onToggleSegment: (id: string) => void; + onSelectAll: () => void; + onDeselectAll: () => void; + onApply: () => void; + onReset: () => void; + error: string | null; +} + +function formatTime(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = (seconds % 60).toFixed(2).padStart(5, '0'); + return `${m}:${s}`; +} + +export function SmartTrimControl({ + status, + segments, + onDetect, + onToggleSegment, + onSelectAll, + onDeselectAll, + onApply, + onReset, + error, +}: SmartTrimControlProps) { + const [noiseDb, setNoiseDb] = useState(-30); + const [minDuration, setMinDuration] = useState(0.5); + + const selectedCount = segments.filter((s) => s.selected).length; + + return ( +
+ {/* Header */} +
+ +

Smart Trim

+ Privacy-safe · runs locally +
+ + {/* Settings */} + {status === 'idle' && ( +
+
+ + setNoiseDb(Number(e.target.value))} + className="w-full accent-violet-500" + /> +
+ -60 dB (very quiet) + -10 dB (louder) +
+
+ +
+ + setMinDuration(Number(e.target.value))} + className="w-full accent-violet-500" + /> +
+ + +
+ )} + + {/* Loading */} + {status === 'detecting' && ( +
+ + Analyzing audio... +
+ )} + + {/* Error */} + {status === 'error' && ( +
+
+ + {error ?? 'Detection failed'} +
+ +
+ )} + + {/* Results */} + {status === 'done' && ( +
+ {segments.length === 0 ? ( +

+ No silence detected. Try adjusting the threshold. +

+ ) : ( + <> + {/* Bulk controls */} +
+ + {segments.length} segment{segments.length !== 1 ? 's' : ''} found + {selectedCount > 0 && ` · ${selectedCount} selected`} + +
+ + +
+
+ + {/* Segment list */} + + + )} + + {/* Action buttons */} +
+ + +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index a12c1f41..76beb0de 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -18,6 +18,9 @@ import ExportOverlay from "./ExportOverlay"; import DownloadResult from "./DownloadResult"; import ImageOverlay from "./ImageOverlay" import { getPresetById } from "@/lib/presets"; +import { useSmartTrim } from '@/hooks/useSmartTrim'; +import { SmartTrimControl } from './SmartTrimControl'; +import { SilenceTimeline } from './SilenceTimeline'; import { cn } from "@/lib/utils"; import { @@ -213,6 +216,36 @@ export default function VideoEditor() { toggleSound, } = useVideoEditor(); + // Get video duration from existing state — adapt 'recipe.duration' or however +// your existing hook exposes it. Example: +const videoDuration = recipe?.duration ?? 0; + +const { + status: smartTrimStatus, + segments: silenceSegments, + runDetection, + toggleSegment, + selectAll, + deselectAll, + applyTrim, + reset: resetSmartTrim, + error: smartTrimError, +} = useSmartTrim(videoDuration); + +// Handler: when user clicks "Apply Trim" +const handleSmartTrimApply = () => { +const { keepRanges } = applyTrim(); + console.log('[SmartTrim] Keep ranges:', keepRanges); + // Phase 3 integration: feed keepRanges into the existing export recipe. + // For now, log them. You can map keepRanges[0] -> recipe trim start/end + // for single-range use cases, or queue multi-segment exports. + if (keepRanges.length > 0) { + // Example: apply first keep range to existing trim control + // setRecipe(prev => ({ ...prev, trimStart: keepRanges[0].start, trimEnd: keepRanges[0].end })); + alert(`Smart Trim ready: ${keepRanges.length} segment(s) to keep. Check console for ranges.`); + } +}; + useKeyboardShortcuts({ file, recipe, @@ -761,3 +794,25 @@ return () => { ); } + +{/* Smart Trim Panel */} + file && runDetection(file, noiseDb, minDuration)} + onToggleSegment={toggleSegment} + onSelectAll={selectAll} + onDeselectAll={deselectAll} + onApply={handleSmartTrimApply} + onReset={resetSmartTrim} + error={smartTrimError} +/> + +{/* Timeline overlay — place this beneath the video preview timeline */} +{smartTrimStatus === 'done' && ( + +)} \ No newline at end of file diff --git a/src/hooks/useSmartTrim.ts b/src/hooks/useSmartTrim.ts new file mode 100644 index 00000000..f7e90f0b --- /dev/null +++ b/src/hooks/useSmartTrim.ts @@ -0,0 +1,98 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { detectSilence } from '@/lib/ffmpeg'; +import type { SilenceSegment, SmartTrimStatus } from '@/lib/types'; + +interface UseSmartTrimReturn { + status: SmartTrimStatus; + segments: SilenceSegment[]; + runDetection: (file: File, noiseDb?: number, minDuration?: number) => Promise; + toggleSegment: (id: string) => void; + selectAll: () => void; + deselectAll: () => void; + applyTrim: () => { keepRanges: Array<{ start: number; end: number }> }; + reset: () => void; + error: string | null; +} + +export function useSmartTrim(videoDuration: number): UseSmartTrimReturn { + const [status, setStatus] = useState('idle'); + const [segments, setSegments] = useState([]); + const [error, setError] = useState(null); + + const runDetection = useCallback( + async (file: File, noiseDb = -30, minDuration = 0.5) => { + setStatus('detecting'); + setError(null); + try { + const detected = await detectSilence(file, noiseDb, minDuration); + setSegments(detected); + setStatus('done'); + } catch (err) { + console.error('[SmartTrim] Detection failed:', err); + setError(err instanceof Error ? err.message : 'Detection failed'); + setStatus('error'); + } + }, + [] + ); + + const toggleSegment = useCallback((id: string) => { + setSegments((prev) => + prev.map((s) => (s.id === id ? { ...s, selected: !s.selected } : s)) + ); + }, []); + + const selectAll = useCallback(() => { + setSegments((prev) => prev.map((s) => ({ ...s, selected: true }))); + }, []); + + const deselectAll = useCallback(() => { + setSegments((prev) => prev.map((s) => ({ ...s, selected: false }))); + }, []); + + /** + * Computes the ranges of video to KEEP (inverting the selected silence segments). + * Returns an array of {start, end} ranges that should be concatenated. + */ + const applyTrim = useCallback(() => { + const selectedSegments = segments + .filter((s) => s.selected) + .sort((a, b) => a.start - b.start); + + const keepRanges: Array<{ start: number; end: number }> = []; + let cursor = 0; + + for (const seg of selectedSegments) { + if (cursor < seg.start) { + keepRanges.push({ start: cursor, end: seg.start }); + } + cursor = seg.end; + } + + if (cursor < videoDuration) { + keepRanges.push({ start: cursor, end: videoDuration }); + } + + return { keepRanges }; + }, [segments, videoDuration]); + + const reset = useCallback(() => { + setStatus('idle'); + setSegments([]); + setError(null); + }, []); + + return { + status, + segments, + runDetection, + toggleSegment, + selectAll, + deselectAll, + applyTrim, + reset, + error, + }; +} \ No newline at end of file diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts index 625387d2..1e958742 100644 --- a/src/lib/ffmpeg.ts +++ b/src/lib/ffmpeg.ts @@ -221,6 +221,7 @@ export async function exportVideo( throw new Error("FFmpeg worker is not available."); } + const sessionId = buildSessionId(); const arrayBuffer = await file.arrayBuffer(); const filePayload: SerializedFile = { @@ -296,6 +297,81 @@ export async function exportVideo( } } +// ─── Smart-Trim: silence detection ────────────────────────────────────────── + +import type { SilenceSegment } from './types'; + +/** + * Runs FFmpeg's silencedetect filter on the input file and returns + * an array of silent intervals. All processing is client-side (WASM). + * + * @param file - The video File object from the user + * @param noiseDb - Silence threshold in dB (default -30 dB) + * @param minDuration - Minimum silence duration to report in seconds (default 0.5) + */ +export async function detectSilence( + file: File, + noiseDb: number = -30, + minDuration: number = 0.5 +): Promise { + const { createFFmpeg, fetchFile } = await import('@ffmpeg/ffmpeg'); + + const ffmpeg = createFFmpeg({ log: false }); + if (!ffmpeg.isLoaded()) { + await ffmpeg.load(); + } + + const inputName = 'smart_trim_input.mp4'; + ffmpeg.FS('writeFile', inputName, await fetchFile(file)); + + // Capture log output — silencedetect writes to stderr + const logLines: string[] = []; + ffmpeg.setLogger(({ message }) => { + logLines.push(message); + }); + + // Run silencedetect filter; output is discarded (null sink) + await ffmpeg.run( + '-i', inputName, + '-af', `silencedetect=noise=${noiseDb}dB:duration=${minDuration}`, + '-f', 'null', + '-' + ); + + // Clean up FS + ffmpeg.FS('unlink', inputName); + + // Parse the log output for silence_start / silence_end lines + const segments: SilenceSegment[] = []; + let currentStart: number | null = null; + let idCounter = 0; + + for (const line of logLines) { + const startMatch = line.match(/silence_start:\s*([\d.]+)/); + const endMatch = line.match(/silence_end:\s*([\d.]+)/); + + if (startMatch) { + currentStart = parseFloat(startMatch[1]); + } + + if (endMatch && currentStart !== null) { + const end = parseFloat(endMatch[1]); + const duration = end - currentStart; + segments.push({ + id: `silence_${idCounter++}`, + start: currentStart, + end, + duration, + selected: true, // pre-select all by default + }); + currentStart = null; + } + } + + console.log('[SmartTrim] Detected silence segments:', segments); + return segments; +} + async function getVideoDuration(file: File): Promise { return new Promise((resolve) => { const video = document.createElement("video"); diff --git a/src/lib/types.ts b/src/lib/types.ts index 90827df9..4006fab1 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -145,3 +145,19 @@ export function isValidRecipe(value: unknown): value is EditRecipe { return true; } + +// ─── Smart-Trim types ──────────────────────────────────────────────────────── + +export interface SilenceSegment { + id: string; + start: number; // seconds + end: number; // seconds + duration: number; + selected: boolean; // whether this segment is marked for removal +} + +export type SmartTrimStatus = + | 'idle' + | 'detecting' + | 'done' + | 'error'; \ No newline at end of file