| import { useState, useRef, useEffect, useCallback } from "react"; |
| import { |
| Input, |
| ALL_FORMATS, |
| BlobSource, |
| CanvasSink, |
| Output, |
| BufferTarget, |
| Conversion, |
| WebMOutputFormat, |
| QUALITY_VERY_HIGH, |
| } from "mediabunny"; |
| import { |
| FileVideo, |
| Type, |
| Download, |
| Play, |
| Pause, |
| Plus, |
| Trash2, |
| X, |
| Layers, |
| } from "lucide-react"; |
| import { pipeline } from "@huggingface/transformers"; |
|
|
| interface TextElement { |
| id: string; |
| text: string; |
| x: number; |
| y: number; |
| fontSize: number; |
| color: string; |
| fontFamily: string; |
| bold: boolean; |
| italic: boolean; |
| strokeColor: string; |
| strokeWidth: number; |
| opacity: number; |
| } |
|
|
| interface VideoMeta { |
| width: number; |
| height: number; |
| duration: number; |
| fps: number; |
| } |
|
|
| type DragState = { |
| on: boolean; |
| mode: "move" | "resize"; |
| id: string; |
| ox: number; |
| oy: number; |
| startX: number; |
| startY: number; |
| startSize: number; |
| didDrag: boolean; |
| }; |
|
|
| const uid = () => Math.random().toString(36).slice(2, 9); |
|
|
| const FONTS = [ |
| |
| "Bebas Neue", |
| "Anton", |
| "Bungee", |
| "Archivo Black", |
| "Black Ops One", |
| "Alfa Slab One", |
| "Titan One", |
| "Rubik Mono One", |
| "Ultra", |
| "Monoton", |
| |
| "Oswald", |
| "Teko", |
| "Fjalla One", |
| "Barlow Condensed", |
| "Rajdhani", |
| "Russo One", |
| "Orbitron", |
| "Michroma", |
| "Chakra Petch", |
| "Exo 2", |
| |
| "Playfair Display", |
| "Cinzel", |
| "Cormorant Garamond", |
| "Lora", |
| "DM Serif Display", |
| |
| "Pacifico", |
| "Permanent Marker", |
| "Caveat", |
| "Dancing Script", |
| "Sacramento", |
| "Satisfy", |
| "Great Vibes", |
| "Lobster", |
| |
| "Righteous", |
| "Bangers", |
| "Luckiest Guy", |
| "Fredoka", |
| "Passion One", |
| "Press Start 2P", |
| "Silkscreen", |
| |
| "Montserrat", |
| "Raleway", |
| "Poppins", |
| "Space Grotesk", |
| "Sora", |
| |
| "Impact", |
| "Arial", |
| ]; |
|
|
| const HANDLE_R = 8; |
| const PAD = 12; |
|
|
| function fontStr(el: TextElement): string { |
| return `${el.italic ? "italic " : ""}${el.bold ? "bold " : ""}${el.fontSize}px "${el.fontFamily}", sans-serif`; |
| } |
|
|
| type Ctx2D = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; |
|
|
| interface TextBounds { |
| left: number; |
| top: number; |
| right: number; |
| bottom: number; |
| tw: number; |
| } |
|
|
| const getFitMeasureCtx = (() => { |
| let ctx: CanvasRenderingContext2D | null = null; |
| return () => { |
| if (!ctx) { |
| const canvas = document.createElement("canvas"); |
| ctx = canvas.getContext("2d")!; |
| } |
| return ctx; |
| }; |
| })(); |
|
|
| function getFloorTimestamp(timestamps: number[], time: number): number | null { |
| if (!timestamps.length) return null; |
| if (time < timestamps[0]) return timestamps[0]; |
|
|
| let lo = 0; |
| let hi = timestamps.length; |
| while (lo < hi) { |
| const mid = (lo + hi) >>> 1; |
| if (timestamps[mid] <= time) lo = mid + 1; |
| else hi = mid; |
| } |
| return timestamps[lo - 1] ?? null; |
| } |
|
|
| function measureTextBounds(ctx: Ctx2D, el: TextElement): TextBounds { |
| ctx.font = fontStr(el); |
| ctx.textAlign = "center"; |
| ctx.textBaseline = "middle"; |
| const tw = ctx.measureText(el.text).width; |
| return { |
| tw, |
| left: el.x - tw / 2 - PAD, |
| top: el.y - el.fontSize * 0.65 - PAD / 2, |
| right: el.x + tw / 2 + PAD, |
| bottom: el.y + el.fontSize * 0.65 + PAD / 2, |
| }; |
| } |
|
|
| function drawTextLayer( |
| ctx: Ctx2D, |
| elements: TextElement[], |
| selectedId?: string | null, |
| ) { |
| for (const el of elements) { |
| ctx.font = fontStr(el); |
| ctx.textAlign = "center"; |
| ctx.textBaseline = "middle"; |
|
|
| ctx.save(); |
| ctx.globalAlpha = el.opacity; |
| if (el.strokeWidth > 0) { |
| ctx.strokeStyle = el.strokeColor; |
| ctx.lineWidth = el.strokeWidth * 2; |
| ctx.lineJoin = "round"; |
| ctx.strokeText(el.text, el.x, el.y); |
| } |
| ctx.fillStyle = el.color; |
| ctx.fillText(el.text, el.x, el.y); |
| ctx.restore(); |
|
|
| if (el.id === selectedId) { |
| const b = measureTextBounds(ctx, el); |
| ctx.save(); |
| ctx.setLineDash([5, 4]); |
| ctx.strokeStyle = "#818cf8"; |
| ctx.lineWidth = 1.5; |
| ctx.lineJoin = "round"; |
| ctx.strokeRect(b.left, b.top, b.right - b.left, b.bottom - b.top); |
| ctx.restore(); |
|
|
| ctx.save(); |
| ctx.beginPath(); |
| ctx.arc(b.right, b.bottom, HANDLE_R, 0, Math.PI * 2); |
| ctx.fillStyle = "#818cf8"; |
| ctx.fill(); |
| ctx.strokeStyle = "#fff"; |
| ctx.lineWidth = 1.5; |
| ctx.stroke(); |
| ctx.restore(); |
| } |
| } |
| } |
|
|
| function computeFitFontSize( |
| text: string, |
| fontFamily: string, |
| bold: boolean, |
| canvasWidth: number, |
| ): number { |
| const ctx = getFitMeasureCtx(); |
| const target = canvasWidth * 0.85; |
| let lo = 10, |
| hi = 600; |
| while (hi - lo > 1) { |
| const mid = Math.floor((lo + hi) / 2); |
| ctx.font = `${bold ? "bold " : ""}${mid}px "${fontFamily}", sans-serif`; |
| if (ctx.measureText(text).width <= target) lo = mid; |
| else hi = mid; |
| } |
| return lo; |
| } |
|
|
| function computeAverageBrightness(canvas: HTMLCanvasElement): number { |
| const ctx = canvas.getContext("2d")!; |
| const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data; |
| let total = 0, |
| count = 0; |
| const step = Math.max(4, Math.floor(data.length / 40000)); |
| for (let i = 0; i < data.length; i += step * 4) { |
| total += data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114; |
| count++; |
| } |
| return total / count; |
| } |
|
|
| function ProgressBar({ value }: { value: number }) { |
| return ( |
| <div className="w-full h-1 bg-white/10 rounded-full overflow-hidden"> |
| <div |
| className="h-full bg-gradient-to-r from-violet-500 to-fuchsia-500 rounded-full transition-all duration-300" |
| style={{ width: `${value}%` }} |
| /> |
| </div> |
| ); |
| } |
|
|
| export default function App() { |
| const pipelineRef = useRef<any>(null); |
| const [modelReady, setModelReady] = useState(false); |
| const pendingRef = useRef<{ file: File; meta: VideoMeta } | null>(null); |
|
|
| const [videoFile, setVideoFile] = useState<File | null>(null); |
| const [videoObjectUrl, setVideoObjectUrl] = useState(""); |
| const [meta, setMeta] = useState<VideoMeta | null>(null); |
| const [curTime, setCurTime] = useState(0); |
| const [isPlaying, setIsPlaying] = useState(false); |
|
|
| const [procStatus, setProcStatus] = useState<"idle" | "running" | "done">( |
| "idle", |
| ); |
| const [procProgress, setProcProgress] = useState(0); |
| const [procMsg, setProcMsg] = useState(""); |
| const fgRef = useRef<Map<number, HTMLCanvasElement>>(new Map()); |
| const tsRef = useRef<number[]>([]); |
|
|
| const [texts, setTexts] = useState<TextElement[]>([]); |
| const [selectedId, setSelectedId] = useState<string | null>(null); |
| const [textPanelOpen, setTextPanelOpen] = useState(false); |
| const [smartColor, setSmartColor] = useState("#ffffff"); |
|
|
| const [renderStatus, setRenderStatus] = useState<"idle" | "running">("idle"); |
| const [renderProgress, setRenderProgress] = useState(0); |
|
|
| const canvasRef = useRef<HTMLCanvasElement>(null); |
| const videoRef = useRef<HTMLVideoElement>(null); |
| const frameCacheRef = useRef<HTMLCanvasElement | null>(null); |
| const lastDrawnTimeRef = useRef(0); |
| const dragRef = useRef<DragState>({ |
| on: false, |
| mode: "move", |
| id: "", |
| ox: 0, |
| oy: 0, |
| startX: 0, |
| startY: 0, |
| startSize: 0, |
| didDrag: false, |
| }); |
| const rafRef = useRef<number>(0); |
|
|
| const textsRef = useRef(texts); |
| textsRef.current = texts; |
| const selectedIdRef = useRef(selectedId); |
| selectedIdRef.current = selectedId; |
|
|
| useEffect(() => { |
| if (pipelineRef.current) return; |
| (async () => { |
| try { |
| pipelineRef.current = await pipeline( |
| "background-removal", |
| "onnx-community/BEN2-ONNX", |
| { device: "webgpu" }, |
| ); |
| } catch (e) { |
| console.error("Model load failed:", e); |
| alert("Failed to load background removal model. See console for details."); |
| return; |
| } |
| setModelReady(true); |
| })(); |
| }, []); |
|
|
| useEffect(() => { |
| if (modelReady && pendingRef.current) { |
| const { file, meta: m } = pendingRef.current; |
| pendingRef.current = null; |
| processFrames(file, m); |
| } |
| }, [modelReady]); |
|
|
| useEffect(() => { |
| const canvas = canvasRef.current; |
| if (!canvas) return; |
| canvas.width = meta?.width ?? 1280; |
| canvas.height = meta?.height ?? 720; |
| }, [meta]); |
|
|
| const findFgFrame = useCallback((t: number): HTMLCanvasElement | null => { |
| const key = getFloorTimestamp(tsRef.current, t); |
| return key == null ? null : (fgRef.current.get(key) ?? null); |
| }, []); |
|
|
| const drawCanvas = useCallback(() => { |
| const canvas = canvasRef.current; |
| const vid = videoRef.current; |
| if (!canvas || canvas.width === 0) return; |
| const ctx = canvas.getContext("2d"); |
| if (!ctx) return; |
| const w = canvas.width, |
| h = canvas.height; |
| ctx.clearRect(0, 0, w, h); |
|
|
| let drawnTime = lastDrawnTimeRef.current; |
| if (vid && vid.readyState >= 2) { |
| ctx.drawImage(vid, 0, 0, w, h); |
| drawnTime = vid.currentTime; |
| lastDrawnTimeRef.current = drawnTime; |
| if (!frameCacheRef.current) |
| frameCacheRef.current = document.createElement("canvas"); |
| const fc = frameCacheRef.current; |
| if (fc.width !== w || fc.height !== h) { |
| fc.width = w; |
| fc.height = h; |
| } |
| fc.getContext("2d")!.drawImage(vid, 0, 0, w, h); |
| } else if (frameCacheRef.current) { |
| ctx.drawImage(frameCacheRef.current, 0, 0); |
| } else { |
| ctx.fillStyle = "#0a0a0a"; |
| ctx.fillRect(0, 0, w, h); |
| } |
|
|
| drawTextLayer(ctx, textsRef.current, selectedIdRef.current); |
|
|
| const fg = findFgFrame(drawnTime); |
| if (fg) ctx.drawImage(fg, 0, 0, w, h); |
| }, [findFgFrame]); |
|
|
| useEffect(() => { |
| const loop = () => { |
| drawCanvas(); |
| rafRef.current = requestAnimationFrame(loop); |
| }; |
| rafRef.current = requestAnimationFrame(loop); |
| return () => cancelAnimationFrame(rafRef.current); |
| }, [drawCanvas]); |
|
|
| function onVideoReady() { |
| const vid = videoRef.current; |
| if (!vid || !vid.videoWidth) return; |
| const c = document.createElement("canvas"); |
| c.width = vid.videoWidth; |
| c.height = vid.videoHeight; |
| c.getContext("2d")!.drawImage(vid, 0, 0, c.width, c.height); |
| const brightness = computeAverageBrightness(c); |
| setSmartColor(brightness > 128 ? "#1a1a2e" : "#ffffff"); |
| } |
|
|
| function onTimeUpdate() { |
| const vid = videoRef.current; |
| if (vid) setCurTime(vid.currentTime); |
| } |
|
|
| function togglePlay() { |
| const vid = videoRef.current; |
| if (!vid) return; |
| if (vid.paused) { |
| void vid.play(); |
| setIsPlaying(true); |
| } else { |
| vid.pause(); |
| setIsPlaying(false); |
| } |
| } |
|
|
| async function handleFile(file: File) { |
| if (videoObjectUrl) URL.revokeObjectURL(videoObjectUrl); |
| const url = URL.createObjectURL(file); |
|
|
| setVideoObjectUrl(url); |
| setVideoFile(file); |
| setMeta(null); |
| setCurTime(0); |
| setIsPlaying(false); |
| setProcStatus("idle"); |
| setProcProgress(0); |
| setProcMsg(""); |
| fgRef.current.clear(); |
| tsRef.current = []; |
| setRenderStatus("idle"); |
| setRenderProgress(0); |
| frameCacheRef.current = null; |
| setTexts([]); |
| setSelectedId(null); |
|
|
| try { |
| const inp = new Input({ |
| formats: ALL_FORMATS, |
| source: new BlobSource(file), |
| }); |
| const vt = await inp.getPrimaryVideoTrack(); |
| if (!vt) { |
| alert("No video track found"); |
| return; |
| } |
|
|
| const duration = await inp.computeDuration(); |
| const stats = await vt.computePacketStats(60); |
| const newMeta: VideoMeta = { |
| width: vt.displayWidth, |
| height: vt.displayHeight, |
| duration, |
| fps: stats.averagePacketRate, |
| }; |
| setMeta(newMeta); |
|
|
| if (modelReady && pipelineRef.current) { |
| processFrames(file, newMeta); |
| } else { |
| pendingRef.current = { file, meta: newMeta }; |
| } |
| } catch (e) { |
| console.error(e); |
| alert("Could not read video: " + e); |
| } |
| } |
|
|
| async function processFrames(fileArg?: File, metaArg?: VideoMeta) { |
| const file = fileArg ?? videoFile; |
| const m = metaArg ?? meta; |
| if (!file || !m || !pipelineRef.current) return; |
|
|
| setProcStatus("running"); |
| setProcProgress(0); |
| fgRef.current.clear(); |
| tsRef.current = []; |
|
|
| try { |
| const inp = new Input({ |
| formats: ALL_FORMATS, |
| source: new BlobSource(file), |
| }); |
| const vt = await inp.getPrimaryVideoTrack(); |
| if (!vt) throw new Error("No video track"); |
|
|
| const sink = new CanvasSink(vt, { |
| width: m.width, |
| height: m.height, |
| fit: "fill", |
| }); |
| const estimatedTotal = Math.ceil(m.duration * m.fps); |
| let count = 0; |
|
|
| for await (const { canvas, timestamp } of sink.canvases()) { |
| setProcMsg(`Removing background · frame ${count + 1}`); |
|
|
| const result = await pipelineRef.current(canvas); |
| const fgCanvas: HTMLCanvasElement = result.toCanvas(); |
|
|
| fgRef.current.set(timestamp, fgCanvas); |
| tsRef.current.push(timestamp); |
| count++; |
|
|
| setProcProgress(Math.min((count / estimatedTotal) * 100, 99)); |
| } |
|
|
| tsRef.current.sort((a, b) => a - b); |
| setProcStatus("done"); |
| setProcProgress(100); |
| setProcMsg(`${count} frames processed`); |
| } catch (e) { |
| console.error(e); |
| setProcStatus("idle"); |
| setProcMsg(""); |
| alert("Processing error: " + e); |
| } |
| } |
|
|
| async function renderVideo() { |
| if (!videoFile || !meta || renderStatus === "running") return; |
| setRenderStatus("running"); |
| setRenderProgress(0); |
|
|
| try { |
| const inp = new Input({ |
| formats: ALL_FORMATS, |
| source: new BlobSource(videoFile), |
| }); |
| const out = new Output({ |
| format: new WebMOutputFormat(), |
| target: new BufferTarget(), |
| }); |
|
|
| const textSnap = [...texts]; |
| const fgMap = fgRef.current; |
| const tsList = [...tsRef.current]; |
| const metaSnap = meta; |
|
|
| function nearestFg(t: number): HTMLCanvasElement | null { |
| const key = getFloorTimestamp(tsList, t); |
| return key == null ? null : (fgMap.get(key) ?? null); |
| } |
|
|
| let offscreen: OffscreenCanvas | null = null; |
| let octx: OffscreenCanvasRenderingContext2D | null = null; |
|
|
| const conv = await Conversion.init({ |
| input: inp, |
| output: out, |
| video: { |
| forceTranscode: true, |
| codec: "vp9", |
| bitrate: QUALITY_VERY_HIGH, |
| process: (sample) => { |
| if (!offscreen) { |
| offscreen = new OffscreenCanvas( |
| sample.displayWidth, |
| sample.displayHeight, |
| ); |
| octx = offscreen.getContext("2d")!; |
| } |
| const ctx = octx!; |
| ctx.clearRect(0, 0, offscreen.width, offscreen.height); |
| sample.draw(ctx, 0, 0); |
|
|
| const scale = offscreen.width / metaSnap.width; |
| const scaledTexts = |
| scale === 1 |
| ? textSnap |
| : textSnap.map((t) => ({ |
| ...t, |
| x: t.x * scale, |
| y: t.y * scale, |
| fontSize: Math.round(t.fontSize * scale), |
| strokeWidth: t.strokeWidth * scale, |
| })); |
| drawTextLayer(ctx, scaledTexts, null); |
|
|
| const fg = nearestFg(sample.timestamp); |
| if (fg) ctx.drawImage(fg, 0, 0, offscreen.width, offscreen.height); |
| return offscreen; |
| }, |
| }, |
| }); |
|
|
| conv.onProgress = (p) => setRenderProgress(Math.round(p * 100)); |
| await conv.execute(); |
|
|
| const buf = out.target.buffer!; |
| const blob = new Blob([buf], { type: "video/webm" }); |
| const url = URL.createObjectURL(blob); |
|
|
| |
| const a = document.createElement("a"); |
| a.href = url; |
| a.download = "text-behind-video.webm"; |
| document.body.appendChild(a); |
| a.click(); |
| document.body.removeChild(a); |
| URL.revokeObjectURL(url); |
|
|
| setRenderStatus("idle"); |
| } catch (e) { |
| console.error(e); |
| alert("Render error: " + e); |
| setRenderStatus("idle"); |
| } |
| } |
|
|
| function getPointerPos( |
| e: React.PointerEvent<HTMLCanvasElement>, |
| ): [number, number] { |
| const canvas = e.currentTarget; |
| const r = canvas.getBoundingClientRect(); |
| const sx = canvas.width / r.width; |
| const sy = canvas.height / r.height; |
| return [(e.clientX - r.left) * sx, (e.clientY - r.top) * sy]; |
| } |
|
|
| function onPointerDown(e: React.PointerEvent<HTMLCanvasElement>) { |
| if (procStatus === "running" || renderStatus === "running") return; |
| const [mx, my] = getPointerPos(e); |
| const canvas = e.currentTarget; |
| const ctx = canvas.getContext("2d")!; |
|
|
| if (selectedId) { |
| const selEl = texts.find((t) => t.id === selectedId); |
| if (selEl) { |
| const b = measureTextBounds(ctx, selEl); |
| const dist = Math.sqrt((mx - b.right) ** 2 + (my - b.bottom) ** 2); |
| if (dist <= HANDLE_R + 4) { |
| canvas.setPointerCapture(e.pointerId); |
| dragRef.current = { |
| on: true, |
| mode: "resize", |
| id: selEl.id, |
| ox: mx, |
| oy: my, |
| startX: selEl.x, |
| startY: selEl.y, |
| startSize: selEl.fontSize, |
| didDrag: false, |
| }; |
| return; |
| } |
| } |
| } |
|
|
| for (let i = texts.length - 1; i >= 0; i--) { |
| const el = texts[i]; |
| const b = measureTextBounds(ctx, el); |
| if (mx >= b.left && mx <= b.right && my >= b.top && my <= b.bottom) { |
| canvas.setPointerCapture(e.pointerId); |
| setSelectedId(el.id); |
| dragRef.current = { |
| on: true, |
| mode: "move", |
| id: el.id, |
| ox: mx, |
| oy: my, |
| startX: el.x, |
| startY: el.y, |
| startSize: el.fontSize, |
| didDrag: false, |
| }; |
| return; |
| } |
| } |
|
|
| setSelectedId(null); |
| } |
|
|
| function onPointerMove(e: React.PointerEvent<HTMLCanvasElement>) { |
| const dr = dragRef.current; |
| if (!dr.on) return; |
| const [mx, my] = getPointerPos(e); |
|
|
| if (!dr.didDrag) { |
| const dist = Math.abs(mx - dr.ox) + Math.abs(my - dr.oy); |
| if (dist < 4) return; |
| dr.didDrag = true; |
| } |
|
|
| if (dr.mode === "move") { |
| setTexts((prev) => |
| prev.map((t) => |
| t.id === dr.id |
| ? { ...t, x: dr.startX + (mx - dr.ox), y: dr.startY + (my - dr.oy) } |
| : t, |
| ), |
| ); |
| } else { |
| const delta = (mx - dr.ox + my - dr.oy) / 2; |
| const newSize = Math.max( |
| 10, |
| Math.min(600, Math.round(dr.startSize + delta * 0.4)), |
| ); |
| setTexts((prev) => |
| prev.map((t) => (t.id === dr.id ? { ...t, fontSize: newSize } : t)), |
| ); |
| } |
| } |
|
|
| function onPointerUp() { |
| const dr = dragRef.current; |
| if (dr.on && !dr.didDrag && dr.id) { |
| setTextPanelOpen(true); |
| } |
| dr.on = false; |
| } |
|
|
| function addText() { |
| const w = canvasRef.current?.width ?? meta?.width ?? 1280; |
| const h = canvasRef.current?.height ?? meta?.height ?? 720; |
| const fitSize = computeFitFontSize("EDIT TEXT", "Archivo Black", false, w); |
| const el: TextElement = { |
| id: uid(), |
| text: "EDIT TEXT", |
| x: w / 2, |
| y: h / 2, |
| fontSize: fitSize, |
| color: smartColor, |
| fontFamily: "Archivo Black", |
| bold: false, |
| italic: false, |
| strokeColor: "#000000", |
| strokeWidth: 0, |
| opacity: 1, |
| }; |
| setTexts((prev) => [...prev, el]); |
| setSelectedId(el.id); |
| setTextPanelOpen(true); |
| } |
|
|
| function updateText(id: string, patch: Partial<TextElement>) { |
| setTexts((prev) => prev.map((t) => (t.id === id ? { ...t, ...patch } : t))); |
| } |
|
|
| const hasVideo = !!videoFile && !!meta; |
| const isProcessing = procStatus === "running"; |
| const isExporting = renderStatus === "running"; |
| const canInteract = hasVideo && !isProcessing && !isExporting; |
| const activeText = texts.find((t) => t.id === selectedId) ?? null; |
|
|
| return ( |
| <div className="fixed inset-0 bg-[#0a0a0a] text-white select-none overflow-hidden"> |
| <video |
| ref={videoRef} |
| src={videoObjectUrl || undefined} |
| className="hidden" |
| onLoadedData={onVideoReady} |
| onTimeUpdate={onTimeUpdate} |
| onEnded={() => setIsPlaying(false)} |
| muted |
| playsInline |
| preload="auto" |
| crossOrigin="anonymous" |
| /> |
| |
| <div |
| className="absolute inset-0 flex items-center justify-center" |
| style={{ paddingBottom: hasVideo ? "136px" : "80px" }} |
| > |
| {!hasVideo && ( |
| <div className="absolute inset-0 flex flex-col items-center justify-center gap-5 pointer-events-none z-10"> |
| <div className="absolute top-1/4 left-1/3 w-1/3 h-1/3 bg-violet-600/8 rounded-full blur-[100px]" /> |
| <div className="absolute bottom-1/3 right-1/3 w-1/4 h-1/4 bg-fuchsia-600/8 rounded-full blur-[80px]" /> |
| |
| <div className="relative z-10 flex flex-col items-center gap-4 text-center"> |
| <div className="p-5 rounded-3xl bg-white/[0.03] border border-white/[0.06]"> |
| <Layers className="w-10 h-10 text-violet-400/50" /> |
| </div> |
| <div> |
| <p |
| className="text-2xl font-black tracking-tight text-white/20" |
| style={{ fontFamily: '"Archivo Black", sans-serif' }} |
| > |
| Text Behind Video |
| </p> |
| <p className="mt-1.5 text-sm text-white/15"> |
| Click{" "} |
| <span className="text-white/25 font-semibold">Video</span>{" "} |
| below to select a clip |
| </p> |
| </div> |
| </div> |
| </div> |
| )} |
| |
| <canvas |
| ref={canvasRef} |
| className="max-w-full max-h-full rounded-xl shadow-2xl ring-1 ring-white/[0.06]" |
| style={{ |
| cursor: isProcessing ? "wait" : "default", |
| opacity: hasVideo ? 1 : 0, |
| }} |
| onPointerDown={onPointerDown} |
| onPointerMove={onPointerMove} |
| onPointerUp={onPointerUp} |
| onPointerLeave={onPointerUp} |
| /> |
| </div> |
| |
| {isProcessing && ( |
| <div |
| className="absolute top-4 left-1/2 -translate-x-1/2 z-30" |
| style={{ animation: "fade-in-down 0.3s ease-out" }} |
| > |
| <div className="glass-panel rounded-2xl px-5 py-3 flex flex-col gap-2 min-w-64"> |
| <p className="text-xs text-white/40 text-center"> |
| {procMsg || "Initialising…"} |
| </p> |
| <ProgressBar value={procProgress} /> |
| <p className="text-[10px] text-white/20 text-center"> |
| {procProgress.toFixed(0)}% |
| </p> |
| </div> |
| </div> |
| )} |
| |
| {hasVideo && meta && ( |
| <div className="absolute bottom-[88px] left-1/2 -translate-x-1/2 w-full max-w-3xl px-6 z-30"> |
| <div className="glass-panel flex items-center gap-3 px-4 py-3 rounded-2xl"> |
| <button |
| onClick={togglePlay} |
| disabled={!canInteract} |
| className="glass-btn w-8 h-8 rounded-xl flex items-center justify-center flex-shrink-0" |
| > |
| {isPlaying ? ( |
| <Pause className="w-4 h-4 fill-current text-white/80" /> |
| ) : ( |
| <Play className="w-4 h-4 fill-current text-white/80" /> |
| )} |
| </button> |
| |
| <span className="text-xs font-mono text-white/30 w-10 text-right flex-shrink-0"> |
| {String(Math.floor(curTime / 60)).padStart(2, "0")}: |
| {String(Math.floor(curTime % 60)).padStart(2, "0")} |
| </span> |
| |
| <input |
| type="range" |
| min={0} |
| max={meta.duration} |
| step={0.016} |
| value={curTime} |
| disabled={!canInteract} |
| onChange={(e) => { |
| const t = parseFloat(e.target.value); |
| setCurTime(t); |
| if (videoRef.current) videoRef.current.currentTime = t; |
| }} |
| className="flex-1 scrubber" |
| /> |
| |
| <span className="text-xs font-mono text-white/30 w-10 flex-shrink-0"> |
| {String(Math.floor(meta.duration / 60)).padStart(2, "0")}: |
| {String(Math.floor(meta.duration % 60)).padStart(2, "0")} |
| </span> |
| </div> |
| </div> |
| )} |
| |
| <div className="absolute bottom-5 left-1/2 -translate-x-1/2 z-40"> |
| <div className="glass-panel flex items-center gap-2 p-2 rounded-2xl"> |
| <label |
| className="glass-btn w-14 h-12 rounded-xl flex flex-col items-center justify-center gap-0.5 cursor-pointer" |
| title="Change video" |
| > |
| <FileVideo className="w-5 h-5 text-white/60" /> |
| <span className="text-[9px] text-white/30 font-medium tracking-wide"> |
| Video |
| </span> |
| <input |
| type="file" |
| accept="video/*" |
| className="hidden" |
| onChange={(e) => { |
| const f = e.target.files?.[0]; |
| if (f) handleFile(f); |
| }} |
| /> |
| </label> |
| |
| <div className="w-px h-7 bg-white/[0.08] mx-0.5" /> |
| |
| <button |
| onClick={() => { |
| if (!canInteract) return; |
| if (!textPanelOpen && texts.length === 0) addText(); |
| else setTextPanelOpen((p) => !p); |
| }} |
| disabled={!canInteract} |
| title="Add / edit text" |
| className={`glass-btn w-14 h-12 rounded-xl flex flex-col items-center justify-center gap-0.5 ${textPanelOpen ? "tool-active" : ""}`} |
| > |
| <Type |
| className={`w-5 h-5 ${textPanelOpen ? "text-violet-400" : "text-white/60"}`} |
| /> |
| <span |
| className={`text-[9px] font-medium tracking-wide ${textPanelOpen ? "text-violet-400/70" : "text-white/30"}`} |
| > |
| Text |
| </span> |
| </button> |
| |
| <div className="w-px h-7 bg-white/[0.08] mx-0.5" /> |
| |
| <button |
| onClick={renderVideo} |
| disabled={!canInteract} |
| title={ |
| isExporting ? `Rendering ${renderProgress}%` : "Export video" |
| } |
| className="glass-btn relative w-14 h-12 rounded-xl flex flex-col items-center justify-center gap-0.5 overflow-hidden" |
| > |
| {isExporting && ( |
| <div |
| className="absolute inset-0 bg-fuchsia-600/25 rounded-xl" |
| style={{ width: `${renderProgress}%` }} |
| /> |
| )} |
| <Download |
| className={`w-5 h-5 z-10 ${isExporting ? "text-fuchsia-400" : "text-white/60"}`} |
| /> |
| <span |
| className={`text-[9px] font-medium tracking-wide z-10 ${isExporting ? "text-fuchsia-400/70" : "text-white/30"}`} |
| > |
| {isExporting ? `${renderProgress}%` : "Export"} |
| </span> |
| </button> |
| </div> |
| </div> |
| |
| <div |
| className={`absolute top-4 right-4 z-30 w-80 transition-transform duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${ |
| textPanelOpen && hasVideo |
| ? "translate-x-0 pointer-events-auto" |
| : "translate-x-[110%] pointer-events-none" |
| }`} |
| style={{ bottom: hasVideo && meta ? "144px" : "80px" }} |
| > |
| <div className="h-full flex flex-col glass-panel rounded-2xl overflow-hidden"> |
| <div className="flex items-center justify-between px-4 py-3 border-b border-white/[0.06] bg-white/[0.03]"> |
| <span className="text-sm font-semibold flex items-center gap-2 text-white/70"> |
| <Type className="w-4 h-4 text-violet-400" /> |
| Typography |
| </span> |
| <button |
| onClick={() => setTextPanelOpen(false)} |
| className="text-white/20 hover:text-white/60 transition-colors" |
| > |
| <X className="w-4 h-4" /> |
| </button> |
| </div> |
| |
| <div className="flex-1 overflow-y-auto panel-scroll px-4 py-3 space-y-4"> |
| {texts.length > 0 && ( |
| <div className="space-y-1"> |
| {texts.map((t) => ( |
| <div |
| key={t.id} |
| onClick={() => setSelectedId(t.id)} |
| className={`flex items-center gap-2 px-3 py-2.5 rounded-xl cursor-pointer transition-all ${ |
| t.id === selectedId |
| ? "bg-violet-500/20 ring-1 ring-violet-400/25" |
| : "bg-white/[0.03] hover:bg-white/[0.07]" |
| }`} |
| > |
| <span |
| className="flex-1 truncate text-xs font-semibold" |
| style={{ |
| fontFamily: `"${t.fontFamily}", sans-serif`, |
| opacity: t.opacity, |
| }} |
| > |
| {t.text} |
| </span> |
| <button |
| onClick={(e) => { |
| e.stopPropagation(); |
| setTexts((p) => p.filter((x) => x.id !== t.id)); |
| if (selectedId === t.id) setSelectedId(null); |
| }} |
| className="text-white/15 hover:text-red-400 transition-colors flex-shrink-0" |
| > |
| <Trash2 className="w-3.5 h-3.5" /> |
| </button> |
| </div> |
| ))} |
| </div> |
| )} |
| |
| <button |
| onClick={addText} |
| className="w-full py-2.5 flex items-center justify-center gap-2 rounded-xl |
| bg-violet-500/15 hover:bg-violet-500/25 border border-violet-400/10 |
| text-violet-300 text-sm font-medium transition-all" |
| > |
| <Plus className="w-4 h-4" /> |
| Add Text Layer |
| </button> |
| |
| {activeText && ( |
| <> |
| <div className="border-t border-white/[0.05] pt-1" /> |
| |
| <div className="space-y-1.5"> |
| <label className="text-[10px] text-white/35 uppercase tracking-wider font-medium"> |
| Content |
| </label> |
| <textarea |
| value={activeText.text} |
| onChange={(e) => |
| updateText(activeText.id, { text: e.target.value }) |
| } |
| className="w-full bg-black/30 border border-white/[0.08] rounded-xl px-3 py-2 |
| text-sm text-white focus:outline-none focus:border-violet-400/50 resize-none h-14" |
| /> |
| </div> |
| |
| <div className="space-y-1.5"> |
| <label className="text-[10px] text-white/35 uppercase tracking-wider font-medium"> |
| Font |
| </label> |
| <div className="space-y-1 max-h-44 overflow-y-auto panel-scroll rounded-xl bg-white/[0.02] p-1 border border-white/[0.05]"> |
| {FONTS.map((font) => ( |
| <button |
| key={font} |
| onClick={() => |
| updateText(activeText.id, { fontFamily: font }) |
| } |
| className={`w-full text-left px-3 py-2 rounded-lg text-[15px] font-bold truncate transition-all ${ |
| activeText.fontFamily === font |
| ? "bg-violet-500/20 ring-1 ring-violet-400/35 text-white" |
| : "text-white/50 hover:bg-white/[0.06] hover:text-white/80" |
| }`} |
| style={{ fontFamily: `"${font}", sans-serif` }} |
| > |
| {font} |
| </button> |
| ))} |
| </div> |
| </div> |
| |
| <div className="space-y-2"> |
| <div className="flex justify-between items-center"> |
| <label className="text-[10px] text-white/35 uppercase tracking-wider font-medium"> |
| Size |
| </label> |
| <span className="text-[10px] text-white/25 font-mono"> |
| {Math.round(activeText.fontSize)}px |
| </span> |
| </div> |
| <input |
| type="range" |
| min={10} |
| max={600} |
| value={activeText.fontSize} |
| onChange={(e) => |
| updateText(activeText.id, { fontSize: +e.target.value }) |
| } |
| /> |
| </div> |
| |
| <div className="space-y-2"> |
| <div className="flex justify-between items-center"> |
| <label className="text-[10px] text-white/35 uppercase tracking-wider font-medium"> |
| Opacity |
| </label> |
| <span className="text-[10px] text-white/25 font-mono"> |
| {Math.round(activeText.opacity * 100)}% |
| </span> |
| </div> |
| <input |
| type="range" |
| min={0} |
| max={100} |
| value={Math.round(activeText.opacity * 100)} |
| onChange={(e) => |
| updateText(activeText.id, { |
| opacity: +e.target.value / 100, |
| }) |
| } |
| /> |
| </div> |
| |
| <div className="space-y-1.5"> |
| <label className="text-[10px] text-white/35 uppercase tracking-wider font-medium"> |
| Appearance |
| </label> |
| <div className="flex items-center gap-2.5"> |
| <div className="relative w-10 h-10 rounded-full overflow-hidden border-2 border-white/15 flex-shrink-0 shadow-inner"> |
| <input |
| type="color" |
| value={activeText.color} |
| onChange={(e) => |
| updateText(activeText.id, { color: e.target.value }) |
| } |
| className="absolute inset-[-8px] w-[calc(100%+16px)] h-[calc(100%+16px)] cursor-pointer" |
| /> |
| </div> |
| <div className="flex flex-1 bg-white/[0.04] border border-white/[0.07] rounded-xl overflow-hidden h-10"> |
| <button |
| onClick={() => |
| updateText(activeText.id, { bold: !activeText.bold }) |
| } |
| className={`flex-1 font-bold text-sm transition-colors ${ |
| activeText.bold |
| ? "bg-white/15 text-white" |
| : "text-white/35 hover:bg-white/[0.08]" |
| }`} |
| > |
| B |
| </button> |
| <div className="w-px bg-white/[0.07]" /> |
| <button |
| onClick={() => |
| updateText(activeText.id, { |
| italic: !activeText.italic, |
| }) |
| } |
| className={`flex-1 italic font-semibold text-sm transition-colors ${ |
| activeText.italic |
| ? "bg-white/15 text-white" |
| : "text-white/35 hover:bg-white/[0.08]" |
| }`} |
| > |
| I |
| </button> |
| </div> |
| </div> |
| </div> |
| |
| <button |
| onClick={() => { |
| setTexts((p) => p.filter((t) => t.id !== activeText.id)); |
| setSelectedId(null); |
| }} |
| className="w-full py-2.5 flex items-center justify-center gap-2 rounded-xl |
| bg-red-500/8 hover:bg-red-500/18 border border-red-400/10 |
| text-red-400 text-sm font-medium transition-all" |
| > |
| <Trash2 className="w-4 h-4" /> |
| Delete Layer |
| </button> |
| |
| <p className="text-[9px] text-white/15 text-center leading-relaxed"> |
| Drag to move · corner handle to resize |
| </p> |
| </> |
| )} |
| </div> |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|