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
27 changes: 25 additions & 2 deletions src/components/FormatSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
"use client";

import { EditRecipe } from "@/lib/types";
import { Film } from "lucide-react";
import { Film, AlertTriangle } from "lucide-react";
import { cn } from "@/lib/utils";

interface Props {
recipe: EditRecipe;
onChange: (patch: Partial<EditRecipe>) => void;
duration?: number;
}

const FORMAT_OPTIONS = [
Expand All @@ -16,7 +17,11 @@ const FORMAT_OPTIONS = [
{ id: "gif", label: "GIF", description: "Animated image — keep clips under 10 s" },
] as const;

export default function FormatSelector({ recipe, onChange }: Props) {
export default function FormatSelector({ recipe, onChange, duration }: Props) {
const effectiveEnd = recipe.trimEnd ?? (duration && duration > 0 ? duration : null);
const clipLength = effectiveEnd !== null ? effectiveEnd - recipe.trimStart : 0;
const isOverGifLimit = recipe.format === "gif" && clipLength > 10.05;

return (
<div>
<div className="flex items-center gap-1 mb-3">
Expand Down Expand Up @@ -48,6 +53,24 @@ export default function FormatSelector({ recipe, onChange }: Props) {
<p className="text-[10px] text-[var(--muted)] mt-2">
{FORMAT_OPTIONS.find((o) => o.id === recipe.format)?.description}
</p>

{recipe.format === "gif" && (
<div
role="status"
aria-live="polite"
className="mt-2.5 flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 p-2.5 text-xs text-amber-600 dark:text-amber-400"
>
<AlertTriangle size={14} className="shrink-0 mt-0.5" />
<div className="flex-1 space-y-0.5">
<p className="font-semibold">GIF Limit: Maximum 10 seconds</p>
<p className="text-[11px] opacity-90">
{isOverGifLimit
? `Clip is currently ${clipLength.toFixed(1)}s. Trimming to under 10s is required to prevent oversized files and browser freezes.`
: "Clips are automatically capped under 10s to ensure fast generation and reasonable file sizes."}
</p>
</div>
</div>
)}
</div>
);
}
24 changes: 21 additions & 3 deletions src/components/TrimControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,19 @@ export default function TrimControl({ recipe, onChange, duration, file }: Props)
const applyDrag = useCallback((clientX: number) => {
const seconds = xToSeconds(clientX);
if (dragging.current === "start") {
const clamped = Math.min(seconds, (recipe.trimEnd ?? duration) - 0.1);
let clamped = Math.min(seconds, (recipe.trimEnd ?? duration) - 0.1);
if (recipe.format === "gif" && (recipe.trimEnd ?? duration) - clamped > 10) {
clamped = Math.max(clamped, (recipe.trimEnd ?? duration) - 10);
}
onChange({ trimStart: Math.max(0, clamped) });
} else if (dragging.current === "end") {
const clamped = Math.max(seconds, recipe.trimStart + 0.1);
let clamped = Math.max(seconds, recipe.trimStart + 0.1);
if (recipe.format === "gif" && clamped - recipe.trimStart > 10) {
clamped = Math.min(clamped, recipe.trimStart + 10);
}
onChange({ trimEnd: Math.min(duration, clamped) });
}
}, [xToSeconds, duration, recipe.trimStart, recipe.trimEnd, onChange]);
}, [xToSeconds, duration, recipe.trimStart, recipe.trimEnd, recipe.format, onChange]);

useEffect(() => {
const onMove = (e: MouseEvent | TouchEvent) => {
Expand Down Expand Up @@ -171,6 +177,12 @@ export default function TrimControl({ recipe, onChange, duration, file }: Props)
return;
}

if (recipe.format === "gif" && n - recipe.trimStart > 10.05) {
setEnd(true);
setEndErrorMsg("GIF format allows a maximum duration of 10s.");
return;
}

setEnd(false);
setEndErrorMsg("");
onChange({ trimEnd: n });
Expand Down Expand Up @@ -329,6 +341,12 @@ export default function TrimControl({ recipe, onChange, duration, file }: Props)
Clip must be at least 0.1 seconds long.
</p>
)}
{recipe.format === "gif" && (
<p className="text-[11px] text-amber-600 dark:text-amber-400 font-heading flex items-center gap-1 mt-1">
<AlertCircle size={11} className="shrink-0" />
GIF format selected: clip length is limited to 10 seconds maximum.
</p>
)}
</div>
);
}
Expand Down
2 changes: 1 addition & 1 deletion src/components/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -681,7 +681,7 @@ export default function VideoEditor() {
</div>
</Section>
<Section icon={<SlidersHorizontal size={12} />} title="Output format" delay={190}>
<FormatSelector recipe={recipe} onChange={updateRecipe} />
<FormatSelector recipe={recipe} duration={duration} onChange={updateRecipe} />
</Section>
<AccordionSection
id="export"
Expand Down
48 changes: 43 additions & 5 deletions src/hooks/tests/useVideoEditor.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { renderHook, act } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { useVideoEditor } from "../useVideoEditor";
import { DEFAULT_RECIPE } from "@/lib/constants";

Expand Down Expand Up @@ -39,40 +39,50 @@ describe("useVideoEditor core controller", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorageMock.clear();
if (typeof window !== "undefined") {
window.history.replaceState(null, "", window.location.pathname);
}
});

afterEach(() => {
localStorageMock.clear();
});

