Shinhati2023 commited on
Commit
390fcba
·
verified ·
1 Parent(s): a2d809a

Create src/App.jsx

Browse files
Files changed (1) hide show
  1. src/App.jsx +319 -0
src/App.jsx ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useRef, useEffect } from 'react';
2
+ import kaboom from "kaboom";
3
+ import { Upload, Play, Image as ImageIcon, Copy, Grid, Film, RefreshCw, Scissors } from 'lucide-react';
4
+
5
+ const STATES = {
6
+ IDLE: 'idle',
7
+ LOADING_VIDEO: 'loading_video',
8
+ VIDEO_READY: 'video_ready',
9
+ EXTRACTING: 'extracting_frames',
10
+ FRAMES_READY: 'frames_ready',
11
+ GENERATING: 'generating_sheet',
12
+ SHEET_READY: 'sheet_ready',
13
+ };
14
+
15
+ const App = () => {
16
+ const [appState, setAppState] = useState(STATES.IDLE);
17
+ const [videoSrc, setVideoSrc] = useState(null);
18
+ const [videoMeta, setVideoMeta] = useState({ duration: 0, width: 0, height: 0 });
19
+ const [thumbnail, setThumbnail] = useState(null);
20
+ const [extractedFrames, setExtractedFrames] = useState([]);
21
+ const [spriteSheetUrl, setSpriteSheetUrl] = useState(null);
22
+ const [sheetMeta, setSheetMeta] = useState({ w: 0, h: 0, cols: 0, rows: 0, frameW: 0, frameH: 0 });
23
+
24
+ const [config, setConfig] = useState({
25
+ frameCount: 16,
26
+ trimStart: 0,
27
+ trimEnd: 100,
28
+ scale: 1,
29
+ });
30
+
31
+ const hiddenVideoRef = useRef(null);
32
+ const kaboomContainerRef = useRef(null);
33
+ const kInstance = useRef(null);
34
+
35
+ // 1. INPUT HANDLER
36
+ const handleFileUpload = (e) => {
37
+ const file = e.target.files?.[0];
38
+ if (!file) return;
39
+ setAppState(STATES.LOADING_VIDEO);
40
+ const url = URL.createObjectURL(file);
41
+ setVideoSrc(url);
42
+ setExtractedFrames([]);
43
+ setSpriteSheetUrl(null);
44
+ };
45
+
46
+ const onVideoLoaded = (e) => {
47
+ const vid = e.target;
48
+ setVideoMeta({ duration: vid.duration, width: vid.videoWidth, height: vid.videoHeight });
49
+
50
+ // Thumbnail Capture
51
+ vid.currentTime = 0.5;
52
+ const captureThumb = () => {
53
+ const cvs = document.createElement('canvas');
54
+ cvs.width = vid.videoWidth / 4; cvs.height = vid.videoHeight / 4;
55
+ cvs.getContext('2d').drawImage(vid, 0, 0, cvs.width, cvs.height);
56
+ setThumbnail(cvs.toDataURL());
57
+ setAppState(STATES.VIDEO_READY);
58
+ vid.removeEventListener('seeked', captureThumb);
59
+ };
60
+ vid.addEventListener('seeked', captureThumb);
61
+ };
62
+
63
+ // 2. PIPELINE: EXTRACT
64
+ const startExtraction = async () => {
65
+ if (!hiddenVideoRef.current) return;
66
+ setAppState(STATES.EXTRACTING);
67
+ setExtractedFrames([]);
68
+
69
+ const vid = hiddenVideoRef.current;
70
+ const { frameCount, trimStart, trimEnd, scale } = config;
71
+ const startTime = (trimStart / 100) * vid.duration;
72
+ const playDuration = ((trimEnd / 100) * vid.duration) - startTime;
73
+ const timeStep = playDuration / frameCount;
74
+
75
+ const cvs = document.createElement('canvas');
76
+ cvs.width = vid.videoWidth * scale;
77
+ cvs.height = vid.videoHeight * scale;
78
+ const ctx = cvs.getContext('2d');
79
+ const newFrames = [];
80
+
81
+ for (let i = 0; i < frameCount; i++) {
82
+ vid.currentTime = startTime + (i * timeStep);
83
+ await new Promise(resolve => {
84
+ const onSeek = () => {
85
+ vid.removeEventListener('seeked', onSeek);
86
+ ctx.clearRect(0,0,cvs.width,cvs.height);
87
+ ctx.drawImage(vid,0,0,cvs.width,cvs.height);
88
+ cvs.toBlob(blob => {
89
+ newFrames.push(URL.createObjectURL(blob));
90
+ setExtractedFrames([...newFrames]);
91
+ resolve();
92
+ }, 'image/png');
93
+ };
94
+ vid.addEventListener('seeked', onSeek);
95
+ });
96
+ }
97
+ setAppState(STATES.FRAMES_READY);
98
+ generateSpriteSheet(newFrames);
99
+ };
100
+
101
+ // 3. PIPELINE: PACK
102
+ const generateSpriteSheet = (frames) => {
103
+ setAppState(STATES.GENERATING);
104
+ const count = frames.length;
105
+ const cols = Math.ceil(Math.sqrt(count));
106
+ const rows = Math.ceil(count / cols);
107
+ const fW = videoMeta.width * config.scale;
108
+ const fH = videoMeta.height * config.scale;
109
+
110
+ const cvs = document.createElement('canvas');
111
+ cvs.width = cols * fW; cvs.height = rows * fH;
112
+ const ctx = cvs.getContext('2d');
113
+
114
+ Promise.all(frames.map((src, i) => new Promise(resolve => {
115
+ const img = new Image();
116
+ img.onload = () => {
117
+ ctx.drawImage(img, (i % cols)*fW, Math.floor(i/cols)*fH, fW, fH);
118
+ resolve();
119
+ };
120
+ img.src = src;
121
+ }))).then(() => {
122
+ setSpriteSheetUrl(cvs.toDataURL('image/png'));
123
+ setSheetMeta({ w: cvs.width, h: cvs.height, cols, rows, frameW: fW, frameH: fH });
124
+ setAppState(STATES.SHEET_READY);
125
+ });
126
+ };
127
+
128
+ // 4. KABOOM PREVIEW
129
+ useEffect(() => {
130
+ if (appState === STATES.SHEET_READY && spriteSheetUrl && kaboomContainerRef.current) {
131
+ kaboomContainerRef.current.innerHTML = ''; // Force clear
132
+ try {
133
+ const k = kaboom({
134
+ root: kaboomContainerRef.current,
135
+ width: 300, height: 300,
136
+ background: [0,0,0,0], global: false
137
+ });
138
+ k.loadSprite("anim", spriteSheetUrl, {
139
+ sliceX: sheetMeta.cols, sliceY: sheetMeta.rows,
140
+ anims: { idle: { from: 0, to: config.frameCount - 1, loop: true, speed: 10 } }
141
+ });
142
+ k.scene("main", () => {
143
+ k.add([
144
+ k.sprite("anim"), k.pos(k.center()), k.anchor("center"),
145
+ k.scale(Math.min(250/sheetMeta.frameW, 250/sheetMeta.frameH))
146
+ ]).play("idle");
147
+ });
148
+ k.go("main");
149
+ kInstance.current = k;
150
+ } catch(e) { console.error(e); }
151
+ }
152
+ }, [spriteSheetUrl, sheetMeta]);
153
+
154
+ // UI HELPERS
155
+ const StepTitle = ({num, text}) => (
156
+ <h2 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-3 flex items-center gap-2">
157
+ <span className="bg-obsidian-border text-gray-300 w-4 h-4 rounded-full flex items-center justify-center">{num}</span> {text}
158
+ </h2>
159
+ );
160
+
161
+ return (
162
+ <div className="min-h-screen p-4 lg:p-8 font-sans">
163
+ <video ref={hiddenVideoRef} src={videoSrc} className="hidden" muted playsInline onLoadedData={onVideoLoaded} />
164
+
165
+ {/* Header */}
166
+ <header className="mb-8 flex items-center gap-3 border-b border-white/5 pb-6">
167
+ <div className="p-2 bg-obsidian-accent/10 rounded-lg">
168
+ <Film className="text-obsidian-accent w-6 h-6" />
169
+ </div>
170
+ <div>
171
+ <h1 className="text-xl font-bold text-white tracking-tight">Kaboom Pipeline</h1>
172
+ <p className="text-gray-500 text-xs">Docker Edition • Video to Sprite Sheet</p>
173
+ </div>
174
+ </header>
175
+
176
+ <div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
177
+
178
+ {/* === LEFT COLUMN === */}
179
+ <div className="lg:col-span-5 space-y-6">
180
+
181
+ {/* Upload Card */}
182
+ <div className="glass-panel rounded-2xl p-5 relative overflow-hidden group">
183
+ <StepTitle num="1" text="Source Media" />
184
+ {!videoSrc ? (
185
+ <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">
186
+ <Upload className="w-6 h-6 text-gray-400 mb-2" />
187
+ <span className="text-sm text-gray-400">Drop Video or GIF</span>
188
+ <input type="file" onChange={handleFileUpload} className="hidden" accept="video/*,image/gif" />
189
+ </label>
190
+ ) : (
191
+ <div className="relative rounded-xl overflow-hidden bg-black/50 aspect-video ring-1 ring-white/10">
192
+ {thumbnail && <img src={thumbnail} className="w-full h-full object-contain opacity-70" />}
193
+ <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">
194
+ <RefreshCw size={14} />
195
+ </button>
196
+ </div>
197
+ )}
198
+ </div>
199
+
200
+ {/* Controls */}
201
+ <div className={`glass-panel rounded-2xl p-5 transition-opacity ${!videoSrc ? 'opacity-40 pointer-events-none' : 'opacity-100'}`}>
202
+ <StepTitle num="2" text="Extraction Config" />
203
+ <div className="space-y-6">
204
+ <div className="space-y-4">
205
+ <div>
206
+ <div className="flex justify-between text-xs mb-2 text-gray-400">
207
+ <span>Frame Count</span> <span className="text-obsidian-accent font-mono">{config.frameCount}</span>
208
+ </div>
209
+ <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" />
210
+ </div>
211
+ <div>
212
+ <div className="flex justify-between text-xs mb-2 text-gray-400">
213
+ <span className="flex items-center gap-1"><Scissors size={12}/> Trim</span> <span className="font-mono">{config.trimStart}% - {config.trimEnd}%</span>
214
+ </div>
215
+ <div className="flex gap-2">
216
+ <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"/>
217
+ <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"/>
218
+ </div>
219
+ </div>
220
+ </div>
221
+
222
+ <button onClick={startExtraction} disabled={appState === STATES.EXTRACTING}
223
+ 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">
224
+ {appState === STATES.EXTRACTING ? "Processing..." : <>Start Extraction <Play size={14} fill="currentColor" /></>}
225
+ </button>
226
+ </div>
227
+ </div>
228
+
229
+ {/* Grid Preview */}
230
+ <div className="glass-panel rounded-2xl p-5 h-64 overflow-y-auto">
231
+ <StepTitle num="3" text="Frame Buffer" />
232
+ <div className="grid grid-cols-5 gap-2">
233
+ {extractedFrames.map((src,i) => (
234
+ <div key={i} className="aspect-square bg-black/40 rounded border border-white/5 relative overflow-hidden">
235
+ <img src={src} className="w-full h-full object-contain pixelated" />
236
+ </div>
237
+ ))}
238
+ {appState === STATES.EXTRACTING && [...Array(config.frameCount - extractedFrames.length)].map((_,i) =>
239
+ <div key={`sk-${i}`} className="aspect-square bg-white/5 rounded animate-pulse" />
240
+ )}
241
+ </div>
242
+ </div>
243
+ </div>
244
+
245
+ {/* === RIGHT COLUMN === */}
246
+ <div className="lg:col-span-7 space-y-6">
247
+
248
+ {/* Atlas Viewer */}
249
+ <div className="glass-panel rounded-2xl p-6 min-h-[350px] flex flex-col relative">
250
+ <div className="flex justify-between items-start mb-4">
251
+ <StepTitle num="4" text="Generated Atlas" />
252
+ {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>}
253
+ </div>
254
+
255
+ <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">
256
+ <div className="absolute inset-0 bg-black/20 pointer-events-none" />
257
+ {spriteSheetUrl ? (
258
+ <div className="relative z-10 shadow-2xl shadow-black">
259
+ <img src={spriteSheetUrl} className="max-w-full max-h-[300px] object-contain pixelated" />
260
+ <div className="absolute inset-0 border border-red-500/20 pointer-events-none" style={{
261
+ 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)`,
262
+ backgroundSize: `${100/sheetMeta.cols}% ${100/sheetMeta.rows}%`
263
+ }} />
264
+ </div>
265
+ ) : (
266
+ <div className="text-white/10 flex flex-col items-center gap-2 z-10">
267
+ <Grid size={40} />
268
+ <span className="text-xs font-mono">Waiting for input...</span>
269
+ </div>
270
+ )}
271
+ </div>
272
+ </div>
273
+
274
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
275
+ {/* Kaboom */}
276
+ <div className="glass-panel rounded-2xl p-5 flex flex-col">
277
+ <StepTitle num="5" text="Kaboom Preview" />
278
+ <div className="bg-black/80 rounded-xl border border-white/10 aspect-square flex items-center justify-center relative overflow-hidden">
279
+ <div ref={kaboomContainerRef} className="w-full h-full" />
280
+ {!spriteSheetUrl && <p className="absolute text-white/20 text-xs">No Data</p>}
281
+ </div>
282
+ </div>
283
+
284
+ {/* Export */}
285
+ <div className="glass-panel rounded-2xl p-5 flex flex-col">
286
+ <StepTitle num="6" text="Export Assets" />
287
+ <div className="flex-1 flex flex-col gap-3">
288
+ <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'}`}>
289
+ <ImageIcon size={14} /> Download PNG
290
+ </a>
291
+
292
+ <div className="relative bg-black/40 rounded-lg border border-white/5 p-3 flex-1 overflow-hidden group">
293
+ <pre className="text-[10px] text-green-400/90 font-mono h-full overflow-auto scrollbar-thin">
294
+ {spriteSheetUrl ? `loadSpriteAtlas("player", "sprite.png", {
295
+ "idle": {
296
+ x: 0, y: 0,
297
+ width: ${sheetMeta.w}, height: ${sheetMeta.h},
298
+ sliceX: ${sheetMeta.cols}, sliceY: ${sheetMeta.rows},
299
+ anims: {
300
+ run: { from: 0, to: ${config.frameCount-1}, speed: 10, loop: true }
301
+ }
302
+ }
303
+ });` : '// Code will appear here'}
304
+ </pre>
305
+ <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">
306
+ <Copy size={12} />
307
+ </button>
308
+ </div>
309
+ </div>
310
+ </div>
311
+ </div>
312
+
313
+ </div>
314
+ </div>
315
+ </div>
316
+ );
317
+ };
318
+
319
+ export default App;