| | 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> |
| | ); |
| | } |
| |
|