From 79621b1593b3aa1b51eb3198ff66ee49abd57a6d Mon Sep 17 00:00:00 2001 From: zack34567 Date: Tue, 19 May 2026 11:12:28 +0530 Subject: [PATCH 1/2] feat: add custom export presets --- src/components/PresetSelector.tsx | 358 +++++++++++++++++++++++------- src/components/VideoEditor.tsx | 18 +- src/components/VideoPreview.tsx | 8 +- src/hooks/useVideoEditor.ts | 71 +++++- src/lib/ffmpeg.ts | 14 +- src/lib/presets.ts | 122 +++++++++- src/lib/tests/presets.test.ts | 18 +- 7 files changed, 503 insertions(+), 106 deletions(-) diff --git a/src/components/PresetSelector.tsx b/src/components/PresetSelector.tsx index fd129ab2..a13071b9 100644 --- a/src/components/PresetSelector.tsx +++ b/src/components/PresetSelector.tsx @@ -1,16 +1,20 @@ "use client"; -import { useCallback, useState } from "react"; +import { ChangeEvent, FormEvent, useCallback, useEffect, useRef, useState } from "react"; -import { Search, Settings2 } from "lucide-react"; +import { Search, Settings2, Save, X } from "lucide-react"; -import { PRESETS } from "@/lib/presets"; +import { CustomPreset, MAX_CUSTOM_PRESETS, PRESETS } from "@/lib/presets"; import { EditRecipe } from "@/lib/types"; import { cn } from "@/lib/utils"; interface Props { recipe: EditRecipe; + customPresets: CustomPreset[]; onChange: (patch: Partial) => void; + onSavePreset: (name: string) => { ok: boolean; message: string }; + onDeletePreset: (id: string) => void; + onSelectCustomPreset: (id: string) => void; } function getOrientationLabel(width: number, height: number): string { @@ -49,6 +53,10 @@ function RatioBox({ ); } +function sanitizeDimensionInput(value: string): string { + return value.replace(/\D/g, "").replace(/^0+(?=\d)/, ""); +} + const QUICK_ACTIONS = [ { preset: "vertical-9-16", @@ -102,8 +110,25 @@ const QUICK_ACTIONS = [ }, ] as const; -export default function PresetSelector({ recipe, onChange }: Props) { +export default function PresetSelector({ + recipe, + customPresets, + onChange, + onSavePreset, + onDeletePreset, + onSelectCustomPreset, +}: Props) { const [search, setSearch] = useState(""); + const [isSaveOpen, setIsSaveOpen] = useState(false); + const [presetName, setPresetName] = useState(""); + const [feedback, setFeedback] = useState(null); + const [widthInput, setWidthInput] = useState(String(recipe.customWidth)); + const [heightInput, setHeightInput] = useState(String(recipe.customHeight)); + const presetNameRef = useRef(null); + + const isCustomRecipe = + recipe.preset === "custom" || + customPresets.some((preset) => preset.id === recipe.preset); const filteredPresets = PRESETS.filter( (preset) => @@ -120,23 +145,55 @@ export default function PresetSelector({ recipe, onChange }: Props) { [onChange], ); - const handleWidthChange = useCallback( - (width: number) => { - if (!isNaN(width) && width >= 16 && width <= 7680) { - onChange({ customWidth: width }); - } - }, - [onChange], - ); + useEffect(() => { + if (isSaveOpen) { + presetNameRef.current?.focus(); + } + }, [isSaveOpen]); - const handleHeightChange = useCallback( - (height: number) => { - if (!isNaN(height) && height >= 16 && height <= 7680) { - onChange({ customHeight: height }); - } - }, - [onChange], - ); + useEffect(() => { + setWidthInput(String(recipe.customWidth)); + }, [recipe.customWidth]); + + useEffect(() => { + setHeightInput(String(recipe.customHeight)); + }, [recipe.customHeight]); + + const handleWidthInputChange = useCallback((event: ChangeEvent) => { + const nextValue = sanitizeDimensionInput(event.target.value); + setWidthInput(nextValue); + if (!nextValue) return; + onChange({ preset: "custom", customWidth: Number(nextValue) }); + }, [onChange]); + + const handleHeightInputChange = useCallback((event: ChangeEvent) => { + const nextValue = sanitizeDimensionInput(event.target.value); + setHeightInput(nextValue); + if (!nextValue) return; + onChange({ preset: "custom", customHeight: Number(nextValue) }); + }, [onChange]); + + const handleOpenSave = () => { + if (customPresets.length >= MAX_CUSTOM_PRESETS) { + setFeedback(`You can save up to ${MAX_CUSTOM_PRESETS} custom presets. Delete one before saving another.`); + return; + } + + setPresetName(""); + setFeedback(null); + setIsSaveOpen(true); + }; + + const handleSave = (event: FormEvent) => { + event.preventDefault(); + const result = onSavePreset(presetName); + setFeedback(result.message); + + if (result.ok) { + setIsSaveOpen(false); + setPresetName(""); + } + }; return (
@@ -170,19 +227,34 @@ export default function PresetSelector({ recipe, onChange }: Props) { })}
-
-
- +
+
+
+ +
+ setSearch(e.target.value)} + className="w-full rounded-lg border border-[var(--border)] bg-[var(--bg)] py-2 pl-9 pr-3 text-sm font-heading text-[var(--text)] transition-shadow focus:outline-none focus:ring-2 focus:ring-film-400" + />
- setSearch(e.target.value)} - className="w-full rounded-lg border border-[var(--border)] bg-[var(--bg)] py-2 pl-9 pr-3 text-sm font-heading text-[var(--text)] transition-shadow focus:outline-none focus:ring-2 focus:ring-film-400" - /> + +
+ {feedback && ( +

{feedback}

+ )} +
{filteredPresets.length === 0 ? (
@@ -272,67 +344,195 @@ export default function PresetSelector({ recipe, onChange }: Props) {
- {recipe.preset === "custom" && ( -
-
- - handleWidthChange(Number(e.target.value))} - className="w-full min-w-20 rounded-md border border-[var(--border)] bg-[var(--bg)] px-3 py-2 text-sm font-heading transition-all focus:outline-none focus:ring-2 focus:ring-film-400 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" - /> + {customPresets.length > 0 && ( +
+

+ Custom +

+
+ {customPresets.map((preset) => { + const active = recipe.preset === preset.id; + const { customWidth, customHeight, quality, format } = preset.recipe; + + return ( +
+ + +
+ ); + })}
+
+ )} + + {isCustomRecipe && ( +
+
+
+ + { + if (!widthInput) setWidthInput(String(recipe.customWidth)); + }} + className="h-10 w-full min-w-0 rounded-md border border-[var(--border)] bg-[var(--bg)] px-3 text-sm font-heading transition-all focus:outline-none focus:ring-2 focus:ring-film-400 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + /> +
+ +
+ + x + +
-
- - × - +
+ + { + if (!heightInput) setHeightInput(String(recipe.customHeight)); + }} + className="h-10 w-full min-w-0 rounded-md border border-[var(--border)] bg-[var(--bg)] px-3 text-sm font-heading transition-all focus:outline-none focus:ring-2 focus:ring-film-400 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + /> +
-
+

+ {recipe.customWidth}x{recipe.customHeight} -{" "} + {getOrientationLabel(recipe.customWidth || 1, recipe.customHeight || 1)} +

+
+ )} + + {isSaveOpen && ( +
+
+
+

+ Save preset +

+ +
+ handleHeightChange(Number(e.target.value))} - className="w-full min-w-20 rounded-md border border-[var(--border)] bg-[var(--bg)] px-3 py-2 text-sm font-heading transition-all focus:outline-none focus:ring-2 focus:ring-film-400 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + id="preset-name" + ref={presetNameRef} + type="text" + value={presetName} + onChange={(event) => setPresetName(event.target.value)} + placeholder="Instagram portrait 1080p" + className="w-full rounded-lg border border-[var(--border)] bg-[var(--bg)] px-3 py-2 text-sm font-heading text-[var(--text)] focus:outline-none focus:ring-2 focus:ring-film-400" /> -
-
- - Ratio - -
- {getOrientationLabel( - recipe.customWidth || 0, - recipe.customHeight || 0, - )} +
+ +
-
+
)}
); -} \ No newline at end of file +} diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index ef9ad3f7..da6559c7 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -18,8 +18,8 @@ import ImageOverlay from "./ImageOverlay" import { cn } from "@/lib/utils"; import { - Layers, Crop, Scissors, RotateCw, Volume2, - SlidersHorizontal, Zap, AlertTriangle, Github, Copy + Layers, Scissors, RotateCw, Volume2, + SlidersHorizontal, Zap, AlertTriangle, Copy } from "lucide-react"; import OnboardingTour from "./OnboardingTour"; import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts"; @@ -206,7 +206,8 @@ function KeyboardShortcutsPanel() { export default function VideoEditor() { const { file, duration, recipe, status, progress, - result, error, updateRecipe, + result, error, customPresets, updateRecipe, + saveCustomPreset, deleteCustomPreset, loadCustomPreset, handleFileSelect, fileError, handleExport, cancelExport, reset, resetSettings, videoRef, seekTo, @@ -571,7 +572,14 @@ export default function VideoEditor() {

)} - +
@@ -628,4 +636,4 @@ export default function VideoEditor() {
); -} \ No newline at end of file +} diff --git a/src/components/VideoPreview.tsx b/src/components/VideoPreview.tsx index 4e6f7e9d..e129a034 100644 --- a/src/components/VideoPreview.tsx +++ b/src/components/VideoPreview.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState, useCallback, RefObject } from "react"; import { EditRecipe } from "@/lib/types"; -import { getPresetById } from "@/lib/presets"; +import { getRecipeDimensions } from "@/lib/presets"; import { cn } from "@/lib/utils"; import { Camera } from "lucide-react"; import ComparisonPreview from "./ComparisonPreview"; @@ -111,11 +111,7 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) { const overlay = (() => { if (!recipe || !showOverlay) return null; - const preset = recipe.preset === "custom" - ? { width: recipe.customWidth, height: recipe.customHeight } - : getPresetById(recipe.preset); - - if (!preset) return null; + const preset = getRecipeDimensions(recipe); // Preview container is 16:9 const containerW = 16; diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts index 77bf9ab4..92e28609 100644 --- a/src/hooks/useVideoEditor.ts +++ b/src/hooks/useVideoEditor.ts @@ -3,7 +3,14 @@ import { useState, useCallback, useEffect, useRef, useMemo } from "react"; import { EditRecipe, ExportResult, ExportStatus, MAX_FILE_SIZE, OverlayPosition, isValidRecipe } from "@/lib/types"; import { DEFAULT_RECIPE, SPEED_STEPS } from "@/lib/constants"; -import { getPresetById } from "@/lib/presets"; +import { + CustomPreset, + MAX_CUSTOM_PRESETS, + getPresetById, + getRecipeDimensions, + loadCustomPresets, + saveCustomPresets, +} from "@/lib/presets"; import { loadFFmpeg, exportVideo, terminateFFmpeg, FFmpegLoadError } from "@/lib/ffmpeg"; import { suggestPreset } from "@/lib/presetSuggestion"; import { validateDimensions, getDownscaledDimensions } from "@/utils/video-validation"; @@ -137,6 +144,7 @@ export function useVideoEditor() { const [result, setResult] = useState(null); const [error, setError] = useState(null); const [fileError, setFileError] = useState(""); + const [customPresets, setCustomPresets] = useState([]); const exportAbortControllerRef = useRef(null); const exportCancelledRef = useRef(false); const videoRef = useRef(null); @@ -161,6 +169,61 @@ export function useVideoEditor() { return next; }); }, []); + + useEffect(() => { + setCustomPresets(loadCustomPresets()); + }, []); + + const saveCustomPreset = useCallback((name: string) => { + const trimmedName = name.trim(); + + if (!trimmedName) { + return { ok: false, message: "Preset name is required." }; + } + + if (customPresets.length >= MAX_CUSTOM_PRESETS) { + return { + ok: false, + message: `You can save up to ${MAX_CUSTOM_PRESETS} custom presets. Delete one before saving another.`, + }; + } + + const id = `custom-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const dimensions = getRecipeDimensions(recipe); + const presetRecipe: EditRecipe = { + ...recipe, + preset: id, + customWidth: dimensions.width, + customHeight: dimensions.height, + }; + const nextPreset: CustomPreset = { + id, + name: trimmedName, + recipe: presetRecipe, + createdAt: Date.now(), + }; + const nextPresets = [...customPresets, nextPreset]; + + setCustomPresets(nextPresets); + saveCustomPresets(nextPresets); + setRecipe(presetRecipe); + + return { ok: true, message: `"${trimmedName}" saved as a custom preset.` }; + }, [customPresets, recipe]); + + const deleteCustomPreset = useCallback((id: string) => { + const nextPresets = customPresets.filter((preset) => preset.id !== id); + setCustomPresets(nextPresets); + saveCustomPresets(nextPresets); + setRecipe((prev) => prev.preset === id ? { ...prev, preset: "custom" } : prev); + }, [customPresets]); + + const loadCustomPreset = useCallback((id: string) => { + const preset = customPresets.find((item) => item.id === id); + if (!preset) return; + setRecipe(preset.recipe); + }, [customPresets]); + const isValidValue = (key: keyof EditRecipe, val: any): boolean => { switch (key) { case "preset": @@ -641,9 +704,13 @@ export function useVideoEditor() { progress, result, error, + customPresets, videoRef, seekTo, updateRecipe, + saveCustomPreset, + deleteCustomPreset, + loadCustomPreset, handleFileSelect, fileError, handleExport, @@ -670,4 +737,4 @@ export function useVideoEditor() { currentTime, toggleSound, }; -} \ No newline at end of file +} diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts index 94c2ddb3..b497f809 100644 --- a/src/lib/ffmpeg.ts +++ b/src/lib/ffmpeg.ts @@ -1,7 +1,7 @@ import { FFmpeg } from "@ffmpeg/ffmpeg"; import { fetchFile, toBlobURL } from "@ffmpeg/util"; import { EditRecipe, ExportResult, BackgroundMusicOptions, ImageOverlayOptions } from "./types"; -import { getPresetById } from "./presets"; +import { getRecipeDimensions } from "./presets"; import { simd } from "wasm-feature-detect"; const CORE_BASE_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/umd"; @@ -306,15 +306,7 @@ export async function exportVideo( overlayOptions?: ImageOverlayOptions ): Promise { const sessionId = buildSessionId(); - let targetW: number, targetH: number; - if (recipe.preset === "custom") { - targetW = recipe.customWidth; - targetH = recipe.customHeight; - } else { - const preset = getPresetById(recipe.preset); - targetW = preset?.width ?? 1920; - targetH = preset?.height ?? 1080; - } + let { width: targetW, height: targetH } = getRecipeDimensions(recipe); targetW = Math.round(targetW / 2) * 2; targetH = Math.round(targetH / 2) * 2; @@ -481,7 +473,7 @@ export async function exportVideo( size: blob.size, width: targetW, height: targetH, - format: recipe.format as "mp4" | "webm" | "mkv", + format: recipe.format, }; } finally { ffmpeg.off("progress", handleProgress); diff --git a/src/lib/presets.ts b/src/lib/presets.ts index 0ec7dee8..c217fb5b 100644 --- a/src/lib/presets.ts +++ b/src/lib/presets.ts @@ -1,3 +1,6 @@ +import type { EditRecipe } from "./types"; +import { DEFAULT_RECIPE } from "./constants"; + export interface Preset { id: string; label: string; @@ -6,7 +9,17 @@ export interface Preset { height: number; } -export const PRESETS: Preset[] = [ +export interface CustomPreset { + id: string; + name: string; + recipe: EditRecipe; + createdAt: number; +} + +export const CUSTOM_PRESET_STORAGE_KEY = "reframe.customPresets"; +export const MAX_CUSTOM_PRESETS = 10; + +export const BUILT_IN_PRESETS: Preset[] = [ { id: "vertical-9-16", label: "9 : 16", platform: "Reels · TikTok · Shorts", width: 1080, height: 1920 }, { id: "instagram-4-5", label: "4 : 5", platform: "Instagram Feed", width: 1080, height: 1350 }, { id: "square-1-1", label: "1 : 1", platform: "Square", width: 1080, height: 1080 }, @@ -20,7 +33,112 @@ export const PRESETS: Preset[] = [ { id: "custom", label: "Custom", platform: "Set your own", width: 1920, height: 1080 }, ]; +export const PRESETS = BUILT_IN_PRESETS; + /** Returns the preset matching the given ID, or undefined if no match is found. */ export function getPresetById(id: string): Preset | undefined { - return PRESETS.find((p) => p.id === id); + return BUILT_IN_PRESETS.find((p) => p.id === id); +} + +export function getRecipeDimensions(recipe: Pick) { + if (recipe.preset === "custom") { + return { width: recipe.customWidth, height: recipe.customHeight }; + } + + const preset = getPresetById(recipe.preset); + return preset + ? { width: preset.width, height: preset.height } + : { width: recipe.customWidth, height: recipe.customHeight }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function normalizeNumber(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function normalizeBoolean(value: unknown, fallback: boolean): boolean { + return typeof value === "boolean" ? value : fallback; +} + +function normalizeRecipe(value: unknown, presetId: string): EditRecipe | null { + if (!isRecord(value)) return null; + + const format = value.format === "webm" || value.format === "mkv" || value.format === "mp4" || value.format === "gif" + ? value.format + : DEFAULT_RECIPE.format; + const framing = value.framing === "fill" || value.framing === "fit" + ? value.framing + : DEFAULT_RECIPE.framing; + const rotate = value.rotate === 90 || value.rotate === 180 || value.rotate === 270 || value.rotate === 0 + ? value.rotate + : DEFAULT_RECIPE.rotate; + + return { + ...DEFAULT_RECIPE, + preset: presetId, + customWidth: normalizeNumber(value.customWidth, DEFAULT_RECIPE.customWidth), + customHeight: normalizeNumber(value.customHeight, DEFAULT_RECIPE.customHeight), + framing, + trimStart: normalizeNumber(value.trimStart, DEFAULT_RECIPE.trimStart), + trimEnd: value.trimEnd === null || typeof value.trimEnd !== "number" + ? DEFAULT_RECIPE.trimEnd + : normalizeNumber(value.trimEnd, DEFAULT_RECIPE.trimEnd ?? 0), + rotate, + keepAudio: normalizeBoolean(value.keepAudio, DEFAULT_RECIPE.keepAudio), + normalizeAudio: normalizeBoolean(value.normalizeAudio, DEFAULT_RECIPE.normalizeAudio), + speed: normalizeNumber(value.speed, DEFAULT_RECIPE.speed), + quality: normalizeNumber(value.quality, DEFAULT_RECIPE.quality), + format, + stabilization: normalizeBoolean(value.stabilization, DEFAULT_RECIPE.stabilization), + brightness: normalizeNumber(value.brightness, DEFAULT_RECIPE.brightness), + contrast: normalizeNumber(value.contrast, DEFAULT_RECIPE.contrast), + saturation: normalizeNumber(value.saturation, DEFAULT_RECIPE.saturation), + soundOnCompletion: normalizeBoolean(value.soundOnCompletion, DEFAULT_RECIPE.soundOnCompletion), + version: DEFAULT_RECIPE.version, + }; +} + +function normalizeCustomPreset(value: unknown): CustomPreset | null { + if (!isRecord(value)) return null; + if (typeof value.id !== "string" || typeof value.name !== "string") return null; + + const recipe = normalizeRecipe(value.recipe, value.id); + if (!recipe) return null; + + return { + id: value.id, + name: value.name, + recipe, + createdAt: normalizeNumber(value.createdAt, Date.now()), + }; +} + +export function loadCustomPresets(): CustomPreset[] { + if (typeof window === "undefined") return []; + + try { + const raw = window.localStorage.getItem(CUSTOM_PRESET_STORAGE_KEY); + if (!raw) return []; + + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + + return parsed + .map(normalizeCustomPreset) + .filter((preset): preset is CustomPreset => preset !== null) + .slice(0, MAX_CUSTOM_PRESETS); + } catch { + return []; + } +} + +export function saveCustomPresets(presets: CustomPreset[]) { + if (typeof window === "undefined") return; + window.localStorage.setItem( + CUSTOM_PRESET_STORAGE_KEY, + JSON.stringify(presets.slice(0, MAX_CUSTOM_PRESETS)) + ); } diff --git a/src/lib/tests/presets.test.ts b/src/lib/tests/presets.test.ts index 93f29da9..54285102 100644 --- a/src/lib/tests/presets.test.ts +++ b/src/lib/tests/presets.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { getPresetById, PRESETS } from "../presets"; +import { getPresetById, getRecipeDimensions, PRESETS } from "../presets"; describe('getPresetById', () => { it('returns correct preset for valid id', () => { @@ -21,4 +21,20 @@ describe('getPresetById', () => { expect(p.platform).toBeTruthy(); }); }); + + it('uses built-in dimensions for built-in preset ids', () => { + expect(getRecipeDimensions({ + preset: 'instagram-4-5', + customWidth: 1920, + customHeight: 1080, + })).toEqual({ width: 1080, height: 1350 }); + }); + + it('uses recipe dimensions for custom preset ids', () => { + expect(getRecipeDimensions({ + preset: 'custom-preset-123', + customWidth: 1080, + customHeight: 1350, + })).toEqual({ width: 1080, height: 1350 }); + }); }); From 9ed86a09523a75d234d94f6b9d3cde36c8541d9b Mon Sep 17 00:00:00 2001 From: zack34567 Date: Tue, 19 May 2026 11:46:32 +0530 Subject: [PATCH 2/2] fix: preserve custom preset dimensions --- src/components/ThumbnailStrip.tsx | 10 +++++++--- src/hooks/useVideoEditor.ts | 1 - src/lib/ffmpeg.ts | 1 - src/lib/tests/presets.test.ts | 8 ++++++++ vitest.config.ts | 5 +++++ vitest.setup.ts | 14 ++++++++++++++ 6 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/components/ThumbnailStrip.tsx b/src/components/ThumbnailStrip.tsx index 90cc5f3c..39ad9d04 100644 --- a/src/components/ThumbnailStrip.tsx +++ b/src/components/ThumbnailStrip.tsx @@ -43,6 +43,10 @@ export default function ThumbnailStrip({ objectUrlsRef.current = []; }, []); + const cancelThumbnailRun = useCallback(() => { + lastRunIdRef.current += 1; + }, []); + const generateThumbnails = useCallback(async () => { if (!videoSrc || duration <= 0) return; @@ -144,10 +148,10 @@ export default function ThumbnailStrip({ generateThumbnails(); } return () => { - lastRunIdRef.current++; + cancelThumbnailRun(); revokeAllObjectUrls(); }; - }, [generateThumbnails, revokeAllObjectUrls, videoSrc, duration]); + }, [cancelThumbnailRun, generateThumbnails, revokeAllObjectUrls, videoSrc, duration]); const formatTime = (seconds: number) => { const m = Math.floor(seconds / 60); @@ -438,4 +442,4 @@ export default function ThumbnailStrip({ `}
); -} \ No newline at end of file +} diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts index 92e28609..691ea596 100644 --- a/src/hooks/useVideoEditor.ts +++ b/src/hooks/useVideoEditor.ts @@ -527,7 +527,6 @@ export function useVideoEditor() { } catch (err) { if (exportCancelledRef.current) return; - console.error("export failed:", err); if (err instanceof FFmpegLoadError) { setError(err.message); } else if (err instanceof Error && err.message.includes('network')) { diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts index b497f809..c0bac725 100644 --- a/src/lib/ffmpeg.ts +++ b/src/lib/ffmpeg.ts @@ -2,7 +2,6 @@ import { FFmpeg } from "@ffmpeg/ffmpeg"; import { fetchFile, toBlobURL } from "@ffmpeg/util"; import { EditRecipe, ExportResult, BackgroundMusicOptions, ImageOverlayOptions } from "./types"; import { getRecipeDimensions } from "./presets"; -import { simd } from "wasm-feature-detect"; const CORE_BASE_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/umd"; diff --git a/src/lib/tests/presets.test.ts b/src/lib/tests/presets.test.ts index 54285102..c6aaf3dd 100644 --- a/src/lib/tests/presets.test.ts +++ b/src/lib/tests/presets.test.ts @@ -37,4 +37,12 @@ describe('getPresetById', () => { customHeight: 1350, })).toEqual({ width: 1080, height: 1350 }); }); + + it('uses recipe dimensions for the custom preset editor', () => { + expect(getRecipeDimensions({ + preset: 'custom', + customWidth: 1080, + customHeight: 1350, + })).toEqual({ width: 1080, height: 1350 }); + }); }); diff --git a/vitest.config.ts b/vitest.config.ts index 38167032..220799df 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,11 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ + oxc: { + jsx: { + runtime: 'automatic', + }, + }, test: { environment: 'jsdom', globals: true, diff --git a/vitest.setup.ts b/vitest.setup.ts index eecd4a61..d2973f28 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -1,2 +1,16 @@ // Vitest setup: polyfills and global mocks import '@testing-library/jest-dom/vitest' + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), +})