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({
+
{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);
+}