Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/components/video-editor/ExportSettingsMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<I18nProvider>
<ExportSettingsMenu
exportFormat="gif"
exportQuality="source"
exportEncodingMode="balanced"
mp4FrameRate={30}
gifFrameRate={15}
gifLoop={true}
gifSizePreset="medium"
gifQualityPreset="balanced"
gifOutputDimensions={{ width: 1280, height: 720 }}
/>
</I18nProvider>,
);

expect(html).toContain("High");
expect(html).toContain("Balanced");
expect(html).toContain("Small file");
});
});
61 changes: 60 additions & 1 deletion src/components/video-editor/ExportSettingsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand All @@ -62,6 +70,8 @@ export function ExportSettingsMenu({
onGifLoopChange,
gifSizePreset,
onGifSizePresetChange,
gifQualityPreset,
onGifQualityPresetChange,
gifOutputDimensions,
onExport,
className,
Expand Down Expand Up @@ -461,6 +471,55 @@ export function ExportSettingsMenu({
</div>
</LayoutGroup>
</div>
<LayoutGroup id="header-gif-quality-toggle">
<div className="grid h-8 grid-cols-3 rounded-xl border border-foreground/5 bg-foreground/5 p-0.5">
{Object.entries(GIF_QUALITY_PRESETS).map(([key, preset]) => {
const typedKey = key as GifQualityPreset;
const isActive = gifQualityPreset === typedKey;
return (
<button
key={key}
type="button"
onClick={() => onGifQualityPresetChange?.(typedKey)}
aria-pressed={isActive}
className="relative rounded-lg text-[11px] font-medium transition-colors"
>
{isActive ? (
<motion.span
layoutId="header-gif-quality-pill"
className="absolute inset-0 rounded-lg bg-neutral-800 dark:bg-white"
transition={{
type: "spring",
stiffness: 420,
damping: 34,
}}
/>
) : null}
<span
className={cn(
"relative z-10",
isActive
? "text-white dark:text-black"
: "text-muted-foreground hover:text-foreground",
)}
>
{typedKey === "high"
? tSettings("export.gifQualityHigh", preset.label)
: typedKey === "balanced"
? tSettings(
"export.gifQualityBalanced",
preset.label,
)
: tSettings(
"export.gifQualitySmall",
preset.label,
)}
</span>
</button>
);
})}
</div>
</LayoutGroup>
<div className="flex items-center justify-between px-1">
<span className="text-[10px] text-muted-foreground/70">
{gifOutputDimensions.width} × {gifOutputDimensions.height}px
Expand Down
19 changes: 19 additions & 0 deletions src/components/video-editor/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import {
GIF_SIZE_PRESETS,
GifExporter,
type GifFrameRate,
type GifQualityPreset,
type GifSizePreset,
ModernVideoExporter,
probeSupportedMp4Dimensions,
Expand Down Expand Up @@ -582,6 +583,9 @@ export default function VideoEditor() {
const [gifSizePreset, setGifSizePreset] = useState<GifSizePreset>(
initialEditorPreferences.gifSizePreset,
);
const [gifQualityPreset, setGifQualityPreset] = useState<GifQualityPreset>(
initialEditorPreferences.gifQualityPreset,
);
const [exportedFilePath, setExportedFilePath] = useState<string | undefined>(undefined);
const [hasPendingExportSave, setHasPendingExportSave] = useState(false);
const [lastSavedSnapshot, setLastSavedSnapshot] = useState<EditorProjectData | null>(null);
Expand Down Expand Up @@ -713,6 +717,7 @@ export default function VideoEditor() {
gifFrameRate,
gifLoop,
gifSizePreset,
gifQualityPreset,
autoCaptionSettings: { ...autoCaptionSettings },
whisperExecutablePath,
whisperModelPath,
Expand Down Expand Up @@ -764,6 +769,7 @@ export default function VideoEditor() {
gifFrameRate,
gifLoop,
gifSizePreset,
gifQualityPreset,
autoCaptionSettings,
whisperExecutablePath,
whisperModelPath,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1595,6 +1602,7 @@ export default function VideoEditor() {
gifFrameRate: GifFrameRate;
gifLoop: boolean;
gifSizePreset: GifSizePreset;
gifQualityPreset: GifQualityPreset;
sourceAudioTrackSettingsByClip: Record<string, SourceAudioTrackSettings>;
defaultSourceAudioTrackSettings: SourceAudioTrackSettings;
}>,
Expand Down Expand Up @@ -1698,6 +1706,7 @@ export default function VideoEditor() {
gifFrameRate,
gifLoop,
gifSizePreset,
gifQualityPreset,
sourceAudioTrackSettingsByClip,
defaultSourceAudioTrackSettings,
}),
Expand Down Expand Up @@ -1759,6 +1768,7 @@ export default function VideoEditor() {
gifFrameRate,
gifLoop,
gifSizePreset,
gifQualityPreset,
frame,
sourceAudioTrackSettingsByClip,
defaultSourceAudioTrackSettings,
Expand Down Expand Up @@ -1953,6 +1963,7 @@ export default function VideoEditor() {
setGifFrameRate(normalizedEditor.gifFrameRate);
setGifLoop(normalizedEditor.gifLoop);
setGifSizePreset(normalizedEditor.gifSizePreset);
setGifQualityPreset(normalizedEditor.gifQualityPreset);

setSelectedZoomId(null);
setSelectedClipId(null);
Expand Down Expand Up @@ -2260,6 +2271,7 @@ export default function VideoEditor() {
setGifFrameRate(initialEditorPreferences.gifFrameRate);
setGifLoop(initialEditorPreferences.gifLoop);
setGifSizePreset(initialEditorPreferences.gifSizePreset);
setGifQualityPreset(initialEditorPreferences.gifQualityPreset);
return;
}
}
Expand Down Expand Up @@ -2432,6 +2444,7 @@ export default function VideoEditor() {
gifFrameRate,
gifLoop,
gifSizePreset,
gifQualityPreset,
whisperExecutablePath,
whisperModelPath,
});
Expand Down Expand Up @@ -2483,6 +2496,7 @@ export default function VideoEditor() {
gifFrameRate,
gifLoop,
gifSizePreset,
gifQualityPreset,
whisperExecutablePath,
whisperModelPath,
]);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -4725,6 +4740,7 @@ export default function VideoEditor() {
gifFrameRate,
gifLoop,
gifSizePreset,
gifQualityPreset,
});

setExportError(null);
Expand All @@ -4740,6 +4756,7 @@ export default function VideoEditor() {
gifFrameRate,
gifLoop,
gifSizePreset,
gifQualityPreset,
exportBackendPreference,
exportPipelineModel,
handleExport,
Expand Down Expand Up @@ -5434,6 +5451,8 @@ export default function VideoEditor() {
onGifLoopChange={setGifLoop}
gifSizePreset={gifSizePreset}
onGifSizePresetChange={setGifSizePreset}
gifQualityPreset={gifQualityPreset}
onGifQualityPresetChange={setGifQualityPreset}
mp4OutputDimensions={mp4OutputDimensions}
gifOutputDimensions={gifOutputDimensions}
onExport={handleStartExportFromDropdown}
Expand Down
18 changes: 18 additions & 0 deletions src/components/video-editor/editorPreferences.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -161,6 +175,7 @@ describe("editorPreferences", () => {
exportFormat: "gif",
gifFrameRate: 30,
gifLoop: false,
gifQualityPreset: "small",
customAspectWidth: "21",
customAspectHeight: "9",
customWallpapers: ["data:image/jpeg;base64,abc"],
Expand All @@ -178,6 +193,7 @@ describe("editorPreferences", () => {
exportFormat: "gif",
gifFrameRate: 30,
gifLoop: false,
gifQualityPreset: "small",
customAspectWidth: "21",
customAspectHeight: "9",
customWallpapers: ["data:image/jpeg;base64,abc"],
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -305,6 +322,7 @@ describe("editorPreferences", () => {
gifFrameRate: 20,
gifLoop: false,
gifSizePreset: "large",
gifQualityPreset: "small",
customAspectWidth: "4",
customAspectHeight: "5",
customWallpapers: ["data:image/jpeg;base64,abc"],
Expand Down
4 changes: 4 additions & 0 deletions src/components/video-editor/editorPreferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ type PersistedEditorControls = Pick<
| "gifFrameRate"
| "gifLoop"
| "gifSizePreset"
| "gifQualityPreset"
>;

type PartialEditorControls = Partial<PersistedEditorControls>;
Expand Down Expand Up @@ -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: [],
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -388,6 +391,7 @@ function normalizeEditorControls(
gifFrameRate: normalized.gifFrameRate,
gifLoop: normalized.gifLoop,
gifSizePreset: normalized.gifSizePreset,
gifQualityPreset: normalized.gifQualityPreset,
};
}

Expand Down
3 changes: 3 additions & 0 deletions src/components/video-editor/exportStartSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const baseOptions = {
gifFrameRate: 20 as const,
gifLoop: true,
gifSizePreset: "medium" as const,
gifQualityPreset: "balanced" as const,
};

describe("resolveExportStartSettings", () => {
Expand Down Expand Up @@ -50,6 +51,7 @@ describe("resolveExportStartSettings", () => {
frameRate: 15,
loop: false,
sizePreset: "medium",
qualityPreset: "balanced",
width: 1280,
height: 720,
},
Expand All @@ -67,6 +69,7 @@ describe("resolveExportStartSettings", () => {
}).gifConfig,
).toMatchObject({
sizePreset: "original",
qualityPreset: "balanced",
width: 1234,
height: 678,
});
Expand Down
4 changes: 4 additions & 0 deletions src/components/video-editor/exportStartSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type ExportSettings,
GIF_SIZE_PRESETS,
type GifFrameRate,
type GifQualityPreset,
type GifSizePreset,
} from "@/lib/exporter";

Expand All @@ -24,6 +25,7 @@ export function resolveExportStartSettings({
gifFrameRate,
gifLoop,
gifSizePreset,
gifQualityPreset,
}: {
sourceWidth: number;
sourceHeight: number;
Expand All @@ -36,6 +38,7 @@ export function resolveExportStartSettings({
gifFrameRate: GifFrameRate;
gifLoop: boolean;
gifSizePreset: GifSizePreset;
gifQualityPreset: GifQualityPreset;
}): ExportSettings {
const gifDimensions =
exportFormat === "gif"
Expand All @@ -55,6 +58,7 @@ export function resolveExportStartSettings({
frameRate: gifFrameRate,
loop: gifLoop,
sizePreset: gifSizePreset,
qualityPreset: gifQualityPreset,
width: gifDimensions.width,
height: gifDimensions.height,
}
Expand Down
Loading