From 5858fcaea492d25022f8fd7f2a2dd056d441b94c Mon Sep 17 00:00:00 2001 From: SatyaViswas Date: Tue, 26 May 2026 17:59:32 +0530 Subject: [PATCH 1/4] feat: add real-time WYSIWYG preview for video aspect ratios, filters, and rotation --- src/components/ThemeToggle.tsx | 38 ++- src/components/VideoEditor.tsx | 477 +++++++++++++++++++------------- src/components/VideoPreview.tsx | 283 ++++++++++--------- src/hooks/useVideoEditor.ts | 283 +++++++++++-------- src/lib/ffmpeg.ts | 151 ++++++---- src/lib/ffmpeg.worker.ts | 273 ++++++++++++------ 6 files changed, 914 insertions(+), 591 deletions(-) diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx index 194a82d8..bc336fb0 100644 --- a/src/components/ThemeToggle.tsx +++ b/src/components/ThemeToggle.tsx @@ -2,7 +2,6 @@ import { useEffect, useState } from "react"; import { useTheme } from "./ThemeProvider"; -import { useEffect, useState } from "react"; export function ThemeToggle() { const { theme, toggleTheme } = useTheme(); @@ -27,14 +26,13 @@ export function ThemeToggle() { } const isDark = theme === "dark"; - const [mounted, setMounted] = useState(false); return ( @@ -99,7 +119,7 @@ function AccordionSection({ id={`${id}-panel`} className={cn( "transition-all duration-200", - isOpen ? "block" : "hidden" + isOpen ? "block" : "hidden", )} >
{children}
@@ -122,37 +142,47 @@ function KeyboardShortcutsPanel() { const [open, setOpen] = useState(false); const shortcuts: { keys: React.ReactNode[]; label: string }[] = [ - { - keys: [ - Ctrl, - +, - Shift, - +, - E - ], - label: "Export video", - }, - { - keys: [M], - label: "Toggle audio mute", - }, - { - keys: [R], - label: "Reset all settings", - }, - { - keys: [Esc], - label: "Cancel export", - }, - { - keys: [1, , 9], - label: "Switch preset by index", - }, - { - keys: [?], - label: "Toggle this panel", - }, -]; + { + keys: [ + Ctrl, + + + + , + Shift, + + + + , + E, + ], + label: "Export video", + }, + { + keys: [M], + label: "Toggle audio mute", + }, + { + keys: [R], + label: "Reset all settings", + }, + { + keys: [Esc], + label: "Cancel export", + }, + { + keys: [ + 1, + + – + , + 9, + ], + label: "Switch preset by index", + }, + { + keys: [?], + label: "Toggle this panel", + }, + ]; return (
@@ -173,9 +203,18 @@ function KeyboardShortcutsPanel() { height="12" viewBox="0 0 12 12" fill="none" - className={cn("text-[var(--muted)] transition-transform duration-200", open && "rotate-180")} + className={cn( + "text-[var(--muted)] transition-transform duration-200", + open && "rotate-180", + )} > - + @@ -185,7 +224,10 @@ function KeyboardShortcutsPanel() { className="px-4 pb-3 space-y-2 border-t border-[var(--border)]" > {shortcuts.map(({ keys, label }) => ( -
  • +
  • {label} {keys}
  • @@ -198,15 +240,31 @@ function KeyboardShortcutsPanel() { export default function VideoEditor() { const { - file, duration, recipe, status, progress, - result, error, exportStartedAt, updateRecipe, - handleFileSelect, fileError, handleExport, cancelExport, reset, resetSettings, + file, + duration, + recipe, + status, + progress, + result, + error, + exportStartedAt, + updateRecipe, + handleFileSelect, + fileError, + handleExport, + cancelExport, + reset, + resetSettings, videoRef, seekTo, - overlayFile, setOverlayFile, - overlayPosition, setOverlayPosition, - overlaySize, setOverlaySize, - overlayOpacity, setOverlayOpacity, + overlayFile, + setOverlayFile, + overlayPosition, + setOverlayPosition, + overlaySize, + setOverlaySize, + overlayOpacity, + setOverlayOpacity, recommendedPreset, currentTime, toggleSound, @@ -242,9 +300,12 @@ export default function VideoEditor() { /** * Updates a text overlay property and syncs with recipe. */ - const handleUpdateTextOverlay = (id: string, updates: Partial) => { + const handleUpdateTextOverlay = ( + id: string, + updates: Partial, + ) => { const updatedOverlays = (recipe.textOverlays || []).map((overlay) => - overlay.id === id ? { ...overlay, ...updates } : overlay + overlay.id === id ? { ...overlay, ...updates } : overlay, ); updateRecipe({ textOverlays: updatedOverlays }); }; @@ -263,7 +324,9 @@ export default function VideoEditor() { useEffect(() => { if (status === "done" && downloadRef.current) { - const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const prefersReducedMotion = window.matchMedia( + "(prefers-reduced-motion: reduce)", + ).matches; downloadRef.current.scrollIntoView({ behavior: prefersReducedMotion ? "instant" : "smooth", block: "center", @@ -272,7 +335,8 @@ export default function VideoEditor() { }, [status]); const isProcessing = status === "loading-engine" || status === "exporting"; - const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform); + const isMac = + typeof navigator !== "undefined" && /Mac/i.test(navigator.platform); const intervalSeconds = useMemo(() => { if (duration <= 30) return 2; @@ -283,21 +347,28 @@ export default function VideoEditor() { const videoSrc = useMemo( () => (file ? URL.createObjectURL(file) : null), - [file] + [file], ); const exportSummary = useMemo(() => { const preset = getPresetById(recipe.preset); - const width = recipe.preset === "custom" ? recipe.customWidth : (preset?.width ?? recipe.customWidth); - const height = recipe.preset === "custom" ? recipe.customHeight : (preset?.height ?? recipe.customHeight); + const width = + recipe.preset === "custom" + ? recipe.customWidth + : (preset?.width ?? recipe.customWidth); + const height = + recipe.preset === "custom" + ? recipe.customHeight + : (preset?.height ?? recipe.customHeight); const framingLabel = recipe.framing === "fit" ? "Fit" : "Fill"; const speedLabel = `${recipe.speed}× speed`; - const qualityLabel = recipe.quality <= 21 - ? "High" - : recipe.quality <= 25 - ? "Balanced" - : "Small file"; + const qualityLabel = + recipe.quality <= 21 + ? "High" + : recipe.quality <= 25 + ? "Balanced" + : "Small file"; return `Exporting to ${width}×${height} ${recipe.format.toUpperCase()} • ${framingLabel} • ${speedLabel} • Quality: ${qualityLabel}`; }, [recipe]); @@ -309,7 +380,10 @@ export default function VideoEditor() { }, [videoSrc]); return ( -
    +
    -
    -

    - REFRAME -

    -

    - Your video, any format -

    -
    - - No login. No ads. 100% private. -
    -
    -
    - - No login. No ads. 100% private - your video never leaves your device. -
    -
    +
    +

    + REFRAME +

    +

    + Your video, any format +

    +
    + + No login. No ads. 100% private. +
    +
    +
    + + No login. No ads. 100% private - your video never leaves your + device. +
    +
    -
    - + {!file && (
    @@ -388,6 +478,12 @@ export default function VideoEditor() { selectedTextId={selectedTextId} onSelectText={setSelectedTextId} onUpdateText={handleUpdateTextOverlay} + overlayFile={overlayFile} + overlayPosition={overlayPosition} + overlaySize={overlaySize} + setOverlaySize={setOverlaySize} + overlayOpacity={overlayOpacity} + setOverlayPosition={setOverlayPosition} />
    @@ -411,10 +507,12 @@ export default function VideoEditor() {

    )} {file && ( -
    +
    -
    - - - Advanced settings -
    -
    - -
    - } - title="Rotation" - isOpen={openSections.rotation} - onToggle={() => toggleSection("rotation")} - > - - - - } - title="Export" - isOpen={openSections.export} - onToggle={() => toggleSection("export")} - > - - -
    -
    toggleSection("audio")} delay={150} > - +
    } @@ -526,7 +597,9 @@ export default function VideoEditor() { max="1" step="0.1" value={recipe.brightness} - onChange={(e) => updateRecipe({ brightness: Number(e.target.value) })} + onChange={(e) => + updateRecipe({ brightness: Number(e.target.value) }) + } aria-label="Adjust brightness" className="w-full accent-film-600" /> @@ -551,7 +624,9 @@ export default function VideoEditor() { max="2" step="0.1" value={recipe.contrast} - onChange={(e) => updateRecipe({ contrast: Number(e.target.value) })} + onChange={(e) => + updateRecipe({ contrast: Number(e.target.value) }) + } aria-label="Adjust contrast" className="w-full accent-film-600" /> @@ -576,14 +651,20 @@ export default function VideoEditor() { max="3" step="0.1" value={recipe.saturation} - onChange={(e) => updateRecipe({ saturation: Number(e.target.value) })} + onChange={(e) => + updateRecipe({ saturation: Number(e.target.value) }) + } aria-label="Adjust saturation" className="w-full accent-film-600" />
    -
    } title="Output format" delay={190}> +
    } + title="Output format" + delay={190} + >
    toggleSection("export")} delay={200} > - + -
    } title="Image overlay" delay={120}> +
    } + title="Image overlay" + delay={120} + >
    -
    - - - Advanced settings -
    -
    - -
    - } - title="Rotation" - isOpen={openSections.rotation} - onToggle={() => toggleSection("rotation")} - > - - - - } - title="Export" - isOpen={openSections.export} - onToggle={() => toggleSection("export")} - > - - -
    -
    )} @@ -647,7 +707,10 @@ export default function VideoEditor() { role="status" className="flex items-start gap-3 p-4 bg-film-50 border border-film-200 rounded-xl text-film-800 text-sm animate-fade-in" > - +

    Error

    {error}

    @@ -655,12 +718,18 @@ export default function VideoEditor() {
    -
    +
    {!file && (

    @@ -700,7 +776,10 @@ export default function VideoEditor() {

    )} -
    +
    } @@ -712,7 +791,10 @@ export default function VideoEditor() { {recommendedPreset && (

    - 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, "")})

    )} @@ -753,19 +835,22 @@ export default function VideoEditor() { id="export-button" type="button" onClick={handleExport} - disabled={!file || isProcessing} - aria-label='Export video' - aria-disabled={!file || isProcessing ? "true" : undefined} - title={!file ? "Upload a video to enable export" : undefined} + disabled={!file || isProcessing} + aria-label="Export video" + aria-disabled={!file || isProcessing ? "true" : undefined} + title={!file ? "Upload a video to enable export" : undefined} className={cn( "w-full flex items-center justify-center gap-3 py-5 min-h-[44px] rounded-xl", "font-display text-2xl tracking-widest transition-all duration-200", file && !isProcessing ? "bg-[var(--accent)] hover:bg-[var(--accent-hover)] hover:scale-[1.02] text-white shadow-[var(--shadow)] active:scale-[0.98] cursor-pointer" - : "bg-[var(--border)] text-[var(--muted)] cursor-not-allowed" + : "bg-[var(--border)] text-[var(--muted)] cursor-not-allowed", )} > - + {isProcessing ? "PROCESSING" : "EXPORT"} diff --git a/src/components/VideoPreview.tsx b/src/components/VideoPreview.tsx index d684c1e5..dee745c2 100644 --- a/src/components/VideoPreview.tsx +++ b/src/components/VideoPreview.tsx @@ -16,6 +16,12 @@ interface Props { selectedTextId?: string | null; onSelectText?: (id: string | null) => void; onUpdateText?: (id: string, updates: Partial) => void; + overlayFile?: File | null; + overlayPosition?: { x: number; y: number }; + overlaySize?: number; + overlayOpacity?: number; + setOverlayPosition?: (p: { x: number; y: number }) => void; + setOverlaySize?: (size: number) => void; } export default function VideoPreview({ @@ -25,19 +31,77 @@ export default function VideoPreview({ selectedTextId = null, onSelectText, onUpdateText, + overlayFile, + overlayPosition, + overlaySize = 250, + overlayOpacity = 100, }: Props) { const lastId = useRef(0); const urlRef = useRef(null); const [isLoading, setIsLoading] = useState(true); - const [showOverlay, setShowOverlay] = useState(false); const [showComparison, setShowComparison] = useState(false); const [containerDimensions, setContainerDimensions] = useState({ width: 0, height: 0, }); const previewContainerRef = useRef(null); + const innerCanvasRef = useRef(null); const onLoadedRef = useRef<(() => void) | null>(null); + const [overlayUrl, setOverlayUrl] = useState(null); + + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [isMounted, setIsMounted] = useState(false); + const [isHovered, setIsHovered] = useState(false); + + // Listen to video media updates to safely drive the custom timeline bar on the client side + useEffect(() => { + const video = videoRef.current; + if (!video) return; + + const handleTimeUpdate = () => setCurrentTime(video.currentTime); + const handleDurationChange = () => setDuration(video.duration || 0); + const handlePlay = () => setIsPlaying(true); + const handlePause = () => setIsPlaying(false); + + video.addEventListener("timeupdate", handleTimeUpdate); + video.addEventListener("durationchange", handleDurationChange); + video.addEventListener("play", handlePlay); + video.addEventListener("pause", handlePause); + + return () => { + video.removeEventListener("timeupdate", handleTimeUpdate); + video.removeEventListener("durationchange", handleDurationChange); + video.removeEventListener("play", handlePlay); + video.removeEventListener("pause", handlePause); + }; + }, [file, videoRef]); + + const formatTime = (secs: number) => { + if (isNaN(secs) || !isFinite(secs)) return "00:00"; + const m = Math.floor(secs / 60) + .toString() + .padStart(2, "0"); + const s = Math.floor(secs % 60) + .toString() + .padStart(2, "0"); + return `${m}:${s}`; + }; + + // Handle local memory compilation for overlay source files safely + useEffect(() => { + if (!overlayFile) { + setOverlayUrl(null); + return; + } + const url = URL.createObjectURL(overlayFile); + setOverlayUrl(url); + return () => URL.revokeObjectURL(url); + }, [overlayFile]); + + /** Capture the current video frame and download it as a PNG. */ const handleGrabFrame = useCallback(() => { const video = videoRef.current; if (!video || video.readyState < 2) return; @@ -52,7 +116,6 @@ export default function VideoPreview({ canvas.toBlob((blob) => { if (!blob) return; - const totalSec = Math.floor(video.currentTime); const mins = String(Math.floor(totalSec / 60)).padStart(2, "0"); const secs = String(totalSec % 60).padStart(2, "0"); @@ -74,10 +137,6 @@ export default function VideoPreview({ setIsLoading(true); const id = ++lastId.current; const url = URL.createObjectURL(file); - - if (urlRef.current) { - URL.revokeObjectURL(urlRef.current); - } urlRef.current = url; const video = videoRef.current; @@ -92,21 +151,18 @@ export default function VideoPreview({ }; onLoadedRef.current = handleLoaded; - - video.addEventListener("loadeddata", handleLoaded); + video.addEventListener("loadedmetadata", handleLoaded); // Optimized to prevent race-condition load locks return () => { if (onLoadedRef.current) { - video.removeEventListener("loadeddata", onLoadedRef.current); + video.removeEventListener("loadedmetadata", onLoadedRef.current); onLoadedRef.current = null; } - if (video) { video.pause(); video.removeAttribute("src"); video.load(); } - if (urlRef.current === url) { URL.revokeObjectURL(urlRef.current); urlRef.current = null; @@ -124,9 +180,7 @@ export default function VideoPreview({ videoRef.current.playbackRate = recipe.speed; }, [recipe, videoRef]); - /** - * Track preview container dimensions for text overlay positioning. - */ + /** Track preview container dimensions for text overlay positioning. */ useEffect(() => { const updateDimensions = () => { if (previewContainerRef.current) { @@ -143,51 +197,34 @@ export default function VideoPreview({ return () => window.removeEventListener("resize", updateDimensions); }, []); - const overlay = (() => { - if (!recipe || !showOverlay) return null; - - const preset = recipe.preset === "custom" + // --- Absolute WYSIWYG Canvas Math --- + const activePreset = recipe + ? recipe.preset === "custom" ? { width: recipe.customWidth, height: recipe.customHeight } - : getPresetById(recipe.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 (recipe.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; + : getPresetById(recipe.preset) + : undefined; + + const containerRatio = 16 / 9; + const outputRatio = activePreset + ? activePreset.width / activePreset.height + : containerRatio; + + let boxTop = 0, + boxBottom = 0, + boxLeft = 0, + boxRight = 0; + + if (outputRatio > containerRatio) { + const boxHeightPct = (containerRatio / outputRatio) * 100; + const barH = (100 - boxHeightPct) / 2; + boxTop = barH; + boxBottom = barH; + } else { + const boxWidthPct = (outputRatio / containerRatio) * 100; + const barW = (100 - boxWidthPct) / 2; + boxLeft = barW; + boxRight = barW; + } const handleKeyDown = (e: React.KeyboardEvent) => { if (e.code === "Space") { @@ -196,81 +233,70 @@ export default function VideoPreview({ 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(); - } + e.preventDefault(); + if (video.paused) video.play().catch(() => {}); + else video.pause(); } } }; + if (!file) return null; + return ( <>
    {isLoading && (
    )} - {/* eslint-disable-next-line jsx-a11y/media-has-caption */} -