Skip to content
Draft
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
7 changes: 7 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

107 changes: 105 additions & 2 deletions src/components/TrimControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

import { EditRecipe } from "@/lib/types";
import { useState, useEffect, useRef, useCallback } from "react";
import { AlertCircle } from "lucide-react";
import { formatDuration } from "@/lib/utils";
import { AlertCircle, Zap, X } from "lucide-react";
import { formatDuration, cn } from "@/lib/utils";
import { useAudioWaveform } from "@/hooks/useAudioWaveform";
import { useSilenceDetection } from "@/hooks/useSilenceDetection";
import WaveformCanvas from "@/components/WaveformCanvas";

const MIN_CLIP_DURATION = 0.1;
Expand All @@ -26,12 +27,16 @@ export default function TrimControl({ recipe, onChange, duration, file }: Props)
);

const { waveform, isLoading: waveformLoading } = useAudioWaveform(file);
const { silentSegments } = useSilenceDetection(file, 0.02);
const hasAudio = waveform.length > 0;

useEffect(() => {
setStartInput(recipe.trimStart.toString());
}, [recipe.trimStart]);

const activeJumpCuts = recipe.jumpCutSegments;
const hasJumpCuts = !!activeJumpCuts && activeJumpCuts.length > 0;

const clipLength =
(recipe.trimEnd ?? duration) - recipe.trimStart;

Expand Down Expand Up @@ -175,6 +180,46 @@ export default function TrimControl({ recipe, onChange, duration, file }: Props)
const inputClass =
"w-full text-sm px-3 py-2 border border-[var(--border)] rounded-md bg-[var(--bg)] font-heading focus:outline-none focus:ring-2 focus:ring-film-400 text-[var(--text)] transition-shadow [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none";

const generateJumpCuts = useCallback(() => {
if (silentSegments.length === 0) return;

const significantSilences = silentSegments.filter(
segment => (segment.end - segment.start) >= 0.5
);

if (significantSilences.length === 0) {
alert("No significant silent sections detected. Adjust your video threshold or check the audio.");
return;
}

const keepSegments: Array<{ start: number; end: number }> = [];
let cursor = 0;

for (const silence of significantSilences) {
if (silence.start > cursor) {
keepSegments.push({
start: cursor,
end: silence.start,
});
}

cursor = silence.end;
}

if (cursor < duration) {
keepSegments.push({
start: cursor,
end: duration,
});
}

onChange({ jumpCutSegments: keepSegments });
}, [silentSegments, duration, onChange]);

const clearJumpCuts = useCallback(() => {
onChange({ jumpCutSegments: undefined });
}, [onChange]);

return (
<div id="trim-control" className="space-y-3">
{duration > 0 && (
Expand All @@ -201,6 +246,30 @@ export default function TrimControl({ recipe, onChange, duration, file }: Props)
right: `${((duration - (recipe.trimEnd ?? duration)) / duration) * 100}%`,
}}
/>
{silentSegments.map((segment, idx) => (
<div
key={`silence-${idx}`}
className="absolute h-1.5 bg-red-400 opacity-40 rounded-full"
title={`Silent: ${formatDuration(segment.start)} - ${formatDuration(segment.end)}`}
style={{
left: `${(segment.start / duration) * 100}%`,
width: `${((segment.end - segment.start) / duration) * 100}%`,
}}
aria-label={`Silent section from ${formatDuration(segment.start)} to ${formatDuration(segment.end)}`}
/>
))}
{hasJumpCuts && activeJumpCuts!.map((seg, idx) => (
<div
key={`jumpcut-${idx}`}
className="absolute h-1.5 bg-green-400 opacity-60 rounded-full"
title={`Keep: ${formatDuration(seg.start)} - ${formatDuration(seg.end)}`}
style={{
left: `${(seg.start / duration) * 100}%`,
width: `${((seg.end - seg.start) / duration) * 100}%`,
}}
aria-label={`Keep segment from ${formatDuration(seg.start)} to ${formatDuration(seg.end)}`}
/>
))}
<div
role="slider"
aria-label="Trim start"
Expand Down Expand Up @@ -323,6 +392,40 @@ export default function TrimControl({ recipe, onChange, duration, file }: Props)
Clip must be at least 0.1 seconds long.
</p>
)}


