Spaces:
Running on Zero
Running on Zero
| // Processing + Result modals. | |
| // Talks to /gradio_api/* directly via fetch — bypasses @gradio/client (which | |
| // hardcodes /gradio/config on hf.space URLs and 404s against gradio.Server). | |
| // Auth comes from the HF OAuth session cookie set after /login/huggingface; | |
| // same-origin fetches forward it automatically with credentials: 'include'. | |
| // Client-side resize: prevents huge phone photos from OOMing ZeroGPU on decode. | |
| // Server still resizes to 1024 longest edge; this is an upstream safety net. | |
| async function resizeImageFile(file, maxEdge = 2048, quality = 0.92) { | |
| if (!/^image\//.test(file.type)) return file; | |
| const bitmap = await createImageBitmap(file); | |
| const { width: w, height: h } = bitmap; | |
| if (Math.max(w, h) <= maxEdge) { bitmap.close?.(); return file; } | |
| const scale = maxEdge / Math.max(w, h); | |
| const tw = Math.round(w * scale); | |
| const th = Math.round(h * scale); | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = tw; canvas.height = th; | |
| canvas.getContext('2d').drawImage(bitmap, 0, 0, tw, th); | |
| bitmap.close?.(); | |
| const blob = await new Promise(r => canvas.toBlob(r, 'image/jpeg', quality)); | |
| return new File([blob], (file.name || 'upload').replace(/\.[^.]+$/, '') + '.jpg', { type: 'image/jpeg' }); | |
| } | |
| // Generate a URL-safe session id. Used to key the server-side result store | |
| // so the frontend can recover after a tab-away/screen-lock. | |
| function newSessionId() { | |
| if (window.crypto && crypto.randomUUID) return crypto.randomUUID().replace(/-/g, ''); | |
| return Array.from(crypto.getRandomValues(new Uint8Array(16))) | |
| .map(b => b.toString(16).padStart(2, '0')).join(''); | |
| } | |
| function ProcessingModal({ open, file, onDone, onError, onClose }) { | |
| const [progress, setProgress] = React.useState(0); | |
| const [step, setStep] = React.useState(0); | |
| const [imageUrl, setImageUrl] = React.useState(null); | |
| const cancelRef = React.useRef({ cancelled: false }); | |
| const steps = [ | |
| { t: 'Reading the brief', s: 'STEP 1' }, | |
| { t: 'Welding the joints', s: 'STEP 2' }, | |
| { t: 'Casting in plastic', s: 'STEP 3' }, | |
| { t: 'Out of the oven', s: 'STEP 4' }, | |
| { t: 'Painting final details', s: 'STEP 5' }, | |
| ]; | |
| React.useEffect(() => { | |
| if (!open || !file) return; | |
| cancelRef.current = { cancelled: false }; | |
| setProgress(0); | |
| setStep(0); | |
| const url = URL.createObjectURL(file); | |
| setImageUrl(url); | |
| // Stamp the URL with a session id so we can recover the result after | |
| // a tab-away. Cleared by the App component on modal close. | |
| const sessionId = newSessionId(); | |
| const params = new URLSearchParams(window.location.search); | |
| params.set('session', sessionId); | |
| window.history.replaceState(null, '', window.location.pathname + '?' + params.toString()); | |
| let p = 0; | |
| const id = setInterval(() => { | |
| if (cancelRef.current.cancelled) { clearInterval(id); return; } | |
| p += 0.6 + Math.random() * 1.2; | |
| if (p > 92) p = 92; | |
| setProgress(p); | |
| setStep(Math.min(4, Math.floor((p / 100) * 5))); | |
| }, 120); | |
| (async () => { | |
| try { | |
| const upload = await resizeImageFile(file, 2048, 0.92); | |
| // Use @gradio/client (loaded via CDN in index.html). It does the | |
| // ZeroGPU handshake — sends `supports-zerogpu-headers: 1` and exchanges | |
| // the iframe's __sign for a fresh proxy token. Manual fetch skips this | |
| // and the scheduler returns "Expired ZeroGPU proxy token". | |
| let tries = 0; | |
| while ((!window.__gradio || !window.__gradio.Client) && tries < 100) { | |
| await new Promise(r => setTimeout(r, 50)); | |
| tries++; | |
| } | |
| if (!window.__gradio || !window.__gradio.Client) { | |
| throw new Error('Gradio client failed to load from CDN'); | |
| } | |
| const { Client, handle_file } = window.__gradio; | |
| const client = await Client.connect(window.location.origin); | |
| const result = await client.predict('/action', { | |
| image_path: handle_file(upload), | |
| session_id: sessionId, | |
| }); | |
| if (cancelRef.current.cancelled) return; | |
| clearInterval(id); | |
| setProgress(100); | |
| setStep(4); | |
| const out = result?.data?.[0]; | |
| const outUrl = typeof out === 'string' ? out : (out?.url || out?.path); | |
| setTimeout(() => { if (!cancelRef.current.cancelled) onDone(outUrl); }, 400); | |
| return; | |
| } catch (err) { | |
| clearInterval(id); | |
| if (!cancelRef.current.cancelled) { | |
| console.error('Inference failed:', err); | |
| onError && onError(err); | |
| } | |
| } | |
| })(); | |
| return () => { | |
| cancelRef.current.cancelled = true; | |
| clearInterval(id); | |
| URL.revokeObjectURL(url); | |
| }; | |
| }, [open, file]); | |
| if (!open) return null; | |
| return ( | |
| <div className={`modal ${open ? 'on' : ''}`}> | |
| <div className="panel"> | |
| <div className="phead"> | |
| <span>ACTION · PROCESSING · SESSION {Math.floor(Math.random() * 9999).toString(16).toUpperCase().padStart(4, '0')}</span> | |
| <span className="close" onClick={onClose}>CLOSE ×</span> | |
| </div> | |
| <div className="proc"> | |
| <div className="preview"> | |
| {imageUrl && <img src={imageUrl} alt="preview" />} | |
| <div className="scan" /> | |
| <span className="corner tl"/><span className="corner tr"/> | |
| <span className="corner bl"/><span className="corner br"/> | |
| </div> | |
| <div className="right"> | |
| <div className="label">── MOULDING</div> | |
| <h2 className="u-dot">{progress < 100 ? 'CASTING' : 'COMPLETING'}</h2> | |
| <div className="pmeta"><span>ACT/BF16 · 1:6</span><span>{Math.floor(progress)}%</span></div> | |
| <div className="bar"><span style={{ width: progress + '%' }} /></div> | |
| <div className="pmeta" style={{ marginTop: 8 }}><span>QWEN-EDIT-2511</span></div> | |
| <div className="steps"> | |
| {steps.map((s, i) => ( | |
| <div className={`step ${i < step ? 'done' : ''} ${i === step ? 'active' : ''}`} key={i}> | |
| <span className="ic"/> | |
| <span className="t">{s.t}</span> | |
| <span className="s">{s.s}</span> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function ResultModal({ open, beforeUrl, afterUrl, onClose }) { | |
| const [split, setSplit] = React.useState(50); | |
| const [aspect, setAspect] = React.useState(null); | |
| // Before image rescaled to the after image's exact dimensions for | |
| // pixel-perfect slider alignment. Display only — doesn't affect download. | |
| const [alignedBeforeUrl, setAlignedBeforeUrl] = React.useState(null); | |
| const compareRef = React.useRef(); | |
| // Read natural dimensions from the result image so the compare frame | |
| // matches its aspect ratio — avoids the 16:9 cover-crop on portraits. | |
| React.useEffect(() => { | |
| if (!afterUrl) { setAspect(null); return; } | |
| const img = new Image(); | |
| img.onload = () => { | |
| if (img.naturalWidth && img.naturalHeight) { | |
| setAspect(img.naturalWidth / img.naturalHeight); | |
| } | |
| }; | |
| img.src = afterUrl; | |
| }, [afterUrl]); | |
| // Rescale the before image (in a canvas) to match the after image's exact | |
| // dimensions. Pipeline outputs are at native ~1024² and don't match small | |
| // uploads pixel-for-pixel — without this, the slider drag would shimmer | |
| // on aspect-ratio differences from mult-of-32 rounding. | |
| React.useEffect(() => { | |
| if (!beforeUrl || !afterUrl) { setAlignedBeforeUrl(null); return; } | |
| let cancelled = false; | |
| let createdUrl = null; | |
| const before = new Image(); | |
| const after = new Image(); | |
| let beforeReady = false, afterReady = false; | |
| const tryDraw = () => { | |
| if (cancelled || !beforeReady || !afterReady) return; | |
| const w = after.naturalWidth, h = after.naturalHeight; | |
| if (!w || !h) return; | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = w; canvas.height = h; | |
| canvas.getContext('2d').drawImage(before, 0, 0, w, h); | |
| canvas.toBlob((blob) => { | |
| if (cancelled || !blob) return; | |
| createdUrl = URL.createObjectURL(blob); | |
| setAlignedBeforeUrl(createdUrl); | |
| }, 'image/png'); | |
| }; | |
| before.onload = () => { beforeReady = true; tryDraw(); }; | |
| after.onload = () => { afterReady = true; tryDraw(); }; | |
| before.src = beforeUrl; | |
| after.src = afterUrl; | |
| return () => { | |
| cancelled = true; | |
| if (createdUrl) URL.revokeObjectURL(createdUrl); | |
| }; | |
| }, [beforeUrl, afterUrl]); | |
| const onMove = (clientX) => { | |
| const el = compareRef.current; | |
| if (!el) return; | |
| const r = el.getBoundingClientRect(); | |
| const pct = Math.max(0, Math.min(100, ((clientX - r.left) / r.width) * 100)); | |
| setSplit(pct); | |
| }; | |
| const startDrag = (e) => { | |
| const move = (ev) => onMove(ev.touches ? ev.touches[0].clientX : ev.clientX); | |
| const up = () => { | |
| window.removeEventListener('mousemove', move); | |
| window.removeEventListener('mouseup', up); | |
| window.removeEventListener('touchmove', move); | |
| window.removeEventListener('touchend', up); | |
| }; | |
| window.addEventListener('mousemove', move); | |
| window.addEventListener('mouseup', up); | |
| window.addEventListener('touchmove', move, { passive: true }); | |
| window.addEventListener('touchend', up); | |
| e.preventDefault(); | |
| }; | |
| const onDownload = async () => { | |
| if (!afterUrl) return; | |
| try { | |
| const res = await fetch(afterUrl); | |
| const blob = await res.blob(); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = 'systms_action.png'; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| } catch (e) { console.error('Download failed:', e); } | |
| }; | |
| if (!open) return null; | |
| return ( | |
| <div className={`modal ${open ? 'on' : ''}`}> | |
| <div className="panel"> | |
| <div className="phead"> | |
| <span>ACTION · RESULT · QWEN-EDIT-2511</span> | |
| <span className="close" onClick={onClose}>CLOSE ×</span> | |
| </div> | |
| <div className="res"> | |
| <div | |
| className="compare" | |
| ref={compareRef} | |
| style={{ '--split': split + '%', ...(aspect ? { aspectRatio: aspect } : {}) }} | |
| onMouseDown={(e) => { onMove(e.clientX); startDrag(e); }} | |
| > | |
| <div className="layer before"><img src={alignedBeforeUrl || beforeUrl} alt="before" /></div> | |
| <div className="layer after"><img src={afterUrl || beforeUrl} alt="after" /></div> | |
| <div className="tag l">SUBJECT · SOURCE</div> | |
| <div className="tag r">FIGURE · MINT</div> | |
| <div className="handle" onMouseDown={startDrag} onTouchStart={startDrag} /> | |
| <div className="knob" onMouseDown={startDrag} onTouchStart={startDrag}> | |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2"> | |
| <path d="M8 6l-4 6 4 6"/><path d="M16 6l4 6-4 6"/> | |
| </svg> | |
| </div> | |
| </div> | |
| <div className="rbar"> | |
| <div className="stats"> | |
| <div className="s"><span className="n">MINT IN BOX</span></div> | |
| <div className="s"><span className="n">ACTION POSE</span></div> | |
| <div className="s"><span className="n">QWEN-EDIT-2511</span></div> | |
| </div> | |
| <div className="actions"> | |
| <button className="btn btn-ghost" onClick={onClose}>Re-process</button> | |
| <button className="btn btn-primary" onClick={onDownload}>Download ↓</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| Object.assign(window, { ProcessingModal, ResultModal }); | |