diff --git a/src/components/video-editor/ExportSettingsMenu.test.tsx b/src/components/video-editor/ExportSettingsMenu.test.tsx new file mode 100644 index 00000000..5fe7c5f3 --- /dev/null +++ b/src/components/video-editor/ExportSettingsMenu.test.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import { I18nProvider } from "@/contexts/I18nContext"; +import { ExportSettingsMenu } from "./ExportSettingsMenu"; + +vi.mock("motion/react", () => ({ + LayoutGroup: ({ children }: { children: ReactNode }) => children ?? null, + motion: { + span: ({ children }: { children?: ReactNode }) => children ?? null, + }, +})); + +describe("ExportSettingsMenu", () => { + it("renders the three GIF quality options", () => { + const html = renderToStaticMarkup( + + + , + ); + + expect(html).toContain("High"); + expect(html).toContain("Balanced"); + expect(html).toContain("Small file"); + }); +}); diff --git a/src/components/video-editor/ExportSettingsMenu.tsx b/src/components/video-editor/ExportSettingsMenu.tsx index 4d690a0a..a4866017 100644 --- a/src/components/video-editor/ExportSettingsMenu.tsx +++ b/src/components/video-editor/ExportSettingsMenu.tsx @@ -10,9 +10,15 @@ import type { ExportPipelineModel, ExportQuality, GifFrameRate, + GifQualityPreset, GifSizePreset, } from "@/lib/exporter"; -import { GIF_FRAME_RATES, GIF_SIZE_PRESETS, MP4_FRAME_RATES } from "@/lib/exporter"; +import { + GIF_FRAME_RATES, + GIF_QUALITY_PRESETS, + GIF_SIZE_PRESETS, + MP4_FRAME_RATES, +} from "@/lib/exporter"; import { cn } from "@/lib/utils"; interface ExportSettingsMenuProps { @@ -36,6 +42,8 @@ interface ExportSettingsMenuProps { onGifLoopChange?: (loop: boolean) => void; gifSizePreset: GifSizePreset; onGifSizePresetChange?: (preset: GifSizePreset) => void; + gifQualityPreset: GifQualityPreset; + onGifQualityPresetChange?: (preset: GifQualityPreset) => void; gifOutputDimensions: { width: number; height: number }; onExport?: () => void; className?: string; @@ -62,6 +70,8 @@ export function ExportSettingsMenu({ onGifLoopChange, gifSizePreset, onGifSizePresetChange, + gifQualityPreset, + onGifQualityPresetChange, gifOutputDimensions, onExport, className, @@ -461,6 +471,55 @@ export function ExportSettingsMenu({ + +
+ {Object.entries(GIF_QUALITY_PRESETS).map(([key, preset]) => { + const typedKey = key as GifQualityPreset; + const isActive = gifQualityPreset === typedKey; + return ( + + ); + })} +
+
{gifOutputDimensions.width} × {gifOutputDimensions.height}px diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 4050189e..814ea84b 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -67,6 +67,7 @@ import { GIF_SIZE_PRESETS, GifExporter, type GifFrameRate, + type GifQualityPreset, type GifSizePreset, ModernVideoExporter, probeSupportedMp4Dimensions, @@ -582,6 +583,9 @@ export default function VideoEditor() { const [gifSizePreset, setGifSizePreset] = useState( initialEditorPreferences.gifSizePreset, ); + const [gifQualityPreset, setGifQualityPreset] = useState( + initialEditorPreferences.gifQualityPreset, + ); const [exportedFilePath, setExportedFilePath] = useState(undefined); const [hasPendingExportSave, setHasPendingExportSave] = useState(false); const [lastSavedSnapshot, setLastSavedSnapshot] = useState(null); @@ -713,6 +717,7 @@ export default function VideoEditor() { gifFrameRate, gifLoop, gifSizePreset, + gifQualityPreset, autoCaptionSettings: { ...autoCaptionSettings }, whisperExecutablePath, whisperModelPath, @@ -764,6 +769,7 @@ export default function VideoEditor() { gifFrameRate, gifLoop, gifSizePreset, + gifQualityPreset, autoCaptionSettings, whisperExecutablePath, whisperModelPath, @@ -856,6 +862,7 @@ export default function VideoEditor() { setGifFrameRate(snapshot.gifFrameRate); setGifLoop(snapshot.gifLoop); setGifSizePreset(snapshot.gifSizePreset); + setGifQualityPreset(snapshot.gifQualityPreset); setAutoCaptionSettings({ ...snapshot.autoCaptionSettings }); setWhisperExecutablePath(snapshot.whisperExecutablePath); setWhisperModelPath(snapshot.whisperModelPath); @@ -1595,6 +1602,7 @@ export default function VideoEditor() { gifFrameRate: GifFrameRate; gifLoop: boolean; gifSizePreset: GifSizePreset; + gifQualityPreset: GifQualityPreset; sourceAudioTrackSettingsByClip: Record; defaultSourceAudioTrackSettings: SourceAudioTrackSettings; }>, @@ -1698,6 +1706,7 @@ export default function VideoEditor() { gifFrameRate, gifLoop, gifSizePreset, + gifQualityPreset, sourceAudioTrackSettingsByClip, defaultSourceAudioTrackSettings, }), @@ -1759,6 +1768,7 @@ export default function VideoEditor() { gifFrameRate, gifLoop, gifSizePreset, + gifQualityPreset, frame, sourceAudioTrackSettingsByClip, defaultSourceAudioTrackSettings, @@ -1953,6 +1963,7 @@ export default function VideoEditor() { setGifFrameRate(normalizedEditor.gifFrameRate); setGifLoop(normalizedEditor.gifLoop); setGifSizePreset(normalizedEditor.gifSizePreset); + setGifQualityPreset(normalizedEditor.gifQualityPreset); setSelectedZoomId(null); setSelectedClipId(null); @@ -2260,6 +2271,7 @@ export default function VideoEditor() { setGifFrameRate(initialEditorPreferences.gifFrameRate); setGifLoop(initialEditorPreferences.gifLoop); setGifSizePreset(initialEditorPreferences.gifSizePreset); + setGifQualityPreset(initialEditorPreferences.gifQualityPreset); return; } } @@ -2432,6 +2444,7 @@ export default function VideoEditor() { gifFrameRate, gifLoop, gifSizePreset, + gifQualityPreset, whisperExecutablePath, whisperModelPath, }); @@ -2483,6 +2496,7 @@ export default function VideoEditor() { gifFrameRate, gifLoop, gifSizePreset, + gifQualityPreset, whisperExecutablePath, whisperModelPath, ]); @@ -4062,6 +4076,7 @@ export default function VideoEditor() { frameRate: settings.gifConfig.frameRate, loop: settings.gifConfig.loop, sizePreset: settings.gifConfig.sizePreset, + qualityPreset: settings.gifConfig.qualityPreset, wallpaper, trimRegions, speedRegions: effectiveSpeedRegions, @@ -4725,6 +4740,7 @@ export default function VideoEditor() { gifFrameRate, gifLoop, gifSizePreset, + gifQualityPreset, }); setExportError(null); @@ -4740,6 +4756,7 @@ export default function VideoEditor() { gifFrameRate, gifLoop, gifSizePreset, + gifQualityPreset, exportBackendPreference, exportPipelineModel, handleExport, @@ -5434,6 +5451,8 @@ export default function VideoEditor() { onGifLoopChange={setGifLoop} gifSizePreset={gifSizePreset} onGifSizePresetChange={setGifSizePreset} + gifQualityPreset={gifQualityPreset} + onGifQualityPresetChange={setGifQualityPreset} mp4OutputDimensions={mp4OutputDimensions} gifOutputDimensions={gifOutputDimensions} onExport={handleStartExportFromDropdown} diff --git a/src/components/video-editor/editorPreferences.test.ts b/src/components/video-editor/editorPreferences.test.ts index 4f80b660..36fc5c04 100644 --- a/src/components/video-editor/editorPreferences.test.ts +++ b/src/components/video-editor/editorPreferences.test.ts @@ -87,6 +87,20 @@ describe("editorPreferences", () => { expect(DEFAULT_EDITOR_PREFERENCES.exportQuality).toBe("source"); }); + it("defaults GIF quality to balanced and clamps invalid stored presets", () => { + vi.stubGlobal( + "localStorage", + createStorageMock({ + [EDITOR_PREFERENCES_STORAGE_KEY]: JSON.stringify({ + gifQualityPreset: "tiny", + }), + }), + ); + + expect(DEFAULT_EDITOR_PREFERENCES.gifQualityPreset).toBe("balanced"); + expect(loadEditorPreferences().gifQualityPreset).toBe("balanced"); + }); + it("defaults cursor preferences to macOS at 2.5x with gentler sway", () => { expect(DEFAULT_EDITOR_PREFERENCES.cursorStyle).toBe("macos"); expect(DEFAULT_EDITOR_PREFERENCES.cursorSize).toBe(2.5); @@ -161,6 +175,7 @@ describe("editorPreferences", () => { exportFormat: "gif", gifFrameRate: 30, gifLoop: false, + gifQualityPreset: "small", customAspectWidth: "21", customAspectHeight: "9", customWallpapers: ["data:image/jpeg;base64,abc"], @@ -178,6 +193,7 @@ describe("editorPreferences", () => { exportFormat: "gif", gifFrameRate: 30, gifLoop: false, + gifQualityPreset: "small", customAspectWidth: "21", customAspectHeight: "9", customWallpapers: ["data:image/jpeg;base64,abc"], @@ -275,6 +291,7 @@ describe("editorPreferences", () => { gifFrameRate: 20, gifLoop: false, gifSizePreset: "large", + gifQualityPreset: "small", customAspectWidth: "4", customAspectHeight: "5", customWallpapers: ["data:image/jpeg;base64,abc", "data:image/jpeg;base64,abc"], @@ -305,6 +322,7 @@ describe("editorPreferences", () => { gifFrameRate: 20, gifLoop: false, gifSizePreset: "large", + gifQualityPreset: "small", customAspectWidth: "4", customAspectHeight: "5", customWallpapers: ["data:image/jpeg;base64,abc"], diff --git a/src/components/video-editor/editorPreferences.ts b/src/components/video-editor/editorPreferences.ts index cbd41756..008ad66f 100644 --- a/src/components/video-editor/editorPreferences.ts +++ b/src/components/video-editor/editorPreferences.ts @@ -56,6 +56,7 @@ type PersistedEditorControls = Pick< | "gifFrameRate" | "gifLoop" | "gifSizePreset" + | "gifQualityPreset" >; type PartialEditorControls = Partial; @@ -137,6 +138,7 @@ export const DEFAULT_EDITOR_PREFERENCES: EditorPreferences = { gifFrameRate: DEFAULT_EDITOR_CONTROLS.gifFrameRate, gifLoop: DEFAULT_EDITOR_CONTROLS.gifLoop, gifSizePreset: DEFAULT_EDITOR_CONTROLS.gifSizePreset, + gifQualityPreset: DEFAULT_EDITOR_CONTROLS.gifQualityPreset, customAspectWidth: "16", customAspectHeight: "9", customWallpapers: [], @@ -337,6 +339,7 @@ function normalizeEditorControls( gifFrameRate: sanitizedRaw.gifFrameRate ?? fallback.gifFrameRate, gifLoop: sanitizedRaw.gifLoop ?? fallback.gifLoop, gifSizePreset: sanitizedRaw.gifSizePreset ?? fallback.gifSizePreset, + gifQualityPreset: sanitizedRaw.gifQualityPreset ?? fallback.gifQualityPreset, }; const normalized = normalizeProjectEditor(candidate); @@ -388,6 +391,7 @@ function normalizeEditorControls( gifFrameRate: normalized.gifFrameRate, gifLoop: normalized.gifLoop, gifSizePreset: normalized.gifSizePreset, + gifQualityPreset: normalized.gifQualityPreset, }; } diff --git a/src/components/video-editor/exportStartSettings.test.ts b/src/components/video-editor/exportStartSettings.test.ts index 357e7fb3..dba39d6f 100644 --- a/src/components/video-editor/exportStartSettings.test.ts +++ b/src/components/video-editor/exportStartSettings.test.ts @@ -13,6 +13,7 @@ const baseOptions = { gifFrameRate: 20 as const, gifLoop: true, gifSizePreset: "medium" as const, + gifQualityPreset: "balanced" as const, }; describe("resolveExportStartSettings", () => { @@ -50,6 +51,7 @@ describe("resolveExportStartSettings", () => { frameRate: 15, loop: false, sizePreset: "medium", + qualityPreset: "balanced", width: 1280, height: 720, }, @@ -67,6 +69,7 @@ describe("resolveExportStartSettings", () => { }).gifConfig, ).toMatchObject({ sizePreset: "original", + qualityPreset: "balanced", width: 1234, height: 678, }); diff --git a/src/components/video-editor/exportStartSettings.ts b/src/components/video-editor/exportStartSettings.ts index 6b57268a..ba1cbd87 100644 --- a/src/components/video-editor/exportStartSettings.ts +++ b/src/components/video-editor/exportStartSettings.ts @@ -9,6 +9,7 @@ import { type ExportSettings, GIF_SIZE_PRESETS, type GifFrameRate, + type GifQualityPreset, type GifSizePreset, } from "@/lib/exporter"; @@ -24,6 +25,7 @@ export function resolveExportStartSettings({ gifFrameRate, gifLoop, gifSizePreset, + gifQualityPreset, }: { sourceWidth: number; sourceHeight: number; @@ -36,6 +38,7 @@ export function resolveExportStartSettings({ gifFrameRate: GifFrameRate; gifLoop: boolean; gifSizePreset: GifSizePreset; + gifQualityPreset: GifQualityPreset; }): ExportSettings { const gifDimensions = exportFormat === "gif" @@ -55,6 +58,7 @@ export function resolveExportStartSettings({ frameRate: gifFrameRate, loop: gifLoop, sizePreset: gifSizePreset, + qualityPreset: gifQualityPreset, width: gifDimensions.width, height: gifDimensions.height, } diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index bb3d00df..39042791 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -6,9 +6,10 @@ import type { ExportPipelineModel, ExportQuality, GifFrameRate, + GifQualityPreset, GifSizePreset, } from "@/lib/exporter"; -import { isValidMp4FrameRate } from "@/lib/exporter"; +import { isValidGifQualityPreset, isValidMp4FrameRate } from "@/lib/exporter"; import { TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT, TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION, @@ -139,6 +140,7 @@ export interface ProjectEditorState { gifFrameRate: GifFrameRate; gifLoop: boolean; gifSizePreset: GifSizePreset; + gifQualityPreset: GifQualityPreset; } export interface EditorProjectData { @@ -1048,6 +1050,9 @@ export function normalizeProjectEditor(editor: Partial): Pro editor.gifSizePreset === "original" ? editor.gifSizePreset : "medium", + gifQualityPreset: isValidGifQualityPreset(editor.gifQualityPreset) + ? editor.gifQualityPreset + : "balanced", }; } diff --git a/src/lib/exporter/gifExporter.test.ts b/src/lib/exporter/gifExporter.test.ts index 174112e8..0675add1 100644 --- a/src/lib/exporter/gifExporter.test.ts +++ b/src/lib/exporter/gifExporter.test.ts @@ -1,7 +1,75 @@ import * as fc from "fast-check"; -import { describe, expect, it } from "vitest"; -import { calculateOutputDimensions, getGifRepeat } from "./gifExporter"; -import { GIF_SIZE_PRESETS, GifSizePreset } from "./types"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const gifMockState = vi.hoisted(() => ({ + constructorOptions: [] as Array>, +})); + +vi.mock("gif.js", () => ({ + default: vi.fn((options: Record) => { + gifMockState.constructorOptions.push(options); + const handlers = new Map void>(); + const quality = typeof options.quality === "number" ? options.quality : 10; + + return { + addFrame: vi.fn(), + abort: vi.fn(), + on: vi.fn((event: string, handler: (value: Blob | number) => void) => { + handlers.set(event, handler); + }), + render: vi.fn(() => { + const size = Math.max(1, 21 - quality); + handlers.get("finished")?.(new Blob(["x".repeat(size)], { type: "image/gif" })); + }), + }; + }), +})); + +vi.mock("./frameRenderer", () => ({ + FrameRenderer: vi.fn(() => ({ + destroy: vi.fn(), + getCanvas: vi.fn(() => ({})), + initialize: vi.fn(), + renderFrame: vi.fn(), + })), +})); + +vi.mock("./streamingDecoder", () => ({ + StreamingVideoDecoder: vi.fn(() => ({ + cancel: vi.fn(), + decodeAll: vi.fn(), + destroy: vi.fn(), + getEffectiveDuration: vi.fn(() => 0), + loadMetadata: vi.fn(() => Promise.resolve({ width: 2, height: 2, duration: 2 })), + })), +})); + +import { calculateOutputDimensions, getGifQuality, getGifRepeat, GifExporter } from "./gifExporter"; +import { GIF_SIZE_PRESETS, type GifSizePreset } from "./types"; + +type GifExporterTestConfig = ConstructorParameters[0]; + +function createGifExporterConfig( + overrides: Partial = {}, +): GifExporterTestConfig { + return { + videoUrl: "file:///test.mp4", + width: 2, + height: 2, + frameRate: 15, + loop: true, + sizePreset: "medium", + wallpaper: "#000000", + zoomRegions: [], + trimRegions: [], + speedRegions: [], + showShadow: false, + shadowIntensity: 0, + backgroundBlur: 0, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + ...overrides, + }; +} /** * Property 2: Loop Encoding Correctness @@ -15,6 +83,15 @@ import { GIF_SIZE_PRESETS, GifSizePreset } from "./types"; * Feature: gif-export, Property 2: Loop Encoding Correctness */ describe("GIF Exporter", () => { + beforeEach(() => { + gifMockState.constructorOptions = []; + vi.stubGlobal("navigator", { hardwareConcurrency: 4 }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + describe("Property 2: Loop Encoding Correctness", () => { /** * Test the loop configuration mapping logic. @@ -48,6 +125,37 @@ describe("GIF Exporter", () => { }); }); + describe("GIF quality preset mapping", () => { + it("should pass the small preset quality to gif.js", async () => { + await new GifExporter(createGifExporterConfig({ qualityPreset: "small" })).export(); + + expect(gifMockState.constructorOptions.at(-1)).toMatchObject({ quality: 20 }); + }); + + it("should default to balanced quality when qualityPreset is omitted", async () => { + await new GifExporter(createGifExporterConfig()).export(); + + expect(gifMockState.constructorOptions.at(-1)).toMatchObject({ quality: 10 }); + }); + + it("should fall back to balanced quality for unknown quality presets", () => { + expect(getGifQuality("future")).toBe(10); + }); + + it("should produce a smaller blob for the small preset than the high preset", async () => { + const highResult = await new GifExporter( + createGifExporterConfig({ qualityPreset: "high" }), + ).export(); + const smallResult = await new GifExporter( + createGifExporterConfig({ qualityPreset: "small" }), + ).export(); + + expect(highResult.success).toBe(true); + expect(smallResult.success).toBe(true); + expect(smallResult.blob?.size).toBeLessThan(highResult.blob?.size ?? 0); + }); + }); + /** * Property 4: Aspect Ratio Preservation * diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts index 4c833852..aa88c32d 100644 --- a/src/lib/exporter/gifExporter.ts +++ b/src/lib/exporter/gifExporter.ts @@ -21,8 +21,10 @@ import type { ExportResult, GIF_SIZE_PRESETS, GifFrameRate, + GifQualityPreset, GifSizePreset, } from "./types"; +import { GIF_QUALITY_PRESETS, isValidGifQualityPreset } from "./types"; const GIF_WORKER_URL = new URL("gif.js/dist/gif.worker.js", import.meta.url).toString(); @@ -35,6 +37,7 @@ interface GifExporterConfig { frameRate: GifFrameRate; loop: boolean; sizePreset: GifSizePreset; + qualityPreset?: GifQualityPreset; wallpaper: string; zoomRegions: ZoomRegion[]; trimRegions?: TrimRegion[]; @@ -128,6 +131,11 @@ export function getGifRepeat(loop: boolean): 0 | 1 { return loop ? 0 : 1; } +export function getGifQuality(qualityPreset: unknown): number { + const preset = isValidGifQualityPreset(qualityPreset) ? qualityPreset : "balanced"; + return GIF_QUALITY_PRESETS[preset].quality; +} + export class GifExporter { private config: GifExporterConfig; private streamingDecoder: StreamingVideoDecoder | null = null; @@ -224,7 +232,7 @@ export class GifExporter { this.gif = new GIF({ workers: WORKER_COUNT, - quality: 10, + quality: getGifQuality(this.config.qualityPreset), width: this.config.width, height: this.config.height, workerScript: GIF_WORKER_URL, diff --git a/src/lib/exporter/index.ts b/src/lib/exporter/index.ts index 3561cb3a..02529d28 100644 --- a/src/lib/exporter/index.ts +++ b/src/lib/exporter/index.ts @@ -29,16 +29,20 @@ export type { ExportSettings, GifExportConfig, GifFrameRate, + GifQualityPreset, GifSizePreset, VideoFrameData, } from "./types"; export { GIF_FRAME_RATES, + GIF_QUALITY_PRESETS, GIF_SIZE_PRESETS, isValidGifFrameRate, + isValidGifQualityPreset, isValidMp4FrameRate, MP4_FRAME_RATES, VALID_GIF_FRAME_RATES, + VALID_GIF_QUALITY_PRESETS, } from "./types"; export { VideoFileDecoder } from "./videoDecoder"; export { VideoExporter } from "./videoExporter"; diff --git a/src/lib/exporter/types.test.ts b/src/lib/exporter/types.test.ts index 81f9553c..c02cf624 100644 --- a/src/lib/exporter/types.test.ts +++ b/src/lib/exporter/types.test.ts @@ -1,6 +1,14 @@ import * as fc from "fast-check"; import { describe, expect, it } from "vitest"; -import { GifFrameRate, isValidGifFrameRate, VALID_GIF_FRAME_RATES } from "./types"; +import { + GIF_QUALITY_PRESETS, + GifFrameRate, + GifQualityPreset, + isValidGifFrameRate, + isValidGifQualityPreset, + VALID_GIF_FRAME_RATES, + VALID_GIF_QUALITY_PRESETS, +} from "./types"; /** * Property 1: Valid Frame Rate Acceptance @@ -53,4 +61,36 @@ describe("GIF Export Types", () => { ); }); }); + + describe("GIF quality presets", () => { + it("should accept all valid quality presets", () => { + fc.assert( + fc.property( + fc.constantFrom(...VALID_GIF_QUALITY_PRESETS), + (qualityPreset: GifQualityPreset) => { + expect(isValidGifQualityPreset(qualityPreset)).toBe(true); + }, + ), + { numRuns: 100 }, + ); + }); + + it("should reject unknown quality presets", () => { + fc.assert( + fc.property( + fc.string().filter((value) => !isValidGifQualityPreset(value)), + (invalidQualityPreset: string) => { + expect(isValidGifQualityPreset(invalidQualityPreset)).toBe(false); + }, + ), + { numRuns: 100 }, + ); + }); + + it("should map high, balanced, and small presets to gif.js quality values", () => { + expect(GIF_QUALITY_PRESETS.high.quality).toBe(1); + expect(GIF_QUALITY_PRESETS.balanced.quality).toBe(10); + expect(GIF_QUALITY_PRESETS.small.quality).toBe(20); + }); + }); }); diff --git a/src/lib/exporter/types.ts b/src/lib/exporter/types.ts index 72682f01..77431ad0 100644 --- a/src/lib/exporter/types.ts +++ b/src/lib/exporter/types.ts @@ -183,10 +183,13 @@ export type GifFrameRate = 15 | 20 | 25 | 30; export type GifSizePreset = "medium" | "large" | "original"; +export type GifQualityPreset = "high" | "balanced" | "small"; + export interface GifExportConfig { frameRate: GifFrameRate; loop: boolean; sizePreset: GifSizePreset; + qualityPreset?: GifQualityPreset; width: number; height: number; } @@ -215,6 +218,15 @@ export const GIF_SIZE_PRESETS: Record = { + high: { quality: 1, label: "High" }, + balanced: { quality: 10, label: "Balanced" }, + small: { quality: 20, label: "Small file" }, +}; + export const GIF_FRAME_RATES: { value: GifFrameRate; label: string }[] = [ { value: 15, label: "15 FPS - Balanced" }, { value: 20, label: "20 FPS - Smooth" }, @@ -225,6 +237,16 @@ export const GIF_FRAME_RATES: { value: GifFrameRate; label: string }[] = [ // Valid frame rates for validation export const VALID_GIF_FRAME_RATES: readonly GifFrameRate[] = [15, 20, 25, 30] as const; +export const VALID_GIF_QUALITY_PRESETS: readonly GifQualityPreset[] = [ + "high", + "balanced", + "small", +] as const; + export function isValidGifFrameRate(rate: number): rate is GifFrameRate { return VALID_GIF_FRAME_RATES.includes(rate as GifFrameRate); } + +export function isValidGifQualityPreset(value: unknown): value is GifQualityPreset { + return VALID_GIF_QUALITY_PRESETS.includes(value as GifQualityPreset); +}