Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions src/components/SilenceTimeline.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="relative w-full h-2 bg-white/5 rounded-full overflow-visible mt-1">
{segments.map((seg) => {
const left = (seg.start / duration) * 100;
const width = ((seg.end - seg.start) / duration) * 100;
return (
<button
key={seg.id}
title={`Silence: ${seg.start.toFixed(2)}s – ${seg.end.toFixed(2)}s (${seg.duration.toFixed(2)}s)\nClick to toggle`}
onClick={() => onToggleSegment(seg.id)}
style={{ left: `${left}%`, width: `${Math.max(width, 0.5)}%` }}
className={`absolute top-0 h-full rounded-sm transition-colors cursor-pointer ${
seg.selected
? 'bg-violet-500/70 hover:bg-violet-400/80'
: 'bg-white/20 hover:bg-white/30'
}`}
/>
);
})}
</div>
);
}
197 changes: 197 additions & 0 deletions src/components/SmartTrimControl.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="rounded-xl border border-white/10 bg-white/5 p-4 space-y-4">
{/* Header */}
<div className="flex items-center gap-2">
<Scissors className="w-4 h-4 text-violet-400" />
<h3 className="text-sm font-semibold text-white">Smart Trim</h3>
<span className="ml-auto text-xs text-white/40">Privacy-safe · runs locally</span>
</div>

{/* Settings */}
{status === 'idle' && (
<div className="space-y-3">
<div className="space-y-1">
<label className="text-xs text-white/60">
Silence threshold: <span className="text-white">{noiseDb} dB</span>
</label>
<input
type="range"
min={-60}
max={-10}
step={1}
value={noiseDb}
onChange={(e) => setNoiseDb(Number(e.target.value))}
className="w-full accent-violet-500"
/>
<div className="flex justify-between text-[10px] text-white/30">
<span>-60 dB (very quiet)</span>
<span>-10 dB (louder)</span>
</div>
</div>

<div className="space-y-1">
<label className="text-xs text-white/60">
Min silence length: <span className="text-white">{minDuration}s</span>
</label>
<input
type="range"
min={0.1}
max={3}
step={0.1}
value={minDuration}
onChange={(e) => setMinDuration(Number(e.target.value))}
className="w-full accent-violet-500"
/>
</div>

<button
onClick={() => onDetect(noiseDb, minDuration)}
className="w-full flex items-center justify-center gap-2 rounded-lg bg-violet-600 hover:bg-violet-500 text-white text-sm font-medium py-2 transition-colors"
>
<Scissors className="w-4 h-4" />
Detect Silence
</button>
</div>
)}

{/* Loading */}
{status === 'detecting' && (
<div className="flex items-center justify-center gap-2 py-4 text-white/60 text-sm">
<Loader2 className="w-4 h-4 animate-spin" />
Analyzing audio...
</div>
)}

{/* Error */}
{status === 'error' && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-red-400 text-sm">
<AlertCircle className="w-4 h-4" />
{error ?? 'Detection failed'}
</div>
<button
onClick={onReset}
className="text-xs text-white/40 underline hover:text-white/60"
>
Try again
</button>
</div>
)}

{/* Results */}
{status === 'done' && (
<div className="space-y-3">
{segments.length === 0 ? (
<p className="text-sm text-white/50 text-center py-2">
No silence detected. Try adjusting the threshold.
</p>
) : (
<>
{/* Bulk controls */}
<div className="flex items-center justify-between">
<span className="text-xs text-white/50">
{segments.length} segment{segments.length !== 1 ? 's' : ''} found
{selectedCount > 0 && ` · ${selectedCount} selected`}
</span>
<div className="flex gap-2">
<button
onClick={onSelectAll}
title="Select all"
className="text-[10px] text-white/40 hover:text-white flex items-center gap-1"
>
<CheckCheck className="w-3 h-3" /> All
</button>
<button
onClick={onDeselectAll}
title="Deselect all"
className="text-[10px] text-white/40 hover:text-white flex items-center gap-1"
>
<Square className="w-3 h-3" /> None
</button>
</div>
</div>

{/* Segment list */}
<ul className="space-y-1 max-h-40 overflow-y-auto pr-1">
{segments.map((seg) => (
<li key={seg.id}>
<button
onClick={() => onToggleSegment(seg.id)}
className={`w-full flex items-center justify-between rounded-lg px-3 py-2 text-xs transition-colors ${
seg.selected
? 'bg-violet-600/20 border border-violet-500/40 text-white'
: 'bg-white/5 border border-transparent text-white/40'
}`}
>
<span>
{formatTime(seg.start)} → {formatTime(seg.end)}
</span>
<span className="text-white/40">{seg.duration.toFixed(2)}s</span>
</button>
</li>
))}
</ul>
</>
)}

{/* Action buttons */}
<div className="flex gap-2 pt-1">
<button
onClick={onApply}
disabled={selectedCount === 0}
className="flex-1 rounded-lg bg-violet-600 hover:bg-violet-500 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium py-2 transition-colors"
>
Apply Trim ({selectedCount})
</button>
<button
onClick={onReset}
className="rounded-lg border border-white/10 hover:bg-white/10 text-white/60 text-sm px-3 py-2 transition-colors"
>
Reset
</button>
</div>
</div>
)}
</div>
);
}
55 changes: 55 additions & 0 deletions src/components/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -761,3 +794,25 @@ return () => {
</div>
);
}

{/* Smart Trim Panel */}
<SmartTrimControl
status={smartTrimStatus}
segments={silenceSegments}
onDetect={(noiseDb, minDuration) => 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' && (
<SilenceTimeline
segments={silenceSegments}
duration={videoDuration}
onToggleSegment={toggleSegment}
/>
)}
Loading