{hasJumpCuts && (
<div className="flex items-center justify-between rounded-lg border border-green-500/30 bg-green-500/10 px-3 py-2">
<p className="text-sm font-heading text-green-200">
{activeJumpCuts!.length} keep segment{activeJumpCuts!.length !== 1 ? "s" : ""} active
</p>

<button
onClick={clearJumpCuts}
className="flex items-center gap-1 rounded-md border border-green-400/30 px-2 py-1 text-xs font-medium text-green-100 transition-colors hover:bg-green-400/10"
aria-label="Clear active jump cuts"
>
<X size={12} />
Clear
</button>
</div>
)}

<button
onClick={generateJumpCuts}
className="mt-4 w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-film-400 text-white font-semibold text-sm hover:bg-film-500 transition-colors"
aria-label={`${hasJumpCuts ? "Regenerate" : "Generate"} jump cuts by removing ${silentSegments.length} silent section${silentSegments.length !== 1 ? "s" : ""}`}
title="Auto-generate jump cuts to remove silence"
>
<Zap size={16} />
{hasJumpCuts ? "Regenerate Jump Cuts" : "Generate Jump Cuts"}
</button>

<p className="mt-2 text-xs text-zinc-400">
1. Detect Silence to generate red silence markers.
{" "}2. Generate Jump Cuts to create green keep-segments.
{" "}3. Export the video to apply FFmpeg jump cuts.
</p>
</div>
);
}
Expand Down
4 changes: 2 additions & 2 deletions src/components/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -354,15 +354,15 @@ export default function VideoEditor() {
paddingTop: 'clamp(0.5rem,2vw,0.75rem)',
}}
>
<span className="flex-shrink-0 w-1.5 h-1.5 rounded-full bg-[var(--accent)] inline-block animate-pulse" />
<span className="flex-shrink-0 w-1.5 h-1.5 rounded-full bg-green-400 inline-block animate-pulse" />
No login. No ads. 100% private.
</div>
</div>
<div
className="flex flex-wrap justify-center text-center items-center gap-2 text-sm font-heading font-semibold uppercase tracking-widest text-[var(--muted)] pb-1"
style={{ justifyContent: 'center', textAlign: 'center', margin: '0', width: 'auto' }}
>
<span className="flex-shrink-0 w-1.5 h-1.5 rounded-full bg-[var(--accent)] inline-block animate-pulse" />
<span className="flex-shrink-0 w-1.5 h-1.5 rounded-full bg-green-400 inline-block animate-pulse" />
No login. No ads. 100% private - your video never leaves your device.
</div>
</header>
Expand Down
161 changes: 161 additions & 0 deletions src/components/__tests__/videoFilter.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { describe, it, expect } from "vitest";
import { buildJumpCutFilterComplex } from "../../lib/ffmpeg";
import { DEFAULT_RECIPE } from "../../lib/constants";

const base = (overrides = {}) => ({ ...DEFAULT_RECIPE, ...overrides });

