From 6d85a2040697133e9044599826ffb14a82dedfad Mon Sep 17 00:00:00 2001 From: Pravallika21-nama Date: Thu, 21 May 2026 00:59:17 +0530 Subject: [PATCH 01/38] fix: remove leftover debug console log from useVideoEditor --- src/hooks/useVideoEditor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts index a2283128..605ccb34 100644 --- a/src/hooks/useVideoEditor.ts +++ b/src/hooks/useVideoEditor.ts @@ -422,7 +422,7 @@ export function useVideoEditor() { const interval = setInterval(() => { const mem = (performance as Performance & { memory?: { usedJSHeapSize: number } }).memory; if (mem) { - console.log("[Reframe Memory]", Math.round(mem.usedJSHeapSize / 1e6), "MB used"); + } }, 1000); From 28d2cb5bdf651e187b5f35a657fb784e9abf22ed Mon Sep 17 00:00:00 2001 From: Pravallika21-nama Date: Thu, 21 May 2026 01:05:54 +0530 Subject: [PATCH 02/38] fix: remove leftover debug console log from useVideoEditor --- src/hooks/useVideoEditor.ts | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts index 605ccb34..b837a0bf 100644 --- a/src/hooks/useVideoEditor.ts +++ b/src/hooks/useVideoEditor.ts @@ -415,20 +415,7 @@ export function useVideoEditor() { setError(null); }, [result]); - useEffect(() => { - if (process.env.NODE_ENV !== "development") return; - if (status !== "exporting") return; - - const interval = setInterval(() => { - const mem = (performance as Performance & { memory?: { usedJSHeapSize: number } }).memory; - if (mem) { - - } - }, 1000); - - return () => clearInterval(interval); - }, [status]); - + useEffect(() => { localStorage.setItem("soundOnCompletion", String(recipe.soundOnCompletion)); }, [recipe.soundOnCompletion]); From 12982e3bfe8dd4a023a6fe0dce499df3b3e35683 Mon Sep 17 00:00:00 2001 From: Pravallika21-nama Date: Thu, 21 May 2026 01:17:55 +0530 Subject: [PATCH 03/38] fix: remove debug console log and cleanup empty block --- src/hooks/useVideoEditor.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts index b837a0bf..0e599fd2 100644 --- a/src/hooks/useVideoEditor.ts +++ b/src/hooks/useVideoEditor.ts @@ -415,7 +415,19 @@ export function useVideoEditor() { setError(null); }, [result]); - + useEffect(() => { + if (process.env.NODE_ENV !== "development") return; + if (status !== "exporting") return; + + const interval = setInterval(() => { + const mem = (performance as Performance & { memory?: { usedJSHeapSize: number } }).memory; + if (mem) { + + }, 1000); + + return () => clearInterval(interval); + }, [status]); + useEffect(() => { localStorage.setItem("soundOnCompletion", String(recipe.soundOnCompletion)); }, [recipe.soundOnCompletion]); From c75e4234838d10f2f898c35d581f7291d29589f8 Mon Sep 17 00:00:00 2001 From: Pravallika21-nama Date: Thu, 21 May 2026 09:28:55 +0530 Subject: [PATCH 04/38] fix: remove leftover debug console logs --- src/hooks/useVideoEditor.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts index 0e599fd2..345305b0 100644 --- a/src/hooks/useVideoEditor.ts +++ b/src/hooks/useVideoEditor.ts @@ -422,7 +422,8 @@ export function useVideoEditor() { const interval = setInterval(() => { const mem = (performance as Performance & { memory?: { usedJSHeapSize: number } }).memory; if (mem) { - + + } }, 1000); return () => clearInterval(interval); From 945e731dd488de38a38243746e436fc4bf9650ab Mon Sep 17 00:00:00 2001 From: SrijanCodes Date: Wed, 20 May 2026 00:19:39 +0530 Subject: [PATCH 05/38] feat: add audio waveform trim visualization (#380) * feat: add audio waveform to trim control * fix: remove package-lock.json and unused prettier dev dependencies * fix: repair TrimControl typecheck * feat: add audio waveform to trim control * fix: remove package-lock.json and unused prettier dev dependencies fix: repair TrimControl typecheck * fix: resolve trim validation icon/messages * fix: resolve CI failures - fix TS error, remove unused imports, add package-lock.json to gitignore Co-authored-by: Akanksha Trehun <146705736+magic-peach@users.noreply.github.com> --- .gitignore | 3 + src/components/TrimControl.tsx | 137 +++++++++++++++++---------------- src/components/VideoEditor.tsx | 6 +- src/hooks/useAudioWaveform.ts | 90 ++++++++++++++++++++++ 4 files changed, 170 insertions(+), 66 deletions(-) create mode 100644 src/hooks/useAudioWaveform.ts diff --git a/.gitignore b/.gitignore index e15687ec..5ae10976 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ next-env.d.ts # bun bun.lockb + +# npm lockfile (project uses bun) +package-lock.json diff --git a/src/components/TrimControl.tsx b/src/components/TrimControl.tsx index 5ba6d682..a4a03d19 100644 --- a/src/components/TrimControl.tsx +++ b/src/components/TrimControl.tsx @@ -16,66 +16,61 @@ export default function TrimControl({ recipe, onChange, duration }: Props) { const [invalidEnd, setEnd] = useState(false); const [startErrorMsg, setStartErrorMsg] = useState(""); const [endErrorMsg, setEndErrorMsg] = useState(""); - const [startInput, setStartInput] = useState( - recipe.trimStart.toString() - ); + const [startInput, setStartInput] = useState(recipe.trimStart.toString()); useEffect(() => { setStartInput(recipe.trimStart.toString()); }, [recipe.trimStart]); - const clipLength = - (recipe.trimEnd ?? duration) - recipe.trimStart; + const clipLength = (recipe.trimEnd ?? duration) - recipe.trimStart; const handleStart = (val: string) => { - setStartInput(val); + setStartInput(val); + + if (val === "") { + setStart(false); + setStartErrorMsg(""); + return; + } + + const n = parseFloat(val); + + if (isNaN(n)) { + setStart(true); + setStartErrorMsg("Enter a valid number."); + return; + } + + if (n < 0) { + setStart(true); + setStartErrorMsg("Start time must be 0 or greater."); + return; + } + + if (duration > 0 && n >= duration) { + setStart(true); + setStartErrorMsg( + `Start time must be less than duration (${duration.toFixed(1)}s).`, + ); + return; + } + + if (recipe.trimEnd !== null && n >= recipe.trimEnd) { + setStart(true); + setStartErrorMsg("Start time must be less than the end time."); + return; + } - if (val === "") { setStart(false); setStartErrorMsg(""); - return; - } - - const n = parseFloat(val); - - if (isNaN(n)) { - setStart(true); - setStartErrorMsg("Enter a valid number."); - return; - } - - if (n < 0) { - setStart(true); - setStartErrorMsg("Start time must be 0 or greater."); - return; - } - - if (duration > 0 && n >= duration) { - setStart(true); - setStartErrorMsg( - `Start time must be less than duration (${duration.toFixed(1)}s).` - ); - return; - } - - if (recipe.trimEnd !== null && n >= recipe.trimEnd) { - setStart(true); - setStartErrorMsg("Start time must be less than the end time."); - return; - } - - setStart(false); - setStartErrorMsg(""); - - onChange({ trimStart: n }); + + onChange({ trimStart: n }); }; const handleEnd = (val: string) => { - if (val === "") { - setEnd(false); - setEndErrorMsg(""); onChange({ trimEnd: null }); + setEnd(false); return; } @@ -104,7 +99,7 @@ export default function TrimControl({ recipe, onChange, duration }: Props) { if (duration > 0 && n > duration + 0.01) { setEnd(true); setEndErrorMsg( - `End time cannot exceed duration (${duration.toFixed(1)}s).` + `End time cannot exceed duration (${duration.toFixed(1)}s).`, ); return; } @@ -120,9 +115,13 @@ export default function TrimControl({ recipe, onChange, duration }: Props) {
-
+
-
-
+ {duration > 0 && ( -

- Clip: {formatDuration(clipLength)} of{" "} - {formatDuration(duration)} -

- )} -
- +

+ Clip: {formatDuration(clipLength)} of {formatDuration(duration)} +

+ )} + ); - } - diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index d43f5d18..3ffb6a67 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -158,7 +158,11 @@ export default function VideoEditor() { )}>
} title="Trim" delay={50}> - +
} title="Rotate" delay={100}> diff --git a/src/hooks/useAudioWaveform.ts b/src/hooks/useAudioWaveform.ts new file mode 100644 index 00000000..25ed57d5 --- /dev/null +++ b/src/hooks/useAudioWaveform.ts @@ -0,0 +1,90 @@ +"use client"; + +import { useEffect, useState } from "react"; + +const DEFAULT_BAR_COUNT = 96; + +type BrowserWindow = Window & + typeof globalThis & { + webkitAudioContext?: typeof AudioContext; + }; + +function downsampleWaveform(channelData: Float32Array, barCount: number): number[] { + const sampleSize = Math.max(1, Math.floor(channelData.length / barCount)); + const peaks = Array.from({ length: barCount }, (_, index) => { + const start = index * sampleSize; + const end = Math.min(start + sampleSize, channelData.length); + let peak = 0; + + for (let i = start; i < end; i += 1) { + peak = Math.max(peak, Math.abs(channelData[i] ?? 0)); + } + + return peak; + }); + + const maxPeak = Math.max(...peaks, 0.01); + return peaks.map((peak) => peak / maxPeak); +} + +export function useAudioWaveform( + file: File | null, + barCount = DEFAULT_BAR_COUNT +) { + const [waveform, setWaveform] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + let isCancelled = false; + let audioContext: AudioContext | null = null; + + async function extractWaveform() { + if (!file) { + setWaveform([]); + setIsLoading(false); + return; + } + + const AudioContextCtor = + window.AudioContext || (window as BrowserWindow).webkitAudioContext; + + if (!AudioContextCtor) { + setWaveform([]); + setIsLoading(false); + return; + } + + setIsLoading(true); + + try { + audioContext = new AudioContextCtor(); + const audioBuffer = await audioContext.decodeAudioData( + await file.arrayBuffer() + ); + const channelData = audioBuffer.getChannelData(0); + const peaks = downsampleWaveform(channelData, barCount); + + if (!isCancelled) { + setWaveform(peaks); + } + } catch { + if (!isCancelled) { + setWaveform([]); + } + } finally { + await audioContext?.close(); + if (!isCancelled) { + setIsLoading(false); + } + } + } + + extractWaveform(); + + return () => { + isCancelled = true; + }; + }, [barCount, file]); + + return { waveform, isLoading }; +} From 7e71d1512638e87b9ae64719c9b753f145eba6e1 Mon Sep 17 00:00:00 2001 From: Vaga Date: Wed, 20 May 2026 00:21:15 +0530 Subject: [PATCH 06/38] feat: implement high-quality GIF export using two-pass FFmpeg pipeline (#724) * feat: integrate ffmpeg deshake filter for video stabilization Signed-off-by: Vagventure * feat: add social media quick-action row to PresetSelector Co-authored-by: Akanksha Trehun <146705736+magic-peach@users.noreply.github.com> * feat: implement two-pass high-quality GIF export via FFmpeg Signed-off-by: Vagventure --------- Signed-off-by: Vagventure --- src/components/ExportSettings.tsx | 10 ++++ src/components/FormatSelector.tsx | 7 +-- src/components/PresetSelector.tsx | 87 ++++++++++++++++++++++++++++++- src/hooks/useVideoEditor.ts | 14 +++-- src/lib/ffmpeg.ts | 46 +++++++++++++++- src/lib/types.ts | 4 +- 6 files changed, 155 insertions(+), 13 deletions(-) diff --git a/src/components/ExportSettings.tsx b/src/components/ExportSettings.tsx index 026f0760..acec541e 100644 --- a/src/components/ExportSettings.tsx +++ b/src/components/ExportSettings.tsx @@ -32,6 +32,8 @@ export default function ExportSettings({ ? "Balanced" : "Small file"; + const isGif = recipe.format === "gif"; + const estimatedSize = formatEstimatedSize( estimateExportSize( @@ -108,8 +110,15 @@ export default function ExportSettings({ {estimatedSize}

+ + {isGif && ( +

+ ⚠ GIF files can be very large. Keep clips under 10 s for best results. +

+ )}
+ {!isGif && (
+ )}
diff --git a/src/components/FormatSelector.tsx b/src/components/FormatSelector.tsx index a9969ab8..81033b6b 100644 --- a/src/components/FormatSelector.tsx +++ b/src/components/FormatSelector.tsx @@ -13,6 +13,7 @@ const FORMAT_OPTIONS = [ { id: "mp4", label: "MP4", description: "Best compatibility, smaller file size" }, { id: "webm", label: "WebM", description: "Open format, optimized for web" }, { id: "mkv", label: "MKV", description: "Container, maximum quality" }, + { id: "gif", label: "GIF", description: "Animated image — keep clips under 10 s" }, ] as const; export default function FormatSelector({ recipe, onChange }: Props) { @@ -24,12 +25,12 @@ export default function FormatSelector({ recipe, onChange }: Props) { Output Format
-
+
{FORMAT_OPTIONS.map((option) => (
); -} +} \ No newline at end of file diff --git a/src/components/PresetSelector.tsx b/src/components/PresetSelector.tsx index 53821604..052a3698 100644 --- a/src/components/PresetSelector.tsx +++ b/src/components/PresetSelector.tsx @@ -49,6 +49,59 @@ function RatioBox({ ); } +const QUICK_ACTIONS = [ + { + preset: "vertical-9-16", + label: "Reels", + platform: "Instagram", + icon: ( + + + + ), + }, + { + preset: "vertical-9-16", + label: "TikTok", + platform: "TikTok", + icon: ( + + + + ), + }, + { + preset: "vertical-9-16", + label: "Short", + platform: "YouTube", + icon: ( + + + + ), + }, + { + preset: "landscape-16-9", + label: "YouTube", + platform: "YouTube", + icon: ( + + + + ), + }, + { + preset: "twitter-hd", + label: "Twitter/X", + platform: "Twitter", + icon: ( + + + + ), + }, +] as const; + export default function PresetSelector({ recipe, onChange }: Props) { const [search, setSearch] = useState(""); @@ -82,7 +135,37 @@ export default function PresetSelector({ recipe, onChange }: Props) { ); return ( -
+
+ {/* Quick-action row */} +
+ {QUICK_ACTIONS.map(({ preset, label, platform, icon }) => { + const isActive = recipe.preset === preset; + return ( + + ); + })} +
+
@@ -246,4 +329,4 @@ export default function PresetSelector({ recipe, onChange }: Props) { )}
); -} +} \ No newline at end of file diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts index 345305b0..80b543e9 100644 --- a/src/hooks/useVideoEditor.ts +++ b/src/hooks/useVideoEditor.ts @@ -147,10 +147,16 @@ export function useVideoEditor() { const [overlaySize, setOverlaySize] = useState(150); const [overlayOpacity, setOverlayOpacity] = useState(100); - const updateRecipe = useCallback((patch: Partial) => { - setRecipe((prev) => ({ ...prev, ...patch })); - }, []); - + const updateRecipe = useCallback((patch: Partial) => { + setRecipe((prev) => { + const next = { ...prev, ...patch }; + // GIF has no audio — force keepAudio off + if (next.format === "gif") { + next.keepAudio = false; + } + return next; + }); +}, []); useEffect(() => { try { const saved = localStorage.getItem("reframe-settings"); diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts index beaeed1b..35b32a5f 100644 --- a/src/lib/ffmpeg.ts +++ b/src/lib/ffmpeg.ts @@ -162,7 +162,7 @@ function buildAudioTrimFilter(recipe: EditRecipe): string { function buildArguments( recipe: EditRecipe, - format: "mp4" | "webm" | "mkv", + format: "mp4" | "webm" | "mkv" | "gif", outputName: string, inputName: string, targetW: number, @@ -312,6 +312,8 @@ export async function exportVideo( return { filename: `output_${sessionId}.webm`, mimeType: "video/webm" }; case "mkv": return { filename: `output_${sessionId}.mkv`, mimeType: "video/x-matroska" }; + case "gif": + return { filename: `output_${sessionId}.gif`, mimeType: "image/gif" }; default: return { filename: `output_${sessionId}.mp4`, mimeType: "video/mp4" }; } @@ -319,7 +321,8 @@ export async function exportVideo( const { filename: outputName, mimeType } = getOutputConfig(recipe.format); const fallbackOutputName = `fallback_${sessionId}.webm`; - const cleanupFiles = new Set([inputName, outputName, fallbackOutputName]); + const paletteName = `palette_${sessionId}.png`; + const cleanupFiles = new Set([inputName, outputName, fallbackOutputName, paletteName]); const handleProgress = ({ progress }: { progress: number }) => { onProgress(Math.min(99, Math.round(progress * 100))); @@ -345,6 +348,45 @@ export async function exportVideo( ffmpeg.on("progress", handleProgress); + // ── Two-pass GIF export ────────────────────────────────────────────────── + if (recipe.format === "gif") { + const vf = buildVideoFilter(recipe, targetW, targetH); + const vfWithPalette = vf ? `${vf},palettegen` : "palettegen"; + const vfWithPaletteUse = vf + ? `[0:v]${vf}[x];[x][1:v]paletteuse` + : "[0:v][1:v]paletteuse"; + + // Pass 1: generate colour palette + const pass1Code = await ffmpeg.exec( + ["-i", inputName, "-vf", vfWithPalette, "-y", paletteName], + undefined, + { signal } + ); + if (pass1Code !== 0) throw new Error("GIF palette generation failed"); + + // Pass 2: render GIF using the palette + const pass2Code = await ffmpeg.exec( + ["-i", inputName, "-i", paletteName, "-lavfi", vfWithPaletteUse, "-y", outputName], + undefined, + { signal } + ); + if (pass2Code !== 0) throw new Error("GIF export failed"); + + const data = await ffmpeg.readFile(outputName, undefined, { signal }); + const blob = new Blob([new Uint8Array(data as Uint8Array)], { type: "image/gif" }); + + ffmpeg.off("progress", handleProgress); + onProgress(100); + return { + blobUrl: URL.createObjectURL(blob), + size: blob.size, + width: targetW, + height: targetH, + format: "gif" as const, + }; + } + // ──────────────────────────────────────────────────────────────────────── + let missingAudioDetected = false; const logListener = ({ message }: { message: string }) => { const msg = message.toLowerCase(); diff --git a/src/lib/types.ts b/src/lib/types.ts index bf167094..bccb5d04 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -9,7 +9,7 @@ export interface EditRecipe { keepAudio: boolean; speed: number; quality: number; - format: "mp4" | "webm" | "mkv"; + format: "mp4" | "webm" | "mkv" | "gif"; stabilization: boolean; brightness: number; contrast: number; @@ -42,7 +42,7 @@ export interface ExportResult { size: number; width: number; height: number; - format: "mp4" | "webm" | "mkv"; + format: "mp4" | "webm" | "mkv" | "gif"; } export type ExportStatus = From 1173b047b23ade4e9f3c3a1d1166ba9c7ea73f22 Mon Sep 17 00:00:00 2001 From: Shruti Sharma Date: Wed, 20 May 2026 06:37:05 +0530 Subject: [PATCH 07/38] feat: audio normalization (#761) * feat: add thumbnail strip for video frame navigation * fix: clean up all merge conflicts, fix memory leak in videoSrc * feat: add audio normalization, fix preview mute and speed * fix: correct useEffect dependency array syntax in VideoPreview * fix: export buildAudioFilter function * fix: add recipe to useEffect deps, add track element to video Co-authored-by: Akanksha Trehun <146705736+magic-peach@users.noreply.github.com> --- src/components/AudioSpeedControl.tsx | 26 ++- src/components/VideoPreview.tsx | 322 ++------------------------- src/lib/constants.ts | 1 + src/lib/ffmpeg.ts | 19 +- src/lib/types.ts | 2 + 5 files changed, 65 insertions(+), 305 deletions(-) diff --git a/src/components/AudioSpeedControl.tsx b/src/components/AudioSpeedControl.tsx index 3200f1f5..3123612b 100644 --- a/src/components/AudioSpeedControl.tsx +++ b/src/components/AudioSpeedControl.tsx @@ -135,6 +135,30 @@ export default function AudioSpeedControl({ recipe, onChange }: Props) {
+ {recipe.keepAudio && ( + + )} + {recipe.keepAudio && (recipe.trimStart !== 0 || recipe.trimEnd !== null) && (
@@ -145,4 +169,4 @@ export default function AudioSpeedControl({ recipe, onChange }: Props) { )}
); -} +} \ No newline at end of file diff --git a/src/components/VideoPreview.tsx b/src/components/VideoPreview.tsx index 603cd6c7..856ac383 100644 --- a/src/components/VideoPreview.tsx +++ b/src/components/VideoPreview.tsx @@ -1,326 +1,52 @@ -/* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/no-noninteractive-tabindex, jsx-a11y/no-noninteractive-element-interactions */ "use client"; -import { useEffect, useRef, useState, useCallback, RefObject } from "react"; +import { useEffect, useRef, RefObject } from "react"; import { EditRecipe } from "@/lib/types"; -import { getPresetById } from "@/lib/presets"; -import { cn } from "@/lib/utils"; -import { Camera } from "lucide-react"; -import { captureFrameAsPng } from "@/lib/frame-export"; -import { DEFAULT_RECIPE } from "@/lib/constants"; interface Props { file: File | null; - recipe?: EditRecipe; videoRef: RefObject; + recipe: EditRecipe; } -export default function VideoPreview({ file, recipe, videoRef }: Props) { - const lastId = useRef(0); +export default function VideoPreview({ file, videoRef ,recipe }: Props) { const urlRef = useRef(null); - const [isLoading, setIsLoading] = useState(true); - const [showOverlay, setShowOverlay] = useState(false); - const [frameNotice, setFrameNotice] = useState<{ - kind: "success" | "error"; - message: string; - } | null>(null); - const [isExportingFrame, setIsExportingFrame] = useState(false); - const isExportingFrameRef = useRef(false); - const onLoadedRef = useRef<(() => void) | null>(null); - const activeRecipe = recipe ?? DEFAULT_RECIPE; - useEffect(() => { - if (!frameNotice) return; - - const timeoutId = window.setTimeout(() => setFrameNotice(null), 2500); - return () => window.clearTimeout(timeoutId); - }, [frameNotice]); - - /** Capture the current video frame and download it as a PNG. */ - const handleGrabFrame = useCallback(async () => { - if (isExportingFrameRef.current) return; - - const video = videoRef.current; - if (!video) { - setFrameNotice({ kind: "error", message: "No video frame is available yet." }); - return; - } - - isExportingFrameRef.current = true; - setIsExportingFrame(true); - - try { - const { blob, filename } = await captureFrameAsPng(video, activeRecipe); - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = filename; - anchor.click(); - window.setTimeout(() => URL.revokeObjectURL(url), 1000); - setFrameNotice({ kind: "success", message: `Saved ${filename}` }); - } catch (error) { - console.error("frame export failed:", error); - setFrameNotice({ - kind: "error", - message: error instanceof Error ? error.message : "Frame export failed.", - }); - } finally { - isExportingFrameRef.current = false; - setIsExportingFrame(false); - } - }, [activeRecipe, videoRef]); - - useEffect(() => { - const handleShortcut = (e: KeyboardEvent) => { - if (e.repeat) return; - - const target = e.target as HTMLElement | null; - if ( - target && - (target.tagName === "INPUT" || - target.tagName === "TEXTAREA" || - target.isContentEditable) - ) { - return; - } - - if (e.code === "KeyT") { - e.preventDefault(); - void handleGrabFrame(); - } - }; - - window.addEventListener("keydown", handleShortcut); - return () => window.removeEventListener("keydown", handleShortcut); - }, [handleGrabFrame]); useEffect(() => { if (!file) return; if (urlRef.current) URL.revokeObjectURL(urlRef.current); - setIsLoading(true); - const id = ++lastId.current; const url = URL.createObjectURL(file); - - // cleanup previous object URL safely - if (urlRef.current) { - URL.revokeObjectURL(urlRef.current); - } urlRef.current = url; - - const video = videoRef.current; - if (!video) return; - - video.src = url; - video.load(); - - // define handler once per effect run - const handleLoaded = () => { - if (lastId.current !== id) return; - video.play().catch(() => {}); - }; - - onLoadedRef.current = handleLoaded; - - video.addEventListener("loadeddata", handleLoaded); + if (videoRef.current) videoRef.current.src = url; return () => { - // cleanup event listener safely - if (onLoadedRef.current) { - video.removeEventListener("loadeddata", onLoadedRef.current); - onLoadedRef.current = null; - } - - // stop playback safely - if (video) { - video.pause(); - video.removeAttribute("src"); - video.load(); - } - - // revoke only if still current - if (urlRef.current === url) { - URL.revokeObjectURL(urlRef.current); - urlRef.current = null; - } + if (urlRef.current) URL.revokeObjectURL(urlRef.current); }; }, [file, videoRef]); - /** - * Compute the overlay geometry for the selected preset + framing mode. - * The preview container always uses a 16:9 aspect-video box. - * We express widths/heights as percentage strings for CSS. - */ - const overlay = (() => { - if (!activeRecipe || !showOverlay) return null; - - const preset = activeRecipe.preset === "custom" - ? { width: activeRecipe.customWidth, height: activeRecipe.customHeight } - : getPresetById(activeRecipe.preset); - - if (!preset) return null; - - // Preview container is 16:9 - const containerW = 16; - const containerH = 9; - const containerRatio = containerW / containerH; // 1.777… - const outputRatio = preset.width / preset.height; - - if (activeRecipe.framing === "fit") { - // Letterbox: the output video fits entirely inside 16:9, padded with bars. - if (outputRatio > containerRatio) { - // Wider output → pillarbox bars on top & bottom - const contentH = (containerRatio / outputRatio) * 100; - const barH = (100 - contentH) / 2; - return { mode: "fit", barTop: `${barH}%`, barBottom: `${barH}%`, barLeft: "0", barRight: "0" }; - } else { - // Taller output → letterbox bars on left & right - const contentW = (outputRatio / containerRatio) * 100; - const barW = (100 - contentW) / 2; - return { mode: "fit", barTop: "0", barBottom: "0", barLeft: `${barW}%`, barRight: `${barW}%` }; - } - } else { - // Fill / crop: the output fills the entire 16:9 preview — show a box representing what survives the crop. - if (outputRatio < containerRatio) { - // Output is taller → crops top & bottom - const visibleH = (outputRatio / containerRatio) * 100; - const cropH = (100 - visibleH) / 2; - return { mode: "fill", barTop: `${cropH}%`, barBottom: `${cropH}%`, barLeft: "0", barRight: "0" }; - } else { - // Output is wider → crops left & right - const visibleW = (containerRatio / outputRatio) * 100; - const cropW = (100 - visibleW) / 2; - return { mode: "fill", barTop: "0", barBottom: "0", barLeft: `${cropW}%`, barRight: `${cropW}%` }; - } - } - })(); - - if (!file) return null; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.code === "Space") { - const target = e.target as HTMLElement; - if ( - target.tagName === "INPUT" || - target.tagName === "TEXTAREA" || - target.isContentEditable - ) { - return; - } - - const video = videoRef.current; - if (video) { - e.preventDefault(); // Prevent default page scroll - if (video.paused) { - video.play().catch(() => {}); - } else { - video.pause(); - } - } - } - }; + // sync mute state to video element + useEffect(() => { + if (!videoRef.current || !recipe) return; + videoRef.current.muted = !recipe.keepAudio; + }, [recipe, videoRef]); + useEffect(() => { + if (!videoRef.current || !recipe) return; + videoRef.current.playbackRate = recipe.speed; + }, [recipe, videoRef]); return ( -
- {frameNotice && ( -
- {frameNotice.message} -
- )} - {isLoading && ( -
- )} - {/* eslint-disable-next-line jsx-a11y/media-has-caption */} +
+