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 */}
+
+ {segments.map((seg) => (
+ -
+
+
+ ))}
+
+ >
+ )}
+
+ {/* 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