From 3d6eb266a1d9b7225b28ffee38864f144356c6b7 Mon Sep 17 00:00:00 2001 From: Rucha Date: Sun, 24 May 2026 22:06:19 +0530 Subject: [PATCH 1/7] fix(frontend): resolve disabled export button and synchronize editor states (#1073) --- src/components/FileUpload.tsx | 22 +++++++++++++++++++++- src/components/VideoEditor.tsx | 10 ++++++++-- src/hooks/useVideoEditor.ts | 24 +++++++++++++++++++++++- src/lib/types.ts | 1 + 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/components/FileUpload.tsx b/src/components/FileUpload.tsx index f9f2127e..c33c04c6 100644 --- a/src/components/FileUpload.tsx +++ b/src/components/FileUpload.tsx @@ -12,6 +12,7 @@ interface Props { currentFile: File | null; fileError: string; duration: number; + isLoading?: boolean; } export default function FileUpload({ @@ -19,6 +20,7 @@ export default function FileUpload({ currentFile, fileError, duration, + isLoading = false, }: Props) { const inputRef = useRef(null); @@ -259,6 +261,24 @@ export default function FileUpload({ /> ); + + // ── Loading state ── + const LoadingState = () => ( +
+
+
+ +
+
+

+ Processing Video +

+

+ Extracting dimensions, aspect ratio, and metadata... +

+
+
+ ); return ( <> @@ -305,7 +325,7 @@ export default function FileUpload({ {warning}

)} - {currentFile ? : } + {isLoading ? : currentFile ? : }
); diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index 1e4e9f0d..42e82e1e 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -271,7 +271,7 @@ export default function VideoEditor() { } }, [status]); - const isProcessing = status === "loading-engine" || status === "exporting"; + const isProcessing = status === "loading" || status === "loading-engine" || status === "exporting"; const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform); const intervalSeconds = useMemo(() => { @@ -370,7 +370,13 @@ export default function VideoEditor() {
- + {!file && (
diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts index d53006ad..ab5d186a 100644 --- a/src/hooks/useVideoEditor.ts +++ b/src/hooks/useVideoEditor.ts @@ -353,12 +353,13 @@ export function useVideoEditor() { const handleFileSelect = useCallback(async (selectedFile: File) => { setResult(null); - setStatus("idle"); + setStatus("loading"); setError(null); setFile(null); setVideoMetadata(null); if (!selectedFile.type.startsWith("video/")) { setFileError("Please upload a video file only."); + setStatus("idle"); return; } @@ -411,6 +412,7 @@ export function useVideoEditor() { setDuration(dur); setVideoMetadata({ width, height, duration: dur }); setFile(selectedFile); + setStatus("idle"); if (dimensionCheck === "warning") { console.warn(`[Reframe] High resolution video detected (${width}×${height}). Export may be slow.`); @@ -613,6 +615,26 @@ export function useVideoEditor() { } },[result?.blobUrl]) + // Reset export status/result/error when recipe or options change after an export/error + useEffect(() => { + if (status === "done" || status === "error") { + setStatus("idle"); + setResult(null); + setError(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + recipe, + musicFile, + musicVolume, + originalAudioVolume, + loopMusic, + overlayFile, + overlayPosition, + overlaySize, + overlayOpacity, + ]); + useEffect(() => { return () => { terminateFFmpeg(); diff --git a/src/lib/types.ts b/src/lib/types.ts index 30cab3aa..8207f60c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -68,6 +68,7 @@ export interface ExportResult { export type ExportStatus = | "idle" + | "loading" | "loading-engine" | "exporting" | "done" From b731c5fafd22f1a4ef236a0988bfc5de362574e0 Mon Sep 17 00:00:00 2001 From: Rucha Date: Sun, 24 May 2026 22:20:30 +0530 Subject: [PATCH 2/7] feat(frontend): implement interactive NLE timeline editor with snapping and playhead sync (#1074) --- src/components/TimelineEditor.tsx | 655 ++++++++++++++++++++++++++++++ src/components/TrimControl.tsx | 59 +-- src/components/VideoEditor.tsx | 9 +- 3 files changed, 660 insertions(+), 63 deletions(-) create mode 100644 src/components/TimelineEditor.tsx diff --git a/src/components/TimelineEditor.tsx b/src/components/TimelineEditor.tsx new file mode 100644 index 00000000..e4d3a200 --- /dev/null +++ b/src/components/TimelineEditor.tsx @@ -0,0 +1,655 @@ +"use client"; + +import Image from "next/image"; +import { useEffect, useRef, useState, useCallback, useMemo } from "react"; +import { EditRecipe } from "@/lib/types"; +import { cn, formatDuration } from "@/lib/utils"; +import { GripVertical } from "lucide-react"; + +interface Thumbnail { + time: number; + dataUrl: string; +} + +interface TimelineEditorProps { + videoSrc: string | null; + duration: number; + currentTime: number; + recipe: EditRecipe; + onChange: (patch: Partial) => void; + onSeek: (time: number) => void; +} + +export default function TimelineEditor({ + videoSrc, + duration, + currentTime, + recipe, + onChange, + onSeek, +}: TimelineEditorProps) { + const [thumbnails, setThumbnails] = useState([]); + const [isGenerating, setIsGenerating] = useState(false); + const [progress, setProgress] = useState(0); + + const trackRef = useRef(null); + const draggingRef = useRef<"start" | "end" | "clip" | "playhead" | null>(null); + const dragStartOffsetRef = useRef(0); + const dragStartClipRef = useRef<{ start: number; end: number }>({ start: 0, end: 0 }); + + const lastRunIdRef = useRef(0); + const objectUrlsRef = useRef([]); + const offscreenVideoRef = useRef(null); + + const effectiveTrimEnd = recipe.trimEnd ?? duration; + const clipLength = effectiveTrimEnd - recipe.trimStart; + + // State for snapping guide + const [snapGuideTime, setSnapGuideTime] = useState(null); + + const revokeAllObjectUrls = useCallback(() => { + objectUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)); + objectUrlsRef.current = []; + }, []); + + const cancelThumbnailRun = useCallback(() => { + lastRunIdRef.current += 1; + }, []); + + // Determine snap intervals + const snapStep = useMemo(() => { + if (duration <= 15) return 1; + if (duration <= 60) return 2; + if (duration <= 180) return 5; + return 10; + }, [duration]); + + // ── Thumbnail Generation ── + const generateThumbnails = useCallback(async () => { + if (!videoSrc || duration <= 0) return; + + const runId = ++lastRunIdRef.current; + setIsGenerating(true); + revokeAllObjectUrls(); + setThumbnails([]); + setProgress(0); + + const video = document.createElement("video"); + offscreenVideoRef.current = video; + + try { + video.src = videoSrc; + video.crossOrigin = "anonymous"; + video.muted = true; + video.preload = "auto"; + + await new Promise((resolve, reject) => { + video.onloadedmetadata = () => resolve(); + video.onerror = () => reject(new Error("Video load failed")); + video.load(); + }); + + if (lastRunIdRef.current !== runId) return; + + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const thumbW = 160; + const thumbH = 90; + canvas.width = thumbW; + canvas.height = thumbH; + + // Calculate how many frames to extract to fit perfectly on screen + const times: number[] = []; + const interval = Math.max(0.5, duration / 12); // extract up to 12 frames + for (let t = 0; t <= duration; t += interval) { + times.push(Math.min(t, duration - 0.1)); + } + if ((times[times.length - 1] ?? 0) < duration - 0.2) { + times.push(duration - 0.1); + } + + const captured: Thumbnail[] = []; + + for (let i = 0; i < times.length; i++) { + if (lastRunIdRef.current !== runId) break; + + const time = times[i] ?? 0; + await new Promise((resolve) => { + const onSeeked = async () => { + video.removeEventListener("seeked", onSeeked); + + if (lastRunIdRef.current !== runId) { + resolve(); + return; + } + + ctx.drawImage(video, 0, 0, thumbW, thumbH); + + try { + const blob = await new Promise((blobResolve) => { + canvas.toBlob((b) => blobResolve(b), "image/jpeg", 0.6); + }); + if (blob && lastRunIdRef.current === runId) { + const url = URL.createObjectURL(blob); + objectUrlsRef.current.push(url); + captured.push({ time, dataUrl: url }); + setThumbnails([...captured]); + } + } catch (err) { + console.error("Failed to generate thumbnail blob", err); + } + + setProgress(Math.round(((i + 1) / times.length) * 100)); + resolve(); + }; + video.addEventListener("seeked", onSeeked); + video.currentTime = time; + }); + } + + if (lastRunIdRef.current === runId) { + setIsGenerating(false); + } + } finally { + video.src = ""; + if (offscreenVideoRef.current === video) { + offscreenVideoRef.current = null; + } + } + }, [videoSrc, duration, revokeAllObjectUrls]); + + useEffect(() => { + if (videoSrc && duration > 0) { + generateThumbnails(); + } + return () => { + cancelThumbnailRun(); + revokeAllObjectUrls(); + }; + }, [cancelThumbnailRun, generateThumbnails, revokeAllObjectUrls, videoSrc, duration]); + + // ── Drag & Drop Interactions ── + const xToSeconds = useCallback((clientX: number) => { + const track = trackRef.current; + if (!track || duration <= 0) return 0; + const { left, width } = track.getBoundingClientRect(); + const ratio = Math.max(0, Math.min(1, (clientX - left) / width)); + return ratio * duration; + }, [duration]); + + // Snapping calculations + const calculateSnap = useCallback((seconds: number, threshold = 0.25) => { + // Candidates for snapping: start (0), end (duration), and multiples of snapStep + const candidates: number[] = [0, duration]; + for (let t = snapStep; t < duration; t += snapStep) { + candidates.push(t); + } + + let closestTime = seconds; + let minDistance = Infinity; + + candidates.forEach((cand) => { + const dist = Math.abs(seconds - cand); + if (dist < minDistance && dist <= threshold) { + minDistance = dist; + closestTime = cand; + } + }); + + return { + time: closestTime, + snapped: minDistance !== Infinity && closestTime !== seconds, + }; + }, [duration, snapStep]); + + const handleDrag = useCallback((clientX: number) => { + if (!draggingRef.current || duration <= 0) return; + + const currentSeconds = xToSeconds(clientX); + + if (draggingRef.current === "playhead") { + const snap = calculateSnap(currentSeconds, 0.15); + onSeek(Math.max(0, Math.min(duration, snap.time))); + setSnapGuideTime(snap.snapped ? snap.time : null); + } else if (draggingRef.current === "start") { + const snap = calculateSnap(currentSeconds, 0.25); + const clamped = Math.max(0, Math.min(snap.time, effectiveTrimEnd - 0.1)); + onChange({ trimStart: parseFloat(clamped.toFixed(2)) }); + setSnapGuideTime(snap.snapped ? clamped : null); + } else if (draggingRef.current === "end") { + const snap = calculateSnap(currentSeconds, 0.25); + const clamped = Math.min(duration, Math.max(snap.time, recipe.trimStart + 0.1)); + onChange({ trimEnd: parseFloat(clamped.toFixed(2)) }); + setSnapGuideTime(snap.snapped ? clamped : null); + } else if (draggingRef.current === "clip") { + const deltaSeconds = currentSeconds - dragStartOffsetRef.current; + let newStart = dragStartClipRef.current.start + deltaSeconds; + let newEnd = dragStartClipRef.current.end + deltaSeconds; + + // Handle boundaries + if (newStart < 0) { + newEnd -= newStart; + newStart = 0; + } + if (newEnd > duration) { + newStart -= (newEnd - duration); + newEnd = duration; + } + + // Snap both sides together + const startSnap = calculateSnap(newStart, 0.25); + const endSnap = calculateSnap(newEnd, 0.25); + + let activeSnapTime: number | null = null; + if (startSnap.snapped) { + const offset = startSnap.time - newStart; + newStart = startSnap.time; + newEnd = Math.min(duration, newEnd + offset); + activeSnapTime = startSnap.time; + } else if (endSnap.snapped) { + const offset = endSnap.time - newEnd; + newEnd = endSnap.time; + newStart = Math.max(0, newStart + offset); + activeSnapTime = endSnap.time; + } + + onChange({ + trimStart: parseFloat(newStart.toFixed(2)), + trimEnd: parseFloat(newEnd.toFixed(2)), + }); + setSnapGuideTime(activeSnapTime); + } + }, [xToSeconds, duration, effectiveTrimEnd, recipe.trimStart, onChange, onSeek, calculateSnap]); + + const handleMouseDown = ( + e: React.MouseEvent, + type: "start" | "end" | "clip" | "playhead" + ) => { + e.preventDefault(); + e.stopPropagation(); + draggingRef.current = type; + + const clientX = e.clientX; + const currentSecs = xToSeconds(clientX); + dragStartOffsetRef.current = currentSecs; + dragStartClipRef.current = { start: recipe.trimStart, end: effectiveTrimEnd }; + + if (type === "playhead") { + handleDrag(clientX); + } + }; + + const handleTouchStart = ( + e: React.TouchEvent, + type: "start" | "end" | "clip" | "playhead" + ) => { + const touch = e.touches[0]; + if (!touch) return; + e.stopPropagation(); + draggingRef.current = type; + + const clientX = touch.clientX; + const currentSecs = xToSeconds(clientX); + dragStartOffsetRef.current = currentSecs; + dragStartClipRef.current = { start: recipe.trimStart, end: effectiveTrimEnd }; + + if (type === "playhead") { + handleDrag(clientX); + } + }; + + useEffect(() => { + const onMove = (e: MouseEvent | TouchEvent) => { + if (!draggingRef.current) return; + + let clientX: number; + if ("touches" in e) { + const touch = e.touches[0]; + if (!touch) return; + clientX = touch.clientX; + } else { + clientX = e.clientX; + } + + handleDrag(clientX); + }; + + const onUp = () => { + draggingRef.current = null; + setSnapGuideTime(null); + }; + + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + document.addEventListener("touchmove", onMove, { passive: true }); + document.addEventListener("touchend", onUp); + + return () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + document.removeEventListener("touchmove", onMove); + document.removeEventListener("touchend", onUp); + }; + }, [handleDrag]); + + // Grid tick markers for the time ruler + const ticks = useMemo(() => { + const result: { time: number; label: string; xPct: number }[] = []; + const tickInterval = snapStep; + for (let t = 0; t <= duration; t += tickInterval) { + const minutes = Math.floor(t / 60); + const seconds = Math.floor(t % 60); + result.push({ + time: t, + label: `${minutes}:${seconds.toString().padStart(2, "0")}`, + xPct: (t / duration) * 100, + }); + } + return result; + }, [duration, snapStep]); + + if (!videoSrc || duration <= 0) return null; + + const playheadPct = (currentTime / duration) * 100; + const clipLeftPct = (recipe.trimStart / duration) * 100; + const clipRightPct = ((duration - effectiveTrimEnd) / duration) * 100; + + return ( +
+ {/* ── Time Ruler ticks ── */} +
handleMouseDown(e, "playhead")} + onTouchStart={(e) => handleTouchStart(e, "playhead")} + > + {ticks.map((tick) => ( +
+ {tick.label} +
+ ))} +
+ + {/* ── Visual Frame Track & Interactivity ── */} +
+ + {/* Continuous backdrop of generated thumbnails */} +
+ {thumbnails.length === 0 && isGenerating && ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ ))} +
+ )} + {thumbnails.length > 0 && ( +
+ {thumbnails.map((thumb) => ( +
+ Timeline Frame +
+ ))} +
+ )} +
+ + {/* Dimmer overlays for regions out of trim bounds */} +
+
+ + {/* Draggable Active Clip Box */} +
handleMouseDown(e, "clip")} + onTouchStart={(e) => handleTouchStart(e, "clip")} + > + {/* Trim Handles */} +
handleMouseDown(e, "start")} + onTouchStart={(e) => handleTouchStart(e, "start")} + role="slider" + aria-label="Drag start trim handle" + aria-valuenow={recipe.trimStart} + > + +
+ +
handleMouseDown(e, "end")} + onTouchStart={(e) => handleTouchStart(e, "end")} + role="slider" + aria-label="Drag end trim handle" + aria-valuenow={effectiveTrimEnd} + > + +
+
+ + {/* Snap Guide Line */} + {snapGuideTime !== null && ( +
+ )} + + {/* Vertical Red Playhead Bar */} +
handleMouseDown(e, "playhead")} + onTouchStart={(e) => handleTouchStart(e, "playhead")} + > +
+
+
+ + {/* ── Metadata / Status Overlay ── */} +
+ + 🎬 Clip range: {recipe.trimStart.toFixed(1)}s – {effectiveTrimEnd.toFixed(1)}s + + + ⏰ Length: {formatDuration(clipLength)} of {formatDuration(duration)} + +
+ + +
+ ); +} diff --git a/src/components/TrimControl.tsx b/src/components/TrimControl.tsx index d48bdd69..27b48daa 100644 --- a/src/components/TrimControl.tsx +++ b/src/components/TrimControl.tsx @@ -177,64 +177,7 @@ export default function TrimControl({ recipe, onChange, duration, file }: Props) return (
- {duration > 0 && ( -
{ - if (dragging.current) return; - const s = xToSeconds(e.clientX); - onChange({ trimStart: s }); - }} - onKeyDown={(e) => { - if (e.key === "ArrowLeft") onChange({ trimStart: Math.max(0, recipe.trimStart - 0.1) }); - if (e.key === "ArrowRight") onChange({ trimStart: Math.min((recipe.trimEnd ?? duration) - 0.1, recipe.trimStart + 0.1) }); - }} - > -
-
-
{ dragging.current = "start"; }} - onTouchStart={() => { dragging.current = "start"; }} - onKeyDown={(e) => { - if (e.key === "ArrowLeft") onChange({ trimStart: Math.max(0, recipe.trimStart - 0.1) }); - if (e.key === "ArrowRight") onChange({ trimStart: Math.min((recipe.trimEnd ?? duration) - 0.1, recipe.trimStart + 0.1) }); - }} - /> -
{ dragging.current = "end"; }} - onTouchStart={() => { dragging.current = "end"; }} - onKeyDown={(e) => { - if (e.key === "ArrowLeft") onChange({ trimEnd: Math.max(recipe.trimStart + 0.1, (recipe.trimEnd ?? duration) - 0.1) }); - if (e.key === "ArrowRight") onChange({ trimEnd: Math.min(duration, (recipe.trimEnd ?? duration) + 0.1) }); - }} - /> -
- )} +
From 6b0e7f84c5864b26e9b8d8e8cebe5774a0b850e4 Mon Sep 17 00:00:00 2001 From: Rucha Date: Sun, 24 May 2026 22:29:04 +0530 Subject: [PATCH 3/7] fix(a11y): resolve jsx-a11y lint errors in TimelineEditor by adding keydown handlers and ARIA attributes --- src/components/TimelineEditor.tsx | 49 +++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/components/TimelineEditor.tsx b/src/components/TimelineEditor.tsx index e4d3a200..799bee60 100644 --- a/src/components/TimelineEditor.tsx +++ b/src/components/TimelineEditor.tsx @@ -363,6 +363,16 @@ export default function TimelineEditor({ className="timeline-ruler" onMouseDown={(e) => handleMouseDown(e, "playhead")} onTouchStart={(e) => handleTouchStart(e, "playhead")} + role="slider" + aria-label="Timeline time ruler" + aria-valuenow={currentTime} + aria-valuemin={0} + aria-valuemax={duration} + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "ArrowLeft") onSeek(Math.max(0, currentTime - 1)); + if (e.key === "ArrowRight") onSeek(Math.min(duration, currentTime + 1)); + }} > {ticks.map((tick) => (
handleMouseDown(e, "clip")} onTouchStart={(e) => handleTouchStart(e, "clip")} + role="button" + tabIndex={0} + aria-label="Active video clip range" + onKeyDown={(e) => { + if (e.key === "ArrowLeft") { + const newStart = Math.max(0, recipe.trimStart - 0.1); + const offset = recipe.trimStart - newStart; + onChange({ trimStart: parseFloat(newStart.toFixed(2)), trimEnd: parseFloat((effectiveTrimEnd - offset).toFixed(2)) }); + } + if (e.key === "ArrowRight") { + const newEnd = Math.min(duration, effectiveTrimEnd + 0.1); + const offset = newEnd - effectiveTrimEnd; + onChange({ trimStart: parseFloat((recipe.trimStart + offset).toFixed(2)), trimEnd: parseFloat(newEnd.toFixed(2)) }); + } + }} > {/* Trim Handles */}
{ + if (e.key === "ArrowLeft") onChange({ trimStart: Math.max(0, recipe.trimStart - 0.1) }); + if (e.key === "ArrowRight") onChange({ trimStart: Math.min(effectiveTrimEnd - 0.1, recipe.trimStart + 0.1) }); + }} >
@@ -439,6 +471,13 @@ export default function TimelineEditor({ role="slider" aria-label="Drag end trim handle" aria-valuenow={effectiveTrimEnd} + aria-valuemin={0} + aria-valuemax={duration} + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "ArrowLeft") onChange({ trimEnd: Math.max(recipe.trimStart + 0.1, effectiveTrimEnd - 0.1) }); + if (e.key === "ArrowRight") onChange({ trimEnd: Math.min(duration, effectiveTrimEnd + 0.1) }); + }} >
@@ -458,6 +497,16 @@ export default function TimelineEditor({ style={{ left: `${playheadPct}%` }} onMouseDown={(e) => handleMouseDown(e, "playhead")} onTouchStart={(e) => handleTouchStart(e, "playhead")} + role="slider" + aria-label="Playhead scrubber" + aria-valuenow={currentTime} + aria-valuemin={0} + aria-valuemax={duration} + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "ArrowLeft") onSeek(Math.max(0, currentTime - 1)); + if (e.key === "ArrowRight") onSeek(Math.min(duration, currentTime + 1)); + }} >
From e285525fb6afaee89719d09fc8f7295bbbb25492 Mon Sep 17 00:00:00 2001 From: Rucha Date: Sun, 24 May 2026 22:55:18 +0530 Subject: [PATCH 4/7] fix(preview): implement dynamic aspect ratios and ResizeObserver to resolve stale preview dimensions (#1075) --- src/components/ComparisonPreview.tsx | 14 ++++++++-- src/components/VideoPreview.tsx | 42 ++++++++++++++++++++-------- 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/src/components/ComparisonPreview.tsx b/src/components/ComparisonPreview.tsx index 2fe4a89e..f0a1a8a4 100644 --- a/src/components/ComparisonPreview.tsx +++ b/src/components/ComparisonPreview.tsx @@ -1,7 +1,7 @@ /* eslint-disable jsx-a11y/no-static-element-interactions */ "use client"; -import { useEffect, useRef, useState, useCallback, RefObject } from "react"; +import { useEffect, useRef, useState, useCallback, useMemo, RefObject } from "react"; import { EditRecipe } from "@/lib/types"; import { getPresetById } from "@/lib/presets"; import { cn } from "@/lib/utils"; @@ -19,6 +19,15 @@ export default function ComparisonPreview({ file, recipe, videoRef }: Props) { const [isDragging, setIsDragging] = useState(false); const containerRef = useRef(null); + const aspectStyle = useMemo(() => { + if (!recipe) return undefined; + const preset = recipe.preset === "custom" + ? { width: recipe.customWidth, height: recipe.customHeight } + : getPresetById(recipe.preset); + if (!preset) return undefined; + return { aspectRatio: `${preset.width} / ${preset.height}` }; + }, [recipe]); + // Calculate overlay for the right (reframed) side const overlay = (() => { if (!recipe) return null; @@ -166,7 +175,8 @@ export default function ComparisonPreview({ file, recipe, videoRef }: Props) { return (
diff --git a/src/components/VideoPreview.tsx b/src/components/VideoPreview.tsx index d684c1e5..75bf5cf8 100644 --- a/src/components/VideoPreview.tsx +++ b/src/components/VideoPreview.tsx @@ -1,7 +1,7 @@ /* 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, useState, useCallback, useMemo, RefObject } from "react"; import { EditRecipe, TextOverlay } from "@/lib/types"; import { getPresetById } from "@/lib/presets"; import { cn } from "@/lib/utils"; @@ -124,23 +124,40 @@ export default function VideoPreview({ videoRef.current.playbackRate = recipe.speed; }, [recipe, videoRef]); + const aspectStyle = useMemo(() => { + if (!recipe) return undefined; + const preset = recipe.preset === "custom" + ? { width: recipe.customWidth, height: recipe.customHeight } + : getPresetById(recipe.preset); + if (!preset) return undefined; + return { aspectRatio: `${preset.width} / ${preset.height}` }; + }, [recipe]); + /** - * Track preview container dimensions for text overlay positioning. + * Track preview container dimensions for text overlay positioning using ResizeObserver. */ useEffect(() => { + const container = previewContainerRef.current; + if (!container) return; + const updateDimensions = () => { - if (previewContainerRef.current) { - const rect = previewContainerRef.current.getBoundingClientRect(); - setContainerDimensions({ - width: rect.width, - height: rect.height, - }); - } + const rect = container.getBoundingClientRect(); + setContainerDimensions({ + width: rect.width, + height: rect.height, + }); }; updateDimensions(); - window.addEventListener("resize", updateDimensions); - return () => window.removeEventListener("resize", updateDimensions); + + const observer = new ResizeObserver(() => { + updateDimensions(); + }); + + observer.observe(container); + return () => { + observer.disconnect(); + }; }, []); const overlay = (() => { @@ -217,7 +234,8 @@ export default function VideoPreview({
Date: Mon, 25 May 2026 23:54:04 +0530 Subject: [PATCH 5/7] feat: Add subtitles/captions support with custom styling options #991 --- src/components/SubtitleControls.tsx | 366 ++++++++++++++++++++++++++++ src/components/VideoEditor.tsx | 29 ++- src/components/VideoPreview.tsx | 69 ++++++ src/hooks/useVideoEditor.ts | 44 +++- src/lib/constants.ts | 5 + src/lib/ffmpeg.ts | 64 ++++- src/lib/subtitles.ts | 77 ++++++ src/lib/tests/subtitles.test.ts | 84 +++++++ src/lib/types.ts | 10 + 9 files changed, 738 insertions(+), 10 deletions(-) create mode 100644 src/components/SubtitleControls.tsx create mode 100644 src/lib/subtitles.ts create mode 100644 src/lib/tests/subtitles.test.ts diff --git a/src/components/SubtitleControls.tsx b/src/components/SubtitleControls.tsx new file mode 100644 index 00000000..cec145c2 --- /dev/null +++ b/src/components/SubtitleControls.tsx @@ -0,0 +1,366 @@ +"use client"; + +import { EditRecipe } from "@/lib/types"; +import { SubtitleItem } from "@/lib/subtitles"; +import { Upload, Trash2, Sliders, Type, Palette, Layout, Search, Play } from "lucide-react"; +import { useMemo, useState, useRef } from "react"; +import { getPresetById } from "@/lib/presets"; + +interface SubtitleControlsProps { + recipe: EditRecipe; + onChange: (patch: Partial) => void; + subtitleFile: File | null; + parsedSubtitles: SubtitleItem[] | null; + onSubtitleSelect: (file: File) => void; + onClearSubtitles: () => void; + onSeek: (time: number) => void; +} + +const FONTS = [ + { value: "Inter", label: "Inter" }, + { value: "Outfit", label: "Outfit" }, + { value: "Roboto", label: "Roboto" }, + { value: "Arial", label: "Arial (Standard)" }, + { value: "Courier New", label: "Courier New" }, + { value: "Times New Roman", label: "Times New Roman" }, + { value: "system-ui", label: "System UI" }, +]; + +const PRESET_COLORS = [ + { hex: "#ffffff", label: "White" }, + { hex: "#ffff00", label: "Yellow" }, + { hex: "#00ffff", label: "Cyan" }, + { hex: "#00ff00", label: "Green" }, + { hex: "#ff00ff", label: "Magenta" }, + { hex: "#000000", label: "Black" }, +]; + +const STYLES = [ + { value: "none", label: "Plain Text" }, + { value: "outline", label: "Black Outline" }, + { value: "box", label: "Semi-Transparent Box" }, + { value: "shadow", label: "Soft Shadow" }, +]; + +export default function SubtitleControls({ + recipe, + onChange, + subtitleFile, + parsedSubtitles, + onSubtitleSelect, + onClearSubtitles, + onSeek, +}: SubtitleControlsProps) { + const [searchQuery, setSearchQuery] = useState(""); + const fileInputRef = useRef(null); + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + onSubtitleSelect(file); + } + }; + + const filteredSubtitles = useMemo(() => { + if (!parsedSubtitles) return []; + if (!searchQuery.trim()) return parsedSubtitles; + const query = searchQuery.toLowerCase(); + return parsedSubtitles.filter((sub) => sub.text.toLowerCase().includes(query)); + }, [parsedSubtitles, searchQuery]); + + const formatTimestamp = (sec: number) => { + const min = Math.floor(sec / 60); + const s = Math.floor(sec % 60); + const ms = Math.floor((sec % 1) * 1000); + return `${String(min).padStart(2, "0")}:${String(s).padStart(2, "0")},${String(ms).padStart(3, "0")}`; + }; + + return ( +
+ {/* File Upload / Clear */} +
+ {!subtitleFile ? ( +
+ + +
+ ) : ( +
+
+

+ {subtitleFile.name} +

+

+ {parsedSubtitles?.length ?? 0} subtitle segments loaded +

+
+ +
+ )} +
+ + {subtitleFile && ( +
+ {/* Typography Settings */} +
+

+ + Typography +

+ + {/* Font Family */} +
+ + +
+ + {/* Font Size */} +
+
+ + + {recipe.subtitleSize}px + +
+
+ onChange({ subtitleSize: Number(e.target.value) })} + className="flex-1 accent-film-600 cursor-pointer" + /> +
+ {(["Small", "Medium", "Large"] as const).map((label, idx) => { + const size = idx === 0 ? 24 : idx === 1 ? 36 : 48; + const isActive = recipe.subtitleSize === size; + return ( + + ); + })} +
+
+
+
+ + {/* Color Settings */} +
+

+ + Colors +

+ + {/* Text Color */} +
+ +
+ onChange({ subtitleColor: e.target.value })} + className="w-9 h-7 rounded border border-[var(--border)] cursor-pointer shrink-0 bg-transparent" + /> + onChange({ subtitleColor: e.target.value })} + placeholder="#ffffff" + className="flex-1 px-2 py-1 text-xs rounded border border-[var(--border)] bg-[var(--bg)] text-[var(--text)] font-mono focus:outline-none focus:ring-2 focus:ring-film-500" + aria-label="Subtitle Hex color input" + /> +
+ + {/* Curated color buttons */} +
+ {PRESET_COLORS.map((color) => ( + + ))} +
+
+
+ + {/* Style & Box Settings */} +
+

+ + Background & Readability +

+ + {/* Background type select */} +
+ +
+ {STYLES.map((style) => { + const isActive = recipe.subtitleBgType === style.value; + return ( + + ); + })} +
+
+ + {/* Background / outline color */} + {recipe.subtitleBgType !== "none" && ( +
+ +
+ onChange({ subtitleBgColor: e.target.value })} + className="w-9 h-7 rounded border border-[var(--border)] cursor-pointer shrink-0 bg-transparent" + /> + onChange({ subtitleBgColor: e.target.value })} + placeholder="#000000" + className="flex-1 px-2 py-1 text-xs rounded border border-[var(--border)] bg-[var(--bg)] text-[var(--text)] font-mono focus:outline-none focus:ring-2 focus:ring-film-500" + aria-label="Subtitle Background Hex color input" + /> +
+
+ )} +
+ + {/* Subtitles Navigation list */} +
+
+

+ + Segments +

+ + {filteredSubtitles.length} of {parsedSubtitles?.length ?? 0} + +
+ + {/* Search Input */} +
+ + + + setSearchQuery(e.target.value)} + className="w-full pl-8 pr-2.5 py-1.5 text-xs rounded-lg border border-[var(--border)] bg-[var(--bg)] text-[var(--text)] placeholder-[var(--muted)] focus:outline-none focus:ring-2 focus:ring-film-500" + /> +
+ + {/* Subtitle segments list */} + {filteredSubtitles.length > 0 ? ( +
+ {filteredSubtitles.map((sub) => ( + + ))} +
+ ) : ( +
+ No matching captions found +
+ )} +
+
+ )} +
+ ); +} diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index 928f1925..487a0d18 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -17,12 +17,14 @@ import ExportSettings from "./ExportSettings"; import ExportOverlay from "./ExportOverlay"; import DownloadResult from "./DownloadResult"; import ImageOverlay from "./ImageOverlay" +import SubtitleControls from "./SubtitleControls"; 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, Github, Copy, + Subtitles } from "lucide-react"; import OnboardingTour from "./OnboardingTour"; import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts"; @@ -210,6 +212,10 @@ export default function VideoEditor() { recommendedPreset, currentTime, toggleSound, + subtitleFile, + parsedSubtitles, + handleSubtitleSelect, + clearSubtitles, } = useVideoEditor(); useKeyboardShortcuts({ @@ -231,6 +237,7 @@ export default function VideoEditor() { trim: false, rotation: false, text: false, + subtitles: false, audio: false, export: false, }); @@ -394,6 +401,7 @@ export default function VideoEditor() { selectedTextId={selectedTextId} onSelectText={setSelectedTextId} onUpdateText={handleUpdateTextOverlay} + parsedSubtitles={parsedSubtitles} />
@@ -463,6 +471,25 @@ export default function VideoEditor() { onSelectText={setSelectedTextId} /> + + } + title="Subtitles / Captions" + isOpen={openSections.subtitles} + onToggle={() => toggleSection("subtitles")} + delay={115} + > + +
void; onUpdateText?: (id: string, updates: Partial) => void; + parsedSubtitles?: SubtitleItem[] | null; } export default function VideoPreview({ @@ -25,7 +27,40 @@ export default function VideoPreview({ selectedTextId = null, onSelectText, onUpdateText, + parsedSubtitles = null, }: Props) { + const [localCurrentTime, setLocalCurrentTime] = useState(0); + + useEffect(() => { + const video = videoRef.current; + if (!video) return; + + const handleTimeUpdate = () => { + setLocalCurrentTime(video.currentTime); + }; + + video.addEventListener("timeupdate", handleTimeUpdate); + return () => { + video.removeEventListener("timeupdate", handleTimeUpdate); + }; + }, [videoRef, file]); + + const activeSub = useMemo(() => { + if (!parsedSubtitles || parsedSubtitles.length === 0) return null; + return parsedSubtitles.find( + (sub) => localCurrentTime >= sub.startTime && localCurrentTime <= sub.endTime + ); + }, [parsedSubtitles, localCurrentTime]); + + const scaledFontSize = useMemo(() => { + if (!recipe) return 24; + const preset = recipe.preset === "custom" + ? { width: recipe.customWidth, height: recipe.customHeight } + : getPresetById(recipe.preset); + const targetH = preset?.height ?? 1080; + const scale = containerDimensions.height / targetH; + return Math.max(12, Math.round(recipe.subtitleSize * scale)); + }, [recipe, containerDimensions.height]); const lastId = useRef(0); const urlRef = useRef(null); const [isLoading, setIsLoading] = useState(true); @@ -290,6 +325,40 @@ export default function VideoPreview({
)} + {/* Subtitles Overlay */} + {activeSub && recipe && ( +
+ {activeSub.text} +
+ )} + {/* Draggable Text Overlays */} {recipe && !isLoading && containerDimensions.width > 0 && ( (null); + const [parsedSubtitles, setParsedSubtitles] = useState(null); + + const handleSubtitleSelect = useCallback(async (selectedFile: File) => { + if (!selectedFile) return; + setSubtitleFile(selectedFile); + try { + const text = await selectedFile.text(); + const subs = parseSRT(text); + setParsedSubtitles(subs); + } catch (err) { + console.error("Failed to parse subtitle file:", err); + setParsedSubtitles([]); + } + }, []); + + const clearSubtitles = useCallback(() => { + setSubtitleFile(null); + setParsedSubtitles(null); + }, []); + const updateRecipe = useCallback((patch: Partial) => { setRecipe((prev) => { const next = { ...prev, ...patch }; @@ -212,6 +235,16 @@ export function useVideoEditor() { return typeof val === "number" && !isNaN(val) && val >= 0 && val <= 2; case "saturation": return typeof val === "number" && !isNaN(val) && val >= 0 && val <= 3; + case "subtitleFont": + return typeof val === "string"; + case "subtitleColor": + return typeof val === "string"; + case "subtitleSize": + return typeof val === "number" && !isNaN(val) && val >= 10 && val <= 120; + case "subtitleBgType": + return ["none", "box", "shadow", "outline"].includes(val); + case "subtitleBgColor": + return typeof val === "string"; default: return true; } @@ -483,7 +516,8 @@ export function useVideoEditor() { position: overlayPosition, size: overlaySize, opacity: overlayOpacity, - } + }, + parsedSubtitles ?? undefined ); if (exportCancelledRef.current) return; @@ -527,6 +561,7 @@ export function useVideoEditor() { recipe, result, status, + parsedSubtitles, ]); @@ -633,6 +668,7 @@ export function useVideoEditor() { overlayPosition, overlaySize, overlayOpacity, + subtitleFile, ]); useEffect(() => { @@ -673,6 +709,8 @@ export function useVideoEditor() { setResult(null); setError(null); setExportStartedAt(null); + setSubtitleFile(null); + setParsedSubtitles(null); try { localStorage.removeItem(STORAGE_KEY); } catch { @@ -738,5 +776,9 @@ export function useVideoEditor() { recommendedPreset, currentTime, toggleSound, + subtitleFile, + parsedSubtitles, + handleSubtitleSelect, + clearSubtitles, }; } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index e713f779..3bd6e330 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -23,5 +23,10 @@ export const DEFAULT_RECIPE: EditRecipe = { soundOnCompletion: false, normalizeAudio: false, textOverlays: [], + subtitleFont: "Inter", + subtitleColor: "#ffffff", + subtitleSize: 36, + subtitleBgType: "outline", + subtitleBgColor: "#000000", version: RECIPE_VERSION, }; diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts index f28aaa0e..e640f713 100644 --- a/src/lib/ffmpeg.ts +++ b/src/lib/ffmpeg.ts @@ -4,6 +4,7 @@ import { EditRecipe, ExportResult, BackgroundMusicOptions, ImageOverlayOptions } import { getPresetById } from "./presets"; import { buildTextFilter } from "./text-overlay"; import { simd } from "wasm-feature-detect"; +import { SubtitleItem } from "./subtitles"; const CORE_BASE_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/umd"; @@ -98,7 +99,42 @@ function buildSessionId(): string { return `${Date.now()}-${Math.random().toString(16).slice(2)}`; } -export function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: number): string { +function buildSubtitleFilter( + sub: SubtitleItem, + recipe: EditRecipe, + targetW: number, + targetH: number +): string { + const escapedText = sub.text + .replace(/\\/g, "\\\\") + .replace(/'/g, "'\\\\''") + .replace(/:/g, "\\:") + .replace(/\n/g, "\n"); + + const xExpr = "(w-text_w)/2"; + const yExpr = "h-th-h*0.12"; + + let styleParams = ""; + if (recipe.subtitleBgType === "box") { + const boxCol = recipe.subtitleBgColor.replace("#", "0x") + "@0.6"; + styleParams = `:box=1:boxcolor=${boxCol}:boxborderw=8`; + } else if (recipe.subtitleBgType === "outline") { + const borderCol = recipe.subtitleBgColor; + styleParams = `:borderw=2:bordercolor=${borderCol}`; + } else if (recipe.subtitleBgType === "shadow") { + const shadowCol = recipe.subtitleBgColor.replace("#", "0x") + "@0.6"; + styleParams = `:shadowcolor=${shadowCol}:shadowx=2:shadowy=2`; + } + + return `drawtext=text='${escapedText}':x=${xExpr}:y=${yExpr}:fontsize=${recipe.subtitleSize}:fontcolor=${recipe.subtitleColor}${styleParams}:enable='between(t,${sub.startTime},${sub.endTime})'`; +} + +export function buildVideoFilter( + recipe: EditRecipe, + targetW: number, + targetH: number, + subtitles?: SubtitleItem[] +): string { const filters: string[] = []; if (recipe.trimStart > 0 || recipe.trimEnd !== null) { @@ -162,6 +198,13 @@ export function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: n filters.push(buildTextFilter(overlay, targetW, targetH)); }); + // Add subtitles if present + if (subtitles && subtitles.length > 0) { + subtitles.forEach((sub) => { + filters.push(buildSubtitleFilter(sub, recipe, targetW, targetH)); + }); + } + return filters.join(","); } @@ -209,9 +252,10 @@ function buildArguments( overlayInputName: string, overlayOptions: ImageOverlayOptions | undefined, hasOriginalAudio: boolean, - videoDuration: number + videoDuration: number, + subtitles?: SubtitleItem[] ): string[] { - const vf = buildVideoFilter(recipe, targetW, targetH); + const vf = buildVideoFilter(recipe, targetW, targetH, subtitles); const audioTrim = hasOriginalAudio ? buildAudioTrimFilter(recipe) : ""; const audioSpeed = hasOriginalAudio ? buildAudioFilter(recipe.speed, recipe.normalizeAudio ?? false) : ""; const afParts = [audioTrim, audioSpeed].filter(Boolean); @@ -337,7 +381,8 @@ export async function exportVideo( onProgress: (percent: number) => void, signal?: AbortSignal, musicOptions?: BackgroundMusicOptions, - overlayOptions?: ImageOverlayOptions + overlayOptions?: ImageOverlayOptions, + subtitles?: SubtitleItem[] ): Promise { const sessionId = buildSessionId(); let targetW: number, targetH: number; @@ -418,7 +463,7 @@ export async function exportVideo( // ── Two-pass GIF export ────────────────────────────────────────────────── if (recipe.format === "gif") { - const vf = buildVideoFilter(recipe, targetW, targetH); + const vf = buildVideoFilter(recipe, targetW, targetH, subtitles); const vfWithPalette = vf ? `${vf},palettegen` : "palettegen"; const vfWithPaletteUse = vf ? `[0:v]${vf}[x];[x][1:v]paletteuse` @@ -486,7 +531,8 @@ export async function exportVideo( let args = buildArguments( recipe, recipe.format, outputName, inputName, targetW, targetH, hasMusicTrack, musicInputName, musicOptions, - hasOverlay, overlayInputName, overlayOptions, true, videoDuration + hasOverlay, overlayInputName, overlayOptions, true, videoDuration, + subtitles ); let exitCode = await ffmpeg.exec(args, undefined, { signal }); @@ -497,7 +543,8 @@ export async function exportVideo( args = buildArguments( recipe, recipe.format, outputName, inputName, targetW, targetH, hasMusicTrack, musicInputName, musicOptions, - hasOverlay, overlayInputName, overlayOptions, false, videoDuration + hasOverlay, overlayInputName, overlayOptions, false, videoDuration, + subtitles ); exitCode = await ffmpeg.exec(args, undefined, { signal }); } @@ -507,7 +554,8 @@ export async function exportVideo( args = buildArguments( recipe, "webm", fallbackOutputName, inputName, targetW, targetH, hasMusicTrack, musicInputName, musicOptions, - hasOverlay, overlayInputName, overlayOptions, !missingAudioDetected, videoDuration + hasOverlay, overlayInputName, overlayOptions, !missingAudioDetected, videoDuration, + subtitles ); const fallbackCode = await ffmpeg.exec(args, undefined, { signal }); diff --git a/src/lib/subtitles.ts b/src/lib/subtitles.ts new file mode 100644 index 00000000..dde11902 --- /dev/null +++ b/src/lib/subtitles.ts @@ -0,0 +1,77 @@ +export interface SubtitleItem { + id: string; + startTime: number; // in seconds + endTime: number; // in seconds + text: string; +} + +/** + * Parses an SRT subtitle file content string into an array of SubtitleItem objects. + */ +export function parseSRT(content: string): SubtitleItem[] { + if (!content) return []; + + // Normalize line endings to LF + const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + + // Split by blank lines (double newlines or more) + const blocks = normalized.trim().split(/\n\s*\n+/); + + const items: SubtitleItem[] = []; + + for (const block of blocks) { + const lines = block.trim().split("\n"); + if (lines.length < 2) continue; + + // A block usually looks like: + // 1 + // 00:00:01,000 --> 00:00:04,000 + // Subtitle text here... + let timestampLineIndex = 0; + if (/^\d+$/.test(lines[0].trim())) { + timestampLineIndex = 1; + } + + const timestampLine = lines[timestampLineIndex]; + if (!timestampLine || !timestampLine.includes("-->")) continue; + + const parts = timestampLine.split("-->"); + if (parts.length !== 2) continue; + + const startTime = parseSRTTimestamp(parts[0].trim()); + const endTime = parseSRTTimestamp(parts[1].trim()); + + if (startTime === null || endTime === null) continue; + + // Subtitle text is the remaining lines + const textLines = lines.slice(timestampLineIndex + 1); + const text = textLines.join("\n").trim(); + if (!text) continue; + + items.push({ + id: `sub-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + startTime, + endTime, + text, + }); + } + + // Sort by startTime to ensure chronological order + return items.sort((a, b) => a.startTime - b.startTime); +} + +/** + * Parses a single SRT timestamp (HH:MM:SS,mmm or HH:MM:SS.mmm) into seconds. + */ +function parseSRTTimestamp(timestamp: string): number | null { + const regex = /(\d{2}):(\d{2}):(\d{2})[,.](\d{3})/; + const match = timestamp.match(regex); + if (!match) return null; + + const hours = parseInt(match[1], 10); + const minutes = parseInt(match[2], 10); + const seconds = parseInt(match[3], 10); + const milliseconds = parseInt(match[4], 10); + + return hours * 3600 + minutes * 60 + seconds + milliseconds / 1000; +} diff --git a/src/lib/tests/subtitles.test.ts b/src/lib/tests/subtitles.test.ts new file mode 100644 index 00000000..af5cf59d --- /dev/null +++ b/src/lib/tests/subtitles.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from "vitest"; +import { parseSRT } from "../subtitles"; + +describe("parseSRT", () => { + it("returns empty array for empty content", () => { + expect(parseSRT("")).toEqual([]); + expect(parseSRT(" ")).toEqual([]); + }); + + it("correctly parses a single basic subtitle block", () => { + const srt = `1 +00:00:01,000 --> 00:00:04,500 +Hello World!`; + const result = parseSRT(srt); + expect(result.length).toBe(1); + expect(result[0].startTime).toBe(1.0); + expect(result[0].endTime).toBe(4.5); + expect(result[0].text).toBe("Hello World!"); + }); + + it("correctly parses multiple subtitle blocks", () => { + const srt = `1 +00:00:01,000 --> 00:00:03,000 +Hello World! + +2 +00:00:04,250 --> 00:00:07,100 +This is a second caption.`; + const result = parseSRT(srt); + expect(result.length).toBe(2); + expect(result[0].startTime).toBe(1.0); + expect(result[0].endTime).toBe(3.0); + expect(result[0].text).toBe("Hello World!"); + + expect(result[1].startTime).toBe(4.25); + expect(result[1].endTime).toBe(7.1); + expect(result[1].text).toBe("This is a second caption."); + }); + + it("handles multi-line subtitles", () => { + const srt = `1 +00:01:15,123 --> 00:01:20,500 +This is a multi-line +subtitle text. +Enjoy it!`; + const result = parseSRT(srt); + expect(result.length).toBe(1); + expect(result[0].startTime).toBe(75.123); + expect(result[0].endTime).toBe(80.5); + expect(result[0].text).toBe("This is a multi-line\nsubtitle text.\nEnjoy it!"); + }); + + it("handles alternative timestamp separators (dot instead of comma)", () => { + const srt = `1 +00:00:05.500 --> 00:00:08.200 +Test dot timestamps.`; + const result = parseSRT(srt); + expect(result.length).toBe(1); + expect(result[0].startTime).toBe(5.5); + expect(result[0].endTime).toBe(8.2); + expect(result[0].text).toBe("Test dot timestamps."); + }); + + it("handles Windows CRLF and trailing spaces", () => { + const srt = "1\r\n00:00:02,000 --> 00:00:05,000\r\nWindows CRLF Test\r\n\r\n2\r\n00:00:06,000 --> 00:00:08,000\r\nSecond Line\r\n"; + const result = parseSRT(srt); + expect(result.length).toBe(2); + expect(result[0].text).toBe("Windows CRLF Test"); + expect(result[1].text).toBe("Second Line"); + }); + + it("ignores blocks with invalid timestamps", () => { + const srt = `1 +invalid timestamp line +Some text + +2 +00:00:01,000 --> 00:00:03,000 +Valid Subtitle`; + const result = parseSRT(srt); + expect(result.length).toBe(1); + expect(result[0].text).toBe("Valid Subtitle"); + }); +}); diff --git a/src/lib/types.ts b/src/lib/types.ts index 8207f60c..208445a9 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -33,6 +33,11 @@ export interface EditRecipe { saturation: number; soundOnCompletion: boolean; textOverlays: TextOverlay[]; + subtitleFont: string; + subtitleColor: string; + subtitleSize: number; + subtitleBgType: "none" | "box" | "shadow" | "outline"; + subtitleBgColor: string; version: number; } @@ -103,6 +108,11 @@ export function isValidRecipe(value: unknown): value is EditRecipe { if (typeof v.saturation !== "number" || !isFinite(v.saturation)) return false; if (typeof v.soundOnCompletion !== "boolean") return false; if (!Array.isArray(v.textOverlays)) return false; + if (typeof v.subtitleFont !== "string") return false; + if (typeof v.subtitleColor !== "string") return false; + if (typeof v.subtitleSize !== "number" || !isFinite(v.subtitleSize)) return false; + if (!["none", "box", "shadow", "outline"].includes(v.subtitleBgType)) return false; + if (typeof v.subtitleBgColor !== "string") return false; return true; } From e71856d45196d53b9bad83de13123daaec3d8cef Mon Sep 17 00:00:00 2001 From: Rucha Date: Tue, 26 May 2026 00:29:25 +0530 Subject: [PATCH 6/7] feat(ffmpeg): support burning subtitles inside background Web Worker --- src/lib/ffmpeg.ts | 6 +++- src/lib/ffmpeg.worker.ts | 62 +++++++++++++++++++++++++++++++++++----- 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts index 5cac4044..2314dfb9 100644 --- a/src/lib/ffmpeg.ts +++ b/src/lib/ffmpeg.ts @@ -1,6 +1,7 @@ import { EditRecipe, ExportResult, BackgroundMusicOptions, ImageOverlayOptions } from "./types"; import { getPresetById } from "./presets"; import { buildTextFilter } from "./text-overlay"; +import { SubtitleItem } from "./subtitles"; export class FFmpegLoadError extends Error {} @@ -25,6 +26,7 @@ type WorkerExportRequest = { musicOptions?: BackgroundMusicOptions; overlayFile?: SerializedFile; overlayOptions?: ImageOverlayOptions; + subtitles?: SubtitleItem[]; }; type WorkerLoadResponse = { type: "ready" }; @@ -211,7 +213,8 @@ export async function exportVideo( onProgress: (percent: number) => void, signal?: AbortSignal, musicOptions?: BackgroundMusicOptions, - overlayOptions?: ImageOverlayOptions + overlayOptions?: ImageOverlayOptions, + subtitles?: SubtitleItem[] ): Promise { await loadFFmpeg(signal, onProgress); @@ -283,6 +286,7 @@ export async function exportVideo( musicOptions: sanitizedMusicOptions, overlayFile: overlayFilePayload, overlayOptions: sanitizedOverlayOptions, + subtitles, } as WorkerExportRequest, transfers ); diff --git a/src/lib/ffmpeg.worker.ts b/src/lib/ffmpeg.worker.ts index bdf9804b..59af726e 100644 --- a/src/lib/ffmpeg.worker.ts +++ b/src/lib/ffmpeg.worker.ts @@ -3,6 +3,7 @@ import { toBlobURL } from "@ffmpeg/util"; import { EditRecipe, BackgroundMusicOptions, ImageOverlayOptions } from "./types"; import { getPresetById } from "./presets"; import { buildTextFilter } from "./text-overlay"; +import { SubtitleItem } from "./subtitles"; const CORE_BASE_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/umd"; const MT_CORE_BASE_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core-mt@0.12.6/dist/esm"; @@ -27,6 +28,7 @@ type ExportRequest = { musicOptions?: BackgroundMusicOptions; overlayFile?: SerializedFile; overlayOptions?: ImageOverlayOptions; + subtitles?: SubtitleItem[]; }; type LoadRequest = { type: "load" }; @@ -76,7 +78,42 @@ async function fetchWithIntegrity(url: string, mimeType: string): Promise 0 || recipe.trimEnd !== null) { @@ -137,6 +174,13 @@ function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: number): filters.push(buildTextFilter(overlay, targetW, targetH)); }); + // Add subtitles if present + if (subtitles && subtitles.length > 0) { + subtitles.forEach((sub) => { + filters.push(buildSubtitleFilter(sub, recipe, targetW, targetH)); + }); + } + return filters.join(","); } @@ -184,9 +228,10 @@ function buildArguments( overlayInputName: string, overlayOptions: ImageOverlayOptions | undefined, hasOriginalAudio: boolean, - videoDuration: number + videoDuration: number, + subtitles?: SubtitleItem[] ): string[] { - const vf = buildVideoFilter(recipe, targetW, targetH); + const vf = buildVideoFilter(recipe, targetW, targetH, subtitles); const audioTrim = hasOriginalAudio ? buildAudioTrimFilter(recipe) : ""; const audioSpeed = hasOriginalAudio ? buildAudioFilter(recipe.speed, recipe.normalizeAudio ?? false) : ""; const afParts = [audioTrim, audioSpeed].filter(Boolean); @@ -427,7 +472,7 @@ async function runExport(request: ExportRequest): Promise { try { if (recipe.format === "gif") { - const vf = buildVideoFilter(recipe, targetW, targetH); + const vf = buildVideoFilter(recipe, targetW, targetH, request.subtitles); const vfWithPalette = vf ? `${vf},palettegen` : "palettegen"; const vfWithPaletteUse = vf ? `[0:v]${vf}[x];[x][1:v]paletteuse` @@ -498,7 +543,8 @@ async function runExport(request: ExportRequest): Promise { overlayInputName, request.overlayOptions, true, - videoDuration + videoDuration, + request.subtitles ); let exitCode = await ffmpeg.exec(args, undefined, { @@ -521,7 +567,8 @@ async function runExport(request: ExportRequest): Promise { overlayInputName, request.overlayOptions, false, - videoDuration + videoDuration, + request.subtitles ); exitCode = await ffmpeg.exec(args, undefined, { signal: activeExportAbortController?.signal, @@ -543,7 +590,8 @@ async function runExport(request: ExportRequest): Promise { overlayInputName, request.overlayOptions, !missingAudioDetected, - videoDuration + videoDuration, + request.subtitles ); const fallbackCode = await ffmpeg.exec(args, undefined, { From 926c23f6d17377dbc0f26dd2f5ea08a42668b9ff Mon Sep 17 00:00:00 2001 From: Rucha Date: Tue, 26 May 2026 00:34:56 +0530 Subject: [PATCH 7/7] fix(tsc): resolve hoisting and strict undefined check TS compile errors --- src/components/VideoPreview.tsx | 8 ++++---- src/lib/subtitles.ts | 25 ++++++++++++++++------- src/lib/tests/subtitles.test.ts | 36 ++++++++++++++++----------------- 3 files changed, 40 insertions(+), 29 deletions(-) diff --git a/src/components/VideoPreview.tsx b/src/components/VideoPreview.tsx index 6d97de04..c1f5245d 100644 --- a/src/components/VideoPreview.tsx +++ b/src/components/VideoPreview.tsx @@ -30,6 +30,10 @@ export default function VideoPreview({ parsedSubtitles = null, }: Props) { const [localCurrentTime, setLocalCurrentTime] = useState(0); + const [containerDimensions, setContainerDimensions] = useState({ + width: 0, + height: 0, + }); useEffect(() => { const video = videoRef.current; @@ -66,10 +70,6 @@ export default function VideoPreview({ 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 onLoadedRef = useRef<(() => void) | null>(null); diff --git a/src/lib/subtitles.ts b/src/lib/subtitles.ts index dde11902..7e80bf66 100644 --- a/src/lib/subtitles.ts +++ b/src/lib/subtitles.ts @@ -28,7 +28,8 @@ export function parseSRT(content: string): SubtitleItem[] { // 00:00:01,000 --> 00:00:04,000 // Subtitle text here... let timestampLineIndex = 0; - if (/^\d+$/.test(lines[0].trim())) { + const firstLine = lines[0]; + if (firstLine && /^\d+$/.test(firstLine.trim())) { timestampLineIndex = 1; } @@ -38,8 +39,12 @@ export function parseSRT(content: string): SubtitleItem[] { const parts = timestampLine.split("-->"); if (parts.length !== 2) continue; - const startTime = parseSRTTimestamp(parts[0].trim()); - const endTime = parseSRTTimestamp(parts[1].trim()); + const part0 = parts[0]; + const part1 = parts[1]; + if (!part0 || !part1) continue; + + const startTime = parseSRTTimestamp(part0.trim()); + const endTime = parseSRTTimestamp(part1.trim()); if (startTime === null || endTime === null) continue; @@ -68,10 +73,16 @@ function parseSRTTimestamp(timestamp: string): number | null { const match = timestamp.match(regex); if (!match) return null; - const hours = parseInt(match[1], 10); - const minutes = parseInt(match[2], 10); - const seconds = parseInt(match[3], 10); - const milliseconds = parseInt(match[4], 10); + const h = match[1]; + const m = match[2]; + const s = match[3]; + const ms = match[4]; + if (!h || !m || !s || !ms) return null; + + const hours = parseInt(h, 10); + const minutes = parseInt(m, 10); + const seconds = parseInt(s, 10); + const milliseconds = parseInt(ms, 10); return hours * 3600 + minutes * 60 + seconds + milliseconds / 1000; } diff --git a/src/lib/tests/subtitles.test.ts b/src/lib/tests/subtitles.test.ts index af5cf59d..2836bd09 100644 --- a/src/lib/tests/subtitles.test.ts +++ b/src/lib/tests/subtitles.test.ts @@ -13,9 +13,9 @@ describe("parseSRT", () => { Hello World!`; const result = parseSRT(srt); expect(result.length).toBe(1); - expect(result[0].startTime).toBe(1.0); - expect(result[0].endTime).toBe(4.5); - expect(result[0].text).toBe("Hello World!"); + expect(result[0]!.startTime).toBe(1.0); + expect(result[0]!.endTime).toBe(4.5); + expect(result[0]!.text).toBe("Hello World!"); }); it("correctly parses multiple subtitle blocks", () => { @@ -28,13 +28,13 @@ Hello World! This is a second caption.`; const result = parseSRT(srt); expect(result.length).toBe(2); - expect(result[0].startTime).toBe(1.0); - expect(result[0].endTime).toBe(3.0); - expect(result[0].text).toBe("Hello World!"); + expect(result[0]!.startTime).toBe(1.0); + expect(result[0]!.endTime).toBe(3.0); + expect(result[0]!.text).toBe("Hello World!"); - expect(result[1].startTime).toBe(4.25); - expect(result[1].endTime).toBe(7.1); - expect(result[1].text).toBe("This is a second caption."); + expect(result[1]!.startTime).toBe(4.25); + expect(result[1]!.endTime).toBe(7.1); + expect(result[1]!.text).toBe("This is a second caption."); }); it("handles multi-line subtitles", () => { @@ -45,9 +45,9 @@ subtitle text. Enjoy it!`; const result = parseSRT(srt); expect(result.length).toBe(1); - expect(result[0].startTime).toBe(75.123); - expect(result[0].endTime).toBe(80.5); - expect(result[0].text).toBe("This is a multi-line\nsubtitle text.\nEnjoy it!"); + expect(result[0]!.startTime).toBe(75.123); + expect(result[0]!.endTime).toBe(80.5); + expect(result[0]!.text).toBe("This is a multi-line\nsubtitle text.\nEnjoy it!"); }); it("handles alternative timestamp separators (dot instead of comma)", () => { @@ -56,17 +56,17 @@ Enjoy it!`; Test dot timestamps.`; const result = parseSRT(srt); expect(result.length).toBe(1); - expect(result[0].startTime).toBe(5.5); - expect(result[0].endTime).toBe(8.2); - expect(result[0].text).toBe("Test dot timestamps."); + expect(result[0]!.startTime).toBe(5.5); + expect(result[0]!.endTime).toBe(8.2); + expect(result[0]!.text).toBe("Test dot timestamps."); }); it("handles Windows CRLF and trailing spaces", () => { const srt = "1\r\n00:00:02,000 --> 00:00:05,000\r\nWindows CRLF Test\r\n\r\n2\r\n00:00:06,000 --> 00:00:08,000\r\nSecond Line\r\n"; const result = parseSRT(srt); expect(result.length).toBe(2); - expect(result[0].text).toBe("Windows CRLF Test"); - expect(result[1].text).toBe("Second Line"); + expect(result[0]!.text).toBe("Windows CRLF Test"); + expect(result[1]!.text).toBe("Second Line"); }); it("ignores blocks with invalid timestamps", () => { @@ -79,6 +79,6 @@ Some text Valid Subtitle`; const result = parseSRT(srt); expect(result.length).toBe(1); - expect(result[0].text).toBe("Valid Subtitle"); + expect(result[0]!.text).toBe("Valid Subtitle"); }); });