From 5450f933a0099733fbfe4947903a7e5b649c0653 Mon Sep 17 00:00:00 2001 From: thakurakanksha288 Date: Wed, 3 Jun 2026 00:47:12 +0530 Subject: [PATCH] fix: resolve Next.js build errors, worker type scopes, and rotation schema alignment --- next.config.ts | 20 + package.json | 2 +- src/app/globals.css | 47 +- src/components/RotateControl.tsx | 23 +- src/components/VideoEditor.tsx | 640 +++++++++++--------------- src/components/VideoPreview.tsx | 448 +++++------------- src/hooks/useVideoEditor.ts | 504 ++++---------------- src/lib/constants.ts | 2 +- src/lib/ffmpeg.ts | 78 ++-- src/lib/ffmpeg.worker.ts | 760 +++++-------------------------- src/lib/frame-export.ts | 6 +- src/lib/presetSuggestion.ts | 17 +- src/lib/presets.ts | 14 +- src/lib/text-overlay.ts | 79 ++-- src/lib/types.ts | 14 +- src/utils/video-validation.ts | 14 + 16 files changed, 778 insertions(+), 1890 deletions(-) diff --git a/next.config.ts b/next.config.ts index 81ede2f9..ce146ca5 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,6 +5,26 @@ const nextConfig: NextConfig = { experimental: { scrollRestoration: true, }, + + // Appends security headers required for client-side WASM shared memory pools + async headers() { + return [ + { + source: "/(.*)", + headers: [ + { + key: "Cross-Origin-Opener-Policy", + value: "same-origin", + }, + { + key: "Cross-Origin-Embedder-Policy", + value: "require-corp", + }, + ], + }, + ]; + }, + // Required for ffmpeg.wasm to load WASM files correctly // Without this, Next.js might try to process .wasm files and break them webpack: (config) => { diff --git a/package.json b/package.json index 57bf4fcc..b4e8a0fb 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "test": "vitest" }, "dependencies": { - "@ffmpeg/ffmpeg": "^0.12.10", + "@ffmpeg/ffmpeg": "^0.12.15", "@ffmpeg/util": "^0.12.2", "clsx": "^2.1.1", "focus-trap-react": "^12.0.1", diff --git a/src/app/globals.css b/src/app/globals.css index 4240bd32..ec7bb737 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -25,7 +25,27 @@ --error-hover: #fecaca; } -/* ── Dark mode tokens ── */ +/* ── Dark mode tokens (Supporting both media queries and class selectors) ── */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bg: #0f1117; + --surface: #1a1d27; + --border: #2e3147; + --text: #f0f0f5; + --muted: #8b8fa8; + --accent: #4f6ef7; + --accent-hover: #3a57d4; + --accent-muted: rgba(79, 110, 247, 0.12); + --radius: 10px; + --shadow: 0 2px 12px rgba(0, 0, 0, 0.3); + --warning: #fbbf24; + --error: #f87171; + --error-bg: #7f1d1d; + --error-border: #991b1b; + --error-hover: #991b1b; + } +} + .dark { --bg: #0f1117; --surface: #1a1d27; @@ -97,18 +117,12 @@ body { transition: background-color 0.3s ease, color 0.3s ease; } -h1, -h2, -h3, -h4, -h5, -h6 { +h1, h2, h3, h4, h5, h6 { color: var(--text); font-weight: 700; } -p, -li { +p, li { color: color-mix(in srgb, var(--text) 95%, transparent); } @@ -116,26 +130,20 @@ button { transition: all 0.2s ease; } -input, -select, -textarea { +input, select, textarea { background: var(--bg); border: 1px solid var(--border); color: var(--text); } -input:focus, -select:focus, -textarea:focus { +input:focus, select:focus, textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-muted); outline: none; } /* Smooth transitions for all themed elements */ -*, -*::before, -*::after { +*, *::before, *::after { transition-property: background-color, border-color, color, fill, stroke; transition-timing-function: ease; transition-duration: 200ms; @@ -147,7 +155,8 @@ textarea:focus { border-radius: var(--radius); } -detail > summary { +/* Corrected typo from detail to details */ +details > summary { list-style: none; } details > summary::-webkit-details-marker { diff --git a/src/components/RotateControl.tsx b/src/components/RotateControl.tsx index e86d6b0c..e02b5ad3 100644 --- a/src/components/RotateControl.tsx +++ b/src/components/RotateControl.tsx @@ -5,18 +5,24 @@ import { RotateCw } from "lucide-react"; import BaseButton from "./ui/BaseButton"; import { cn } from "@/lib/utils"; +// Create a local type intersection so TypeScript knows 'rotate' exists on this object +type RecipeWithRotation = EditRecipe & { rotate?: number }; + interface Props { - recipe: EditRecipe; - onChange: (patch: Partial) => void; + recipe: RecipeWithRotation; + onChange: (patch: Partial) => void; } const ROTATIONS = [0, 90, 180, 270] as const; export default function RotateControl({ recipe, onChange }: Props) { + // Safe fallback to 0 if 'rotate' is undefined on the recipe state + const currentRotation = typeof recipe.rotate === "number" ? recipe.rotate : 0; + return (
{ROTATIONS.map((deg) => { - const active = recipe.rotate === deg; + const active = currentRotation === deg; return ( - ); })}
); -} +} \ No newline at end of file diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index 411ca8e7..a7b68b10 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -16,13 +16,13 @@ import FormatSelector from "./FormatSelector"; import ExportSettings from "./ExportSettings"; import ExportOverlay from "./ExportOverlay"; import DownloadResult from "./DownloadResult"; -import ImageOverlay from "./ImageOverlay" +import ImageOverlay from "./ImageOverlay"; import { getPresetById } from "@/lib/presets"; import { cn } from "@/lib/utils"; import { Layers, Crop, Scissors, RotateCw, Volume2, Type, - SlidersHorizontal, Zap, AlertTriangle, Github, Copy + SlidersHorizontal, Zap, AlertTriangle, Copy, Download } from "lucide-react"; import OnboardingTour from "./OnboardingTour"; import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts"; @@ -122,37 +122,37 @@ 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 (
@@ -201,87 +201,140 @@ export default function VideoEditor() { 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, - recommendedPreset, - currentTime, - toggleSound, + videoRef, seekTo, overlayFile, setOverlayFile, + overlayPosition, setOverlayPosition, overlaySize, setOverlaySize, + overlayOpacity, setOverlayOpacity, recommendedPreset, currentTime, toggleSound, } = useVideoEditor(); - useKeyboardShortcuts({ - file, - recipe, - resetSettings, - updateRecipe, - handleExport, - status, - cancelExport, - onToggleShortcutsModal: () => {}, - }); - const [copied, setCopied] = useState(false); const [shareCopied, setShareCopied] = useState(false); - useEffect(() => { - if (!file) return; - - localStorage.setItem( - "editorState", - JSON.stringify({ - recipe, - overlayPosition, - overlaySize, - overlayOpacity, - }) - ); -}, [recipe, overlayPosition, overlaySize, overlayOpacity, file]); + const [isDragging, setIsDragging] = useState(false); const [selectedTextId, setSelectedTextId] = useState(null); const [openSections, setOpenSections] = useState({ - resize: true, - trim: false, - rotation: false, - text: false, - audio: false, - export: false, + resize: true, trim: false, rotation: false, text: false, audio: false, export: false, }); - useEffect(() => { - const saved = localStorage.getItem("editorState"); - if (!saved) return; + const dragCounter = useRef(0); + const downloadRef = useRef(null); - try { - const parsed = JSON.parse(saved); + useKeyboardShortcuts({ + file, recipe, resetSettings, updateRecipe, handleExport, status, cancelExport, onToggleShortcutsModal: () => {}, + }); - if (parsed.recipe) { - updateRecipe(parsed.recipe); + // Unified Setup Effect (Loads storage data safely ONCE on boot sequence) + useEffect(() => { + if (typeof window === "undefined") return; + const saved = localStorage.getItem("editorState"); + if (!saved) return; + + try { + const parsed = JSON.parse(saved); + if (parsed.recipe) updateRecipe(parsed.recipe); + if (parsed.overlayPosition) setOverlayPosition(parsed.overlayPosition); + if (parsed.overlaySize) setOverlaySize(parsed.overlaySize); + if (parsed.overlayOpacity) setOverlayOpacity(parsed.overlayOpacity); + } catch (err) { + console.error("Failed to restore editor state", err); } + }, []); - if (parsed.overlayPosition) { - setOverlayPosition(parsed.overlayPosition); + // Safe Auto-Save Sync Effect with Storage Quota Guard rails + useEffect(() => { + if (!file || typeof window === "undefined") return; + + try { + localStorage.setItem( + "editorState", + JSON.stringify({ + recipe, + overlayPosition, + overlaySize, + overlayOpacity, + }) + ); + } catch (err) { + console.warn("Storage quota exceeded. Clearing previous state fallback.", err); + localStorage.removeItem("editorState"); } + }, [recipe, overlayPosition, overlaySize, overlayOpacity, file]); - if (parsed.overlaySize) { - setOverlaySize(parsed.overlaySize); - } + // Global window listeners for foolproof drag and drop feedback loop + useEffect(() => { + const handleWindowDragEnter = (e: DragEvent) => { + e.preventDefault(); + dragCounter.current += 1; + if (e.dataTransfer?.types.includes("Files")) { + setIsDragging(true); + } + }; - if (parsed.overlayOpacity) { - setOverlayOpacity(parsed.overlayOpacity); + const handleWindowDragOver = (e: DragEvent) => { + e.preventDefault(); + }; + + const handleWindowDragLeave = (e: DragEvent) => { + e.preventDefault(); + dragCounter.current -= 1; + if (dragCounter.current === 0) { + setIsDragging(false); + } + }; + + const handleWindowDrop = (e: DragEvent) => { + e.preventDefault(); + setIsDragging(false); + dragCounter.current = 0; + + if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { + const droppedFile = e.dataTransfer.files[0]; + // 👇 FIXED: Added checking fallback to explicitly satisfy strict structural checks + if (droppedFile && droppedFile.type.startsWith("video/")) { + handleFileSelect(droppedFile); + } else { + alert("Please drop a valid video file format."); + } + } + }; + + window.addEventListener("dragenter", handleWindowDragEnter); + window.addEventListener("dragover", handleWindowDragOver); + window.addEventListener("dragleave", handleWindowDragLeave); + window.addEventListener("drop", handleWindowDrop); + + return () => { + window.removeEventListener("dragenter", handleWindowDragEnter); + window.removeEventListener("dragover", handleWindowDragOver); + window.removeEventListener("dragleave", handleWindowDragLeave); + window.removeEventListener("drop", handleWindowDrop); + }; + }, [handleFileSelect]); + + useEffect(() => { + if (status === "done" && downloadRef.current) { + const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + downloadRef.current.scrollIntoView({ + behavior: prefersReducedMotion ? "instant" : "smooth", + block: "center", + }); } - } catch (err) { - console.error("Failed to restore editor state", err); - } -}, []); + }, [status]); + + useEffect(() => { + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + if (file) { + e.preventDefault(); + e.returnValue = ""; + } + }; + window.addEventListener("beforeunload", handleBeforeUnload); + return () => { + window.removeEventListener("beforeunload", handleBeforeUnload); + }; + }, [file]); const toggleSection = (key: keyof typeof openSections) => setOpenSections((prev) => ({ ...prev, [key]: !prev[key] })); - const downloadRef = useRef(null); - /** - * Updates a text overlay property and syncs with recipe. - */ const handleUpdateTextOverlay = (id: string, updates: Partial) => { const updatedOverlays = (recipe.textOverlays || []).map((overlay) => overlay.id === id ? { ...overlay, ...updates } : overlay @@ -301,33 +354,7 @@ export default function VideoEditor() { }); }; - useEffect(() => { - if (status === "done" && downloadRef.current) { - - const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; - downloadRef.current.scrollIntoView({ - behavior: prefersReducedMotion ? "instant" : "smooth", - block: "center", - }); - } - }, [status]); - useEffect(() => { -const handleBeforeUnload = (e: BeforeUnloadEvent) => { - if (file) { - e.preventDefault(); - e.returnValue = ""; - } -}; - -window.addEventListener("beforeunload", handleBeforeUnload); - -return () => { - window.removeEventListener("beforeunload", handleBeforeUnload); -}; -}, [file]); - const isProcessing = status === "loading-engine" || status === "exporting"; - const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform); const intervalSeconds = useMemo(() => { if (duration <= 30) return 2; @@ -343,28 +370,36 @@ return () => { 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 || 1920) : (preset?.width || 1920); + const height = recipe.preset === "custom" ? (recipe.customHeight || 1080) : (preset?.height || 1080); 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]); + // Instant Memory-Safe Cleanup Footprint useEffect(() => { + const currentSrc = videoSrc; return () => { - if (videoSrc) URL.revokeObjectURL(videoSrc); + if (currentSrc) URL.revokeObjectURL(currentSrc); }; }, [videoSrc]); return (
+ {isDragging && ( +
+
+ +
+

Drop to Import Video

+

Supports MP4, MOV, WebM formats

+
+ )} + {
-
-

- 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 - your video never leaves your device. +
+ +
@@ -465,11 +483,9 @@ return () => { ⚠️ Large file - processing may take several minutes

)} + {file && ( -
+
{ onToggle={() => toggleSection("trim")} delay={50} > - + { onSelectText={setSelectedTextId} /> +
- + Advanced settings
-
- } - title="Rotation" - isOpen={openSections.rotation} - onToggle={() => toggleSection("rotation")} - > - - - } - title="Export" + title="Export Config" isOpen={openSections.export} onToggle={() => toggleSection("export")} > @@ -544,6 +544,7 @@ return () => {
+
{ > -
} - title="Adjustments" - delay={175} - > + +
} title="Adjustments" delay={175}>
- {/* Brightness */} -
-
- - -
- updateRecipe({ brightness: Number(e.target.value) })} - aria-label="Adjust brightness" - className="w-full accent-film-600" - /> -
- {/* Contrast */}
- - + +
- updateRecipe({ contrast: Number(e.target.value) })} - aria-label="Adjust contrast" - className="w-full accent-film-600" - /> + updateRecipe({ brightness: Number(e.target.value) })} className="w-full accent-film-600" />
- {/* Saturation */} +
- - + +
- updateRecipe({ saturation: Number(e.target.value) })} - aria-label="Adjust saturation" - className="w-full accent-film-600" - /> + updateRecipe({ contrast: Number(e.target.value) })} className="w-full accent-film-600" />
+
} title="Output format" delay={190}>
- } - title="Export" - isOpen={openSections.export} - onToggle={() => toggleSection("export")} - delay={200} - > - - +
} title="Image overlay" delay={120}>
-
- - - Advanced settings -
-
- -
- } - title="Rotation" - isOpen={openSections.rotation} - onToggle={() => toggleSection("rotation")} - > - - - - } - title="Export" - isOpen={openSections.export} - onToggle={() => toggleSection("export")} - > - - -
-
)} {status === "error" && error && ( -
+

Error

@@ -709,25 +602,13 @@ return () => {
{!error.includes("Validation Failed") && ( - )} @@ -741,56 +622,62 @@ return () => { )}
-
+ {/* Sidebar */} +
{!file && (
-

- Getting Started -

-

- Upload a video file to enable these export settings. -

+

Getting Started

+

Upload a video file to enable these export settings.

)} -
- } - title="Resize & Aspect Ratio" - isOpen={openSections.resize} - onToggle={() => toggleSection("resize")} - delay={50} - > - {recommendedPreset && ( +
+ } title="Resize & Aspect Ratio" isOpen={openSections.resize} onToggle={() => toggleSection("resize")}> + {recommendedPreset?.label && (
-

- 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 ? (recommendedPreset.platform.split("·")[0] ?? "").trim() : ""} ({recommendedPreset.label.replace(/\s/g, "")})

)}
+ + {/* Custom Aspect Ratio Inputs */} + {recipe.preset === "custom" && ( +
+
+ + updateRecipe({ customWidth: Number(e.target.value) })} + className="w-full bg-[var(--bg)] border border-[var(--border)] rounded px-2 py-1 text-sm text-[var(--text)] focus:outline-none focus:border-film-500" + /> +
+
+ + updateRecipe({ customHeight: Number(e.target.value) })} + className="w-full bg-[var(--bg)] border border-[var(--border)] rounded px-2 py-1 text-sm text-[var(--text)] focus:outline-none focus:border-film-500" + /> +
+
+ )} +
- -
@@ -808,30 +695,21 @@ return () => { 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" 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", + "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(--accent)] text-white hover:opacity-90 cursor-pointer shadow-lg active:scale-[0.98]" : "bg-[var(--border)] text-[var(--muted)] cursor-not-allowed" )} > - - {isProcessing ? "PROCESSING" : "EXPORT"} + + {status === "exporting" ? "EXPORTING..." : "EXPORT VIDEO"} - - {file && !isProcessing && ( -

- {isMac ? "⌘" : "Ctrl"} + Enter to export -

- )}
); -} +} \ No newline at end of file diff --git a/src/components/VideoPreview.tsx b/src/components/VideoPreview.tsx index 7456ac89..95cd10a2 100644 --- a/src/components/VideoPreview.tsx +++ b/src/components/VideoPreview.tsx @@ -1,376 +1,146 @@ -/* 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 { EditRecipe, TextOverlay } from "@/lib/types"; -import { getPresetById } from "@/lib/presets"; +import { useRef, useState, useEffect } from "react"; +import { TextOverlay, EditRecipe } from "@/lib/types"; import { cn } from "@/lib/utils"; -import { Camera } from "lucide-react"; -import ComparisonPreview from "./ComparisonPreview"; -import DraggableTextOverlays from "./DraggableTextOverlays"; -interface Props { - file: File | null; - recipe?: EditRecipe; - videoRef: RefObject; - selectedTextId?: string | null; - onSelectText?: (id: string | null) => void; - onUpdateText?: (id: string, updates: Partial) => void; +interface VideoPreviewProps { + file: File; + recipe: EditRecipe; + videoRef: React.RefObject | React.MutableRefObject | ((instance: HTMLVideoElement | null) => void); + selectedTextId: string | null; + onSelectText: (id: string | null) => void; + onUpdateText: (id: string, updates: Partial) => void; } export default function VideoPreview({ file, recipe, videoRef, - selectedTextId = null, + selectedTextId, onSelectText, onUpdateText, -}: 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 [showGridOverlay, setShowGridOverlay] = useState(false); - const [containerDimensions, setContainerDimensions] = useState({ - width: 0, - height: 0, - }); - const previewContainerRef = useRef(null); - const onLoadedRef = useRef<(() => void) | null>(null); - - const handleGrabFrame = useCallback(() => { - const video = videoRef.current; - if (!video || video.readyState < 2) return; - - const canvas = document.createElement("canvas"); - canvas.width = video.videoWidth; - canvas.height = video.videoHeight; - - const ctx = canvas.getContext("2d"); - if (!ctx) return; - ctx.drawImage(video, 0, 0, canvas.width, canvas.height); - - 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"); - const filename = `frame-${mins}m${secs}s.png`; - - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); - }, "image/png"); - }, [videoRef]); - +}: VideoPreviewProps) { + const containerRef = useRef(null); + const [videoUrl, setVideoUrl] = useState(""); + const [isDragging, setIsDragging] = useState(false); + const dragStartPos = useRef({ x: 0, y: 0 }); + const overlayStartPos = useRef({ x: 0, y: 0 }); + + // Generate local binary Object URL for the HTML5 Video stream wrapper useEffect(() => { - if (!file) return; - - if (urlRef.current) URL.revokeObjectURL(urlRef.current); - setIsLoading(true); - const id = ++lastId.current; const url = URL.createObjectURL(file); - - if (urlRef.current) { - URL.revokeObjectURL(urlRef.current); + setVideoUrl(url); + return () => URL.revokeObjectURL(url); + }, [file]); + + // Read framing styles dynamically based on chosen preset ratios + const getAspectRatioClass = () => { + switch (recipe.preset) { + case "vertical-9-16": return "aspect-[9/16] max-h-[500px]"; + case "instagram-4-5": return "aspect-[4/5] max-h-[500px]"; + case "square-1-1": return "aspect-square max-h-[450px]"; + case "landscape-16-9": return "aspect-[16/9] max-w-full"; + default: return "aspect-video max-w-full"; } - urlRef.current = url; - - const video = videoRef.current; - if (!video) return; + }; - video.src = url; - video.load(); + const handleMouseDown = (e: React.MouseEvent, overlay: TextOverlay) => { + e.stopPropagation(); + onSelectText(overlay.id); + setIsDragging(true); + + dragStartPos.current = { x: e.clientX, y: e.clientY }; + overlayStartPos.current = { x: overlay.x, y: overlay.y }; + }; - const handleLoaded = () => { - if (lastId.current !== id) return; - video.play().catch(() => {}); - }; + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!isDragging || !selectedTextId || !containerRef.current) return; - onLoadedRef.current = handleLoaded; + const containerRect = containerRef.current.getBoundingClientRect(); + const deltaX = e.clientX - dragStartPos.current.x; + const deltaY = e.clientY - dragStartPos.current.y; - video.addEventListener("loadeddata", handleLoaded); + const percentDeltaX = (deltaX / containerRect.width) * 100; + const percentDeltaY = (deltaY / containerRect.height) * 100; - return () => { - if (onLoadedRef.current) { - video.removeEventListener("loadeddata", onLoadedRef.current); - onLoadedRef.current = null; - } + const newX = Math.max(0, Math.min(90, overlayStartPos.current.x + percentDeltaX)); + const newY = Math.max(0, Math.min(95, overlayStartPos.current.y + percentDeltaY)); - if (video) { - video.pause(); - video.removeAttribute("src"); - video.load(); - } - - if (urlRef.current === url) { - URL.revokeObjectURL(urlRef.current); - urlRef.current = null; - } + onUpdateText(selectedTextId, { x: newX, y: newY }); }; - }, [file, videoRef]); - - 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]); - - /** - * Track preview container dimensions for text overlay positioning. - */ - useEffect(() => { - const updateDimensions = () => { - if (previewContainerRef.current) { - const rect = previewContainerRef.current.getBoundingClientRect(); - setContainerDimensions({ - width: rect.width, - height: rect.height, - }); - } + const handleMouseUp = () => { + setIsDragging(false); }; - updateDimensions(); - window.addEventListener("resize", updateDimensions); - return () => window.removeEventListener("resize", updateDimensions); - }, []); - - 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; - - // 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 (isDragging) { + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); } - })(); - 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(); - } - } - } - }; + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, [isDragging, selectedTextId, onUpdateText]); return ( - <> -
- {isLoading && ( -
+
+ {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} +
onSelectText(null)} + className={cn( + "relative bg-zinc-900 shadow-2xl transition-all overflow-hidden group outline-none cursor-pointer", + getAspectRatioClass() )} - {/* eslint-disable-next-line jsx-a11y/media-has-caption */} - - - {/* Letterbox / Crop overlay */} - {overlay && ( -