// demo.jsx — ThumbForge live demo. // User drops a video file. We extract N frames at evenly-spaced times, // then loop through them on a canvas to render a "moving thumbnail". // A "Use sample" option runs the same pipeline against a procedurally- // generated sample video so the demo works without uploads. function ThumbForgeDemo() { const [state, setState] = React.useState("idle"); // idle | loading | ready | error const [error, setError] = React.useState(""); const [filename, setFilename] = React.useState(""); const [duration, setDuration] = React.useState(0); const [resolution, setResolution] = React.useState(""); const [frames, setFrames] = React.useState([]); // ImageBitmap[] const [dragOver, setDragOver] = React.useState(false); // Tweakable rendering params const [frameCount, setFrameCount] = React.useState(8); const [fps, setFps] = React.useState(6); const [width, setWidth] = React.useState(640); const [aspect, setAspect] = React.useState("16:9"); const canvasRef = React.useRef(null); const fileInputRef = React.useRef(null); const rafRef = React.useRef(0); const startedRef = React.useRef(0); // ── Frame extraction ──────────────────────────────────────────── async function extractFromVideoElement(video, count) { const tmp = document.createElement("canvas"); const aspectRatio = video.videoWidth / video.videoHeight || 16/9; const h = Math.round(width / aspectRatio); tmp.width = width; tmp.height = h; const ctx = tmp.getContext("2d"); const dur = video.duration; const stamps = []; for (let i = 0; i < count; i++) { stamps.push((dur * (i + 0.5)) / count); } const out = []; for (const t of stamps) { await seek(video, t); ctx.drawImage(video, 0, 0, tmp.width, tmp.height); const bmp = await createImageBitmap(tmp); out.push(bmp); } return out; } function seek(video, t) { return new Promise((resolve) => { const onSeeked = () => { video.removeEventListener("seeked", onSeeked); resolve(); }; video.addEventListener("seeked", onSeeked); video.currentTime = Math.min(t, Math.max(0, video.duration - 0.05)); }); } async function handleFile(file) { setState("loading"); setError(""); setFilename(file.name); const url = URL.createObjectURL(file); const video = document.createElement("video"); video.muted = true; video.preload = "auto"; video.src = url; video.playsInline = true; try { await new Promise((res, rej) => { video.addEventListener("loadedmetadata", () => res()); video.addEventListener("error", () => rej(new Error("Couldn't decode that file. Try MP4, WebM, or MOV."))); setTimeout(() => rej(new Error("Loading the video took too long.")), 12000); }); setDuration(video.duration); setResolution(`${video.videoWidth}\u00d7${video.videoHeight}`); const f = await extractFromVideoElement(video, frameCount); setFrames(f); setState("ready"); } catch (e) { setError(e.message || "Something went wrong."); setState("error"); } finally { URL.revokeObjectURL(url); } } // ── Sample (procedural) ────────────────────────────────────────── async function useSample() { setState("loading"); setError(""); setFilename("sample-footage.mp4"); setDuration(12.0); setResolution("1920\u00d71080"); // Generate frames procedurally so the demo always works. const out = []; const h = Math.round(width / (16/9)); for (let i = 0; i < frameCount; i++) { const c = document.createElement("canvas"); c.width = width; c.height = h; const ctx = c.getContext("2d"); const t = i / frameCount; // sky gradient that shifts hue across frames const hue = 25 + t * 60; const g = ctx.createLinearGradient(0, 0, 0, h); g.addColorStop(0, `hsl(${hue}, 70%, 18%)`); g.addColorStop(1, `hsl(${hue + 30}, 80%, 55%)`); ctx.fillStyle = g; ctx.fillRect(0, 0, c.width, h); // distant mountains ctx.fillStyle = `hsla(${hue + 40}, 30%, 14%, 0.85)`; ctx.beginPath(); ctx.moveTo(0, h * 0.78); for (let x = 0; x <= c.width; x += 20) { const y = h * 0.78 - Math.sin((x + i*30) / 70) * 18 - Math.sin((x + i*55) / 120) * 28; ctx.lineTo(x, y); } ctx.lineTo(c.width, h); ctx.lineTo(0, h); ctx.closePath(); ctx.fill(); // foreground hill ctx.fillStyle = `hsla(${hue + 60}, 25%, 8%, 0.95)`; ctx.beginPath(); ctx.moveTo(0, h * 0.92); for (let x = 0; x <= c.width; x += 16) { const y = h * 0.92 - Math.sin((x - i*60) / 50) * 10 - 6; ctx.lineTo(x, y); } ctx.lineTo(c.width, h); ctx.lineTo(0, h); ctx.closePath(); ctx.fill(); // a moving "subject" (sun/disc) sweeping across const sx = c.width * (0.1 + t * 0.8); const sy = h * (0.32 - Math.sin(t * Math.PI) * 0.08); const grad = ctx.createRadialGradient(sx, sy, 4, sx, sy, 80); grad.addColorStop(0, `hsla(${hue - 10}, 95%, 88%, 1)`); grad.addColorStop(1, `hsla(${hue - 10}, 95%, 88%, 0)`); ctx.fillStyle = grad; ctx.fillRect(0, 0, c.width, h); ctx.beginPath(); ctx.arc(sx, sy, 14, 0, Math.PI*2); ctx.fillStyle = `hsl(${hue - 10}, 95%, 88%)`; ctx.fill(); // timecode ctx.font = "12px ui-monospace, monospace"; ctx.fillStyle = "rgba(255,255,255,0.75)"; const tt = ((duration || 12) * (i + 0.5) / frameCount).toFixed(2); ctx.fillText(`t = ${tt}s`, 14, h - 14); out.push(await createImageBitmap(c)); } setFrames(out); setState("ready"); } // ── Re-render frames if user changes count after a load ───────── // (We keep it simple — sample regenerates; uploaded video needs re-import.) React.useEffect(() => { if (state === "ready" && filename === "sample-footage.mp4") { useSample(); } // eslint-disable-next-line }, [frameCount]); // ── Animation loop ────────────────────────────────────────────── React.useEffect(() => { if (state !== "ready" || frames.length === 0) return; const cvs = canvasRef.current; if (!cvs) return; const first = frames[0]; cvs.width = first.width; cvs.height = first.height; const ctx = cvs.getContext("2d"); startedRef.current = performance.now(); function tick(now) { const elapsed = (now - startedRef.current) / 1000; const idx = Math.floor(elapsed * fps) % frames.length; ctx.drawImage(frames[idx], 0, 0); rafRef.current = requestAnimationFrame(tick); } rafRef.current = requestAnimationFrame(tick); return () => cancelAnimationFrame(rafRef.current); }, [state, frames, fps]); // ── DnD handlers ──────────────────────────────────────────────── function onDrop(e) { e.preventDefault(); setDragOver(false); const file = [...(e.dataTransfer.files || [])].find(f => f.type.startsWith("video/")) || e.dataTransfer.files[0]; if (file) handleFile(file); } return (
{/* LEFT — drop zone / status */}
{ e.preventDefault(); setDragOver(true); }} onDragLeave={() => setDragOver(false)} onDrop={onDrop} >
Input Local · nothing uploaded
{state === "idle" && (

Drop a video file here.

ThumbForge runs on your machine. Drag any .mp4 / .mov / .webm — we'll extract frames, loop them, and you'll see exactly the moving thumbnail you'd ship.

e.target.files[0] && handleFile(e.target.files[0])}/>
)} {state === "loading" && (
EXTRACTING FRAMES…

{filename}

)} {state === "error" && (
ERROR

{error}

)} {state === "ready" && (
SOURCE
{filename}
{duration ? duration.toFixed(1) + "s" : "—"}
{resolution || "—"}
setFrameCount(+e.target.value)} />
setFps(+e.target.value)} />
)}
{/* RIGHT — preview */}
Output preview Animated · {frames.length || 0} fr
{state === "ready" ? ( <> ● Live ) : (
{state === "loading" ? "RENDERING…" : "AWAITING SOURCE"}
)}
EXPORT {width} × {Math.round(width / (16/9))}
{["GIF", "WebP", "MP4", "WebM", "APNG"].map(f => ( {f} ))}

In the desktop app, hit ⌃E to export the loop in any of these formats with custom encoder presets.

); } window.ThumbForgeDemo = ThumbForgeDemo;