From b3c72cd9ed55f645f3b20db70c7975f491278e33 Mon Sep 17 00:00:00 2001 From: zack34567 Date: Tue, 19 May 2026 11:12:28 +0530 Subject: [PATCH 1/5] feat: add custom export presets --- src/components/PresetSelector.tsx | 245 ++++++++++-- src/components/VideoEditor.tsx | 16 +- src/components/VideoPreview.tsx | 10 +- src/hooks/useVideoEditor.ts | 65 +++- src/lib/ffmpeg.ts | 614 +++++++++++++++--------------- src/lib/presets.ts | 115 +++++- src/lib/tests/presets.test.ts | 18 +- 7 files changed, 722 insertions(+), 361 deletions(-) diff --git a/src/components/PresetSelector.tsx b/src/components/PresetSelector.tsx index 916c0349..94eee55d 100644 --- a/src/components/PresetSelector.tsx +++ b/src/components/PresetSelector.tsx @@ -1,14 +1,22 @@ "use client"; -import { PRESETS } from "@/lib/presets"; +import { + BUILT_IN_PRESETS, + CustomPreset, + MAX_CUSTOM_PRESETS, +} from "@/lib/presets"; import { EditRecipe } from "@/lib/types"; -import { Settings2, Lock, Unlock } from "lucide-react"; -import { useState, useCallback, useRef } from "react"; +import { Settings2, Lock, Unlock, Save, X } from "lucide-react"; +import { FormEvent, useEffect, useState, useCallback, useRef } 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 { @@ -37,51 +45,108 @@ function RatioBox({ width, height, active }: { width: number; height: number; ac ); } -export default function PresetSelector({ recipe, onChange }: Props) { - +export default function PresetSelector({ + recipe, + customPresets, + onChange, + onSavePreset, + onDeletePreset, + onSelectCustomPreset, +}: Props) { const [locked, setLocked] = useState(false); - const [aspectRatio, setAspectRatio] = useState(16 / 9); + const [isSaveOpen, setIsSaveOpen] = useState(false); + const [presetName, setPresetName] = useState(""); + const [feedback, setFeedback] = useState(null); const lockedRef = useRef(false); -const aspectRatioRef = useRef(16 / 9); - -const handleToggleLock = useCallback(() => { - if (!lockedRef.current) { - const w = recipe.customWidth ?? 1920; - const h = recipe.customHeight ?? 1080; - const ratio = h !== 0 ? w / h : 16 / 9; - setAspectRatio(ratio); - aspectRatioRef.current = ratio; - } - setLocked((prev) => { - lockedRef.current = !prev; - return !prev; - }); -}, [recipe.customWidth, recipe.customHeight]); + const aspectRatioRef = useRef(16 / 9); + const presetNameRef = useRef(null); + const isCustomRecipe = recipe.preset === "custom" || customPresets.some((preset) => preset.id === recipe.preset); + + useEffect(() => { + if (isSaveOpen) { + presetNameRef.current?.focus(); + } + }, [isSaveOpen]); + + const handleToggleLock = useCallback(() => { + if (!lockedRef.current) { + const w = recipe.customWidth ?? 1920; + const h = recipe.customHeight ?? 1080; + const ratio = h !== 0 ? w / h : 16 / 9; + aspectRatioRef.current = ratio; + } + setLocked((prev) => { + lockedRef.current = !prev; + return !prev; + }); + }, [recipe.customWidth, recipe.customHeight]); const handleWidthChange = useCallback((w: number) => { - const patch: Partial = { customWidth: w }; - if (lockedRef.current) patch.customHeight = Math.round(w / aspectRatioRef.current); - onChange(patch); -}, [onChange]); + const patch: Partial = { preset: "custom", customWidth: w }; + if (lockedRef.current) patch.customHeight = Math.round(w / aspectRatioRef.current); + onChange(patch); + }, [onChange]); + + const handleHeightChange = useCallback((h: number) => { + const patch: Partial = { preset: "custom", customHeight: h }; + if (lockedRef.current) patch.customWidth = Math.round(h * aspectRatioRef.current); + onChange(patch); + }, [onChange]); -const handleHeightChange = useCallback((h: number) => { - const patch: Partial = { customHeight: h }; - if (lockedRef.current) patch.customWidth = Math.round(h * aspectRatioRef.current); - onChange(patch); -}, [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 (
+
+

+ Built-in +

+ +
+ + {feedback && ( +

+ {feedback} +

+ )} +
- {PRESETS.filter((p) => p.id !== "custom").map((preset) => { + {BUILT_IN_PRESETS.filter((p) => p.id !== "custom").map((preset) => { const active = recipe.preset === preset.id; return (
- {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 && (
)} + + {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 56b82381..e945d270 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -49,7 +49,8 @@ function Section({ icon, title, children, delay = 0 }: SectionProps) { 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, @@ -122,7 +123,7 @@ export default function VideoEditor() { {file && (
- +
} title="Output size"> - +
} title="Framing" delay={100}> @@ -360,4 +368,4 @@ export default function VideoEditor() {
); -} \ No newline at end of file +} diff --git a/src/components/VideoPreview.tsx b/src/components/VideoPreview.tsx index 808359cd..d5a9c533 100644 --- a/src/components/VideoPreview.tsx +++ b/src/components/VideoPreview.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState, RefObject } from "react"; import { EditRecipe } from "@/lib/types"; -import { getPresetById } from "@/lib/presets"; +import { getRecipeDimensions } from "@/lib/presets"; import { cn } from "@/lib/utils"; interface Props { @@ -78,11 +78,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; @@ -218,4 +214,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 d3401d9d..19d8f544 100644 --- a/src/hooks/useVideoEditor.ts +++ b/src/hooks/useVideoEditor.ts @@ -4,6 +4,13 @@ import { useState, useCallback, useEffect, useRef } from "react"; import { EditRecipe, ExportResult, ExportStatus, MAX_FILE_SIZE } from "@/lib/types"; import { DEFAULT_RECIPE } from "@/lib/constants"; import { loadFFmpeg, exportVideo, terminateFFmpeg, FFmpegLoadError } from "@/lib/ffmpeg"; +import { + CustomPreset, + MAX_CUSTOM_PRESETS, + getRecipeDimensions, + loadCustomPresets, + saveCustomPresets, +} from "@/lib/presets"; const DEFAULT_TITLE = "Reframe — Resize, trim, and export videos in your browser"; @@ -69,14 +76,66 @@ 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); + 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]); + const handleFileSelect = useCallback(async (selectedFile: File) => { setResult(null); setStatus("idle"); @@ -272,6 +331,7 @@ export function useVideoEditor() { file, duration, recipe, + customPresets, status, progress, result, @@ -279,6 +339,9 @@ export function useVideoEditor() { videoRef, seekTo, updateRecipe, + saveCustomPreset, + deleteCustomPreset, + loadCustomPreset, handleFileSelect, fileError, handleExport, @@ -286,4 +349,4 @@ export function useVideoEditor() { reset, resetSettings, }; -} \ No newline at end of file +} diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts index 2f42389c..24d2310f 100644 --- a/src/lib/ffmpeg.ts +++ b/src/lib/ffmpeg.ts @@ -1,311 +1,303 @@ -import { FFmpeg } from "@ffmpeg/ffmpeg"; -import { fetchFile, toBlobURL } from "@ffmpeg/util"; -import { EditRecipe, ExportResult } from "./types"; -import { getPresetById } from "./presets"; -import { simd } from "wasm-feature-detect"; - -const CORE_BASE_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/umd"; - -let ffmpegInstance: FFmpeg | null = null; - -/** - * Error thrown when the FFmpeg WebAssembly core fails to load. - * This typically happens when the user is offline, the CDN is unreachable (or if the url is wrong), - * or there are network interruptions during the initialization phase. - */ -export class FFmpegLoadError extends Error { - constructor(message: string) { - super(message); - this.name = "FFmpegLoadError"; - } -} -export async function loadFFmpeg(signal?: AbortSignal, - onProgress?: (percent: number) => void): Promise { - if (ffmpegInstance?.loaded) { - onProgress?.(100); - return ffmpegInstance; - } - - const ffmpeg = ffmpegInstance ?? new FFmpeg(); - ffmpegInstance = ffmpeg; - - const handleProgress = ({ - progress, - }: { - progress: number; - }) => { - onProgress?.(Math.round(progress * 100)); - }; - - try { - - ffmpeg.on("progress", handleProgress); - // Check if the user's browser supports WebAssembly SIMD - const isSimdSupported = await simd(); - - // Dynamically set the core filename - const coreName = isSimdSupported ? "ffmpeg-core-simd" : "ffmpeg-core"; - - // Load FFmpeg using the dynamic URLs + the new signal parameter - await ffmpeg.load({ - coreURL: await toBlobURL(`${CORE_BASE_URL}/${coreName}.js`, "text/javascript"), - wasmURL: await toBlobURL(`${CORE_BASE_URL}/${coreName}.wasm`, "application/wasm"), - }, { signal }); - onProgress?.(100); - return ffmpeg; - } catch (err) { - if (ffmpegInstance === ffmpeg) { - ffmpegInstance = null; - } - throw new FFmpegLoadError("Failed to load the FFmpeg engine. Check your internet connection."); - } finally { - ffmpeg.off("progress", handleProgress); - } -} - -/** Terminates the active FFmpeg instance and releases its memory. */ -export function terminateFFmpeg() { - ffmpegInstance?.terminate(); - ffmpegInstance = null; -} - -/** Generates a unique session ID used to isolate FFmpeg file names across concurrent exports. */ -function buildSessionId(): string { - if (typeof crypto !== "undefined" && "randomUUID" in crypto) { - return crypto.randomUUID(); - } - return `${Date.now()}-${Math.random().toString(16).slice(2)}`; -} - -/** Builds the FFmpeg -vf filter chain string from the current recipe settings. */ -function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: number): string { - const filters: string[] = []; - - if (recipe.trimStart > 0 || recipe.trimEnd !== null) { - const end = recipe.trimEnd !== null ? recipe.trimEnd : 999999; - filters.push(`trim=start=${recipe.trimStart}:end=${end}`); - filters.push("setpts=PTS-STARTPTS"); - } - - if (recipe.rotate === 90) { - filters.push("transpose=1"); - } else if (recipe.rotate === 180) { - filters.push("transpose=1,transpose=1"); - } else if (recipe.rotate === 270) { - filters.push("transpose=2"); - } - - if (recipe.stabilization) { - filters.push("deshake=x=-1:y=-1:w=-1:h=-1:rx=16:ry=16"); - } - - if (recipe.framing === "fit") { - filters.push( - `scale=${targetW}:${targetH}:force_original_aspect_ratio=decrease`, - `pad=${targetW}:${targetH}:(ow-iw)/2:(oh-ih)/2:color=black` - ); - } else { - filters.push( - `scale=${targetW}:${targetH}:force_original_aspect_ratio=increase`, - `crop=${targetW}:${targetH}` - ); - } - - if (recipe.speed !== 1) { - const pts = (1 / recipe.speed).toFixed(4); - filters.push(`setpts=${pts}*PTS`); - } - filters.push( - `eq=brightness=${recipe.brightness}:contrast=${recipe.contrast}:saturation=${recipe.saturation}` - ); - return filters.join(","); -} - -/** Builds an atempo filter chain for the given playback speed, chaining multiple filters for speeds outside the 0.5–2.0 range. */ -export function buildAudioFilter(speed: number): string { - if (speed === 1) return ""; - - const filters: string[] = []; - let remaining = speed; - - // Chain filters for slow speeds - while (remaining < 0.5) { - filters.push("atempo=0.5"); - remaining /= 0.5; - } - - // Chain filters for fast speeds - while (remaining > 2.0) { - filters.push("atempo=2.0"); - remaining /= 2.0; - } - - // Add final remaining filter if not exactly 1.0 - // using a small epsilon check to avoid floating point issues - if (Math.abs(remaining - 1.0) > 0.001) { - filters.push(`atempo=${Number(remaining.toFixed(4))}`); - } - - return filters.join(","); -} - -function buildAudioTrimFilter(recipe: EditRecipe): string { - if (recipe.trimStart === 0 && recipe.trimEnd === null) return ""; - const end = recipe.trimEnd !== null ? recipe.trimEnd : 999999; - return `atrim=start=${recipe.trimStart}:end=${end},asetpts=PTS-STARTPTS`; -} - -export async function exportVideo( - ffmpeg: FFmpeg, - file: File, - recipe: EditRecipe, - onProgress: (percent: number) => void, - signal?: AbortSignal -): 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; - } - - // dimensions must be even for libx264 - targetW = Math.round(targetW / 2) * 2; - targetH = Math.round(targetH / 2) * 2; - - const ext = file.name.split(".").pop() ?? "mp4"; - const inputName = `input_${sessionId}.${ext}`; - - // Determine output filename and MIME type based on format - const getOutputConfig = (format: string) => { - switch (format) { - case "webm": - return { filename: `output_${sessionId}.webm`, mimeType: "video/webm" }; - case "mkv": - return { filename: `output_${sessionId}.mkv`, mimeType: "video/x-matroska" }; - default: // mp4 - return { filename: `output_${sessionId}.mp4`, mimeType: "video/mp4" }; - } - }; - - const { filename: outputName, mimeType } = getOutputConfig(recipe.format); - const fallbackOutputName = `fallback_${sessionId}.webm`; - const cleanupFiles = new Set([inputName, outputName, fallbackOutputName]); - - const handleProgress = ({ progress }: { progress: number }) => { - onProgress(Math.min(99, Math.round(progress * 100))); - }; - - try { - await ffmpeg.writeFile(inputName, await fetchFile(file), { signal }); - - ffmpeg.on("progress", handleProgress); - - const vf = buildVideoFilter(recipe, targetW, targetH); - const audioTrim = buildAudioTrimFilter(recipe); - const audioSpeed = buildAudioFilter(recipe.speed); - const afParts = [audioTrim, audioSpeed].filter(Boolean); - const af = afParts.join(","); - - const args = ["-i", inputName]; - if (vf) args.push("-vf", vf); - - if (!recipe.keepAudio) { - args.push("-an"); - } else if (af) { - args.push("-af", af); - } - - // Add codec-specific arguments based on selected format - if (recipe.format === "webm") { - args.push( - "-c:v", "libvpx-vp9", - "-b:v", "0", - "-crf", String(recipe.quality) - ); - if (recipe.keepAudio) { - args.push("-c:a", "libopus"); - } - } else if (recipe.format === "mkv") { - args.push( - "-c:v", "libx264", - "-crf", String(recipe.quality), - "-preset", "medium" - ); - if (recipe.keepAudio) { - args.push("-c:a", "aac", "-b:a", "128k"); - } - } else { - // MP4 (default) - args.push( - "-c:v", "libx264", - "-crf", String(recipe.quality), - "-preset", "medium", - "-movflags", "+faststart" - ); - if (recipe.keepAudio) { - args.push("-c:a", "aac", "-b:a", "128k"); - } - } - - args.push(outputName); - - const exitCode = await ffmpeg.exec(args, undefined, { signal }); - - // If the requested format fails, try WebM as fallback - if (exitCode !== 0) { - const fallbackArgs = [ - "-i", inputName, - ...(vf ? ["-vf", vf] : []), - ...(recipe.keepAudio ? (af ? ["-af", af] : []) : ["-an"]), - "-c:v", "libvpx-vp9", - "-b:v", "0", - "-crf", String(recipe.quality), - ...(recipe.keepAudio ? ["-c:a", "libopus"] : []), - fallbackOutputName, - ]; - - const fallbackCode = await ffmpeg.exec(fallbackArgs, undefined, { signal }); - - if (fallbackCode !== 0) { - throw new Error("Export failed"); - } - - const data = await ffmpeg.readFile(fallbackOutputName, undefined, { signal }); - const blob = new Blob([new Uint8Array(data as Uint8Array)], { type: "video/webm" }); - - onProgress(100); - return { - blobUrl: URL.createObjectURL(blob), - size: blob.size, - width: targetW, - height: targetH, - format: "webm", - }; - } - - const data = await ffmpeg.readFile(outputName, undefined, { signal }); - const blob = new Blob([new Uint8Array(data as Uint8Array)], { type: mimeType }); - - onProgress(100); - return { - blobUrl: URL.createObjectURL(blob), - size: blob.size, - width: targetW, - height: targetH, - format: recipe.format as "mp4" | "webm" | "mkv", - }; - } finally { - ffmpeg.off("progress", handleProgress); - for (const path of cleanupFiles) { - try { - await ffmpeg.deleteFile(path); - } catch { - } - } - } -} \ No newline at end of file +import { FFmpeg } from "@ffmpeg/ffmpeg"; +import { fetchFile, toBlobURL } from "@ffmpeg/util"; +import { EditRecipe, ExportResult } 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"; + +let ffmpegInstance: FFmpeg | null = null; + +/** + * Error thrown when the FFmpeg WebAssembly core fails to load. + * This typically happens when the user is offline, the CDN is unreachable (or if the url is wrong), + * or there are network interruptions during the initialization phase. + */ +export class FFmpegLoadError extends Error { + constructor(message: string) { + super(message); + this.name = "FFmpegLoadError"; + } +} +export async function loadFFmpeg(signal?: AbortSignal, + onProgress?: (percent: number) => void): Promise { + if (ffmpegInstance?.loaded) { + onProgress?.(100); + return ffmpegInstance; + } + + const ffmpeg = ffmpegInstance ?? new FFmpeg(); + ffmpegInstance = ffmpeg; + + const handleProgress = ({ + progress, + }: { + progress: number; + }) => { + onProgress?.(Math.round(progress * 100)); + }; + + try { + + ffmpeg.on("progress", handleProgress); + // Check if the user's browser supports WebAssembly SIMD + const isSimdSupported = await simd(); + + // Dynamically set the core filename + const coreName = isSimdSupported ? "ffmpeg-core-simd" : "ffmpeg-core"; + + // Load FFmpeg using the dynamic URLs + the new signal parameter + await ffmpeg.load({ + coreURL: await toBlobURL(`${CORE_BASE_URL}/${coreName}.js`, "text/javascript"), + wasmURL: await toBlobURL(`${CORE_BASE_URL}/${coreName}.wasm`, "application/wasm"), + }, { signal }); + onProgress?.(100); + return ffmpeg; + } catch (err) { + if (ffmpegInstance === ffmpeg) { + ffmpegInstance = null; + } + throw new FFmpegLoadError("Failed to load the FFmpeg engine. Check your internet connection."); + } finally { + ffmpeg.off("progress", handleProgress); + } +} + +/** Terminates the active FFmpeg instance and releases its memory. */ +export function terminateFFmpeg() { + ffmpegInstance?.terminate(); + ffmpegInstance = null; +} + +/** Generates a unique session ID used to isolate FFmpeg file names across concurrent exports. */ +function buildSessionId(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID(); + } + return `${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +/** Builds the FFmpeg -vf filter chain string from the current recipe settings. */ +function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: number): string { + const filters: string[] = []; + + if (recipe.trimStart > 0 || recipe.trimEnd !== null) { + const end = recipe.trimEnd !== null ? recipe.trimEnd : 999999; + filters.push(`trim=start=${recipe.trimStart}:end=${end}`); + filters.push("setpts=PTS-STARTPTS"); + } + + if (recipe.rotate === 90) { + filters.push("transpose=1"); + } else if (recipe.rotate === 180) { + filters.push("transpose=1,transpose=1"); + } else if (recipe.rotate === 270) { + filters.push("transpose=2"); + } + + if (recipe.stabilization) { + filters.push("deshake=x=-1:y=-1:w=-1:h=-1:rx=16:ry=16"); + } + + if (recipe.framing === "fit") { + filters.push( + `scale=${targetW}:${targetH}:force_original_aspect_ratio=decrease`, + `pad=${targetW}:${targetH}:(ow-iw)/2:(oh-ih)/2:color=black` + ); + } else { + filters.push( + `scale=${targetW}:${targetH}:force_original_aspect_ratio=increase`, + `crop=${targetW}:${targetH}` + ); + } + + if (recipe.speed !== 1) { + const pts = (1 / recipe.speed).toFixed(4); + filters.push(`setpts=${pts}*PTS`); + } + filters.push( + `eq=brightness=${recipe.brightness}:contrast=${recipe.contrast}:saturation=${recipe.saturation}` + ); + return filters.join(","); +} + +/** Builds an atempo filter chain for the given playback speed, chaining multiple filters for speeds outside the 0.5–2.0 range. */ +export function buildAudioFilter(speed: number): string { + if (speed === 1) return ""; + + const filters: string[] = []; + let remaining = speed; + + // Chain filters for slow speeds + while (remaining < 0.5) { + filters.push("atempo=0.5"); + remaining /= 0.5; + } + + // Chain filters for fast speeds + while (remaining > 2.0) { + filters.push("atempo=2.0"); + remaining /= 2.0; + } + + // Add final remaining filter if not exactly 1.0 + // using a small epsilon check to avoid floating point issues + if (Math.abs(remaining - 1.0) > 0.001) { + filters.push(`atempo=${Number(remaining.toFixed(4))}`); + } + + return filters.join(","); +} + +function buildAudioTrimFilter(recipe: EditRecipe): string { + if (recipe.trimStart === 0 && recipe.trimEnd === null) return ""; + const end = recipe.trimEnd !== null ? recipe.trimEnd : 999999; + return `atrim=start=${recipe.trimStart}:end=${end},asetpts=PTS-STARTPTS`; +} + +export async function exportVideo( + ffmpeg: FFmpeg, + file: File, + recipe: EditRecipe, + onProgress: (percent: number) => void, + signal?: AbortSignal +): Promise { + const sessionId = buildSessionId(); + let { width: targetW, height: targetH } = getRecipeDimensions(recipe); + + // dimensions must be even for libx264 + targetW = Math.round(targetW / 2) * 2; + targetH = Math.round(targetH / 2) * 2; + + const ext = file.name.split(".").pop() ?? "mp4"; + const inputName = `input_${sessionId}.${ext}`; + + // Determine output filename and MIME type based on format + const getOutputConfig = (format: string) => { + switch (format) { + case "webm": + return { filename: `output_${sessionId}.webm`, mimeType: "video/webm" }; + case "mkv": + return { filename: `output_${sessionId}.mkv`, mimeType: "video/x-matroska" }; + default: // mp4 + return { filename: `output_${sessionId}.mp4`, mimeType: "video/mp4" }; + } + }; + + const { filename: outputName, mimeType } = getOutputConfig(recipe.format); + const fallbackOutputName = `fallback_${sessionId}.webm`; + const cleanupFiles = new Set([inputName, outputName, fallbackOutputName]); + + const handleProgress = ({ progress }: { progress: number }) => { + onProgress(Math.min(99, Math.round(progress * 100))); + }; + + try { + await ffmpeg.writeFile(inputName, await fetchFile(file), { signal }); + + ffmpeg.on("progress", handleProgress); + + const vf = buildVideoFilter(recipe, targetW, targetH); + const audioTrim = buildAudioTrimFilter(recipe); + const audioSpeed = buildAudioFilter(recipe.speed); + const afParts = [audioTrim, audioSpeed].filter(Boolean); + const af = afParts.join(","); + + const args = ["-i", inputName]; + if (vf) args.push("-vf", vf); + + if (!recipe.keepAudio) { + args.push("-an"); + } else if (af) { + args.push("-af", af); + } + + // Add codec-specific arguments based on selected format + if (recipe.format === "webm") { + args.push( + "-c:v", "libvpx-vp9", + "-b:v", "0", + "-crf", String(recipe.quality) + ); + if (recipe.keepAudio) { + args.push("-c:a", "libopus"); + } + } else if (recipe.format === "mkv") { + args.push( + "-c:v", "libx264", + "-crf", String(recipe.quality), + "-preset", "medium" + ); + if (recipe.keepAudio) { + args.push("-c:a", "aac", "-b:a", "128k"); + } + } else { + // MP4 (default) + args.push( + "-c:v", "libx264", + "-crf", String(recipe.quality), + "-preset", "medium", + "-movflags", "+faststart" + ); + if (recipe.keepAudio) { + args.push("-c:a", "aac", "-b:a", "128k"); + } + } + + args.push(outputName); + + const exitCode = await ffmpeg.exec(args, undefined, { signal }); + + // If the requested format fails, try WebM as fallback + if (exitCode !== 0) { + const fallbackArgs = [ + "-i", inputName, + ...(vf ? ["-vf", vf] : []), + ...(recipe.keepAudio ? (af ? ["-af", af] : []) : ["-an"]), + "-c:v", "libvpx-vp9", + "-b:v", "0", + "-crf", String(recipe.quality), + ...(recipe.keepAudio ? ["-c:a", "libopus"] : []), + fallbackOutputName, + ]; + + const fallbackCode = await ffmpeg.exec(fallbackArgs, undefined, { signal }); + + if (fallbackCode !== 0) { + throw new Error("Export failed"); + } + + const data = await ffmpeg.readFile(fallbackOutputName, undefined, { signal }); + const blob = new Blob([new Uint8Array(data as Uint8Array)], { type: "video/webm" }); + + onProgress(100); + return { + blobUrl: URL.createObjectURL(blob), + size: blob.size, + width: targetW, + height: targetH, + format: "webm", + }; + } + + const data = await ffmpeg.readFile(outputName, undefined, { signal }); + const blob = new Blob([new Uint8Array(data as Uint8Array)], { type: mimeType }); + + onProgress(100); + return { + blobUrl: URL.createObjectURL(blob), + size: blob.size, + width: targetW, + height: targetH, + format: recipe.format as "mp4" | "webm" | "mkv", + }; + } finally { + ffmpeg.off("progress", handleProgress); + for (const path of cleanupFiles) { + try { + await ffmpeg.deleteFile(path); + } catch { + } + } + } +} diff --git a/src/lib/presets.ts b/src/lib/presets.ts index 0ec7dee8..4eea4e92 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,105 @@ 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) { + 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..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 6ea55a07b4f2b1fc92d3e3ce88033ac91fbdbd50 Mon Sep 17 00:00:00 2001 From: zack34567 Date: Tue, 19 May 2026 11:31:17 +0530 Subject: [PATCH 2/5] fix: sanitize custom preset dimensions --- src/components/PresetSelector.tsx | 44 +++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/src/components/PresetSelector.tsx b/src/components/PresetSelector.tsx index 94eee55d..1404667e 100644 --- a/src/components/PresetSelector.tsx +++ b/src/components/PresetSelector.tsx @@ -7,7 +7,7 @@ import { } from "@/lib/presets"; import { EditRecipe } from "@/lib/types"; import { Settings2, Lock, Unlock, Save, X } from "lucide-react"; -import { FormEvent, useEffect, useState, useCallback, useRef } from "react"; +import { ChangeEvent, FormEvent, useEffect, useState, useCallback, useRef } from "react"; import { cn } from "@/lib/utils"; interface Props { @@ -45,6 +45,10 @@ function RatioBox({ width, height, active }: { width: number; height: number; ac ); } +function sanitizeDimensionInput(value: string): string { + return value.replace(/\D/g, "").replace(/^0+(?=\d)/, ""); +} + export default function PresetSelector({ recipe, customPresets, @@ -57,6 +61,8 @@ export default function PresetSelector({ 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 lockedRef = useRef(false); const aspectRatioRef = useRef(16 / 9); @@ -69,6 +75,14 @@ export default function PresetSelector({ } }, [isSaveOpen]); + useEffect(() => { + setWidthInput(String(recipe.customWidth)); + }, [recipe.customWidth]); + + useEffect(() => { + setHeightInput(String(recipe.customHeight)); + }, [recipe.customHeight]); + const handleToggleLock = useCallback(() => { if (!lockedRef.current) { const w = recipe.customWidth ?? 1920; @@ -94,6 +108,20 @@ export default function PresetSelector({ onChange(patch); }, [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.`); @@ -271,9 +299,12 @@ export default function PresetSelector({ min={16} max={7680} step={2} - value={recipe.customWidth} + value={widthInput} spellCheck={false} - onChange={(e) => handleWidthChange(Number(e.target.value))} + onChange={handleWidthInputChange} + onBlur={() => { + if (!widthInput) setWidthInput(String(recipe.customWidth)); + }} className="w-full text-sm px-3 py-1.5 border border-[var(--border)] rounded-md bg-[var(--bg)] font-heading focus:outline-none focus:ring-2 focus:ring-film-400 transition-shadow" /> {recipe.customWidth % 2 !== 0 && ( @@ -308,9 +339,12 @@ export default function PresetSelector({ min={16} max={7680} step={2} - value={recipe.customHeight} + value={heightInput} spellCheck={false} - onChange={(e) => handleHeightChange(Number(e.target.value))} + onChange={handleHeightInputChange} + onBlur={() => { + if (!heightInput) setHeightInput(String(recipe.customHeight)); + }} className="w-full text-sm px-3 py-1.5 border border-[var(--border)] rounded-md bg-[var(--bg)] font-heading focus:outline-none focus:ring-2 focus:ring-film-400 transition-shadow" /> {recipe.customHeight % 2 !== 0 && ( From 1fb7a6da84c0c0129fc959b4cdcaef901a8c31da Mon Sep 17 00:00:00 2001 From: zack34567 Date: Tue, 19 May 2026 11:46:32 +0530 Subject: [PATCH 3/5] fix: preserve custom preset dimensions --- src/hooks/useVideoEditor.ts | 1 - src/lib/ffmpeg.ts | 12 ++---------- src/lib/presets.ts | 4 ++++ src/lib/tests/presets.test.ts | 8 ++++++++ 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts index 19d8f544..24416be8 100644 --- a/src/hooks/useVideoEditor.ts +++ b/src/hooks/useVideoEditor.ts @@ -224,7 +224,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 24d2310f..1272b51c 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 } 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"; @@ -40,16 +39,9 @@ export async function loadFFmpeg(signal?: AbortSignal, try { ffmpeg.on("progress", handleProgress); - // Check if the user's browser supports WebAssembly SIMD - const isSimdSupported = await simd(); - - // Dynamically set the core filename - const coreName = isSimdSupported ? "ffmpeg-core-simd" : "ffmpeg-core"; - - // Load FFmpeg using the dynamic URLs + the new signal parameter await ffmpeg.load({ - coreURL: await toBlobURL(`${CORE_BASE_URL}/${coreName}.js`, "text/javascript"), - wasmURL: await toBlobURL(`${CORE_BASE_URL}/${coreName}.wasm`, "application/wasm"), + coreURL: await toBlobURL(`${CORE_BASE_URL}/ffmpeg-core.js`, "text/javascript"), + wasmURL: await toBlobURL(`${CORE_BASE_URL}/ffmpeg-core.wasm`, "application/wasm"), }, { signal }); onProgress?.(100); return ffmpeg; diff --git a/src/lib/presets.ts b/src/lib/presets.ts index 4eea4e92..aead4623 100644 --- a/src/lib/presets.ts +++ b/src/lib/presets.ts @@ -41,6 +41,10 @@ export function getPresetById(id: string): Preset | undefined { } 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 } 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 }); + }); }); From 2b6e94cdbd726ac62602be4c6660c3e1324dcffd Mon Sep 17 00:00:00 2001 From: zack34567 Date: Tue, 19 May 2026 11:56:06 +0530 Subject: [PATCH 4/5] fix: improve selected file upload layout --- src/components/FileUpload.tsx | 78 +++++++++++++++++++---------------- 1 file changed, 43 insertions(+), 35 deletions(-) diff --git a/src/components/FileUpload.tsx b/src/components/FileUpload.tsx index e22a910b..688021aa 100644 --- a/src/components/FileUpload.tsx +++ b/src/components/FileUpload.tsx @@ -101,50 +101,58 @@ export default function FileUpload({ }; const FileInfo = () => ( -
- - -
-
-

- {currentFile?.name} -

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

+ {currentFile?.name} +

+
+ +

+ {formatBytes(currentFile?.size ?? 0)} + {duration !== null + ? ` - ${formatDuration(duration)}` + : " - Loading metadata..."} +

+
-

- {formatBytes(currentFile?.size ?? 0)} - {duration !== null - ? ` • ${formatDuration(duration)}` - : " • Loading metadata..."} -

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

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

-

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

+ {fileError && ( -

+

{fileError}

)} + Date: Tue, 19 May 2026 14:15:05 +0530 Subject: [PATCH 5/5] fix: simplify custom dimension layout --- src/components/PresetSelector.tsx | 37 ++++++++++++++----------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/src/components/PresetSelector.tsx b/src/components/PresetSelector.tsx index faae005d..2592197d 100644 --- a/src/components/PresetSelector.tsx +++ b/src/components/PresetSelector.tsx @@ -333,11 +333,12 @@ export default function PresetSelector({ )} {isCustomRecipe && ( -
-
+
+
+
@@ -354,20 +355,20 @@ export default function PresetSelector({ onBlur={() => { if (!widthInput) setWidthInput(String(recipe.customWidth)); }} - 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" + 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
-
+
@@ -384,21 +385,17 @@ export default function PresetSelector({ onBlur={() => { if (!heightInput) setHeightInput(String(recipe.customHeight)); }} - 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" + 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, + )} +

)}