ACTION-HF / static /flow.jsx
systms's picture
Rescale before image to after's exact dims for slider alignment
cd337f0
Raw
History Blame Contribute Delete
11.8 kB
// 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 });