diff --git a/src/components/FileUpload.tsx b/src/components/FileUpload.tsx index 6e10fd77..d25214bd 100644 --- a/src/components/FileUpload.tsx +++ b/src/components/FileUpload.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRef, useState, useEffect} from "react"; +import { useRef, useState, useEffect } from "react"; import { Film, FolderOpen } from "lucide-react"; import LottiePlayer from "./LottiePlayer"; import uploadAnim from "@/lib/lottie/upload.json"; @@ -25,7 +25,7 @@ export default function FileUpload({ const [dragging, setDragging] = useState(false); const [error, setError] = useState(""); const [warning, setWarning] = useState(""); - + useEffect(() => { const handler = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "o") { @@ -41,18 +41,11 @@ export default function FileUpload({ setError(""); setWarning(""); - // Validate type if (!file.type.startsWith("video/")) { setError("Only video files are allowed."); return; } - if (file.size > 500 * 1024 * 1024) { - setError("File size exceeds 500MB limit. Please select a smaller video."); - return; - } - - // Hard limit if (file.size > MAX_FILE_SIZE) { setError( `File too large (${formatBytes( @@ -62,7 +55,6 @@ export default function FileUpload({ return; } - // Soft warning if (file.size > WARNING_FILE_SIZE) { const estimatedMinutes = Math.max(1, Math.round(file.size / (100 * 1024 * 1024))); setWarning( @@ -83,58 +75,62 @@ export default function FileUpload({ if (file) handleFile(file); }; - const FileInfo = () => ( -
-
- -
- -
- -
- - - -
-
-

- {currentFile?.name} -

- {currentFile && ( - - {currentFile.name.includes(".") - ? currentFile.name.split(".").pop() - : "VIDEO"} - - )} + const FileInfo = () => ( +
+
+
+
+
-
-

{formatBytes(currentFile?.size ?? 0)}

- -

+ + +

+
+ {currentFile && ( + + {currentFile.name.includes(".") ? currentFile.name.split(".").pop() : "VIDEO"} + + )} +

+ {currentFile?.name} +

+
+ +

+ {formatBytes(currentFile?.size ?? 0)} {duration > 0 - ? `Duration: ${formatDuration(duration)}` - : "Loading duration..."} + ? ` - ${formatDuration(duration)}` + : " - Loading duration..."}

-
- + +
+
+
+ + MP4 / MOV / AVI / WebM +
+

+ Supports MP4, MOV, AVI, MKV, WebM, and most video formats +

+
{fileError && ( -

+

{fileError}

)} +
+ ); -

- Supports: MP4, MOV, AVI, MKV, WebM, and most video formats -

- - {fileError && ( -

{fileError}

- )} - - { - const f = e.target.files?.[0]; - if (f) handleFile(f); - }} - /> -
-); const DropZone = () => (
{ e.preventDefault(); @@ -192,7 +170,6 @@ export default function FileUpload({ : "border-[var(--border)] bg-[var(--bg)] hover:border-film-400 hover:bg-film-50/40" )} > - {/* Premium Light Beam Shimmer Effect */} {dragging && (
)} @@ -235,18 +212,18 @@ export default function FileUpload({

)} - { - const f = e.target.files?.[0]; - - if (f) handleFile(f); - }} - /> -
+ { + const f = e.target.files?.[0]; + + if (f) handleFile(f); + }} + /> +
); return ( diff --git a/src/components/PresetSelector.tsx b/src/components/PresetSelector.tsx index 53821604..2592197d 100644 --- a/src/components/PresetSelector.tsx +++ b/src/components/PresetSelector.tsx @@ -1,16 +1,22 @@ "use client"; -import { useCallback, useState } from "react"; - -import { Search, Settings2 } from "lucide-react"; - -import { PRESETS } from "@/lib/presets"; +import { + BUILT_IN_PRESETS, + CustomPreset, + MAX_CUSTOM_PRESETS, +} from "@/lib/presets"; import { EditRecipe } from "@/lib/types"; +import { Search, Settings2, Save, X } from "lucide-react"; +import { ChangeEvent, FormEvent, useCallback, useEffect, useRef, useState } from "react"; 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,16 +55,51 @@ function RatioBox({ ); } -export default function PresetSelector({ recipe, onChange }: Props) { +function sanitizeDimensionInput(value: string): string { + return value.replace(/\D/g, "").replace(/^0+(?=\d)/, ""); +} + +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 filteredPresets = PRESETS.filter( + const isCustomRecipe = + recipe.preset === "custom" || + customPresets.some((preset) => preset.id === recipe.preset); + + const filteredPresets = BUILT_IN_PRESETS.filter( (preset) => preset.id !== "custom" && (preset.label.toLowerCase().includes(search.toLowerCase()) || preset.platform.toLowerCase().includes(search.toLowerCase())), ); + useEffect(() => { + if (isSaveOpen) { + presetNameRef.current?.focus(); + } + }, [isSaveOpen]); + + useEffect(() => { + setWidthInput(String(recipe.customWidth)); + }, [recipe.customWidth]); + + useEffect(() => { + setHeightInput(String(recipe.customHeight)); + }, [recipe.customHeight]); + const handlePresetSelect = useCallback( (presetId: string) => { onChange({ preset: presetId }); @@ -69,33 +110,85 @@ export default function PresetSelector({ recipe, onChange }: Props) { const handleWidthChange = useCallback( (width: number) => { - onChange({ customWidth: width }); + onChange({ preset: "custom", customWidth: width }); }, [onChange], ); const handleHeightChange = useCallback( (height: number) => { - onChange({ customHeight: height }); + onChange({ preset: "custom", customHeight: height }); }, [onChange], ); + const handleWidthInputChange = useCallback((event: ChangeEvent) => { + const nextValue = sanitizeDimensionInput(event.target.value); + setWidthInput(nextValue); + if (!nextValue) return; + handleWidthChange(Number(nextValue)); + }, [handleWidthChange]); + + const handleHeightInputChange = useCallback((event: ChangeEvent) => { + const nextValue = sanitizeDimensionInput(event.target.value); + setHeightInput(nextValue); + if (!nextValue) return; + handleHeightChange(Number(nextValue)); + }, [handleHeightChange]); + + 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 (
-
-
- +
+
+
+ +
+ 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 ? (
@@ -110,7 +203,7 @@ export default function PresetSelector({ recipe, onChange }: Props) { key={preset.id} type="button" onClick={() => handlePresetSelect(preset.id)} - title={`${preset.label} — ${preset.width}×${preset.height} — ${getOrientationLabel(preset.width, preset.height)}`} + title={`${preset.label} - ${preset.width}x${preset.height} - ${getOrientationLabel(preset.width, preset.height)}`} aria-label={`Select ${preset.label} preset, ${preset.width} by ${preset.height} pixels`} aria-pressed={active} className={cn( @@ -147,7 +240,7 @@ export default function PresetSelector({ recipe, onChange }: Props) {
- {recipe.preset === "custom" && ( -
-
+ {customPresets.length > 0 && ( +
+

+ Custom +

+
+ {customPresets.map((preset) => { + const active = recipe.preset === preset.id; + const width = preset.recipe.customWidth; + const height = preset.recipe.customHeight; + + return ( +
+ + +
+ ); + })} +
+
+ )} + + {isCustomRecipe && ( +
+
+
handleWidthChange(Number(e.target.value))} - className="w-full 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" + value={widthInput} + spellCheck={false} + onChange={handleWidthInputChange} + onBlur={() => { + 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" />
-
- - × +
+ + x
-
+
handleHeightChange(Number(e.target.value))} - className="w-full 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" + value={heightInput} + spellCheck={false} + onChange={handleHeightInputChange} + onBlur={() => { + 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" />
+
-
- - Ratio - -
- {getOrientationLabel( - recipe.customWidth || 0, - recipe.customHeight || 0, - )} +

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

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

+ Save preset +

+
-
+ + + setPresetName(event.target.value)} + maxLength={60} + placeholder="Instagram portrait 1080p" + className="w-full rounded-md border border-[var(--border)] bg-[var(--bg)] px-3 py-2 text-sm font-heading focus:outline-none focus:ring-2 focus:ring-film-400" + /> + +
+ + +
+
)}
diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index f89872d5..e3dcd185 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -50,8 +50,9 @@ function Section({ icon, title, children, delay = 0 }: SectionProps) { export default function VideoEditor() { const { - file, duration, recipe, status, progress, - result, error, updateRecipe, + file, duration, recipe, status, progress, + result, error, customPresets, updateRecipe, + saveCustomPreset, deleteCustomPreset, loadCustomPreset, handleFileSelect, fileError, handleExport, cancelExport, reset, resetSettings, videoRef, seekTo, @@ -130,7 +131,7 @@ export default function VideoEditor() { {file && (
- +

- We detected a {recommendedPreset.label.replace(/\s/g, "")} video → Recommended: {recommendedPreset.platform.split("·")[0].trim()} ({recommendedPreset.label.replace(/\s/g, "")}) + We detected a {recommendedPreset.label.replace(/\s/g, "")} video - Recommended: {recommendedPreset.platform.split("·")[0].trim()} ({recommendedPreset.label.replace(/\s/g, "")})

)} - +
} title="Framing" delay={100}> @@ -366,4 +374,4 @@ export default function VideoEditor() {
); -} \ No newline at end of file +} diff --git a/src/components/VideoPreview.tsx b/src/components/VideoPreview.tsx index ff9d71e2..cff84e84 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"; @@ -109,11 +109,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; @@ -263,4 +259,4 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) { )}
); -} \ No newline at end of file +} diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts index a2283128..489e6ddf 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 } 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"; @@ -133,6 +140,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); @@ -147,10 +155,61 @@ export function useVideoEditor() { const [overlaySize, setOverlaySize] = useState(150); const [overlayOpacity, setOverlayOpacity] = useState(100); + useEffect(() => { + setCustomPresets(loadCustomPresets()); + }, []); + const updateRecipe = useCallback((patch: Partial) => { setRecipe((prev) => ({ ...prev, ...patch })); }, []); + 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 dimensions = getRecipeDimensions(recipe); + const id = `custom-preset-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const preset: CustomPreset = { + id, + name: trimmedName, + createdAt: Date.now(), + recipe: { + ...recipe, + preset: id, + customWidth: dimensions.width, + customHeight: dimensions.height, + }, + }; + + const nextPresets = [preset, ...customPresets].slice(0, MAX_CUSTOM_PRESETS); + setCustomPresets(nextPresets); + saveCustomPresets(nextPresets); + + return { ok: true, message: `"${trimmedName}" saved.` }; + }, [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]); + useEffect(() => { try { const saved = localStorage.getItem("reframe-settings"); @@ -308,7 +367,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')) { @@ -325,7 +383,21 @@ export function useVideoEditor() { exportAbortControllerRef.current = null; } } - }, [file, recipe, result, status, overlayFile, overlayPosition, overlaySize, overlayOpacity, duration]); + }, [ + file, + recipe, + result, + status, + duration, + musicFile, + musicVolume, + originalAudioVolume, + loopMusic, + overlayFile, + overlayPosition, + overlaySize, + overlayOpacity, + ]); useEffect(() => { @@ -360,7 +432,7 @@ export function useVideoEditor() { window.addEventListener("beforeunload", handler); return () => window.removeEventListener("beforeunload", handler); }, [status]); - + useEffect(() => { const handleKeydown = (e: KeyboardEvent) => { if ( @@ -442,6 +514,7 @@ export function useVideoEditor() { file, duration, recipe, + customPresets, status, progress, result, @@ -449,6 +522,9 @@ export function useVideoEditor() { videoRef, seekTo, updateRecipe, + saveCustomPreset, + deleteCustomPreset, + loadCustomPreset, handleFileSelect, fileError, handleExport, @@ -473,4 +549,4 @@ export function useVideoEditor() { setOverlayOpacity, recommendedPreset, }; -} \ No newline at end of file +} diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts index beaeed1b..93c92300 100644 --- a/src/lib/ffmpeg.ts +++ b/src/lib/ffmpeg.ts @@ -1,18 +1,15 @@ import { FFmpeg } from "@ffmpeg/ffmpeg"; import { fetchFile } from "@ffmpeg/util"; import { EditRecipe, ExportResult, BackgroundMusicOptions, ImageOverlayOptions } from "./types"; -import { getPresetById } from "./presets"; -import { simd } from "wasm-feature-detect"; +import { getRecipeDimensions } from "./presets"; const CORE_BASE_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/umd"; -// Added from main branch for subresource security verification const SRI_HASHES: Record = { - "ffmpeg-core.js": "sha384-sKfkiFtvUk+vexk+0EUhEh366190/4WpgUAsUvaxEfyg7+E1Zt5Y5hrsU808g8Q9", + "ffmpeg-core.js": "sha384-sKfkiFtvUk+vexk+0EUhEh366190/4WpgUAsUvaxEfyg7+E1Zt5Y5hrsU808g8Q9", "ffmpeg-core.wasm": "sha384-U1VDhkPYrM3wTCT4/vjSpSsKqG/UjljYrYCI4hBSJ02svbCkxuCi6U6u/peg5vpW", }; -// Added from main branch to perform secure binary verification async function fetchWithIntegrity(url: string, mimeType: string): Promise { const key = url.split("/").pop()!; const integrity = SRI_HASHES[key]; @@ -28,9 +25,6 @@ async function fetchWithIntegrity(url: string, mimeType: string): Promise void ): Promise { if (ffmpegInstance?.loaded) { @@ -57,7 +51,6 @@ export async function loadFFmpeg( try { ffmpeg.on("progress", handleProgress); - // Secure engine load using verified runtime checksum hashes from main await ffmpeg.load({ coreURL: await fetchWithIntegrity(`${CORE_BASE_URL}/ffmpeg-core.js`, "text/javascript"), wasmURL: await fetchWithIntegrity(`${CORE_BASE_URL}/ffmpeg-core.wasm`, "application/wasm"), @@ -65,7 +58,7 @@ export async function loadFFmpeg( onProgress?.(100); return ffmpeg; - } catch (err) { + } catch { if (ffmpegInstance === ffmpeg) { ffmpegInstance = null; } @@ -96,7 +89,6 @@ function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: number): filters.push("setpts=PTS-STARTPTS"); } - if (recipe.stabilization) { filters.push("deshake"); } @@ -210,9 +202,9 @@ function buildArguments( const scaledW = overlayOptions!.size; const alpha = (overlayOptions!.opacity / 100).toFixed(2); const posMap: Record = { - "top-left": "20:20", - "top-right": "W-w-20:20", - "bottom-left": "20:H-h-20", + "top-left": "20:20", + "top-right": "W-w-20:20", + "bottom-left": "20:H-h-20", "bottom-right": "W-w-20:H-h-20", }; const pos = posMap[overlayOptions!.position] ?? "W-w-20:H-h-20"; @@ -226,7 +218,7 @@ function buildArguments( if (hasMusicTrack) { const musicVol = (musicOptions!.musicVolume / 100).toFixed(2); if (hasOriginalAudio) { - const origVol = (musicOptions!.originalAudioVolume / 100).toFixed(2); + const origVol = (musicOptions!.originalAudioVolume / 100).toFixed(2); const origChain = afParts.length > 0 ? `[0:a]${afParts.join(",")},volume=${origVol}[orig]` : `[0:a]volume=${origVol}[orig]`; @@ -290,15 +282,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; @@ -358,7 +342,6 @@ export async function exportVideo( }; ffmpeg.on("log", logListener); - // Attempt 1: Process with standard audio streams let args = buildArguments( recipe, recipe.format, outputName, inputName, targetW, targetH, hasMusicTrack, musicInputName, musicOptions, @@ -367,7 +350,6 @@ export async function exportVideo( let exitCode = await ffmpeg.exec(args, undefined, { signal }); - // Attempt 2: Auto-recover if the file has no original audio track if (exitCode !== 0 && missingAudioDetected) { missingAudioDetected = false; args = buildArguments( @@ -378,7 +360,6 @@ export async function exportVideo( exitCode = await ffmpeg.exec(args, undefined, { signal }); } - // Fallback Attempt 3: Switch codecs to WebM if container errors happen if (exitCode !== 0) { args = buildArguments( recipe, "webm", fallbackOutputName, inputName, targetW, targetH, @@ -428,4 +409,4 @@ export async function exportVideo( export function formatBytes(bytes: number): string { if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} \ No newline at end of file +} diff --git a/src/lib/presets.ts b/src/lib/presets.ts index 0ec7dee8..aead4623 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,109 @@ 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 + : 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), + 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), + }; +} + +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..c6aaf3dd 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,28 @@ 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 }); + }); + + it('uses recipe dimensions for the custom preset editor', () => { + expect(getRecipeDimensions({ + preset: 'custom', + customWidth: 1080, + customHeight: 1350, + })).toEqual({ width: 1080, height: 1350 }); + }); });