From 534a8a4fbfe16ecd425d64a9df5882d4f004d166 Mon Sep 17 00:00:00 2001 From: Mohammed Danyal Date: Tue, 19 May 2026 04:28:21 +0530 Subject: [PATCH 1/6] feat: add frame grab PNG export for video preview --- bun.lock | 2 ++ src/components/VideoPreview.tsx | 29 +++++++++++++---------------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/bun.lock b/bun.lock index 3b78ac97..2de96ed8 100644 --- a/bun.lock +++ b/bun.lock @@ -858,6 +858,8 @@ "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], "psl": ["psl@1.15.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w=="], diff --git a/src/components/VideoPreview.tsx b/src/components/VideoPreview.tsx index da392734..ff9d71e2 100644 --- a/src/components/VideoPreview.tsx +++ b/src/components/VideoPreview.tsx @@ -20,6 +20,7 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) { const [showOverlay, setShowOverlay] = useState(false); const onLoadedRef = useRef<(() => void) | null>(null); + /** Capture the current video frame and download it as a PNG. */ const handleGrabFrame = useCallback(() => { const video = videoRef.current; if (!video || video.readyState < 2) return; @@ -48,7 +49,6 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) { URL.revokeObjectURL(url); }, "image/png"); }, [videoRef]); - useEffect(() => { if (!file) return; @@ -57,6 +57,7 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) { const id = ++lastId.current; const url = URL.createObjectURL(file); + // cleanup previous object URL safely if (urlRef.current) { URL.revokeObjectURL(urlRef.current); } @@ -68,6 +69,7 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) { video.src = url; video.load(); + // define handler once per effect run const handleLoaded = () => { if (lastId.current !== id) return; video.play().catch(() => {}); @@ -78,17 +80,20 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) { video.addEventListener("loadeddata", handleLoaded); 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; @@ -96,16 +101,11 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) { }; }, [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]); - + /** + * 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 (!recipe || !showOverlay) return null; @@ -196,10 +196,7 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) { className={cn("w-full h-full object-contain transition-opacity duration-300", isLoading ? "opacity-0" : "opacity-100")} onLoadedData={() => setIsLoading(false)} playsInline - muted={!recipe?.keepAudio} - > - - + /> {/* Letterbox / Crop overlay */} {overlay && ( @@ -238,7 +235,7 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) { + + {/* Per-overlay controls */} + {textOverlays.map((overlay, index) => ( +
+ {/* Header row */} +
+ + Text {index + 1} + + +
+ + {/* Text input */} + onUpdate(overlay.id, { text: e.target.value })} + className="w-full rounded border border-[var(--border)] bg-transparent px-2 py-1 text-[11px] text-[var(--text)] focus:border-film-500 focus:outline-none" + placeholder="Enter text…" + aria-label={`Text content for overlay ${index + 1}`} + /> + + {/* Font size + weight row */} +
+ + onUpdate(overlay.id, { fontSize: Number(e.target.value) })} + className="w-14 rounded border border-[var(--border)] bg-transparent px-1.5 py-0.5 text-[10px] text-[var(--text)] text-center focus:border-film-500 focus:outline-none" + aria-label={`Font size for overlay ${index + 1}`} + /> + px + +
+ + {/* Color presets */} +
+ Color: + {COLOR_PRESETS.map((c) => ( +
+
+ ))} + + {textOverlays.length === 0 && ( +

+ No text overlays added +

+ )} + + ); +} diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index 8e7164cd..32a106bc 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -15,11 +15,12 @@ import ExportSettings from "./ExportSettings"; import ExportOverlay from "./ExportOverlay"; import DownloadResult from "./DownloadResult"; import ImageOverlay from "./ImageOverlay" +import TextOverlayPanel from "./TextOverlayPanel"; import { cn } from "@/lib/utils"; import { Layers, Crop, Scissors, RotateCw, Volume2, - SlidersHorizontal, Zap, AlertTriangle, Github, Copy + SlidersHorizontal, Zap, AlertTriangle, Github, Copy, Type } from "lucide-react"; import OnboardingTour from "./OnboardingTour"; import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts"; @@ -151,6 +152,7 @@ export default function VideoEditor() { recommendedPreset, currentTime, toggleSound, + textOverlays, addTextOverlay, updateTextOverlay, removeTextOverlay, } = useVideoEditor(); useKeyboardShortcuts({ @@ -246,7 +248,13 @@ export default function VideoEditor() { {file && (
- +
} title="Rotate" delay={100}> +
} title="Text overlay" delay={130}> + +
} title="Audio & Speed" delay={150}> diff --git a/src/components/VideoPreview.tsx b/src/components/VideoPreview.tsx index ff9d71e2..dba4188d 100644 --- a/src/components/VideoPreview.tsx +++ b/src/components/VideoPreview.tsx @@ -2,7 +2,7 @@ "use client"; import { useEffect, useRef, useState, useCallback, RefObject } from "react"; -import { EditRecipe } from "@/lib/types"; +import { EditRecipe, TextOverlay } from "@/lib/types"; import { getPresetById } from "@/lib/presets"; import { cn } from "@/lib/utils"; import { Camera } from "lucide-react"; @@ -11,15 +11,115 @@ interface Props { file: File | null; recipe?: EditRecipe; videoRef: RefObject; + textOverlays?: TextOverlay[]; + onUpdateTextOverlay?: (id: string, patch: Partial>) => void; } -export default function VideoPreview({ file, recipe, videoRef }: Props) { +export default function VideoPreview({ file, recipe, videoRef, textOverlays, onUpdateTextOverlay }: Props) { const lastId = useRef(0); const urlRef = useRef(null); const [isLoading, setIsLoading] = useState(true); const [showOverlay, setShowOverlay] = useState(false); + const [editingTextId, setEditingTextId] = useState(null); const onLoadedRef = useRef<(() => void) | null>(null); + /* ── Drag and Tap state for text overlays ── */ + const containerRef = useRef(null); + const lastTapRef = useRef<{ id: string; time: number }>({ id: "", time: 0 }); + const dragRef = useRef<{ + id: string; + startX: number; + startY: number; + origX: number; + origY: number; + target: HTMLElement; + pointerId: number; + } | null>(null); + + /** Begin dragging a text overlay. */ + const handlePointerDown = useCallback( + (e: React.PointerEvent, overlay: TextOverlay) => { + // Ignore if the element is being edited + if ((e.target as HTMLElement).isContentEditable) return; + + const now = Date.now(); + if (lastTapRef.current.id === overlay.id && now - lastTapRef.current.time < 300) { + // Double tap detected + setEditingTextId(overlay.id); + setTimeout(() => { + const el = document.getElementById(`overlay-text-${overlay.id}`); + if (el) { + el.focus(); + if (typeof window.getSelection !== "undefined" && typeof document.createRange !== "undefined") { + const range = document.createRange(); + range.selectNodeContents(el); + range.collapse(false); + const sel = window.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(range); + } + } + }, 0); + lastTapRef.current = { id: "", time: 0 }; + return; + } + lastTapRef.current = { id: overlay.id, time: now }; + + e.preventDefault(); + e.stopPropagation(); + + const target = e.currentTarget as HTMLElement; + target.setPointerCapture(e.pointerId); + + dragRef.current = { + id: overlay.id, + startX: e.clientX, + startY: e.clientY, + origX: overlay.x, + origY: overlay.y, + target, + pointerId: e.pointerId + }; + }, + [] + ); + + /** Move the text overlay while dragging. */ + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + const drag = dragRef.current; + const container = containerRef.current; + if (!drag || !container || !onUpdateTextOverlay) return; + if (e.pointerId !== drag.pointerId) return; + + e.preventDefault(); + e.stopPropagation(); + + const rect = container.getBoundingClientRect(); + const dx = ((e.clientX - drag.startX) / rect.width) * 100; + const dy = ((e.clientY - drag.startY) / rect.height) * 100; + const newX = Math.max(0, Math.min(100, drag.origX + dx)); + const newY = Math.max(0, Math.min(100, drag.origY + dy)); + onUpdateTextOverlay(drag.id, { x: newX, y: newY }); + }, + [onUpdateTextOverlay] + ); + + /** Finish dragging. */ + const handlePointerUp = useCallback((e: React.PointerEvent) => { + const drag = dragRef.current; + if (drag && e.pointerId === drag.pointerId) { + e.preventDefault(); + e.stopPropagation(); + try { + drag.target.releasePointerCapture(drag.pointerId); + } catch (err) { + // ignore if already released + } + dragRef.current = null; + } + }, []); + /** Capture the current video frame and download it as a PNG. */ const handleGrabFrame = useCallback(() => { const video = videoRef.current; @@ -177,6 +277,7 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) { return (
)} + {/* Draggable text overlays */} + {textOverlays?.map((overlay) => ( +
handlePointerDown(e, overlay)} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + onPointerCancel={handlePointerUp} + aria-label={`Text overlay: ${overlay.text}`} + > + { + setEditingTextId(overlay.id); + setTimeout(() => { + const el = document.getElementById(`overlay-text-${overlay.id}`); + if (el) { + el.focus(); + // Place cursor at the end + if (typeof window.getSelection !== "undefined" && typeof document.createRange !== "undefined") { + const range = document.createRange(); + range.selectNodeContents(el); + range.collapse(false); + const sel = window.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(range); + } + } + }, 0); + }} + onBlur={(e) => { + setEditingTextId(null); + onUpdateTextOverlay?.(overlay.id, { + text: (e.target as HTMLElement).textContent || "", + }); + }} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + (e.target as HTMLElement).blur(); + } + }} + > + {overlay.text} + +
+ ))} + {/* Toggle button */} {recipe && !isLoading && ( )}
From dc46fe70199a7f927974785a28e8161258e1bed9 Mon Sep 17 00:00:00 2001 From: Mohammed danyal Date: Fri, 22 May 2026 15:01:07 +0530 Subject: [PATCH 4/6] Fix duplicate state declaration in VideoPreview Removed duplicate state declaration for editingTextId. --- src/components/VideoPreview.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/VideoPreview.tsx b/src/components/VideoPreview.tsx index 24370426..e56f740b 100644 --- a/src/components/VideoPreview.tsx +++ b/src/components/VideoPreview.tsx @@ -476,4 +476,4 @@ export default function VideoPreview({ file, recipe, videoRef, textOverlays, onU )}
); -} \ No newline at end of file +} From 0c6730623bb24fc740ee45ae8de358ebb6717ac4 Mon Sep 17 00:00:00 2001 From: Mohammed danyal Date: Fri, 22 May 2026 15:06:36 +0530 Subject: [PATCH 5/6] feat: add text overlay --- src/hooks/useVideoEditor.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts index 01e138c8..eeb0cc0a 100644 --- a/src/hooks/useVideoEditor.ts +++ b/src/hooks/useVideoEditor.ts @@ -485,7 +485,7 @@ export function useVideoEditor() { exportAbortControllerRef.current = null; } } - }, [file, recipe, result, status, overlayFile, overlayPosition, overlaySize, overlayOpacity, duration, loopMusic, musicFile, musicVolume, originalAudioVolume, textOverlays]); + }, [file, recipe, result, status, overlayFile, overlayPosition, overlaySize, overlayOpacity, duration, textOverlays, loopMusic, musicFile, musicVolume, originalAudioVolume]); useEffect(() => { @@ -679,4 +679,4 @@ export function useVideoEditor() { updateTextOverlay, removeTextOverlay, }; -} \ No newline at end of file +} From b6fd20d05285e3c5625cf9742d2c5e951d7d713e Mon Sep 17 00:00:00 2001 From: Mohammed Danyal Date: Sat, 23 May 2026 17:54:05 +0530 Subject: [PATCH 6/6] feat: add text overlay --- 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 eeb0cc0a..aa6657cb 100644 --- a/src/hooks/useVideoEditor.ts +++ b/src/hooks/useVideoEditor.ts @@ -1,7 +1,7 @@ "use client"; import { useState, useCallback, useEffect, useRef, useMemo } from "react"; -import { EditRecipe, ExportResult, ExportStatus, MAX_FILE_SIZE, OverlayPosition, TextOverlay } from "@/lib/types"; +import { EditRecipe, ExportResult, isValidRecipe , ExportStatus, MAX_FILE_SIZE, OverlayPosition, TextOverlay } from "@/lib/types"; import { DEFAULT_RECIPE, SPEED_STEPS } from "@/lib/constants"; import { getPresetById } from "@/lib/presets"; import { loadFFmpeg, exportVideo, terminateFFmpeg, FFmpegLoadError } from "@/lib/ffmpeg";