Spaces:
Sleeping
Sleeping
| import React, { useState, useRef, useEffect } from 'react'; | |
| import kaboom from "kaboom"; | |
| import { Upload, Play, Image as ImageIcon, Copy, Grid, Film, RefreshCw, Scissors } from 'lucide-react'; | |
| const STATES = { | |
| IDLE: 'idle', | |
| LOADING_VIDEO: 'loading_video', | |
| VIDEO_READY: 'video_ready', | |
| EXTRACTING: 'extracting_frames', | |
| FRAMES_READY: 'frames_ready', | |
| GENERATING: 'generating_sheet', | |
| SHEET_READY: 'sheet_ready', | |
| }; | |
| const App = () => { | |
| const [appState, setAppState] = useState(STATES.IDLE); | |
| const [videoSrc, setVideoSrc] = useState(null); | |
| const [videoMeta, setVideoMeta] = useState({ duration: 0, width: 0, height: 0 }); | |
| const [thumbnail, setThumbnail] = useState(null); | |
| const [extractedFrames, setExtractedFrames] = useState([]); | |
| const [spriteSheetUrl, setSpriteSheetUrl] = useState(null); | |
| const [sheetMeta, setSheetMeta] = useState({ w: 0, h: 0, cols: 0, rows: 0, frameW: 0, frameH: 0 }); | |
| const [config, setConfig] = useState({ | |
| frameCount: 16, | |
| trimStart: 0, | |
| trimEnd: 100, | |
| scale: 1, | |
| }); | |
| const hiddenVideoRef = useRef(null); | |
| const kaboomContainerRef = useRef(null); | |
| const kInstance = useRef(null); | |
| // 1. INPUT HANDLER | |
| const handleFileUpload = (e) => { | |
| const file = e.target.files?.[0]; | |
| if (!file) return; | |
| setAppState(STATES.LOADING_VIDEO); | |
| const url = URL.createObjectURL(file); | |
| setVideoSrc(url); | |
| setExtractedFrames([]); | |
| setSpriteSheetUrl(null); | |
| }; | |
| const onVideoLoaded = (e) => { | |
| const vid = e.target; | |
| setVideoMeta({ duration: vid.duration, width: vid.videoWidth, height: vid.videoHeight }); | |
| // Thumbnail Capture | |
| vid.currentTime = 0.5; | |
| const captureThumb = () => { | |
| const cvs = document.createElement('canvas'); | |
| cvs.width = vid.videoWidth / 4; cvs.height = vid.videoHeight / 4; | |
| cvs.getContext('2d').drawImage(vid, 0, 0, cvs.width, cvs.height); | |
| setThumbnail(cvs.toDataURL()); | |
| setAppState(STATES.VIDEO_READY); | |
| vid.removeEventListener('seeked', captureThumb); | |
| }; | |
| vid.addEventListener('seeked', captureThumb); | |
| }; | |
| // 2. PIPELINE: EXTRACT | |
| const startExtraction = async () => { | |
| if (!hiddenVideoRef.current) return; | |
| setAppState(STATES.EXTRACTING); | |
| setExtractedFrames([]); | |
| const vid = hiddenVideoRef.current; | |
| const { frameCount, trimStart, trimEnd, scale } = config; | |
| const startTime = (trimStart / 100) * vid.duration; | |
| const playDuration = ((trimEnd / 100) * vid.duration) - startTime; | |
| const timeStep = playDuration / frameCount; | |
| const cvs = document.createElement('canvas'); | |
| cvs.width = vid.videoWidth * scale; | |
| cvs.height = vid.videoHeight * scale; | |
| const ctx = cvs.getContext('2d'); | |
| const newFrames = []; | |
| for (let i = 0; i < frameCount; i++) { | |
| vid.currentTime = startTime + (i * timeStep); | |
| await new Promise(resolve => { | |
| const onSeek = () => { | |
| vid.removeEventListener('seeked', onSeek); | |
| ctx.clearRect(0,0,cvs.width,cvs.height); | |
| ctx.drawImage(vid,0,0,cvs.width,cvs.height); | |
| cvs.toBlob(blob => { | |
| newFrames.push(URL.createObjectURL(blob)); | |
| setExtractedFrames([...newFrames]); | |
| resolve(); | |
| }, 'image/png'); | |
| }; | |
| vid.addEventListener('seeked', onSeek); | |
| }); | |
| } | |
| setAppState(STATES.FRAMES_READY); | |
| generateSpriteSheet(newFrames); | |
| }; | |
| // 3. PIPELINE: PACK | |
| const generateSpriteSheet = (frames) => { | |
| setAppState(STATES.GENERATING); | |
| const count = frames.length; | |
| const cols = Math.ceil(Math.sqrt(count)); | |
| const rows = Math.ceil(count / cols); | |
| const fW = videoMeta.width * config.scale; | |
| const fH = videoMeta.height * config.scale; | |
| const cvs = document.createElement('canvas'); | |
| cvs.width = cols * fW; cvs.height = rows * fH; | |
| const ctx = cvs.getContext('2d'); | |
| Promise.all(frames.map((src, i) => new Promise(resolve => { | |
| const img = new Image(); | |
| img.onload = () => { | |
| ctx.drawImage(img, (i % cols)*fW, Math.floor(i/cols)*fH, fW, fH); | |
| resolve(); | |
| }; | |
| img.src = src; | |
| }))).then(() => { | |
| setSpriteSheetUrl(cvs.toDataURL('image/png')); | |
| setSheetMeta({ w: cvs.width, h: cvs.height, cols, rows, frameW: fW, frameH: fH }); | |
| setAppState(STATES.SHEET_READY); | |
| }); | |
| }; | |
| // 4. KABOOM PREVIEW | |
| useEffect(() => { | |
| if (appState === STATES.SHEET_READY && spriteSheetUrl && kaboomContainerRef.current) { | |
| kaboomContainerRef.current.innerHTML = ''; // Force clear | |
| try { | |
| const k = kaboom({ | |
| root: kaboomContainerRef.current, | |
| width: 300, height: 300, | |
| background: [0,0,0,0], global: false | |
| }); | |
| k.loadSprite("anim", spriteSheetUrl, { | |
| sliceX: sheetMeta.cols, sliceY: sheetMeta.rows, | |
| anims: { idle: { from: 0, to: config.frameCount - 1, loop: true, speed: 10 } } | |
| }); | |
| k.scene("main", () => { | |
| k.add([ | |
| k.sprite("anim"), k.pos(k.center()), k.anchor("center"), | |
| k.scale(Math.min(250/sheetMeta.frameW, 250/sheetMeta.frameH)) | |
| ]).play("idle"); | |
| }); | |
| k.go("main"); | |
| kInstance.current = k; | |
| } catch(e) { console.error(e); } | |
| } | |
| }, [spriteSheetUrl, sheetMeta]); | |
| // UI HELPERS | |
| const StepTitle = ({num, text}) => ( | |
| <h2 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-3 flex items-center gap-2"> | |
| <span className="bg-obsidian-border text-gray-300 w-4 h-4 rounded-full flex items-center justify-center">{num}</span> {text} | |
| </h2> | |
| ); | |
| return ( | |
| <div className="min-h-screen p-4 lg:p-8 font-sans"> | |
| <video ref={hiddenVideoRef} src={videoSrc} className="hidden" muted playsInline onLoadedData={onVideoLoaded} /> | |
| {/* Header */} | |
| <header className="mb-8 flex items-center gap-3 border-b border-white/5 pb-6"> | |
| <div className="p-2 bg-obsidian-accent/10 rounded-lg"> | |
| <Film className="text-obsidian-accent w-6 h-6" /> | |
| </div> | |
| <div> | |
| <h1 className="text-xl font-bold text-white tracking-tight">Kaboom Pipeline</h1> | |
| <p className="text-gray-500 text-xs">Docker Edition • Video to Sprite Sheet</p> | |
| </div> | |
| </header> | |
| <div className="grid grid-cols-1 lg:grid-cols-12 gap-6"> | |
| {/* === LEFT COLUMN === */} | |
| <div className="lg:col-span-5 space-y-6"> | |
| {/* Upload Card */} | |
| <div className="glass-panel rounded-2xl p-5 relative overflow-hidden group"> | |
| <StepTitle num="1" text="Source Media" /> | |
| {!videoSrc ? ( | |
| <label className="border border-dashed border-white/10 rounded-xl h-40 flex flex-col items-center justify-center cursor-pointer hover:bg-white/5 transition-all"> | |
| <Upload className="w-6 h-6 text-gray-400 mb-2" /> | |
| <span className="text-sm text-gray-400">Drop Video or GIF</span> | |
| <input type="file" onChange={handleFileUpload} className="hidden" accept="video/*,image/gif" /> | |
| </label> | |
| ) : ( | |
| <div className="relative rounded-xl overflow-hidden bg-black/50 aspect-video ring-1 ring-white/10"> | |
| {thumbnail && <img src={thumbnail} className="w-full h-full object-contain opacity-70" />} | |
| <button onClick={() => setVideoSrc(null)} className="absolute top-2 right-2 p-2 bg-black/50 hover:bg-red-500/20 hover:text-red-400 rounded-full text-white/50 backdrop-blur transition-all"> | |
| <RefreshCw size={14} /> | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| {/* Controls */} | |
| <div className={`glass-panel rounded-2xl p-5 transition-opacity ${!videoSrc ? 'opacity-40 pointer-events-none' : 'opacity-100'}`}> | |
| <StepTitle num="2" text="Extraction Config" /> | |
| <div className="space-y-6"> | |
| <div className="space-y-4"> | |
| <div> | |
| <div className="flex justify-between text-xs mb-2 text-gray-400"> | |
| <span>Frame Count</span> <span className="text-obsidian-accent font-mono">{config.frameCount}</span> | |
| </div> | |
| <input type="range" min="4" max="64" value={config.frameCount} onChange={(e) => setConfig({...config, frameCount: +e.target.value})} className="w-full h-1.5 bg-white/10 rounded-lg appearance-none cursor-pointer accent-obsidian-accent" /> | |
| </div> | |
| <div> | |
| <div className="flex justify-between text-xs mb-2 text-gray-400"> | |
| <span className="flex items-center gap-1"><Scissors size={12}/> Trim</span> <span className="font-mono">{config.trimStart}% - {config.trimEnd}%</span> | |
| </div> | |
| <div className="flex gap-2"> | |
| <input type="range" value={config.trimStart} onChange={(e)=>setConfig({...config, trimStart: +e.target.value})} className="w-full h-1 bg-white/10 rounded accent-gray-500"/> | |
| <input type="range" value={config.trimEnd} onChange={(e)=>setConfig({...config, trimEnd: +e.target.value})} className="w-full h-1 bg-white/10 rounded accent-gray-500"/> | |
| </div> | |
| </div> | |
| </div> | |
| <button onClick={startExtraction} disabled={appState === STATES.EXTRACTING} | |
| className="w-full bg-obsidian-accent hover:bg-obsidian-accent/90 text-white text-sm font-semibold py-3 rounded-xl flex items-center justify-center gap-2 transition-all shadow-lg shadow-obsidian-accent/20 disabled:opacity-50 disabled:cursor-not-allowed"> | |
| {appState === STATES.EXTRACTING ? "Processing..." : <>Start Extraction <Play size={14} fill="currentColor" /></>} | |
| </button> | |
| </div> | |
| </div> | |
| {/* Grid Preview */} | |
| <div className="glass-panel rounded-2xl p-5 h-64 overflow-y-auto"> | |
| <StepTitle num="3" text="Frame Buffer" /> | |
| <div className="grid grid-cols-5 gap-2"> | |
| {extractedFrames.map((src,i) => ( | |
| <div key={i} className="aspect-square bg-black/40 rounded border border-white/5 relative overflow-hidden"> | |
| <img src={src} className="w-full h-full object-contain pixelated" /> | |
| </div> | |
| ))} | |
| {appState === STATES.EXTRACTING && [...Array(config.frameCount - extractedFrames.length)].map((_,i) => | |
| <div key={`sk-${i}`} className="aspect-square bg-white/5 rounded animate-pulse" /> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| {/* === RIGHT COLUMN === */} | |
| <div className="lg:col-span-7 space-y-6"> | |
| {/* Atlas Viewer */} | |
| <div className="glass-panel rounded-2xl p-6 min-h-[350px] flex flex-col relative"> | |
| <div className="flex justify-between items-start mb-4"> | |
| <StepTitle num="4" text="Generated Atlas" /> | |
| {sheetMeta.w > 0 && <span className="text-[10px] bg-white/5 border border-white/10 px-2 py-1 rounded text-gray-400 font-mono">{sheetMeta.w}x{sheetMeta.h}px</span>} | |
| </div> | |
| <div className="flex-1 rounded-xl border border-white/10 bg-[#1a1a1a] relative overflow-hidden flex items-center justify-center p-8 bg-[url('https://kaboomjs.com/site/img/checker.png')] bg-repeat"> | |
| <div className="absolute inset-0 bg-black/20 pointer-events-none" /> | |
| {spriteSheetUrl ? ( | |
| <div className="relative z-10 shadow-2xl shadow-black"> | |
| <img src={spriteSheetUrl} className="max-w-full max-h-[300px] object-contain pixelated" /> | |
| <div className="absolute inset-0 border border-red-500/20 pointer-events-none" style={{ | |
| backgroundImage: `linear-gradient(to right, rgba(255,50,50,0.2) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,50,50,0.2) 1px, transparent 1px)`, | |
| backgroundSize: `${100/sheetMeta.cols}% ${100/sheetMeta.rows}%` | |
| }} /> | |
| </div> | |
| ) : ( | |
| <div className="text-white/10 flex flex-col items-center gap-2 z-10"> | |
| <Grid size={40} /> | |
| <span className="text-xs font-mono">Waiting for input...</span> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> | |
| {/* Kaboom */} | |
| <div className="glass-panel rounded-2xl p-5 flex flex-col"> | |
| <StepTitle num="5" text="Kaboom Preview" /> | |
| <div className="bg-black/80 rounded-xl border border-white/10 aspect-square flex items-center justify-center relative overflow-hidden"> | |
| <div ref={kaboomContainerRef} className="w-full h-full" /> | |
| {!spriteSheetUrl && <p className="absolute text-white/20 text-xs">No Data</p>} | |
| </div> | |
| </div> | |
| {/* Export */} | |
| <div className="glass-panel rounded-2xl p-5 flex flex-col"> | |
| <StepTitle num="6" text="Export Assets" /> | |
| <div className="flex-1 flex flex-col gap-3"> | |
| <a href={spriteSheetUrl} download="sprite.png" className={`flex items-center justify-center gap-2 bg-white/5 hover:bg-white/10 border border-white/10 text-gray-300 text-xs py-3 rounded-lg transition-all ${!spriteSheetUrl && 'opacity-50 pointer-events-none'}`}> | |
| <ImageIcon size={14} /> Download PNG | |
| </a> | |
| <div className="relative bg-black/40 rounded-lg border border-white/5 p-3 flex-1 overflow-hidden group"> | |
| <pre className="text-[10px] text-green-400/90 font-mono h-full overflow-auto scrollbar-thin"> | |
| {spriteSheetUrl ? `loadSpriteAtlas("player", "sprite.png", { | |
| "idle": { | |
| x: 0, y: 0, | |
| width: ${sheetMeta.w}, height: ${sheetMeta.h}, | |
| sliceX: ${sheetMeta.cols}, sliceY: ${sheetMeta.rows}, | |
| anims: { | |
| run: { from: 0, to: ${config.frameCount-1}, speed: 10, loop: true } | |
| } | |
| } | |
| });` : '// Code will appear here'} | |
| </pre> | |
| <button onClick={() => navigator.clipboard.writeText('...')} className="absolute top-2 right-2 p-1.5 bg-white/10 hover:bg-white/20 rounded text-white opacity-0 group-hover:opacity-100 transition-opacity"> | |
| <Copy size={12} /> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default App; | |