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}) => (
Docker Edition • Video to Sprite Sheet
No Data
}
{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'}