diff --git a/src/components/FormatSelector.tsx b/src/components/FormatSelector.tsx index 81033b6bf..33d430754 100644 --- a/src/components/FormatSelector.tsx +++ b/src/components/FormatSelector.tsx @@ -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) => void; + duration?: number; } const FORMAT_OPTIONS = [ @@ -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 (
@@ -48,6 +53,24 @@ export default function FormatSelector({ recipe, onChange }: Props) {

{FORMAT_OPTIONS.find((o) => o.id === recipe.format)?.description}

+ + {recipe.format === "gif" && ( +
+ +
+

GIF Limit: Maximum 10 seconds

+

+ {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."} +

+
+
+ )}
); } \ No newline at end of file diff --git a/src/components/TrimControl.tsx b/src/components/TrimControl.tsx index 8a3f947c2..ef950dd6a 100644 --- a/src/components/TrimControl.tsx +++ b/src/components/TrimControl.tsx @@ -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) => { @@ -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 }); @@ -329,6 +341,12 @@ export default function TrimControl({ recipe, onChange, duration, file }: Props) Clip must be at least 0.1 seconds long.

)} + {recipe.format === "gif" && ( +

+ + GIF format selected: clip length is limited to 10 seconds maximum. +

+ )}
); } diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index 38045cf01..2d2d5cde6 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -681,7 +681,7 @@ export default function VideoEditor() {
} title="Output format" delay={190}> - +
{ 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); @@ -69,10 +78,11 @@ describe("useVideoEditor core controller", () => { // 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 }); }); @@ -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(); }); }); diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts index 2833bd853..42281a19d 100644 --- a/src/hooks/useVideoEditor.ts +++ b/src/hooks/useVideoEditor.ts @@ -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; @@ -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; @@ -500,7 +514,7 @@ export function useVideoEditor() { return { ...prev, trimStart: 0, - trimEnd: null, + trimEnd: prev.format === "gif" && dur > 10 ? 10 : null, ...(shouldApplySuggestion ? { preset: suggestedPreset } : {}), }; }); diff --git a/src/lib/exportEstimate.test.ts b/src/lib/exportEstimate.test.ts index fa7180f40..d186e2032 100644 --- a/src/lib/exportEstimate.test.ts +++ b/src/lib/exportEstimate.test.ts @@ -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 { @@ -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); });