Vid2SS / src /App.jsx
Shinhati2023's picture
Create src/App.jsx
390fcba verified
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;