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 (
);
}
export default function App() {
const pipelineRef = useRef(null);
const [modelReady, setModelReady] = useState(false);
const pendingRef = useRef<{ file: File; meta: VideoMeta } | null>(null);
const [videoFile, setVideoFile] = useState(null);
const [videoObjectUrl, setVideoObjectUrl] = useState("");
const [meta, setMeta] = useState(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