describe("buildJumpCutFilterComplex", () => {

it("returns empty filterComplex when no segments are provided", () => {
const { filterComplex } = buildJumpCutFilterComplex(
base({ jumpCutSegments: [] }),
1280, 720, false
);
expect(filterComplex).toBe("");
});

it("returns empty filterComplex when jumpCutSegments is undefined", () => {
const { filterComplex } = buildJumpCutFilterComplex(
base(),
1280, 720, false
);
expect(filterComplex).toBe("");
});

it("trims each segment with its own atrim/trim bounds", () => {
const recipe = base({
jumpCutSegments: [
{ start: 0, end: 3 },
{ start: 7, end: 12 },
],
});
const { filterComplex } = buildJumpCutFilterComplex(recipe, 1280, 720, false);
expect(filterComplex).toContain("trim=start=0:end=3");
expect(filterComplex).toContain("trim=start=7:end=12");
});

it("each segment resets timestamps with setpts=PTS-STARTPTS", () => {
const recipe = base({
jumpCutSegments: [{ start: 2, end: 5 }],
});
const { filterComplex } = buildJumpCutFilterComplex(recipe, 1280, 720, false);
expect(filterComplex).toContain("setpts=PTS-STARTPTS");
});

it("uses concat filter with correct n= count — 2 segments", () => {
const recipe = base({
jumpCutSegments: [
{ start: 0, end: 2 },
{ start: 5, end: 8 },
],
});
const { filterComplex } = buildJumpCutFilterComplex(recipe, 1280, 720, false);
expect(filterComplex).toContain("concat=n=2:v=1:a=0");
});

it("uses concat filter with correct n= count — 3 segments", () => {
const recipe = base({
jumpCutSegments: [
{ start: 0, end: 2 },
{ start: 5, end: 8 },
{ start: 10, end: 14 },
],
});
const { filterComplex } = buildJumpCutFilterComplex(recipe, 1280, 720, false);
expect(filterComplex).toContain("concat=n=3:v=1:a=0");
});

it("includes a=1 in concat when hasAudio is true", () => {
const recipe = base({
jumpCutSegments: [{ start: 0, end: 3 }, { start: 6, end: 10 }],
});
const { filterComplex } = buildJumpCutFilterComplex(recipe, 1280, 720, true);
expect(filterComplex).toContain("concat=n=2:v=1:a=1");
});

it("includes atrim for each audio segment when hasAudio is true", () => {
const recipe = base({
jumpCutSegments: [
{ start: 1, end: 4 },
{ start: 8, end: 11 },
],
});
const { filterComplex } = buildJumpCutFilterComplex(recipe, 1280, 720, true);
expect(filterComplex).toContain("atrim=start=1:end=4");
expect(filterComplex).toContain("atrim=start=8:end=11");
});

it("does NOT include atrim when hasAudio is false", () => {
const recipe = base({
jumpCutSegments: [{ start: 0, end: 5 }],
});
const { filterComplex } = buildJumpCutFilterComplex(recipe, 1280, 720, false);
expect(filterComplex).not.toContain("atrim");
});

it("applies speed (atempo) to each audio segment", () => {
const recipe = base({
jumpCutSegments: [{ start: 0, end: 5 }],
speed: 1.5,
});
const { filterComplex } = buildJumpCutFilterComplex(recipe, 1280, 720, true);
expect(filterComplex).toContain("atempo=1.5000");
});

it("output labels are [vout] and [aout] with audio", () => {
const recipe = base({
jumpCutSegments: [{ start: 0, end: 3 }],
});
const { videoOut, audioOut } = buildJumpCutFilterComplex(recipe, 1280, 720, true);
expect(videoOut).toBe("[vout]");
expect(audioOut).toBe("[aout]");
});

it("audioOut is empty string when hasAudio is false", () => {
const recipe = base({
jumpCutSegments: [{ start: 0, end: 3 }],
});
const { audioOut } = buildJumpCutFilterComplex(recipe, 1280, 720, false);
expect(audioOut).toBe("");
});

it("applies rotation to each segment", () => {
const recipe = base({
jumpCutSegments: [{ start: 0, end: 5 }],
rotate: 90,
});
const { filterComplex } = buildJumpCutFilterComplex(recipe, 1280, 720, false);
expect(filterComplex).toContain("transpose=1");
});

it("applies eq filter to each segment when color adjustments are non-neutral", () => {
const recipe = base({
jumpCutSegments: [{ start: 0, end: 5 }],
brightness: 0.2,
contrast: 1.1,
saturation: 0.9,
});
const { filterComplex } = buildJumpCutFilterComplex(recipe, 1280, 720, false);
expect(filterComplex).toContain("eq=brightness=0.2:contrast=1.1:saturation=0.9");
});

it("applies fit framing (scale+pad) to each segment", () => {
const recipe = base({
jumpCutSegments: [{ start: 0, end: 5 }],
framing: "fit",
});
const { filterComplex } = buildJumpCutFilterComplex(recipe, 1280, 720, false);
expect(filterComplex).toContain("force_original_aspect_ratio=decrease");
expect(filterComplex).toContain("pad=1280:720");
});

it("applies fill framing (scale+crop) to each segment", () => {
const recipe = base({
jumpCutSegments: [{ start: 0, end: 5 }],
framing: "fill",
});
const { filterComplex } = buildJumpCutFilterComplex(recipe, 1280, 720, false);
expect(filterComplex).toContain("force_original_aspect_ratio=increase");
expect(filterComplex).toContain("crop=1280:720");
});
});
5 changes: 4 additions & 1 deletion src/hooks/useAudioWaveform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export function useAudioWaveform(
) {
const [waveform, setWaveform] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [duration, setDuration] = useState(0);

useEffect(() => {
let isCancelled = false;
Expand Down Expand Up @@ -66,10 +67,12 @@ export function useAudioWaveform(

if (!isCancelled) {
setWaveform(peaks);
setDuration(audioBuffer.duration);
}
} catch {
if (!isCancelled) {
setWaveform([]);
setDuration(0);
}
} finally {
await audioContext?.close();
Expand All @@ -86,5 +89,5 @@ export function useAudioWaveform(
};
}, [barCount, file]);

return { waveform, isLoading };
return { waveform, isLoading, duration };
}
Loading
Loading