Xenova's picture
Xenova HF Staff
Upload 13 files
5e2773d verified
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; // absolute canvas pixels
y: number; // absolute canvas pixels
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 = [
// Display / Heavy
"Bebas Neue",
"Anton",
"Bungee",
"Archivo Black",
"Black Ops One",
"Alfa Slab One",
"Titan One",
"Rubik Mono One",
"Ultra",
"Monoton",
// Bold Sans
"Oswald",
"Teko",
"Fjalla One",
"Barlow Condensed",
"Rajdhani",
"Russo One",
"Orbitron",
"Michroma",
"Chakra Petch",
"Exo 2",
// Serif / Elegant
"Playfair Display",
"Cinzel",
"Cormorant Garamond",
"Lora",
"DM Serif Display",
// Script / Handwriting
"Pacifico",
"Permanent Marker",
"Caveat",
"Dancing Script",
"Sacramento",
"Satisfy",
"Great Vibes",
"Lobster",
// Fun / Decorative
"Righteous",
"Bangers",
"Luckiest Guy",
"Fredoka",
"Passion One",
"Press Start 2P",
"Silkscreen",
// Clean / Modern
"Montserrat",
"Raleway",
"Poppins",
"Space Grotesk",
"Sora",
// System
"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);
// Trigger download immediately
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>
);
}