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