Manager
View Site
Name
Type
React (.jsx)
HTML (.html)
Icon
Description
Code Editor
Revision History (0)
import React, { useState, useRef, useEffect } from 'react'; import { Upload, Image as ImageIcon, Download, Play, Pause, Trash2, Crosshair, Scissors, Layers, Settings, Video, Check, AlertCircle, Clock, Eraser } from 'lucide-react'; export default function App() { // --- State --- const [videoSrc, setVideoSrc] = useState(null); const [videoDimensions, setVideoDimensions] = useState({ width: 0, height: 0, duration: 0 }); // Processing Settings const [frameCount, setFrameCount] = useState(16); const [cropSize, setCropSize] = useState(0.8); // 0 to 1 (percentage of shortest side) const [trimRange, setTrimRange] = useState({ start: 0, end: 0 }); // Video Trim (in seconds) // Eraser / Mask Settings const [eraser, setEraser] = useState({ enabled: false, x: 0.8, // Percentage of crop width y: 0.8, // Percentage of crop height width: 0.15, height: 0.1 }); // Background Removal Settings const [removeBg, setRemoveBg] = useState(false); const [contiguous, setContiguous] = useState(true); const [targetColor, setTargetColor] = useState('#00ff00'); const [tolerance, setTolerance] = useState(100); const [isPickingColor, setIsPickingColor] = useState(false); // Output State const [processing, setProcessing] = useState(false); const [progress, setProgress] = useState(0); const [spriteSheetUrl, setSpriteSheetUrl] = useState(null); const [gifUrl, setGifUrl] = useState(null); const [previewFrameIndex, setPreviewFrameIndex] = useState(0); const [isPlayingPreview, setIsPlayingPreview] = useState(false); const [frames, setFrames] = useState([]); const [calculatedFps, setCalculatedFps] = useState(0); // New: Store calculated FPS // Refs & Workers const videoRef = useRef(null); const fileInputRef = useRef(null); const previewIntervalRef = useRef(null); const gifWorkerBlobUrl = useRef(null); // --- Initialization --- useEffect(() => { if (!document.getElementById('gifjs-script')) { const script = document.createElement('script'); script.id = 'gifjs-script'; script.src = 'https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.js'; document.body.appendChild(script); } const fetchWorker = async () => { try { const response = await fetch('https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.worker.js'); const blob = await response.blob(); gifWorkerBlobUrl.current = URL.createObjectURL(blob); } catch (e) { console.error("Failed to load GIF worker", e); } }; fetchWorker(); return () => { if (gifWorkerBlobUrl.current) URL.revokeObjectURL(gifWorkerBlobUrl.current); }; }, []); // --- Handlers --- const handleFileUpload = (e) => { const file = e.target.files[0]; if (file) { const url = URL.createObjectURL(file); setVideoSrc(url); setSpriteSheetUrl(null); setGifUrl(null); setFrames([]); setRemoveBg(false); setIsPickingColor(false); setTrimRange({ start: 0, end: 0 }); // Reset trim setEraser({ enabled: false, x: 0.8, y: 0.8, width: 0.15, height: 0.1 }); setCalculatedFps(0); } }; const onVideoLoaded = () => { if (videoRef.current) { const dur = videoRef.current.duration; setVideoDimensions({ width: videoRef.current.videoWidth, height: videoRef.current.videoHeight, duration: dur }); // Initialize trim range to full duration if not set if (trimRange.end === 0) { setTrimRange({ start: 0, end: dur }); } } }; // Handler for scrubbing trim sliders const handleTrimChange = (type, value) => { const val = parseFloat(value); setTrimRange(prev => { const newRange = { ...prev, [type]: val }; // Validation to prevent crossing if (type === 'start' && val >= prev.end) newRange.start = prev.end - 0.1; if (type === 'end' && val <= prev.start) newRange.end = prev.start + 0.1; // Seek video to show the frame being trimmed to if (videoRef.current) { videoRef.current.currentTime = newRange[type]; } return newRange; }); }; const hexToRgb = (hex) => { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); return result ? { r: parseInt(result[1], 16), g: parseInt(result[2], 16), b: parseInt(result[3], 16) } : null; }; // --- Image Processing --- const colorDistance = (r1, g1, b1, r2, g2, b2) => { return Math.sqrt(Math.pow(r1 - r2, 2) + Math.pow(g1 - g2, 2) + Math.pow(b1 - b2, 2)); }; const processFrameData = (ctx, width, height) => { // 1. Background Removal if (removeBg) { const imgData = ctx.getImageData(0, 0, width, height); const data = imgData.data; const target = hexToRgb(targetColor); if (target) { if (contiguous) { const stack = []; const visited = new Int32Array(width * height); const checkAndPush = (x, y) => { if (x < 0 || x >= width || y < 0 || y >= height) return; const idx = y * width + x; if (visited[idx]) return; const pos = idx * 4; const dist = colorDistance(data[pos], data[pos+1], data[pos+2], target.r, target.g, target.b); if (dist <= tolerance) { stack.push([x, y]); visited[idx] = 1; } }; // Seed corners checkAndPush(0, 0); checkAndPush(width - 1, 0); checkAndPush(0, height - 1); checkAndPush(width - 1, height - 1); while (stack.length > 0) { const [x, y] = stack.pop(); const idx = (y * width + x) * 4; data[idx + 3] = 0; // Alpha 0 checkAndPush(x + 1, y); checkAndPush(x - 1, y); checkAndPush(x, y + 1); checkAndPush(x, y - 1); } } else { for (let i = 0; i < data.length; i += 4) { const dist = colorDistance(data[i], data[i+1], data[i+2], target.r, target.g, target.b); if (dist <= tolerance) { data[i + 3] = 0; } } } ctx.putImageData(imgData, 0, 0); } } // 2. Eraser (Post-processing mask) if (eraser.enabled) { const eX = Math.floor(eraser.x * width); const eY = Math.floor(eraser.y * height); const eW = Math.floor(eraser.width * width); const eH = Math.floor(eraser.height * height); ctx.clearRect(eX, eY, eW, eH); } }; const generateSpriteSheet = async () => { if (!videoRef.current) return; setProcessing(true); setProgress(0); setFrames([]); setGifUrl(null); setIsPlayingPreview(false); const vid = videoRef.current; // Use Trimmed Duration const duration = trimRange.end - trimRange.start; if (duration <= 0) { setProcessing(false); return; } const timeStep = duration / frameCount; // Seconds per frame // Calculate FPS and Delay const fps = frameCount / duration; const gifDelayMs = timeStep * 1000; setCalculatedFps(parseFloat(fps.toFixed(2))); // Store for filename // Dimensions const minDim = Math.min(vid.videoWidth, vid.videoHeight); const size = Math.floor(minDim * cropSize); const sourceX = (vid.videoWidth - size) / 2; const sourceY = (vid.videoHeight - size) / 2; const capturedFrames = []; // Capture Loop const captureFrame = async (index) => { return new Promise((resolve) => { // Offset by trimStart const currentTime = trimRange.start + (index * timeStep); vid.currentTime = currentTime; const onSeeked = () => { const tempCanvas = document.createElement('canvas'); tempCanvas.width = size; tempCanvas.height = size; const ctx = tempCanvas.getContext('2d'); // Draw cropped portion ctx.drawImage(vid, sourceX, sourceY, size, size, 0, 0, size, size); // Process (Remove Background + Erase) processFrameData(ctx, size, size); capturedFrames.push(tempCanvas); vid.removeEventListener('seeked', onSeeked); setProgress(Math.round(((index + 1) / frameCount) * 80)); resolve(); }; vid.addEventListener('seeked', onSeeked, { once: true }); }); }; for (let i = 0; i < frameCount; i++) { await captureFrame(i); } // 1. Generate Sprite Sheet const cols = Math.ceil(Math.sqrt(frameCount)); const rows = Math.ceil(frameCount / cols); const finalCanvas = document.createElement('canvas'); finalCanvas.width = cols * size; finalCanvas.height = rows * size; const finalCtx = finalCanvas.getContext('2d'); capturedFrames.forEach((frame, i) => { const col = i % cols; const row = Math.floor(i / cols); finalCtx.drawImage(frame, col * size, row * size); }); setSpriteSheetUrl(finalCanvas.toDataURL('image/png')); setFrames(capturedFrames); // 2. Generate GIF with calculated speed await generateGif(capturedFrames, size, gifDelayMs); setProcessing(false); }; const generateGif = async (canvases, size, delay) => { setProgress(85); if (window.GIF && gifWorkerBlobUrl.current) { const TRANSPARENT_HEX = 0xFF00FF; const TRANSPARENT_CSS = '#FF00FF'; const gif = new window.GIF({ workers: 2, quality: 10, width: size, height: size, workerScript: gifWorkerBlobUrl.current, transparent: TRANSPARENT_HEX }); canvases.forEach(originalCanvas => { const buffer = document.createElement('canvas'); buffer.width = size; buffer.height = size; const ctx = buffer.getContext('2d'); ctx.fillStyle = TRANSPARENT_CSS; ctx.fillRect(0, 0, size, size); ctx.drawImage(originalCanvas, 0, 0); gif.addFrame(buffer, { delay: delay, copy: true, dispose: 2 }); }); gif.on('progress', (p) => { setProgress(85 + Math.round(p * 15)); }); gif.on('finished', (blob) => { setGifUrl(URL.createObjectURL(blob)); setProgress(100); }); try { gif.render(); } catch (err) { console.error("GIF Render failed", err); setProcessing(false); } } else { console.warn("GIF library or Worker not loaded"); setProgress(100); } }; // Sync preview playback speed with video speed useEffect(() => { if (isPlayingPreview && frames.length > 0) { // Determine delay. If we calculated it, use it. If not (just uploaded), use 100ms default const delay = calculatedFps > 0 ? (1000 / calculatedFps) : 100; previewIntervalRef.current = setInterval(() => { setPreviewFrameIndex((prev) => (prev + 1) % frames.length); }, delay); } else { clearInterval(previewIntervalRef.current); } return () => clearInterval(previewIntervalRef.current); }, [isPlayingPreview, frames, calculatedFps]); const pickColorFromVideo = () => { setIsPickingColor(!isPickingColor); }; const handleVideoClick = (e) => { if (!isPickingColor || !videoRef.current) return; const vid = videoRef.current; const rect = e.target.getBoundingClientRect(); const renderedRatio = rect.width / rect.height; const originalRatio = vid.videoWidth / vid.videoHeight; let displayedWidth = rect.width; let displayedHeight = rect.height; let offsetX = 0; let offsetY = 0; if (renderedRatio > originalRatio) { displayedWidth = rect.height * originalRatio; offsetX = (rect.width - displayedWidth) / 2; } else { displayedHeight = rect.width / originalRatio; offsetY = (rect.height - displayedHeight) / 2; } const clickX = e.clientX - rect.left - offsetX; const clickY = e.clientY - rect.top - offsetY; if (clickX < 0 || clickX > displayedWidth || clickY < 0 || clickY > displayedHeight) return; const scaleX = vid.videoWidth / displayedWidth; const scaleY = vid.videoHeight / displayedHeight; const finalX = clickX * scaleX; const finalY = clickY * scaleY; const canvas = document.createElement('canvas'); canvas.width = 1; canvas.height = 1; const ctx = canvas.getContext('2d'); ctx.drawImage(vid, finalX, finalY, 1, 1, 0, 0, 1, 1); const p = ctx.getImageData(0, 0, 1, 1).data; const hex = "#" + ("000000" + ((p[0] << 16) | (p[1] << 8) | p[2]).toString(16)).slice(-6); setTargetColor(hex); setRemoveBg(true); setIsPickingColor(false); }; const getCropStyle = () => { if (!videoRef.current || videoDimensions.width === 0) return {}; const minDim = Math.min(videoDimensions.width, videoDimensions.height); const cropPixelSize = minDim * cropSize; return { width: `${(cropPixelSize / videoDimensions.width) * 100}%`, height: `${(cropPixelSize / videoDimensions.height) * 100}%` }; }; return ( <div className="min-h-screen bg-slate-900 text-slate-100 font-sans p-2 md:p-8"> <div className="max-w-6xl mx-auto space-y-6 md:space-y-8"> {/* Header */} <header className="flex flex-col md:flex-row md:items-center justify-between border-b border-slate-700 pb-4 md:pb-6 gap-4"> <div className="flex items-center gap-3"> <div className="bg-indigo-500 p-2 rounded-lg shrink-0"> <Layers className="w-6 h-6 text-white" /> </div> <div> <h1 className="text-xl md:text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-indigo-400 to-cyan-400">SpriteForge</h1> <p className="text-slate-400 text-xs md:text-sm">Video to Sprite Sheet & GIF</p> </div> </div> <button onClick={() => { setVideoSrc(null); setSpriteSheetUrl(null); setGifUrl(null); setEraser({ enabled: false, x: 0.8, y: 0.8, width: 0.15, height: 0.1 }); setCalculatedFps(0); }} className="text-xs text-slate-500 hover:text-white underline text-left md:text-right"> Reset All </button> </header> {/* Main Interface */} {!videoSrc ? ( // Upload State <div className="border-2 border-dashed border-slate-700 hover:border-indigo-500 hover:bg-slate-800/50 rounded-2xl p-8 md:p-16 flex flex-col items-center justify-center transition-all cursor-pointer group text-center" onClick={() => fileInputRef.current?.click()} > <input type="file" accept="video/*" ref={fileInputRef} className="hidden" onChange={handleFileUpload} /> <div className="w-16 h-16 md:w-20 md:h-20 bg-slate-800 rounded-full flex items-center justify-center mb-6 group-hover:scale-110 transition-transform shadow-lg"> <Upload className="w-8 h-8 md:w-10 md:h-10 text-indigo-400" /> </div> <h2 className="text-lg md:text-xl font-semibold mb-2">Tap to Upload Video</h2> <p className="text-slate-400 text-sm">MP4, WebM, MOV supported</p> </div> ) : ( // Editor State <div className="flex flex-col lg:grid lg:grid-cols-3 gap-6 md:gap-8"> {/* Left Column: Preview & Source */} <div className="lg:col-span-2 space-y-4 md:space-y-6 order-2 lg:order-1"> {/* Video Preview Area */} <div className="bg-black/50 rounded-xl p-2 md:p-4 border border-slate-700 flex justify-center"> <div className="relative group w-full flex justify-center" style={{ aspectRatio: videoDimensions.width && videoDimensions.height ? `${videoDimensions.width} / ${videoDimensions.height}` : 'auto', maxHeight: '60vh' }} > <video ref={videoRef} src={videoSrc} className={`w-full h-full object-contain ${isPickingColor ? 'cursor-crosshair' : ''}`} controls={!isPickingColor} playsInline onLoadedMetadata={onVideoLoaded} onClick={handleVideoClick} crossOrigin="anonymous" /> {/* Crop Overlay Visualizer */} <div className="absolute inset-0 pointer-events-none flex items-center justify-center opacity-50"> <div className="border-2 border-yellow-400 shadow-[0_0_0_9999px_rgba(0,0,0,0.7)] box-content transition-all relative" style={getCropStyle()} > {/* ERASER VISUALIZER (Inside Crop) */} {eraser.enabled && ( <div className="absolute bg-red-500/40 border border-red-500 animate-pulse" style={{ left: `${eraser.x * 100}%`, top: `${eraser.y * 100}%`, width: `${eraser.width * 100}%`, height: `${eraser.height * 100}%` }} > <div className="text-[10px] text-white absolute -top-4 left-0 bg-red-600 px-1 rounded">Eraser</div> </div> )} </div> </div> {isPickingColor && ( <div className="absolute top-4 bg-black/90 text-white px-4 py-2 rounded-lg text-sm flex items-center animate-pulse border border-yellow-400 pointer-events-none z-10 justify-center shadow-xl"> <Crosshair className="w-4 h-4 mr-2" /> Tap background color </div> )} </div> </div> {/* Action Bar */} <button onClick={generateSpriteSheet} disabled={processing} className={`w-full py-4 rounded-xl font-bold text-lg shadow-lg flex items-center justify-center gap-2 transition-all active:scale-95 ${processing ? 'bg-slate-700 cursor-not-allowed text-slate-400' : 'bg-gradient-to-r from-indigo-600 to-blue-600 hover:from-indigo-500 hover:to-blue-500 text-white'}`} > {processing ? ( <> <div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div> <span className="text-base">{progress < 90 ? `Capturing ${progress}%` : `Encoding GIF ${progress}%`}</span> </> ) : ( <> <ImageIcon className="w-5 h-5" /> Generate Sprite & GIF </> )} </button> {/* Result Preview */} {(spriteSheetUrl || gifUrl) && ( <div className="bg-slate-800 rounded-xl p-4 md:p-6 border border-slate-700 animate-in fade-in slide-in-from-bottom-4 scroll-mt-4" id="results"> <div className="flex justify-between items-center mb-4"> <h3 className="text-lg font-semibold flex items-center gap-2"> <ImageIcon className="w-5 h-5 text-green-400" /> Results </h3> <div className="flex gap-2"> <button onClick={() => setIsPlayingPreview(!isPlayingPreview)} className="bg-slate-700 hover:bg-slate-600 text-white p-2 rounded-lg transition-colors" title="Play Preview" > {isPlayingPreview ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />} </button> </div> </div> <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> {/* Full Sheet */} {spriteSheetUrl && ( <div className="space-y-2"> <div className="flex justify-between items-center"> <div> <p className="text-xs text-slate-400 uppercase tracking-wider font-bold">Sprite Sheet</p> {calculatedFps > 0 && <p className="text-[10px] text-indigo-400">FPS: {calculatedFps}</p>} </div> <a href={spriteSheetUrl} download={`sprite-sheet-${calculatedFps}fps-${Date.now()}.png`} className="text-xs bg-slate-700 hover:bg-slate-600 px-3 py-1.5 rounded text-white flex items-center gap-1 font-medium" > <Download className="w-3 h-3" /> PNG </a> </div> <div className="bg-[url('https://www.transparenttextures.com/patterns/stardust.png')] bg-slate-700/50 rounded-lg overflow-hidden border border-slate-600 h-48 flex items-center justify-center p-2"> <img src={spriteSheetUrl} alt="Sprite Sheet" className="max-w-full max-h-full object-contain" /> </div> </div> )} {/* GIF Output */} {gifUrl && ( <div className="space-y-2"> <div className="flex justify-between items-center"> <p className="text-xs text-slate-400 uppercase tracking-wider font-bold">Animated GIF</p> <a href={gifUrl} download={`sprite-animation-${Date.now()}.gif`} className="text-xs bg-slate-700 hover:bg-slate-600 px-3 py-1.5 rounded text-white flex items-center gap-1 font-medium" > <Download className="w-3 h-3" /> GIF </a> </div> <div className="bg-[url('https://www.transparenttextures.com/patterns/stardust.png')] bg-slate-700/50 rounded-lg h-48 flex items-center justify-center border border-slate-600"> <img src={gifUrl} alt="Generated GIF" className="max-h-full max-w-full object-contain" /> </div> </div> )} </div> </div> )} </div> {/* Right Column: Settings */} <div className="space-y-4 md:space-y-6 order-1 lg:order-2"> {/* Loop & Trim Settings */} <div className="bg-slate-800 rounded-xl p-4 md:p-6 border border-slate-700"> <h3 className="text-lg font-semibold mb-6 flex items-center gap-2 text-indigo-300"> <Clock className="w-5 h-5" /> Loop & Trim </h3> <div className="space-y-4"> <div> <div className="flex justify-between mb-1"> <label className="text-xs font-medium text-slate-400">Start</label> <span className="text-xs text-indigo-400 font-mono">{trimRange.start.toFixed(2)}s</span> </div> <input type="range" min="0" max={videoDimensions.duration} step="0.05" value={trimRange.start} onChange={(e) => handleTrimChange('start', e.target.value)} className="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-indigo-500" /> </div> <div> <div className="flex justify-between mb-1"> <label className="text-xs font-medium text-slate-400">End</label> <span className="text-xs text-indigo-400 font-mono">{trimRange.end.toFixed(2)}s</span> </div> <input type="range" min="0" max={videoDimensions.duration} step="0.05" value={trimRange.end} onChange={(e) => handleTrimChange('end', e.target.value)} className="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-indigo-500" /> </div> </div> </div> {/* Eraser / Watermark Settings */} <div className="bg-slate-800 rounded-xl p-4 md:p-6 border border-slate-700"> <div className="flex items-center justify-between mb-4"> <h3 className="text-lg font-semibold flex items-center gap-2 text-indigo-300"> <Eraser className="w-5 h-5" /> Eraser </h3> <div className="flex items-center"> <input type="checkbox" id="eraserToggle" checked={eraser.enabled} onChange={(e) => setEraser(prev => ({ ...prev, enabled: e.target.checked }))} className="sr-only peer" /> <label htmlFor="eraserToggle" className="relative inline-flex items-center cursor-pointer"> <div className="w-9 h-5 bg-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-red-600"></div> </label> </div> </div> {eraser.enabled && ( <div className="space-y-3 animate-in fade-in slide-in-from-top-2"> <div className="grid grid-cols-2 gap-4"> <div> <label className="text-xs text-slate-400 mb-1 block">Position X</label> <input type="range" min="0" max="1" step="0.01" value={eraser.x} onChange={(e) => setEraser(prev => ({ ...prev, x: parseFloat(e.target.value) }))} className="w-full h-2 bg-slate-700 rounded-lg accent-red-500" /> </div> <div> <label className="text-xs text-slate-400 mb-1 block">Position Y</label> <input type="range" min="0" max="1" step="0.01" value={eraser.y} onChange={(e) => setEraser(prev => ({ ...prev, y: parseFloat(e.target.value) }))} className="w-full h-2 bg-slate-700 rounded-lg accent-red-500" /> </div> <div> <label className="text-xs text-slate-400 mb-1 block">Width</label> <input type="range" min="0.05" max="1" step="0.01" value={eraser.width} onChange={(e) => setEraser(prev => ({ ...prev, width: parseFloat(e.target.value) }))} className="w-full h-2 bg-slate-700 rounded-lg accent-red-500" /> </div> <div> <label className="text-xs text-slate-400 mb-1 block">Height</label> <input type="range" min="0.05" max="1" step="0.01" value={eraser.height} onChange={(e) => setEraser(prev => ({ ...prev, height: parseFloat(e.target.value) }))} className="w-full h-2 bg-slate-700 rounded-lg accent-red-500" /> </div> </div> <p className="text-[10px] text-slate-500 text-center">Move the sliders to cover the watermark inside the yellow frame.</p> </div> )} </div> {/* Crop & Scale */} <div className="bg-slate-800 rounded-xl p-4 md:p-6 border border-slate-700"> <h3 className="text-lg font-semibold mb-6 flex items-center gap-2 text-indigo-300"> <Scissors className="w-5 h-5" /> Crop & Scale </h3> <div className="space-y-6"> <div> <div className="flex justify-between mb-2"> <label className="text-sm font-medium text-slate-300">Central Zoom</label> <span className="text-xs text-indigo-400 font-mono">{Math.round(cropSize * 100)}%</span> </div> <input type="range" min="0.2" max="1" step="0.05" value={cropSize} onChange={(e) => setCropSize(parseFloat(e.target.value))} className="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-indigo-500 touch-action-manipulation" /> </div> <div> <div className="flex justify-between mb-2"> <label className="text-sm font-medium text-slate-300">Total Frames</label> <span className="text-xs text-indigo-400 font-mono">{frameCount} frames</span> </div> <input type="range" min="4" max="64" step="4" value={frameCount} onChange={(e) => setFrameCount(parseInt(e.target.value))} className="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-indigo-500 touch-action-manipulation" /> </div> </div> </div> {/* Background Removal */} <div className="bg-slate-800 rounded-xl p-4 md:p-6 border border-slate-700"> <div className="flex justify-between items-start mb-6"> <h3 className="text-lg font-semibold flex items-center gap-2 text-indigo-300"> <Settings className="w-5 h-5" /> Remove BG </h3> <div className="flex items-center"> <input type="checkbox" id="removeBgToggle" checked={removeBg} onChange={(e) => setRemoveBg(e.target.checked)} className="sr-only peer" /> <label htmlFor="removeBgToggle" className="relative inline-flex items-center cursor-pointer"> <div className="w-11 h-6 bg-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-indigo-600"></div> </label> </div> </div> {removeBg && ( <div className="space-y-6 animate-in fade-in slide-in-from-top-2"> <div> <label className="text-sm font-medium text-slate-300 mb-2 block">Key Color</label> <div className="flex gap-2"> <div className="w-10 h-10 rounded border border-slate-600 shadow-inner shrink-0" style={{ backgroundColor: targetColor }} ></div> <button onClick={pickColorFromVideo} className={`flex-1 px-3 rounded-lg text-sm font-medium border transition-colors ${isPickingColor ? 'bg-indigo-600 border-indigo-500 text-white' : 'bg-slate-700 border-slate-600 text-slate-300 hover:bg-slate-600'}`} > {isPickingColor ? 'Tap video...' : 'Pick from Video'} </button> <div className="relative w-10 h-10 overflow-hidden rounded border border-slate-600 shrink-0"> <input type="color" value={targetColor} onChange={(e) => setTargetColor(e.target.value)} className="absolute -top-2 -left-2 w-16 h-16 p-0 border-0 bg-transparent cursor-pointer" /> </div> </div> </div> <div className="flex items-center justify-between bg-slate-900/50 p-3 rounded-lg border border-slate-600"> <label className="text-sm font-medium text-slate-300 cursor-pointer select-none" htmlFor="contiguousMode"> Contiguous <span className="block text-xs text-slate-500 font-normal">Edges only</span> </label> <input type="checkbox" id="contiguousMode" checked={contiguous} onChange={(e) => setContiguous(e.target.checked)} className="w-5 h-5 accent-indigo-500 rounded focus:ring-indigo-500" /> </div> <div> <div className="flex justify-between mb-2"> <label className="text-sm font-medium text-slate-300">Tolerance</label> <span className="text-xs text-indigo-400 font-mono">{tolerance}</span> </div> <input type="range" min="10" max="250" value={tolerance} onChange={(e) => setTolerance(parseInt(e.target.value))} className="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-indigo-500 touch-action-manipulation" /> </div> </div> )} {!removeBg && ( <div className="text-sm text-slate-500 italic p-4 bg-slate-900/50 rounded-lg border border-slate-800"> <div className="flex items-start gap-2"> <AlertCircle className="w-4 h-4 shrink-0 mt-0.5" /> Enable this to remove green/blue screens or solid backgrounds. </div> </div> )} </div> </div> </div> )} </div> </div> ); }
No revisions found. Save the file to create a backup.
Delete
Update App