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/TextOverlayPanel.tsx b/src/components/TextOverlayPanel.tsx new file mode 100644 index 00000000..6059da23 --- /dev/null +++ b/src/components/TextOverlayPanel.tsx @@ -0,0 +1,152 @@ +import { TextOverlay } from "@/lib/types"; +import { Plus, Trash2, Bold, Type } from "lucide-react"; + +interface TextOverlayPanelProps { + textOverlays: TextOverlay[]; + onAdd: () => void; + onUpdate: (id: string, patch: Partial>) => void; + onRemove: (id: string) => void; +} + +const COLOR_PRESETS = [ + "#ffffff", + "#000000", + "#e63946", + "#f59e0b", + "#22c55e", + "#3b82f6", + "#a855f7", +]; + +/** + * Sidebar panel for managing text overlays. + * Follows the same compact UI patterns used by ImageOverlayPanel. + */ +export default function TextOverlayPanel({ + textOverlays, + onAdd, + onUpdate, + onRemove, +}: TextOverlayPanelProps) { + return ( +
+ {/* Add Text button */} + + + {/* 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 da392734..e56f740b 100644 --- a/src/components/VideoPreview.tsx +++ b/src/components/VideoPreview.tsx @@ -2,53 +2,198 @@ "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"; +import { captureFrameAsPng } from "@/lib/frame-export"; +import { DEFAULT_RECIPE } from "@/lib/constants"; 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); - const handleGrabFrame = useCallback(() => { - const video = videoRef.current; - if (!video || video.readyState < 2) return; + /* ── 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 + }; + }, + [] + ); - const canvas = document.createElement("canvas"); - canvas.width = video.videoWidth; - canvas.height = video.videoHeight; + /** 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] + ); - const ctx = canvas.getContext("2d"); - if (!ctx) return; - ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + /** 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; + } + }, []); - canvas.toBlob((blob) => { - if (!blob) return; + const [frameNotice, setFrameNotice] = useState<{ + kind: "success" | "error"; + message: string; + } | null>(null); + const [isExportingFrame, setIsExportingFrame] = useState(false); + const isExportingFrameRef = useRef(false); + const activeRecipe = recipe ?? DEFAULT_RECIPE; + + useEffect(() => { + if (!frameNotice) 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 timeoutId = window.setTimeout(() => setFrameNotice(null), 2500); + return () => window.clearTimeout(timeoutId); + }, [frameNotice]); + /** Capture the current video frame and download it as a PNG. */ + const handleGrabFrame = useCallback(async () => { + if (isExportingFrameRef.current) return; + + const video = videoRef.current; + if (!video) { + setFrameNotice({ kind: "error", message: "No video frame is available yet." }); + return; + } + + isExportingFrameRef.current = true; + setIsExportingFrame(true); + + try { + const { blob, filename } = await captureFrameAsPng(video, activeRecipe); const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); - }, "image/png"); - }, [videoRef]); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + window.setTimeout(() => URL.revokeObjectURL(url), 1000); + setFrameNotice({ kind: "success", message: `Saved ${filename}` }); + } catch (error) { + console.error("frame export failed:", error); + setFrameNotice({ + kind: "error", + message: error instanceof Error ? error.message : "Frame export failed.", + }); + } finally { + isExportingFrameRef.current = false; + setIsExportingFrame(false); + } + }, [activeRecipe, videoRef]); + + useEffect(() => { + const handleShortcut = (e: KeyboardEvent) => { + if (e.repeat) return; + const target = e.target as HTMLElement | null; + if ( + target && + (target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable) + ) { + return; + } + + if (e.code === "KeyT") { + e.preventDefault(); + void handleGrabFrame(); + } + }; + + window.addEventListener("keydown", handleShortcut); + return () => window.removeEventListener("keydown", handleShortcut); + }, [handleGrabFrame]); useEffect(() => { if (!file) return; @@ -57,6 +202,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,9 +214,10 @@ 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(() => {}); + video.play().catch(() => { }); }; onLoadedRef.current = handleLoaded; @@ -78,17 +225,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 +246,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; @@ -167,7 +312,7 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) { if (video) { e.preventDefault(); // Prevent default page scroll if (video.paused) { - video.play().catch(() => {}); + video.play().catch(() => { }); } else { video.pause(); } @@ -177,6 +322,7 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) { return (
setIsLoading(false)} playsInline - muted={!recipe?.keepAudio} - > - - + /> {/* Letterbox / Crop overlay */} {overlay && ( @@ -233,16 +376,81 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) {
)} + {/* 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 && ( )}
); -} \ No newline at end of file +} diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts index 377d0a80..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, isValidRecipe } 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"; @@ -148,16 +148,43 @@ export function useVideoEditor() { const [overlaySize, setOverlaySize] = useState(150); const [overlayOpacity, setOverlayOpacity] = useState(100); const [currentTime, setCurrentTime] = useState(0); - const updateRecipe = useCallback((patch: Partial) => { - setRecipe((prev) => { - const next = { ...prev, ...patch }; - // GIF has no audio — force keepAudio off - if (next.format === "gif") { - next.keepAudio = false; - } - return next; - }); -}, []); + const [textOverlays, setTextOverlays] = useState([]); + + /** Add a new text overlay with sensible defaults (centered near top). */ + const addTextOverlay = useCallback(() => { + const id = + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(16).slice(2)}`; + setTextOverlays((prev) => [ + ...prev, + { id, text: "Your text", x: 50, y: 30, fontSize: 24, color: "#ffffff", fontWeight: "normal" }, + ]); + }, []); + + /** Update a single text overlay by id. */ + const updateTextOverlay = useCallback((id: string, patch: Partial>) => { + setTextOverlays((prev) => + prev.map((t) => (t.id === id ? { ...t, ...patch } : t)) + ); + }, []); + + /** Remove a text overlay by id. */ + const removeTextOverlay = useCallback((id: string) => { + setTextOverlays((prev) => prev.filter((t) => t.id !== id)); + }, []); + + const updateRecipe = useCallback((patch: Partial) => { + setRecipe((prev) => { + const next = { ...prev, ...patch }; + // GIF has no audio — force keepAudio off + if (next.format === "gif") { + next.keepAudio = false; + } + return next; + }); + }, []); + const isValidValue = (key: keyof EditRecipe, val: any): boolean => { switch (key) { case "preset": @@ -431,7 +458,8 @@ export function useVideoEditor() { position: overlayPosition, size: overlaySize, opacity: overlayOpacity, - } + }, + textOverlays.length > 0 ? textOverlays : undefined ); if (exportCancelledRef.current) return; @@ -457,7 +485,7 @@ export function useVideoEditor() { exportAbortControllerRef.current = null; } } - }, [file, recipe, result, status, overlayFile, overlayPosition, overlaySize, overlayOpacity, duration, loopMusic, musicFile, musicVolume, originalAudioVolume]); + }, [file, recipe, result, status, overlayFile, overlayPosition, overlaySize, overlayOpacity, duration, textOverlays, loopMusic, musicFile, musicVolume, originalAudioVolume]); useEffect(() => { @@ -581,6 +609,7 @@ export function useVideoEditor() { setProgress(0); setResult(null); setError(null); + setTextOverlays([]); try { localStorage.removeItem(STORAGE_KEY); } catch { @@ -645,5 +674,9 @@ export function useVideoEditor() { recommendedPreset, currentTime, toggleSound, + textOverlays, + addTextOverlay, + updateTextOverlay, + removeTextOverlay, }; -} \ No newline at end of file +} diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts index ef9f6157..617d072e 100644 --- a/src/lib/ffmpeg.ts +++ b/src/lib/ffmpeg.ts @@ -1,10 +1,11 @@ import { FFmpeg } from "@ffmpeg/ffmpeg"; import { fetchFile } from "@ffmpeg/util"; -import { EditRecipe, ExportResult, BackgroundMusicOptions, ImageOverlayOptions } from "./types"; +import { EditRecipe, ExportResult, BackgroundMusicOptions, ImageOverlayOptions, TextOverlay } from "./types"; import { getPresetById } from "./presets"; import { simd } from "wasm-feature-detect"; const CORE_BASE_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/umd"; +const DEFAULT_FONT_URL = "https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Me5Q.ttf"; // Added from main branch for subresource security verification const SRI_HASHES: Record = { @@ -131,7 +132,32 @@ export function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: n return filters.join(","); } - export function buildAudioFilter(speed: number, normalizeAudio: boolean): string { +/** + * Build FFmpeg drawtext filter expressions for text overlays. + * Each overlay's percentage-based position is converted to absolute + * pixel coordinates using the target dimensions. + */ +function buildDrawtextFilters( + textOverlays: TextOverlay[], + targetW: number, + targetH: number +): string[] { + return textOverlays.map((t) => { + // Escape special characters for FFmpeg drawtext + const escaped = t.text + .replace(/\\/g, "\\\\") + .replace(/'/g, "'\\\''") + .replace(/:/g, "\\:"); + const px = Math.round((t.x / 100) * targetW); + const py = Math.round((t.y / 100) * targetH); + const bold = t.fontWeight === "bold" ? ":borderw=2" : ""; + return `drawtext=fontfile=font.ttf:text='${escaped}':fontsize=${t.fontSize}:fontcolor=${t.color}:x=${px}-(tw/2):y=${py}-(th/2)${bold}`; + }); +} + +export function buildAudioFilter(speed: number): string { + if (speed === 1) return ""; + const filters: string[] = []; let remaining = speed; @@ -149,8 +175,6 @@ export function buildVideoFilter(recipe: EditRecipe, targetW: number, targetH: n filters.push(`atempo=${Number(remaining.toFixed(4))}`); } - if (normalizeAudio) filters.push("loudnorm=I=-14:TP=-1.5:LRA=11"); - return filters.join(","); } @@ -173,11 +197,12 @@ function buildArguments( hasOverlay: boolean, overlayInputName: string, overlayOptions: ImageOverlayOptions | undefined, - hasOriginalAudio: boolean + hasOriginalAudio: boolean, + textOverlays?: TextOverlay[] ): string[] { const vf = buildVideoFilter(recipe, targetW, targetH); const audioTrim = hasOriginalAudio ? buildAudioTrimFilter(recipe) : ""; -const audioSpeed = hasOriginalAudio ? buildAudioFilter(recipe.speed, recipe.normalizeAudio ?? false) : ""; + const audioSpeed = hasOriginalAudio ? buildAudioFilter(recipe.speed) : ""; const afParts = [audioTrim, audioSpeed].filter(Boolean); const af = afParts.join(","); @@ -221,6 +246,15 @@ const audioSpeed = hasOriginalAudio ? buildAudioFilter(recipe.speed, recipe.norm videoOut = "[vout]"; } + // Append drawtext filters for text overlays + if (textOverlays && textOverlays.length > 0) { + const dtFilters = buildDrawtextFilters(textOverlays, targetW, targetH); + const prevOut = videoOut; + const dtLabel = "[vtxt]"; + filterParts.push(`${prevOut}${dtFilters.join(",")}${dtLabel}`); + videoOut = dtLabel; + } + let audioOut = ""; if (shouldKeepAudio) { if (hasMusicTrack) { @@ -257,7 +291,12 @@ const audioSpeed = hasOriginalAudio ? buildAudioFilter(recipe.speed, recipe.norm args.push("-map", "0:a"); } } else { - if (vf) args.push("-vf", vf); + // Build combined video filter with drawtext appended + const dtFilters = textOverlays && textOverlays.length > 0 + ? buildDrawtextFilters(textOverlays, targetW, targetH) + : []; + const allVf = [vf, ...dtFilters].filter(Boolean).join(","); + if (allVf) args.push("-vf", allVf); if (!shouldKeepAudio) { args.push("-an"); } else if (af && hasOriginalAudio) { @@ -287,7 +326,8 @@ export async function exportVideo( onProgress: (percent: number) => void, signal?: AbortSignal, musicOptions?: BackgroundMusicOptions, - overlayOptions?: ImageOverlayOptions + overlayOptions?: ImageOverlayOptions, + textOverlays?: TextOverlay[] ): Promise { const sessionId = buildSessionId(); let targetW: number, targetH: number; @@ -334,7 +374,7 @@ export async function exportVideo( const vf = buildVideoFilter(recipe, targetW, targetH); const audioTrim = buildAudioTrimFilter(recipe); - const audioSpeed = buildAudioFilter(recipe.speed, recipe.normalizeAudio ?? false); + const audioSpeed = buildAudioFilter(recipe.speed); const afParts = [audioTrim, audioSpeed].filter(Boolean); const af = afParts.join(","); @@ -353,6 +393,16 @@ export async function exportVideo( cleanupFiles.add(overlayInputName); } + if (textOverlays && textOverlays.length > 0) { + // ffmpeg.wasm drawtext requires a fontfile + try { + await ffmpeg.writeFile("font.ttf", await fetchFile(DEFAULT_FONT_URL), { signal }); + cleanupFiles.add("font.ttf"); + } catch (err) { + console.warn("Failed to load default font for text overlay", err); + } + } + ffmpeg.on("progress", handleProgress); // ── Two-pass GIF export ────────────────────────────────────────────────── @@ -411,7 +461,8 @@ export async function exportVideo( let args = buildArguments( recipe, recipe.format, outputName, inputName, targetW, targetH, hasMusicTrack, musicInputName, musicOptions, - hasOverlay, overlayInputName, overlayOptions, true + hasOverlay, overlayInputName, overlayOptions, true, + textOverlays ); let exitCode = await ffmpeg.exec(args, undefined, { signal }); @@ -422,7 +473,8 @@ export async function exportVideo( args = buildArguments( recipe, recipe.format, outputName, inputName, targetW, targetH, hasMusicTrack, musicInputName, musicOptions, - hasOverlay, overlayInputName, overlayOptions, false + hasOverlay, overlayInputName, overlayOptions, false, + textOverlays ); exitCode = await ffmpeg.exec(args, undefined, { signal }); } @@ -432,7 +484,8 @@ export async function exportVideo( args = buildArguments( recipe, "webm", fallbackOutputName, inputName, targetW, targetH, hasMusicTrack, musicInputName, musicOptions, - hasOverlay, overlayInputName, overlayOptions, !missingAudioDetected + hasOverlay, overlayInputName, overlayOptions, !missingAudioDetected, + textOverlays ); const fallbackCode = await ffmpeg.exec(args, undefined, { signal }); diff --git a/src/lib/types.ts b/src/lib/types.ts index dbf9ac6b..ef7350bc 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -34,6 +34,19 @@ export interface ImageOverlayOptions { opacity: number; } +/** A single text overlay positioned on the video preview. */ +export interface TextOverlay { + id: string; + text: string; + /** Horizontal position as a percentage (0–100) of the preview width. */ + x: number; + /** Vertical position as a percentage (0–100) of the preview height. */ + y: number; + fontSize: number; + color: string; + fontWeight: "normal" | "bold"; +} + export interface BackgroundMusicOptions { file: File | null; musicVolume: number;