it("should initialize with the default recipe", () => {
const { result } = renderHook(() => useVideoEditor());
const { result, unmount } = renderHook(() => useVideoEditor());
expect(result.current.recipe).toEqual({
...DEFAULT_RECIPE,
soundOnCompletion: false,
});
expect(result.current.status).toBe("idle");
expect(result.current.file).toBeNull();
unmount();
});

it("should safely update recipe with valid patch values", () => {
const { result } = renderHook(() => useVideoEditor());
const { result, unmount } = renderHook(() => useVideoEditor());
act(() => {
result.current.updateRecipe({ speed: 2, quality: 20 });
});
expect(result.current.recipe.speed).toBe(2);
expect(result.current.recipe.quality).toBe(20);
unmount();
});

it("should ignore invalid patch values via isValidValue check", () => {
const { result } = renderHook(() => useVideoEditor());
const { result, unmount } = renderHook(() => useVideoEditor());
act(() => {
// 9999 is invalid for speed, -50 is invalid for quality
result.current.updateRecipe({ speed: 9999, quality: -50 } as any);
});
// Values should remain as defaults
expect(result.current.recipe.speed).toBe(DEFAULT_RECIPE.speed);
expect(result.current.recipe.quality).toBe(DEFAULT_RECIPE.quality);
unmount();
});

it("should reset settings to default when resetSettings is called", () => {
const { result } = renderHook(() => useVideoEditor());
const { result, unmount } = renderHook(() => useVideoEditor());
act(() => {
result.current.updateRecipe({ speed: 1.5 });
});
Expand All @@ -82,5 +92,33 @@ describe("useVideoEditor core controller", () => {
result.current.resetSettings();
});
expect(result.current.recipe.speed).toBe(DEFAULT_RECIPE.speed);
unmount();
});

it("should clamp trimEnd to 10s maximum when format is set to gif", () => {
const { result, unmount } = renderHook(() => useVideoEditor());
act(() => {
result.current.updateRecipe({ trimStart: 0, trimEnd: 30 });
});
expect(result.current.recipe.trimEnd).toBe(30);

act(() => {
result.current.updateRecipe({ format: "gif" });
});
expect(result.current.recipe.format).toBe("gif");
expect(result.current.recipe.trimEnd).toBe(10);
expect(result.current.recipe.keepAudio).toBe(false);
unmount();
});

it("should clamp trimEnd to at most trimStart + 10s when format is gif", () => {
const { result, unmount } = renderHook(() => useVideoEditor());
act(() => {
result.current.updateRecipe({ format: "gif", trimStart: 5, trimEnd: 25 });
});
expect(result.current.recipe.format).toBe("gif");
expect(result.current.recipe.trimStart).toBe(5);
expect(result.current.recipe.trimEnd).toBe(15);
unmount();
});
});
18 changes: 16 additions & 2 deletions src/hooks/useVideoEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,13 @@ function validateRecipe(recipe: EditRecipe, duration: number): string | null {

[recipe.saturation < 0 || recipe.saturation > 3, "Saturation must be between 0 and 3."],
[recipe.sharpness < 0 || recipe.sharpness > 3, "Sharpness must be between 0 and 3."],
[
recipe.format === "gif" &&
(recipe.trimEnd !== null ? recipe.trimEnd : duration > 0 ? duration : 0) -
recipe.trimStart >
10.05,
"GIF export duration must be 10 seconds or less. Please adjust your trim range under 10 seconds.",
],
];

return validations.find(([condition]) => condition)?.[1] ?? null;
Expand Down Expand Up @@ -275,10 +282,17 @@ export function useVideoEditor() {
// GIF has no audio — force keepAudio off
if (next.format === "gif") {
next.keepAudio = false;
// Enforce maximum 10s duration for GIF format
const effectiveEnd = next.trimEnd ?? (duration > 0 ? duration : null);
if (effectiveEnd !== null && effectiveEnd - next.trimStart > 10) {
next.trimEnd = parseFloat(
Math.min(duration > 0 ? duration : next.trimStart + 10, next.trimStart + 10).toFixed(1)
);
}
}
return next;
});
}, []);
}, [duration]);

useEffect(() => {
if (typeof window === "undefined") return;
Expand Down Expand Up @@ -500,7 +514,7 @@ export function useVideoEditor() {
return {
...prev,
trimStart: 0,
trimEnd: null,
trimEnd: prev.format === "gif" && dur > 10 ? 10 : null,
...(shouldApplySuggestion ? { preset: suggestedPreset } : {}),
};
});
Expand Down
8 changes: 3 additions & 5 deletions src/lib/exportEstimate.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { describe, test, expect } from "vitest";
import { estimateExportSize, formatEstimatedSize } from "./exportEstimate";
import { EditRecipe } from "./types";
import { describe, test, expect } from "vitest";

// Minimal recipe factory — only the fields estimateExportSize cares about
function makeRecipe(overrides: Partial<EditRecipe> = {}): EditRecipe {
Expand Down Expand Up @@ -50,9 +48,9 @@ describe("estimateExportSize", () => {
expect(long / short).toBeCloseTo(4, 0);
});

test("higher resolution (4k) produces a larger estimate than 720p", () => {
const hd = estimateExportSize(makeRecipe({ preset: "720p" }), 60);
const uhd = estimateExportSize(makeRecipe({ preset: "4k" }), 60);
test("higher resolution produces a larger estimate than lower resolution", () => {
const hd = estimateExportSize(makeRecipe({ preset: "twitter-hd" }), 60);
const uhd = estimateExportSize(makeRecipe({ preset: "landscape-16-9" }), 60);
expect(uhd).toBeGreaterThan(hd);
});

Expand Down
Loading