diff --git a/src/components/video-editor/ExportSettingsMenu.test.tsx b/src/components/video-editor/ExportSettingsMenu.test.tsx
new file mode 100644
index 000000000..5fe7c5f39
--- /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 de5695078..e760dde4e 100644
--- a/src/components/video-editor/ExportSettingsMenu.tsx
+++ b/src/components/video-editor/ExportSettingsMenu.tsx
@@ -11,9 +11,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";
interface ExportSettingsMenuProps {
exportFormat: ExportFormat;
@@ -38,6 +44,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;
@@ -119,6 +127,8 @@ export function ExportSettingsMenu({
onGifLoopChange,
gifSizePreset,
onGifSizePresetChange,
+ gifQualityPreset,
+ onGifQualityPresetChange,
gifOutputDimensions,
onExport,
className,
@@ -257,6 +267,20 @@ export function ExportSettingsMenu({
label: preset.label,
}))}
/>
+ ({
+ value: key as GifQualityPreset,
+ label:
+ key === "high"
+ ? tSettings("export.gifQualityHigh", preset.label)
+ : key === "balanced"
+ ? tSettings("export.gifQualityBalanced", preset.label)
+ : tSettings("export.gifQualitySmall", preset.label),
+ }))}
+ />
{gifOutputDimensions.width} × {gifOutputDimensions.height}px
diff --git a/src/components/video-editor/editorPreferences.test.ts b/src/components/video-editor/editorPreferences.test.ts
index bcb3584bc..cc06a835c 100644
--- a/src/components/video-editor/editorPreferences.test.ts
+++ b/src/components/video-editor/editorPreferences.test.ts
@@ -110,6 +110,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 Tahoe at 2.5x with gentler sway", () => {
expect(DEFAULT_EDITOR_PREFERENCES.cursorStyle).toBe("tahoe");
expect(DEFAULT_EDITOR_PREFERENCES.cursorSize).toBe(2.5);
@@ -192,6 +206,7 @@ describe("editorPreferences", () => {
exportFormat: "gif",
gifFrameRate: 30,
gifLoop: false,
+ gifQualityPreset: "small",
customAspectWidth: "21",
customAspectHeight: "9",
customWallpapers: ["data:image/jpeg;base64,abc"],
@@ -209,6 +224,7 @@ describe("editorPreferences", () => {
exportFormat: "gif",
gifFrameRate: 30,
gifLoop: false,
+ gifQualityPreset: "small",
customAspectWidth: "21",
customAspectHeight: "9",
customWallpapers: ["data:image/jpeg;base64,abc"],
@@ -305,6 +321,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"],
@@ -335,6 +352,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/export/useExportDialogActions.ts b/src/components/video-editor/export/useExportDialogActions.ts
index bfebc537c..b4c68231d 100644
--- a/src/components/video-editor/export/useExportDialogActions.ts
+++ b/src/components/video-editor/export/useExportDialogActions.ts
@@ -79,6 +79,7 @@ export function useExportDialogActions({
gifFrameRate: settings.gifFrameRate,
gifLoop: settings.gifLoop,
gifSizePreset: settings.gifSizePreset,
+ gifQualityPreset: settings.gifQualityPreset,
});
},
[videoPath, videoPlaybackRef, hasCaptionsForSidecar, settings],
diff --git a/src/components/video-editor/export/useExportRunner.ts b/src/components/video-editor/export/useExportRunner.ts
index 21a2e2bd1..242ab7c3b 100644
--- a/src/components/video-editor/export/useExportRunner.ts
+++ b/src/components/video-editor/export/useExportRunner.ts
@@ -139,6 +139,7 @@ export function useExportRunner(input: ExportRunnerInput) {
frameRate: settings.gifConfig.frameRate,
loop: settings.gifConfig.loop,
sizePreset: settings.gifConfig.sizePreset,
+ qualityPreset: settings.gifConfig.qualityPreset,
...buildExportRenderOptions({
appearance,
timeline,
diff --git a/src/components/video-editor/export/useExportSettings.ts b/src/components/video-editor/export/useExportSettings.ts
index 5afdcf750..dce8ea462 100644
--- a/src/components/video-editor/export/useExportSettings.ts
+++ b/src/components/video-editor/export/useExportSettings.ts
@@ -7,6 +7,7 @@ import type {
ExportPipelineModel,
ExportQuality,
GifFrameRate,
+ GifQualityPreset,
GifSizePreset,
} from "@/lib/exporter";
import { projectCaptionCues } from "../captionTimeline";
@@ -41,6 +42,9 @@ export function useExportSettings(
const [gifFrameRate, setGifFrameRate] = useState(preferences.gifFrameRate);
const [gifLoop, setGifLoop] = useState(preferences.gifLoop);
const [gifSizePreset, setGifSizePreset] = useState(preferences.gifSizePreset);
+ const [gifQualityPreset, setGifQualityPreset] = useState(
+ preferences.gifQualityPreset,
+ );
useEffect(() => {
saveEditorPreferences({
publishDestination,
@@ -54,6 +58,7 @@ export function useExportSettings(
gifFrameRate,
gifLoop,
gifSizePreset,
+ gifQualityPreset,
});
}, [
publishDestination,
@@ -67,6 +72,7 @@ export function useExportSettings(
gifFrameRate,
gifLoop,
gifSizePreset,
+ gifQualityPreset,
]);
const captionSidecarCues = useMemo(
@@ -107,6 +113,8 @@ export function useExportSettings(
setGifLoop,
gifSizePreset,
setGifSizePreset,
+ gifQualityPreset,
+ setGifQualityPreset,
captionSidecarCues,
};
}
diff --git a/src/components/video-editor/exportPreferences.ts b/src/components/video-editor/exportPreferences.ts
index 54a65c7f1..41554acba 100644
--- a/src/components/video-editor/exportPreferences.ts
+++ b/src/components/video-editor/exportPreferences.ts
@@ -6,9 +6,10 @@ import type {
ExportPipelineModel,
ExportQuality,
GifFrameRate,
+ GifQualityPreset,
GifSizePreset,
} from "@/lib/exporter";
-import { isValidMp4FrameRate } from "@/lib/exporter/types";
+import { isValidGifQualityPreset, isValidMp4FrameRate } from "@/lib/exporter/types";
export interface ExportPreferences {
exportEncodingMode: ExportEncodingMode;
@@ -20,6 +21,7 @@ export interface ExportPreferences {
gifFrameRate: GifFrameRate;
gifLoop: boolean;
gifSizePreset: GifSizePreset;
+ gifQualityPreset: GifQualityPreset;
publishDestination: "link" | "local";
includeCaptionSidecar: boolean;
}
@@ -78,6 +80,9 @@ export function normalizeExportPreferences(raw: Partial): Exp
raw.gifSizePreset === "original"
? raw.gifSizePreset
: "medium",
+ gifQualityPreset: isValidGifQualityPreset(raw.gifQualityPreset)
+ ? raw.gifQualityPreset
+ : "balanced",
publishDestination: raw.publishDestination === "local" ? "local" : "link",
includeCaptionSidecar: raw.includeCaptionSidecar === true,
};
diff --git a/src/components/video-editor/exportStartSettings.test.ts b/src/components/video-editor/exportStartSettings.test.ts
index 723b33d4d..26af77b4e 100644
--- a/src/components/video-editor/exportStartSettings.test.ts
+++ b/src/components/video-editor/exportStartSettings.test.ts
@@ -14,6 +14,7 @@ const baseOptions = {
gifFrameRate: 20 as const,
gifLoop: true,
gifSizePreset: "medium" as const,
+ gifQualityPreset: "balanced" as const,
};
describe("resolveExportStartSettings", () => {
@@ -53,6 +54,7 @@ describe("resolveExportStartSettings", () => {
frameRate: 15,
loop: false,
sizePreset: "medium",
+ qualityPreset: "balanced",
width: 1280,
height: 720,
},
@@ -70,6 +72,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 0c7341af9..99d9b2304 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";
@@ -25,6 +26,7 @@ export function resolveExportStartSettings({
gifFrameRate,
gifLoop,
gifSizePreset,
+ gifQualityPreset,
}: {
sourceWidth: number;
sourceHeight: number;
@@ -38,6 +40,7 @@ export function resolveExportStartSettings({
gifFrameRate: GifFrameRate;
gifLoop: boolean;
gifSizePreset: GifSizePreset;
+ gifQualityPreset: GifQualityPreset;
}): ExportSettings {
const gifDimensions =
exportFormat === "gif"
@@ -58,6 +61,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/layout/EditorExportMenu.tsx b/src/components/video-editor/layout/EditorExportMenu.tsx
index ff2dee842..c59e2876d 100644
--- a/src/components/video-editor/layout/EditorExportMenu.tsx
+++ b/src/components/video-editor/layout/EditorExportMenu.tsx
@@ -104,6 +104,8 @@ export function EditorExportMenu(props: Props) {
setGifLoop,
gifSizePreset,
setGifSizePreset,
+ gifQualityPreset,
+ setGifQualityPreset,
includeCaptionSidecar,
setIncludeCaptionSidecar,
} = exportSettings;
@@ -151,6 +153,8 @@ export function EditorExportMenu(props: Props) {
onGifLoopChange={setGifLoop}
gifSizePreset={gifSizePreset}
onGifSizePresetChange={setGifSizePreset}
+ gifQualityPreset={gifQualityPreset}
+ onGifQualityPresetChange={setGifQualityPreset}
showCaptionSidecarOption={hasCaptionsForSidecar && exportFormat === "mp4"}
includeCaptionSidecar={includeCaptionSidecar}
onIncludeCaptionSidecarChange={setIncludeCaptionSidecar}
diff --git a/src/i18n/locales/de/settings.json b/src/i18n/locales/de/settings.json
index 3dae35b70..f0f3b783d 100644
--- a/src/i18n/locales/de/settings.json
+++ b/src/i18n/locales/de/settings.json
@@ -232,6 +232,10 @@
},
"fpsTitle": "FPS",
"loop": "Wiederholen",
+ "gifQuality": "Qualität",
+ "gifQualityHigh": "Hoch",
+ "gifQualityBalanced": "Ausgewogen",
+ "gifQualitySmall": "Kleine Datei",
"outputDimensions": "Ausgabe: {{dimensions}}px",
"sizePresetOriginalShort": "Orig",
"sizePresetMediumShort": "Med",
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json
index 9db0e74eb..79b9ad44f 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -238,6 +238,10 @@
},
"fpsTitle": "FPS",
"loop": "Loop",
+ "gifQuality": "Quality",
+ "gifQualityHigh": "High",
+ "gifQualityBalanced": "Balanced",
+ "gifQualitySmall": "Small file",
"outputDimensions": "Output: {{dimensions}}px",
"sizePresetOriginalShort": "Orig",
"sizePresetMediumShort": "Med",
diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json
index 5eafdfdde..08603718b 100644
--- a/src/i18n/locales/es/settings.json
+++ b/src/i18n/locales/es/settings.json
@@ -232,6 +232,10 @@
},
"fpsTitle": "FPS",
"loop": "Bucle",
+ "gifQuality": "Calidad",
+ "gifQualityHigh": "Alta",
+ "gifQualityBalanced": "Equilibrada",
+ "gifQualitySmall": "Archivo pequeño",
"outputDimensions": "Salida: {{dimensions}}px",
"sizePresetOriginalShort": "Orig",
"sizePresetMediumShort": "Med",
diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json
index 26283e789..61ca804e1 100644
--- a/src/i18n/locales/fr/settings.json
+++ b/src/i18n/locales/fr/settings.json
@@ -232,6 +232,10 @@
},
"fpsTitle": "IPS",
"loop": "Boucle",
+ "gifQuality": "Qualité",
+ "gifQualityHigh": "Élevée",
+ "gifQualityBalanced": "Équilibrée",
+ "gifQualitySmall": "Petit fichier",
"outputDimensions": "Sortie : {{dimensions}} px",
"sizePresetOriginalShort": "Orig",
"sizePresetMediumShort": "Moy",
diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json
index b43fdba9a..29774ed40 100644
--- a/src/i18n/locales/it/settings.json
+++ b/src/i18n/locales/it/settings.json
@@ -232,6 +232,10 @@
},
"fpsTitle": "FPS",
"loop": "Loop",
+ "gifQuality": "Qualità",
+ "gifQualityHigh": "Alta",
+ "gifQualityBalanced": "Bilanciata",
+ "gifQualitySmall": "File piccolo",
"outputDimensions": "Output: {{dimensions}}px",
"sizePresetOriginalShort": "Orig",
"sizePresetMediumShort": "Med",
diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json
index 33488fc0b..e3c7e1f26 100644
--- a/src/i18n/locales/ko/settings.json
+++ b/src/i18n/locales/ko/settings.json
@@ -232,6 +232,10 @@
},
"fpsTitle": "FPS",
"loop": "반복",
+ "gifQuality": "품질",
+ "gifQualityHigh": "높음",
+ "gifQualityBalanced": "균형",
+ "gifQualitySmall": "작은 파일",
"outputDimensions": "출력: {{dimensions}}px",
"sizePresetOriginalShort": "원본",
"sizePresetMediumShort": "중간",
diff --git a/src/i18n/locales/nl/settings.json b/src/i18n/locales/nl/settings.json
index 9d863cb53..da65882eb 100644
--- a/src/i18n/locales/nl/settings.json
+++ b/src/i18n/locales/nl/settings.json
@@ -232,6 +232,10 @@
},
"fpsTitle": "FPS",
"loop": "Herhalen",
+ "gifQuality": "Kwaliteit",
+ "gifQualityHigh": "Hoog",
+ "gifQualityBalanced": "Gebalanceerd",
+ "gifQualitySmall": "Klein bestand",
"outputDimensions": "Uitvoer: {{dimensions}}px",
"sizePresetOriginalShort": "Orig",
"sizePresetMediumShort": "Gem",
diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json
index 7541913a7..0e17be23e 100644
--- a/src/i18n/locales/pt-BR/settings.json
+++ b/src/i18n/locales/pt-BR/settings.json
@@ -232,6 +232,10 @@
},
"fpsTitle": "FPS",
"loop": "Repetir",
+ "gifQuality": "Qualidade",
+ "gifQualityHigh": "Alta",
+ "gifQualityBalanced": "Equilibrada",
+ "gifQualitySmall": "Arquivo pequeno",
"outputDimensions": "Saída: {{dimensions}}px",
"sizePresetOriginalShort": "Orig",
"sizePresetMediumShort": "Med",
diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json
index aadb70e02..a7343347f 100644
--- a/src/i18n/locales/ru/settings.json
+++ b/src/i18n/locales/ru/settings.json
@@ -232,6 +232,10 @@
},
"fpsTitle": "FPS",
"loop": "Зациклить",
+ "gifQuality": "Качество",
+ "gifQualityHigh": "Высокое",
+ "gifQualityBalanced": "Сбалансированное",
+ "gifQualitySmall": "Маленький файл",
"outputDimensions": "Размер: {{dimensions}}px",
"sizePresetOriginalShort": "Ориг",
"sizePresetMediumShort": "Сред",
diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json
index ba4a68c6a..f2e00dec7 100644
--- a/src/i18n/locales/zh-CN/settings.json
+++ b/src/i18n/locales/zh-CN/settings.json
@@ -232,6 +232,10 @@
},
"fpsTitle": "帧率",
"loop": "循环",
+ "gifQuality": "质量",
+ "gifQualityHigh": "高",
+ "gifQualityBalanced": "均衡",
+ "gifQualitySmall": "小文件",
"outputDimensions": "输出:{{dimensions}}px",
"sizePresetOriginalShort": "原",
"sizePresetMediumShort": "中",
diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json
index b7b944030..19fe2c833 100644
--- a/src/i18n/locales/zh-TW/settings.json
+++ b/src/i18n/locales/zh-TW/settings.json
@@ -232,6 +232,10 @@
},
"fpsTitle": "FPS",
"loop": "循環",
+ "gifQuality": "品質",
+ "gifQualityHigh": "高",
+ "gifQualityBalanced": "平衡",
+ "gifQualitySmall": "小檔案",
"outputDimensions": "輸出:{{dimensions}}px",
"sizePresetOriginalShort": "原",
"sizePresetMediumShort": "中",
diff --git a/src/lib/exporter/gifExporter.test.ts b/src/lib/exporter/gifExporter.test.ts
index 8b261d976..5da7ad7d3 100644
--- a/src/lib/exporter/gifExporter.test.ts
+++ b/src/lib/exporter/gifExporter.test.ts
@@ -1,11 +1,152 @@
+import { createRequire } from "node:module";
import * as fc from "fast-check";
-import { describe, expect, it } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const gifMockState = vi.hoisted(() => ({
+ addFrameCalls: [] as Array<{ image: unknown; options: unknown }>,
+ canvas: {} as unknown,
+ constructorOptions: [] as Array>,
+ decodedFrames: 0,
+}));
+
+vi.mock("gif.js", () => ({
+ default: vi.fn((options: Record) => {
+ gifMockState.constructorOptions.push(options);
+ const handlers = new Map void>();
+
+ return {
+ addFrame: vi.fn((image: unknown, frameOptions: unknown) => {
+ gifMockState.addFrameCalls.push({ image, options: frameOptions });
+ }),
+ abort: vi.fn(),
+ on: vi.fn((event: string, handler: (value: Blob | number) => void) => {
+ handlers.set(event, handler);
+ }),
+ render: vi.fn(() => {
+ handlers.get("finished")?.(new Blob(["x"], { type: "image/gif" }));
+ }),
+ };
+ }),
+}));
+
+vi.mock("./frameRenderer", () => ({
+ FrameRenderer: vi.fn(() => ({
+ destroy: vi.fn(),
+ getCanvas: vi.fn(() => gifMockState.canvas),
+ initialize: vi.fn(),
+ renderFrame: vi.fn(),
+ })),
+}));
+
+vi.mock("./streamingDecoder", () => ({
+ StreamingVideoDecoder: vi.fn(() => ({
+ cancel: vi.fn(),
+ decodeAll: vi.fn(
+ async (
+ _frameRate: number,
+ _trimRegions: unknown,
+ _speedRegions: unknown,
+ onFrame: (
+ videoFrame: unknown,
+ exportTimestampUs: number,
+ sourceTimestampMs: number,
+ cursorTimestampMs: number,
+ ) => Promise,
+ ) => {
+ for (let frame = 0; frame < gifMockState.decodedFrames; frame++) {
+ await onFrame({}, 0, 0, 0);
+ }
+ },
+ ),
+ destroy: vi.fn(),
+ getEffectiveDuration: vi.fn(() => 0),
+ loadMetadata: vi.fn(() => Promise.resolve({ width: 2, height: 2, duration: 2 })),
+ })),
+}));
+
import {
buildGifFrameRendererConfig,
calculateOutputDimensions,
+ getGifQualitySettings,
getGifRepeat,
+ GifExporter,
+ reduceGifColorDepth,
} from "./gifExporter";
-import { GIF_SIZE_PRESETS, GifSizePreset } from "./types";
+import {
+ GIF_QUALITY_PRESETS,
+ GIF_SIZE_PRESETS,
+ type GifQualityPreset,
+ type GifSizePreset,
+} from "./types";
+
+// gif.js is mocked above; this is the real encoder its workers run.
+const RealGifEncoder = createRequire(import.meta.url)("gif.js/src/GIFEncoder.js");
+
+function createNoisyGradientFrame(width: number, height: number) {
+ const pixels = new Uint8ClampedArray(width * height * 4);
+ let seed = 1;
+ for (let offset = 0; offset < pixels.length; offset += 4) {
+ const pixel = offset / 4;
+ const x = pixel % width;
+ const y = Math.floor(pixel / width);
+ seed = (seed * 1103515245 + 12345) % 2147483648;
+ const noise = (seed % 9) - 4;
+ pixels[offset] = (x / width) * 255 + noise;
+ pixels[offset + 1] = (y / height) * 255 + noise;
+ pixels[offset + 2] = ((x + y) / (width + height)) * 255 + noise;
+ pixels[offset + 3] = 255;
+ }
+ return pixels;
+}
+
+function encodeWithRealGifEncoder(
+ source: Uint8ClampedArray,
+ width: number,
+ height: number,
+ preset: GifQualityPreset,
+) {
+ const { quality, dither, colorBits } = GIF_QUALITY_PRESETS[preset];
+ const pixels = source.slice();
+ if (colorBits < 8) {
+ reduceGifColorDepth(pixels, colorBits);
+ }
+
+ const encoder = new RealGifEncoder(width, height);
+ encoder.writeHeader();
+ encoder.setRepeat(0);
+ encoder.setDelay(66);
+ encoder.setQuality(quality);
+ encoder.setDither(dither);
+ encoder.addFrame(pixels);
+ encoder.finish();
+
+ const stream = encoder.stream();
+ return (stream.pages.length - 1) * stream.constructor.pageSize + stream.cursor;
+}
+
+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
@@ -19,6 +160,18 @@ import { GIF_SIZE_PRESETS, GifSizePreset } from "./types";
* Feature: gif-export, Property 2: Loop Encoding Correctness
*/
describe("GIF Exporter", () => {
+ beforeEach(() => {
+ gifMockState.addFrameCalls = [];
+ gifMockState.canvas = {};
+ gifMockState.constructorOptions = [];
+ gifMockState.decodedFrames = 0;
+ vi.stubGlobal("navigator", { hardwareConcurrency: 4 });
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
describe("Property 2: Loop Encoding Correctness", () => {
/**
* Test the loop configuration mapping logic.
@@ -52,6 +205,91 @@ describe("GIF Exporter", () => {
});
});
+ describe("GIF quality preset mapping", () => {
+ it("should pass the small preset settings to gif.js", async () => {
+ await new GifExporter(createGifExporterConfig({ qualityPreset: "small" })).export();
+
+ expect(gifMockState.constructorOptions.at(-1)).toMatchObject({
+ quality: 20,
+ dither: false,
+ });
+ });
+
+ it("should default to balanced settings when qualityPreset is omitted", async () => {
+ await new GifExporter(createGifExporterConfig()).export();
+
+ expect(gifMockState.constructorOptions.at(-1)).toMatchObject({
+ quality: 10,
+ dither: "FloydSteinberg",
+ });
+ });
+
+ it("should fall back to balanced settings for unknown quality presets", () => {
+ expect(getGifQualitySettings("future")).toBe(GIF_QUALITY_PRESETS.balanced);
+ });
+
+ it("should add canvas frames unchanged for the balanced preset", async () => {
+ gifMockState.decodedFrames = 1;
+ const canvas = { width: 2, height: 2, getContext: vi.fn() };
+ gifMockState.canvas = canvas;
+
+ await new GifExporter(createGifExporterConfig({ qualityPreset: "balanced" })).export();
+
+ expect(canvas.getContext).not.toHaveBeenCalled();
+ expect(gifMockState.addFrameCalls).toEqual([
+ { image: canvas, options: expect.objectContaining({ copy: true }) },
+ ]);
+ });
+
+ it("should add color-reduced frames for the small preset", async () => {
+ gifMockState.decodedFrames = 1;
+ const frame = { data: new Uint8ClampedArray([255, 123, 7, 200]) };
+ gifMockState.canvas = {
+ width: 1,
+ height: 1,
+ getContext: vi.fn(() => ({ getImageData: vi.fn(() => frame) })),
+ };
+
+ await new GifExporter(createGifExporterConfig({ qualityPreset: "small" })).export();
+
+ expect(gifMockState.addFrameCalls).toHaveLength(1);
+ expect(gifMockState.addFrameCalls[0].image).toBe(frame);
+ expect([...frame.data]).toEqual([255, 123, 0, 200]);
+ });
+
+ it("should encode a smaller file for the small preset with the real encoder", () => {
+ const width = 160;
+ const height = 90;
+ const source = createNoisyGradientFrame(width, height);
+
+ const balancedSize = encodeWithRealGifEncoder(source, width, height, "balanced");
+ const smallSize = encodeWithRealGifEncoder(source, width, height, "small");
+
+ expect(smallSize).toBeLessThan(balancedSize * 0.8);
+ });
+ });
+
+ describe("reduceGifColorDepth", () => {
+ it("should keep black, white, and alpha while reducing channel levels", () => {
+ const pixels = new Uint8ClampedArray([0, 255, 8, 17]);
+
+ reduceGifColorDepth(pixels, 5);
+
+ expect([...pixels]).toEqual([0, 255, 8, 17]);
+ });
+
+ it("should map every channel value onto the reduced level count", () => {
+ const levels = new Set();
+ for (let value = 0; value < 256; value++) {
+ const pixels = new Uint8ClampedArray([value, value, value, 255]);
+ reduceGifColorDepth(pixels, 5);
+ levels.add(pixels[0]);
+ }
+
+ expect(levels.size).toBe(32);
+ });
+ });
+
/**
* Property 4: Aspect Ratio Preservation
*
diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts
index 2df730000..49749c3e1 100644
--- a/src/lib/exporter/gifExporter.ts
+++ b/src/lib/exporter/gifExporter.ts
@@ -22,8 +22,11 @@ import type {
ExportResult,
GIF_SIZE_PRESETS,
GifFrameRate,
+ GifQualityPreset,
+ GifQualitySettings,
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();
@@ -37,6 +40,7 @@ interface GifExporterConfig {
frameRate: GifFrameRate;
loop: boolean;
sizePreset: GifSizePreset;
+ qualityPreset?: GifQualityPreset;
wallpaper: string;
zoomRegions: ZoomRegion[];
trimRegions?: TrimRegion[];
@@ -193,6 +197,21 @@ export function buildGifFrameRendererConfig(
};
}
+export function getGifQualitySettings(qualityPreset: unknown): GifQualitySettings {
+ const preset = isValidGifQualityPreset(qualityPreset) ? qualityPreset : "balanced";
+ return GIF_QUALITY_PRESETS[preset];
+}
+
+export function reduceGifColorDepth(pixels: Uint8ClampedArray, colorBits: number) {
+ const shift = 8 - colorBits;
+ const scale = 255 / (255 >> shift);
+ for (let offset = 0; offset < pixels.length; offset += 4) {
+ pixels[offset] = (pixels[offset] >> shift) * scale;
+ pixels[offset + 1] = (pixels[offset + 1] >> shift) * scale;
+ pixels[offset + 2] = (pixels[offset + 2] >> shift) * scale;
+ }
+}
+
export class GifExporter {
private config: GifExporterConfig;
private streamingDecoder: StreamingVideoDecoder | null = null;
@@ -234,16 +253,17 @@ export class GifExporter {
const cores = navigator.hardwareConcurrency || 4;
const WORKER_COUNT = Math.max(1, Math.min(8, cores - 1));
+ const { quality, dither } = getGifQualitySettings(this.config.qualityPreset);
this.gif = new GIF({
workers: WORKER_COUNT,
- quality: 10,
+ quality,
width: this.config.width,
height: this.config.height,
workerScript: GIF_WORKER_URL,
repeat,
background: "#000000",
transparent: null,
- dither: "FloydSteinberg",
+ dither,
});
// Calculate effective duration and frame count (excluding trim regions)
@@ -350,7 +370,16 @@ export class GifExporter {
private addRenderedGifFrame(frameDelay: number) {
const canvas = this.renderer!.getCanvas();
- this.gif!.addFrame(canvas, { delay: frameDelay, copy: true });
+ const { colorBits } = getGifQualitySettings(this.config.qualityPreset);
+ const context = colorBits < 8 ? canvas.getContext("2d") : null;
+ if (!context) {
+ this.gif!.addFrame(canvas, { delay: frameDelay, copy: true });
+ return;
+ }
+
+ const frame = context.getImageData(0, 0, canvas.width, canvas.height);
+ reduceGifColorDepth(frame.data, colorBits);
+ this.gif!.addFrame(frame, { delay: frameDelay });
}
private reportProgress(currentFrame: number, totalFrames: number) {
diff --git a/src/lib/exporter/index.ts b/src/lib/exporter/index.ts
index 3561cb3a1..17f88b753 100644
--- a/src/lib/exporter/index.ts
+++ b/src/lib/exporter/index.ts
@@ -29,16 +29,21 @@ export type {
ExportSettings,
GifExportConfig,
GifFrameRate,
+ GifQualityPreset,
+ GifQualitySettings,
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 81f9553c3..c02cf6245 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 f474f9988..c07d0d400 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;
}
@@ -216,6 +219,22 @@ export const GIF_SIZE_PRESETS: Record = {
+ high: { quality: 1, dither: "FloydSteinberg", colorBits: 8, label: "High" },
+ balanced: { quality: 10, dither: "FloydSteinberg", colorBits: 8, label: "Balanced" },
+ small: { quality: 20, dither: false, colorBits: 5, label: "Small file" },
+};
+
export const GIF_FRAME_RATES: { value: GifFrameRate; label: string }[] = [
{ value: 15, label: "15 FPS - Balanced" },
{ value: 20, label: "20 FPS - Smooth" },
@@ -226,6 +245,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);
+}