Spaces:
Running on Zero
Running on Zero
Download studio.html from PrunaAI/P-Q-Image-2.1-Remove-Object: direct link, hf CLI and curl.
- Browser
- Download file 40.5 kB
-
https://huggingface.co/spaces/PrunaAI/P-Q-Image-2.1-Remove-Object/resolve/refs%2Fpr%2F1/studio.html
- Command line
-
hf download hf://spaces/PrunaAI/P-Q-Image-2.1-Remove-Object@refs/pr/1/studio.html
-
curl -L -o studio.html https://huggingface.co/spaces/PrunaAI/P-Q-Image-2.1-Remove-Object/resolve/refs%2Fpr%2F1/studio.html
40.5 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>P-Q-Image-2.1-Remove-Object</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com" /> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet" /> | |
| <style> | |
| :root { | |
| --bg: #120b1b; --panel: #171021; --panel2: #1d1429; --raised: #251b34; | |
| --border: rgba(74,57,98,0.75); --hairline: rgba(255,255,255,0.08); | |
| --text: #fafafa; --muted: #a3a3a3; --dim: #71717a; | |
| --accent: #9334e9; --accent2: #8f35ff; --accent-soft: rgba(147,52,233,0.14); | |
| --lavender: #d8b4fe; --link: #bf8bff; | |
| --glow: radial-gradient(ellipse 60% 35% at 50% -5%, rgba(147,52,233,0.18), transparent 55%); | |
| --stage-base: #0e0a16; --stage-square: #171221; --thumb-bg: #120c1c; | |
| --radius: 10px; | |
| } | |
| body.light { | |
| --bg: #f6f3fa; --panel: #ffffff; --panel2: #f6f3fa; --raised: #f3eef8; | |
| --border: rgba(126,34,206,0.16); --hairline: rgba(24,24,27,0.08); | |
| --text: #18181b; --muted: #71717a; --dim: #a1a1aa; | |
| --accent: #9334e9; --accent2: #7c22ce; --accent-soft: rgba(147,52,233,0.10); | |
| --lavender: #7e22ce; --link: #6d28d9; | |
| --glow: radial-gradient(ellipse 60% 35% at 50% -5%, rgba(147,52,233,0.10), transparent 55%); | |
| --stage-base: #e9e4f2; --stage-square: #f8f5fc; --thumb-bg: #f3eef8; | |
| } | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| [hidden] { display: none ; } | |
| body { | |
| font-family: "Inter", ui-sans-serif, system-ui, sans-serif; | |
| background: var(--bg); background-image: var(--glow); background-repeat: no-repeat; | |
| background-attachment: fixed; | |
| color: var(--text); height: 100vh; | |
| display: flex; flex-direction: column; overflow: hidden; | |
| font-size: 13px; -webkit-font-smoothing: antialiased; | |
| } | |
| .topbar { | |
| display: flex; align-items: center; gap: 14px; padding: 8px 16px; | |
| background: var(--panel); border-bottom: 1px solid var(--hairline); flex-shrink: 0; | |
| } | |
| .brand { font-weight: 700; font-size: 0.95rem; white-space: nowrap; letter-spacing: -0.02em; display: flex; align-items: center; } | |
| .brand img { height: 26px; margin-right: 10px; } | |
| .studio-badge { | |
| font-size: 0.6rem; font-weight: 650; letter-spacing: 0.12em; text-transform: uppercase; | |
| color: var(--lavender); border: 1px solid var(--border); border-radius: 999px; | |
| padding: 2px 8px; margin-left: 10px; background: var(--accent-soft); | |
| } | |
| .mode-tabs { display: flex; gap: 2px; background: var(--panel2); border: 1px solid var(--border); border-radius: 9px; padding: 3px; } | |
| .mode-tabs button { | |
| border: none; border-radius: 7px; padding: 6px 16px; font-weight: 550; font-size: 0.82rem; | |
| background: transparent; color: var(--muted); cursor: pointer; transition: color 0.15s, background 0.15s; | |
| font-family: inherit; | |
| } | |
| .mode-tabs button:hover { color: var(--text); } | |
| .mode-tabs button.active { background: var(--raised); color: var(--text); box-shadow: 0 1px 3px rgba(0,0,0,0.45); } | |
| body.light .mode-tabs button.active { box-shadow: 0 1px 3px rgba(88,28,135,0.15); } | |
| .spacer { flex: 1; } | |
| .ml-badge { color: var(--muted); text-decoration: none; font-size: 0.8rem; font-weight: 500; transition: color 0.15s; white-space: nowrap; } | |
| .ml-badge:hover { color: var(--text); } | |
| .theme-toggle { | |
| width: 34px; height: 34px; border: 1px solid var(--border); border-radius: 9px; | |
| background: transparent; color: var(--muted); cursor: pointer; flex-shrink: 0; | |
| display: inline-flex; align-items: center; justify-content: center; | |
| transition: color 0.15s, background 0.15s; | |
| } | |
| .theme-toggle:hover { color: var(--text); background: var(--panel2); } | |
| .theme-toggle svg { width: 17px; height: 17px; } | |
| .theme-toggle .theme-icon-moon { display: none; } | |
| .theme-toggle[data-mode="light"] .theme-icon-sun { display: none; } | |
| .theme-toggle[data-mode="light"] .theme-icon-moon { display: block; } | |
| .main { display: flex; flex: 1; min-height: 0; } | |
| .rail { | |
| width: 52px; flex-shrink: 0; display: flex; flex-direction: column; align-items: center; | |
| gap: 6px; padding: 10px 0; background: var(--panel); border-right: 1px solid var(--hairline); | |
| } | |
| .rail button { | |
| width: 38px; height: 38px; border: 1px solid transparent; border-radius: 8px; | |
| background: transparent; color: var(--muted); cursor: pointer; | |
| display: flex; align-items: center; justify-content: center; | |
| transition: color 0.15s, background 0.15s; | |
| } | |
| .rail button:hover { background: var(--panel2); color: var(--text); } | |
| .rail button.active { color: var(--accent2); background: var(--accent-soft); } | |
| .viewport { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: auto; position: relative; } | |
| .workflow-view { position: absolute; inset: 0; padding: 12px; } | |
| .workflow-view iframe { width: 100%; height: 100%; border: none; border-radius: 10px; background: var(--stage-base); } | |
| .doc-grid { flex: 1; min-height: 0; display: flex; gap: 16px; width: 100%; align-items: stretch; justify-content: center; padding: 20px 20px 10px; } | |
| .doc { | |
| flex: 1; min-width: 0; max-width: 640px; display: flex; flex-direction: column; | |
| background: var(--panel); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; | |
| } | |
| .doc figcaption { | |
| padding: 8px 12px; font-size: 0.62rem; font-weight: 650; letter-spacing: 0.12em; | |
| color: var(--dim); border-bottom: 1px solid var(--hairline); text-transform: uppercase; | |
| display: flex; align-items: center; | |
| } | |
| .doc .stage { | |
| position: relative; flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; | |
| overflow: hidden; background-color: var(--stage-base); | |
| background-image: | |
| linear-gradient(45deg, var(--stage-square) 25%, transparent 25%), | |
| linear-gradient(-45deg, var(--stage-square) 25%, transparent 25%), | |
| linear-gradient(45deg, transparent 75%, var(--stage-square) 75%), | |
| linear-gradient(-45deg, transparent 75%, var(--stage-square) 75%); | |
| background-size: 20px 20px; | |
| background-position: 0 0, 0 10px, 10px -10px, -10px 0; | |
| } | |
| .doc img { max-width: 100%; max-height: 100%; object-fit: contain; display: block; } | |
| .doc img.zoomed { max-width: none; max-height: none; } | |
| .doc .stage.scrollable { overflow: auto; justify-content: flex-start; align-items: flex-start; } | |
| .drop-hint { color: var(--dim); font-size: 0.85rem; text-align: center; padding: 20px; } | |
| .drop-hint b { color: var(--muted); display: block; margin-bottom: 6px; } | |
| .zoom-btn { | |
| margin-left: auto; border: 1px solid var(--border); background: transparent; color: var(--muted); | |
| border-radius: 5px; font-size: 0.62rem; padding: 2px 8px; cursor: pointer; letter-spacing: 0.08em; | |
| font-family: inherit; | |
| } | |
| .zoom-btn:hover { color: var(--text); border-color: var(--accent); } | |
| .doc-bar { | |
| display: flex; align-items: center; gap: 10px; padding: 6px 12px; | |
| border-top: 1px solid var(--hairline); background: var(--panel); | |
| font-size: 0.72rem; color: var(--muted); flex-shrink: 0; | |
| } | |
| .doc-bar button { | |
| border: 1px solid var(--border); background: transparent; color: var(--muted); | |
| border-radius: 5px; padding: 3px 10px; font-size: 0.68rem; cursor: pointer; letter-spacing: 0.06em; | |
| font-family: inherit; | |
| } | |
| .doc-bar button:hover, .doc-bar button.active { color: var(--text); border-color: var(--accent); } | |
| .doc-bar button.active { background: var(--accent-soft); color: var(--lavender); } | |
| .split-wrap { position: absolute; inset: 0; overflow: hidden; } | |
| .split-wrap img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; } | |
| .split-divider { | |
| position: absolute; top: 0; bottom: 0; width: 3px; background: var(--accent); | |
| cursor: ew-resize; z-index: 5; box-shadow: 0 0 8px rgba(147,52,233,0.6); | |
| } | |
| .split-divider::after { | |
| content: "◂ ▸"; position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); | |
| background: var(--accent); color: white; border-radius: 999px; padding: 4px 7px; | |
| font-size: 0.6rem; white-space: nowrap; | |
| } | |
| .history-strip { | |
| display: flex; gap: 8px; padding: 8px 12px; overflow-x: auto; flex-shrink: 0; | |
| background: var(--panel); border-top: 1px solid var(--hairline); align-items: center; | |
| } | |
| .history-strip .hlabel { font-size: 0.62rem; font-weight: 650; letter-spacing: 0.14em; color: var(--dim); text-transform: uppercase; white-space: nowrap; } | |
| .history-chip { | |
| width: 52px; height: 52px; border-radius: 6px; border: 1px solid var(--border); | |
| overflow: hidden; cursor: pointer; flex-shrink: 0; position: relative; | |
| } | |
| .history-chip:hover { border-color: var(--accent); } | |
| .history-chip img { width: 100%; height: 100%; object-fit: cover; } | |
| .history-chip .hseed { | |
| position: absolute; bottom: 0; left: 0; right: 0; background: rgba(0,0,0,0.65); | |
| color: #ddd; font-size: 0.52rem; padding: 1px 3px; text-align: center; | |
| } | |
| body.light .history-chip .hseed { background: rgba(255,255,255,0.78); color: #3f3f46; } | |
| .panels { | |
| width: 320px; flex-shrink: 0; display: flex; flex-direction: column; gap: 10px; | |
| padding: 12px; overflow-y: auto; background: var(--panel); border-left: 1px solid var(--hairline); | |
| } | |
| .panel { background: var(--panel2); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; } | |
| .panel-title { font-size: 0.62rem; font-weight: 650; letter-spacing: 0.12em; color: var(--dim); text-transform: uppercase; margin-bottom: 10px; cursor: pointer; user-select: none; display: flex; align-items: center; justify-content: space-between; } | |
| .panel .panel-title::after { content: "▾"; font-size: 0.7rem; color: var(--dim); } | |
| .panel.collapsed .panel-title::after { content: "▸"; } | |
| .panel.collapsed .panel-body { display: none; } | |
| .panel label { display: block; font-size: 0.72rem; font-weight: 550; color: var(--muted); margin: 10px 0 4px; } | |
| .panel label:first-of-type { margin-top: 0; } | |
| textarea, input[type="text"], input[type="number"], select { | |
| width: 100%; background: var(--raised); color: var(--text); border: 1px solid var(--border); | |
| border-radius: 7px; padding: 8px 10px; font-family: inherit; font-size: 0.83rem; resize: vertical; | |
| transition: border-color 0.15s, box-shadow 0.15s; | |
| } | |
| select { appearance: none; background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), linear-gradient(135deg, var(--muted) 50%, transparent 50%); background-position: calc(100% - 16px) 50%, calc(100% - 11px) 50%; background-size: 5px 5px; background-repeat: no-repeat; } | |
| textarea:focus, input:focus, select:focus { outline: none; border-color: var(--accent2); box-shadow: 0 0 0 3px var(--accent-soft); } | |
| textarea.code { font-family: "IBM Plex Mono", ui-monospace, monospace; font-size: 0.72rem; } | |
| textarea[readonly] { color: var(--muted); } | |
| .row { display: flex; gap: 8px; align-items: center; } | |
| .row > * { flex: 1; } | |
| .switch { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; } | |
| .switch input { display: none; } | |
| .switch .sw { | |
| display: inline-block; | |
| width: 34px; height: 20px; border-radius: 999px; background: var(--raised); | |
| border: 1px solid var(--border); position: relative; flex-shrink: 0; transition: background 0.18s, border-color 0.18s; | |
| } | |
| .switch .sw::after { | |
| content: ""; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; | |
| border-radius: 50%; background: var(--muted); transition: transform 0.18s, background 0.18s; | |
| } | |
| .switch input:checked + .sw { background: var(--accent); border-color: var(--accent); } | |
| .switch input:checked + .sw::after { transform: translateX(14px); background: #fff; } | |
| .switch .sw-label { font-size: 0.75rem; color: var(--muted); } | |
| .layer-row { | |
| display: flex; align-items: center; gap: 10px; padding: 6px 8px; | |
| border: 1px solid var(--border); border-radius: 6px; margin-top: 6px; | |
| background: var(--raised); | |
| } | |
| .layer-row .thumb { | |
| width: 40px; height: 40px; border-radius: 4px; object-fit: cover; flex-shrink: 0; | |
| background: var(--thumb-bg); border: 1px solid var(--border); | |
| display: flex; align-items: center; justify-content: center; | |
| color: var(--dim); font-size: 0.65rem; text-align: center; | |
| } | |
| .layer-row .thumb img { width: 100%; height: 100%; object-fit: cover; border-radius: 3px; } | |
| .layer-row .lname { flex: 1; font-size: 0.78rem; color: var(--text); } | |
| .layer-row .lsub { font-size: 0.62rem; color: var(--dim); display: block; } | |
| .hint { font-size: 0.68rem; color: var(--dim); margin-top: 8px; line-height: 1.4; } | |
| #run-btn { | |
| margin-top: 4px; padding: 12px; border: 1px solid rgba(147,52,233,0.7); border-radius: 8px; cursor: pointer; | |
| background: var(--accent); color: white; box-shadow: inset 0 1px rgba(255,255,255,0.2); | |
| font-weight: 600; font-size: 0.9rem; letter-spacing: 0.01em; font-family: inherit; | |
| transition: filter 0.15s, transform 0.05s; | |
| } | |
| #run-btn:hover { filter: brightness(1.12); } | |
| #run-btn:active { transform: translateY(1px); } | |
| #run-btn:disabled { opacity: 0.5; cursor: wait; } | |
| .statusbar { | |
| display: flex; align-items: center; gap: 16px; padding: 7px 16px; flex-shrink: 0; | |
| background: var(--panel); border-top: 1px solid var(--hairline); | |
| font-size: 0.75rem; color: var(--muted); | |
| } | |
| .statusbar .pulse { width: 8px; height: 8px; border-radius: 50%; background: #4fd1a5; } | |
| .statusbar.busy .pulse { background: var(--accent2); animation: pulse 1s infinite alternate; } | |
| @keyframes pulse { from { opacity: 0.4; } to { opacity: 1; } } | |
| .foot-links { display: flex; gap: 12px; align-items: center; } | |
| .foot-links a { color: var(--dim); text-decoration: none; font-size: 0.72rem; font-weight: 500; } | |
| .foot-links a:hover { color: var(--lavender); } | |
| #file-input { display: none; } | |
| #mask-canvas { position: absolute; opacity: 0.5; cursor: crosshair; touch-action: none; z-index: 2; } | |
| .tool-toggle { display: flex; gap: 2px; background: var(--raised); border: 1px solid var(--border); border-radius: 7px; padding: 2px; } | |
| .tool-toggle button, .mask-clear { | |
| flex: 1; border: none; border-radius: 5px; padding: 5px 8px; font-size: 0.72rem; font-weight: 550; | |
| background: transparent; color: var(--muted); cursor: pointer; font-family: inherit; | |
| } | |
| .tool-toggle button.active { background: var(--accent-soft); color: var(--lavender); } | |
| .mask-clear { border: 1px solid var(--border); background: var(--raised); margin-top: 8px; width: 100%; } | |
| .mask-clear:hover { color: var(--text); border-color: var(--accent); } | |
| .panel label.instruction-label { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--hairline); } | |
| .mask-find { display: inline-flex; align-items: center; justify-content: center; gap: 5px; } | |
| .mask-find { min-width: 0; } | |
| .mask-find svg { width: 13px; height: 13px; flex-shrink: 0; } | |
| .mask-find:disabled { opacity: 0.5; cursor: wait; } | |
| input[type="range"] { width: 100%; accent-color: var(--accent); } | |
| @media (max-width: 860px) { | |
| html, body { height: auto ; overflow: visible ; } | |
| body { display: block; } | |
| .topbar { flex-wrap: wrap; gap: 8px; padding: 8px 12px; } | |
| .brand { font-size: 0.88rem; } | |
| .topbar .ml-badge { display: none; } | |
| .main { display: block; height: auto; min-height: 0; } | |
| .rail { | |
| flex-direction: row; width: 100%; justify-content: center; gap: 10px; | |
| padding: 6px 10px; border-right: none; border-bottom: 1px solid var(--hairline); | |
| } | |
| .rail button { width: 44px; height: 44px; } | |
| .viewport { display: block; padding: 10px; overflow: visible; } | |
| .workflow-view { position: static; height: 78vh; padding: 0 0 10px; } | |
| .doc-grid { flex-direction: column; align-items: stretch; height: auto; } | |
| .doc { max-width: 100%; width: 100%; } | |
| .doc .stage { min-height: 220px; } | |
| .panels { | |
| width: 100%; border-left: none; border-top: 1px solid var(--hairline); | |
| padding: 10px 12px; overflow-y: visible; | |
| } | |
| .mode-tabs button { padding: 6px 13px; font-size: 0.8rem; } | |
| .foot-links { display: none; } | |
| .statusbar { position: sticky; bottom: 0; } | |
| } | |
| #examples { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; max-height: 290px; overflow-y: auto; } | |
| .ex-thumb { | |
| position: relative; aspect-ratio: 1; padding: 0; overflow: hidden; cursor: pointer; | |
| border: 1px solid var(--border); border-radius: 6px; background: var(--raised); font-family: inherit; | |
| } | |
| .ex-thumb:hover { border-color: var(--accent); } | |
| .ex-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; } | |
| .ex-thumb span { | |
| position: absolute; left: 0; right: 0; bottom: 0; padding: 2px 4px; text-align: left; | |
| font-size: 0.56rem; color: #eee; background: rgba(0,0,0,0.62); | |
| white-space: nowrap; overflow: hidden; text-overflow: ellipsis; | |
| } | |
| body.light .ex-thumb span { background: rgba(255,255,255,0.82); color: #3f3f46; } | |
| </style> | |
| </head> | |
| <body> | |
| <header class="topbar"> | |
| <div class="brand"><img src="/pruna-logo.png" alt="Pruna" />P-Q-Image-2.1-Remove-Object<span class="studio-badge">Studio</span></div> | |
| <div class="mode-tabs"> | |
| <button id="mode-remove" class="active">Remove</button> | |
| <button id="mode-workflow">Workflow</button> | |
| </div> | |
| <div class="spacer"></div> | |
| <a class="ml-badge" href="https://huggingface.co/PrunaAI/P-Q-Image-2.1-Remove-Object" target="_blank" rel="noopener">Model card</a> | |
| <a class="ml-badge" href="https://huggingface.co/PrunaAI/P-Q-Image-2.1-Remove-Object-Comfy" target="_blank" rel="noopener">ComfyUI</a> | |
| <a class="ml-badge" href="https://www.pruna.ai/" target="_blank" rel="noopener">pruna.ai</a> | |
| <button type="button" class="theme-toggle" id="theme-toggle" data-mode="dark" aria-label="Switch to light mode" title="Switch to light mode"> | |
| <svg class="theme-icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> | |
| <circle cx="12" cy="12" r="4"></circle> | |
| <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"></path> | |
| </svg> | |
| <svg class="theme-icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> | |
| <path d="M21 14.5A8.5 8.5 0 1 1 9.5 3 7 7 0 0 0 21 14.5z"></path> | |
| </svg> | |
| </button> | |
| </header> | |
| <div class="main"> | |
| <div class="rail"> | |
| <button id="tool-upload" title="Upload a photo"><svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg></button> | |
| <button id="tool-clear-doc" title="Clear photo and mask"><svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button> | |
| </div> | |
| <div class="viewport"> | |
| <div class="workflow-view" id="workflow-view" hidden> | |
| <iframe src="/workflow/?ui=canvas" title="Workflow canvas"></iframe> | |
| </div> | |
| <div class="doc-grid"> | |
| <figure class="doc" id="doc-before"> | |
| <figcaption>Photo · paint the mask</figcaption> | |
| <div class="stage" id="before-stage"> | |
| <img id="source-img" alt="" hidden /> | |
| <canvas id="mask-canvas" hidden></canvas> | |
| <div class="drop-hint" id="source-hint"><b>No photo</b>Pick an example, use the upload tool, or drop a file here</div> | |
| </div> | |
| </figure> | |
| <figure class="doc" id="doc-after"> | |
| <figcaption>Result</figcaption> | |
| <div class="stage" id="after-stage"> | |
| <img id="result-img" alt="" hidden /> | |
| <div class="split-wrap" id="split-wrap" hidden> | |
| <img id="split-source" alt="" style="clip-path: inset(0 50% 0 0)" /> | |
| <div class="split-divider" id="split-divider" style="left:50%"></div> | |
| </div> | |
| <div class="drop-hint" id="result-hint"><b>Nothing yet</b>Run to see the result</div> | |
| </div> | |
| <div class="doc-bar"> | |
| <span id="result-meta">—</span> | |
| <div class="spacer"></div> | |
| <button id="split-toggle" hidden>SPLIT</button> | |
| </div> | |
| </figure> | |
| </div> | |
| <div class="history-strip" id="history-strip"></div> | |
| </div> | |
| <aside class="panels"> | |
| <section class="panel"> | |
| <div class="panel-title">Examples</div> | |
| <div id="examples"></div> | |
| </section> | |
| <section class="panel"> | |
| <div class="panel-title">Mask</div> | |
| <div class="tool-toggle"> | |
| <button id="tool-brush" class="active">Brush</button> | |
| <button id="tool-eraser">Eraser</button> | |
| </div> | |
| <label for="brush-size">Brush size</label> | |
| <input type="range" id="brush-size" min="4" max="120" value="28" /> | |
| <button class="mask-clear" id="mask-clear">Clear mask</button> | |
| <div class="hint" id="mask-hint">Paint roughly over the object; include its shadow or reflection if they should go too, and keep the mask off anything that should stay. It is drawn as a red outline for the model. With no mask, the instruction alone says what to remove.</div> | |
| <label for="prompt" class="instruction-label">Instruction</label> | |
| <textarea id="prompt" rows="4" placeholder="e.g. Remove the man on the right."></textarea> | |
| <div class="hint">Name the object as it appears, and say which one when there are several.</div> | |
| <button class="mask-clear mask-find" id="mask-find" title="Mask what the instruction names, with SAM 3" hidden><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/></svg>Auto-generate mask from instruction</button> | |
| </section> | |
| <section class="panel"> | |
| <div class="panel-title">Variant</div> | |
| <select id="variant"></select> | |
| <div class="hint" id="variant-hint"></div> | |
| </section> | |
| <section class="panel"> | |
| <div class="panel-title">Advanced</div> | |
| <div class="row"> | |
| <div><label for="seed">Seed</label><input type="number" id="seed" value="42" min="0" /></div> | |
| <label class="switch"><input type="checkbox" id="randomize" checked /><span class="sw"></span><span class="sw-label">Randomize</span></label> | |
| </div> | |
| </section> | |
| <button id="run-btn">Remove Object</button> | |
| </aside> | |
| </div> | |
| <footer class="statusbar" id="statusbar"> | |
| <div class="pulse"></div> | |
| <span id="status">Idle</span> | |
| <div class="spacer"></div> | |
| <span class="foot-links"> | |
| <a href="https://github.com/PrunaAI/pruna" target="_blank" rel="noopener">GitHub</a> | |
| <a href="https://discord.gg/JFQmtFKCjd" target="_blank" rel="noopener">Discord</a> | |
| <a href="https://x.com/PrunaAI" target="_blank" rel="noopener">X</a> | |
| </span> | |
| <span id="seed-out"></span> | |
| </footer> | |
| <input type="file" id="file-input" accept="image/*" /> | |
| <script type="module"> | |
| import { client } from "https://cdn.jsdelivr.net/npm/@gradio/client@2.7.1/dist/index.min.js"; | |
| const $ = (id) => document.getElementById(id); | |
| const CONFIG = __STUDIO_CONFIG__; | |
| const state = { mode: "remove", sourcePath: null, sourceUrl: null, app: null, maskPainted: false, maskPath: null, tool: "brush" }; | |
| const history = []; | |
| const VARIANT_NOTES = { | |
| "6-step": "Recommended. 6 steps on PrunaAI's 6-step distillation LoRA, strength 0.7. Up to ~2 MP.", | |
| "8-step": "8 steps on PrunaAI's 8-step distillation LoRA, strength 0.7. Up to ~2 MP.", | |
| "base": "40 steps on the base model alone: slower, without the few-step model's drift. Up to ~1 MP.", | |
| }; | |
| for (const v of CONFIG.variants) { | |
| const opt = document.createElement("option"); | |
| opt.value = v; | |
| opt.textContent = v; | |
| $("variant").appendChild(opt); | |
| } | |
| $("variant").value = CONFIG.defaultVariant; | |
| const showVariantNote = () => { $("variant-hint").textContent = VARIANT_NOTES[$("variant").value] || ""; }; | |
| $("variant").onchange = showVariantNote; | |
| showVariantNote(); | |
| async function getApp() { | |
| if (!state.app) state.app = await client(location.origin); | |
| return state.app; | |
| } | |
| async function uploadFile(file) { | |
| const form = new FormData(); | |
| form.append("files", file); | |
| const resp = await fetch("/gradio_api/upload", { method: "POST", body: form }); | |
| if (!resp.ok) throw new Error(`Upload failed (${resp.status})`); | |
| const paths = await resp.json(); | |
| return Array.isArray(paths) ? paths[0] : paths; | |
| } | |
| function setStatus(text, busy = false) { | |
| $("status").textContent = text; | |
| $("statusbar").classList.toggle("busy", busy); | |
| $("run-btn").disabled = busy; | |
| } | |
| // ---------- theme ---------- | |
| const themeBtn = $("theme-toggle"); | |
| function applyTheme(mode) { | |
| document.body.classList.toggle("light", mode === "light"); | |
| themeBtn.dataset.mode = mode; | |
| const label = mode === "dark" ? "Switch to light mode" : "Switch to dark mode"; | |
| themeBtn.setAttribute("aria-label", label); | |
| themeBtn.setAttribute("title", label); | |
| try { localStorage.setItem("pruna-theme", mode); } catch (e) {} | |
| } | |
| themeBtn.onclick = () => applyTheme(document.body.classList.contains("light") ? "dark" : "light"); | |
| (function initTheme() { | |
| let mode = "dark"; | |
| try { if (localStorage.getItem("pruna-theme") === "light") mode = "light"; } catch (e) {} | |
| applyTheme(mode); | |
| })(); | |
| function setMode(mode) { | |
| state.mode = mode; | |
| for (const m of ["remove", "workflow"]) $(`mode-${m}`).classList.toggle("active", mode === m); | |
| const workflow = mode === "workflow"; | |
| $("workflow-view").hidden = !workflow; | |
| document.querySelector(".doc-grid").style.display = workflow ? "none" : ""; | |
| document.querySelector(".rail").style.display = workflow ? "none" : ""; | |
| document.querySelector(".panels").style.display = workflow ? "none" : ""; | |
| $("history-strip").style.display = workflow ? "none" : ""; | |
| if (!workflow) requestAnimationFrame(placeMask); | |
| } | |
| $("mode-remove").onclick = () => setMode("remove"); | |
| $("mode-workflow").onclick = () => setMode("workflow"); | |
| // ---------- photo ---------- | |
| // Show a photo (object URL) as the source; resolves once it is laid out with an empty mask. | |
| function showSource(url, path) { | |
| state.sourcePath = path; | |
| if (state.sourceUrl === url) return Promise.resolve(); | |
| state.sourceUrl = url; | |
| const img = $("source-img"); | |
| return new Promise((resolve) => { | |
| img.onload = () => { resetMask(); resolve(); }; | |
| img.src = url; | |
| img.hidden = false; | |
| $("mask-canvas").hidden = false; | |
| $("source-hint").style.display = "none"; | |
| }); | |
| } | |
| async function loadPhoto(file, maskUrl = null) { | |
| setStatus("Uploading…", true); | |
| try { | |
| const path = await uploadFile(file); | |
| await showSource(URL.createObjectURL(file), path); | |
| if (maskUrl) await loadMask(maskUrl); | |
| setStatus(maskUrl ? "Example loaded — hit Remove Object" : "Paint over the object to remove"); | |
| } catch (err) { | |
| setStatus(String(err)); | |
| } | |
| } | |
| $("tool-upload").onclick = () => $("file-input").click(); | |
| $("file-input").onchange = (e) => { | |
| const file = e.target.files[0]; | |
| e.target.value = ""; | |
| if (file) loadPhoto(file); | |
| }; | |
| $("before-stage").addEventListener("dragover", (e) => e.preventDefault()); | |
| $("before-stage").addEventListener("drop", (e) => { | |
| e.preventDefault(); | |
| const file = e.dataTransfer.files[0]; | |
| if (file) loadPhoto(file); | |
| }); | |
| $("tool-clear-doc").onclick = () => { | |
| state.sourcePath = null; | |
| state.sourceUrl = null; | |
| $("source-img").hidden = true; | |
| $("mask-canvas").hidden = true; | |
| $("source-hint").style.display = ""; | |
| $("result-img").hidden = true; | |
| $("result-hint").style.display = ""; | |
| $("result-meta").textContent = "—"; | |
| $("seed-out").textContent = ""; | |
| $("split-toggle").hidden = true; | |
| $("split-wrap").hidden = true; | |
| clearMask(); | |
| }; | |
| // ---------- mask painting ---------- | |
| const maskCanvas = $("mask-canvas"); | |
| const maskCtx = maskCanvas.getContext("2d"); | |
| function placeMask() { | |
| const img = $("source-img"); | |
| if (img.hidden) return; | |
| Object.assign(maskCanvas.style, { | |
| left: img.offsetLeft + "px", top: img.offsetTop + "px", | |
| width: img.offsetWidth + "px", height: img.offsetHeight + "px", | |
| }); | |
| } | |
| function clearMask() { | |
| maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height); | |
| state.maskPainted = false; | |
| state.maskPath = null; | |
| } | |
| function resetMask() { | |
| const img = $("source-img"); | |
| maskCanvas.width = img.naturalWidth || 1; // resizing clears the canvas | |
| maskCanvas.height = img.naturalHeight || 1; | |
| clearMask(); | |
| placeMask(); | |
| } | |
| new ResizeObserver(placeMask).observe($("before-stage")); | |
| let lastPoint = null; | |
| function maskPoint(e) { | |
| const r = maskCanvas.getBoundingClientRect(); | |
| return { x: (e.clientX - r.left) * maskCanvas.width / r.width, y: (e.clientY - r.top) * maskCanvas.height / r.height }; | |
| } | |
| function paint(from, to) { | |
| const r = maskCanvas.getBoundingClientRect(); | |
| maskCtx.globalCompositeOperation = state.tool === "eraser" ? "destination-out" : "source-over"; | |
| maskCtx.strokeStyle = "#ff0000"; | |
| maskCtx.lineCap = "round"; | |
| maskCtx.lineJoin = "round"; | |
| maskCtx.lineWidth = Number($("brush-size").value) * maskCanvas.width / r.width; | |
| maskCtx.beginPath(); | |
| maskCtx.moveTo(from.x, from.y); | |
| maskCtx.lineTo(to.x, to.y); | |
| maskCtx.stroke(); | |
| state.maskPainted = true; | |
| state.maskPath = null; | |
| } | |
| maskCanvas.addEventListener("pointerdown", (e) => { | |
| e.preventDefault(); | |
| maskCanvas.setPointerCapture(e.pointerId); | |
| lastPoint = maskPoint(e); | |
| paint(lastPoint, lastPoint); | |
| }); | |
| maskCanvas.addEventListener("pointermove", (e) => { | |
| if (!lastPoint) return; | |
| const p = maskPoint(e); | |
| paint(lastPoint, p); | |
| lastPoint = p; | |
| }); | |
| for (const ev of ["pointerup", "pointercancel"]) maskCanvas.addEventListener(ev, () => { lastPoint = null; }); | |
| for (const tool of ["brush", "eraser"]) { | |
| $(`tool-${tool}`).onclick = () => { | |
| state.tool = tool; | |
| $("tool-brush").classList.toggle("active", tool === "brush"); | |
| $("tool-eraser").classList.toggle("active", tool === "eraser"); | |
| }; | |
| } | |
| $("mask-clear").onclick = clearMask; | |
| // Load a black/white mask image (white = remove) into the painting canvas. | |
| async function loadMask(url) { | |
| const blob = await (await fetch(url)).blob(); | |
| const bitmap = await createImageBitmap(blob); | |
| const tmp = document.createElement("canvas"); | |
| tmp.width = maskCanvas.width; | |
| tmp.height = maskCanvas.height; | |
| const ctx = tmp.getContext("2d"); | |
| ctx.imageSmoothingEnabled = false; | |
| ctx.drawImage(bitmap, 0, 0, tmp.width, tmp.height); | |
| const data = ctx.getImageData(0, 0, tmp.width, tmp.height); | |
| const px = data.data; | |
| for (let i = 0; i < px.length; i += 4) { | |
| const on = px[i] > 127; | |
| px[i] = 255; px[i + 1] = 0; px[i + 2] = 0; px[i + 3] = on ? 255 : 0; | |
| } | |
| clearMask(); | |
| maskCtx.putImageData(data, 0, 0); | |
| state.maskPainted = true; | |
| } | |
| // The painted strokes as a black/white PNG at the photo's size (white = remove), uploaded once per change. | |
| async function uploadMask() { | |
| if (!state.maskPainted) return ""; | |
| if (state.maskPath) return state.maskPath; | |
| const out = document.createElement("canvas"); | |
| out.width = maskCanvas.width; | |
| out.height = maskCanvas.height; | |
| const ctx = out.getContext("2d"); | |
| ctx.drawImage(maskCanvas, 0, 0); | |
| ctx.globalCompositeOperation = "source-in"; | |
| ctx.fillStyle = "#fff"; | |
| ctx.fillRect(0, 0, out.width, out.height); | |
| ctx.globalCompositeOperation = "destination-over"; | |
| ctx.fillStyle = "#000"; | |
| ctx.fillRect(0, 0, out.width, out.height); | |
| const blob = await new Promise((resolve) => out.toBlob(resolve, "image/png")); | |
| state.maskPath = await uploadFile(new File([blob], "mask.png", { type: "image/png" })); | |
| return state.maskPath; | |
| } | |
| // ---------- text-guided mask (SAM 3) ---------- | |
| if (CONFIG.textMask) { | |
| $("mask-find").hidden = false; | |
| $("mask-hint").textContent = "Paint roughly over the object, its shadow or reflection too if they should go, or let SAM 3 find it from the instruction below and touch that up; with nothing painted, Remove Object finds it by itself. The mask is drawn as a red outline for the model."; | |
| } | |
| $("mask-find").onclick = async () => { | |
| const prompt = $("prompt").value.trim(); | |
| if (!state.sourcePath) { setStatus("Upload a photo or pick an example first."); return; } | |
| if (!prompt) { setStatus("Write what to remove in the instruction first."); $("prompt").focus(); return; } | |
| const sourceUrl = state.sourceUrl; | |
| setStatus("Finding the object…", true); | |
| $("mask-find").disabled = true; | |
| try { | |
| const app = await getApp(); | |
| const payload = unwrap(await app.predict("/find_mask", { source: state.sourcePath, text: prompt })); | |
| if (typeof payload.mask_data !== "string") { setStatus("Nothing found (see console)."); return; } | |
| if (state.sourceUrl !== sourceUrl) return; // the photo changed while it ran | |
| await loadMask(payload.mask_data); | |
| setStatus("Mask found. Touch it up if needed, then hit Remove Object"); | |
| } catch (err) { | |
| setStatus(err?.message || String(err)); | |
| } finally { | |
| $("mask-find").disabled = false; | |
| } | |
| }; | |
| // ---------- history ---------- | |
| function showResultImage(uri) { | |
| $("result-img").src = uri; | |
| $("result-img").hidden = false; | |
| $("result-hint").style.display = "none"; | |
| $("split-toggle").hidden = $("source-img").hidden; | |
| if (splitOn) $("split-source").src = $("source-img").src; | |
| } | |
| function renderHistory() { | |
| const strip = $("history-strip"); | |
| const label = document.createElement("span"); | |
| label.className = "hlabel"; | |
| label.textContent = "History"; | |
| strip.replaceChildren(label); | |
| history.forEach((h) => { | |
| const chip = document.createElement("div"); | |
| chip.className = "history-chip"; | |
| chip.title = h.prompt || ""; | |
| const img = document.createElement("img"); | |
| img.src = h.uri; | |
| const tag = document.createElement("div"); | |
| tag.className = "hseed"; | |
| tag.textContent = h.seed ?? ""; | |
| chip.appendChild(img); | |
| chip.appendChild(tag); | |
| chip.onclick = async () => { | |
| setMode("remove"); | |
| await showSource(h.sourceUrl, h.sourcePath); // keeps the mask when it is the same photo | |
| $("prompt").value = h.prompt; | |
| $("seed").value = h.seed ?? 42; | |
| $("variant").value = h.variant; | |
| showVariantNote(); | |
| $("randomize").checked = false; | |
| $("result-meta").textContent = h.info; | |
| showResultImage(h.uri); | |
| }; | |
| strip.appendChild(chip); | |
| }); | |
| } | |
| renderHistory(); | |
| // ---------- before/after split view ---------- | |
| let splitOn = false; | |
| $("split-toggle").onclick = () => { | |
| if ($("result-img").hidden) return; | |
| splitOn = !splitOn; | |
| $("split-toggle").classList.toggle("active", splitOn); | |
| $("split-wrap").hidden = !splitOn; | |
| if (splitOn) $("split-source").src = $("source-img").src; | |
| }; | |
| (function () { | |
| const divider = $("split-divider"); | |
| let dragging = false; | |
| divider.addEventListener("pointerdown", (e) => { dragging = true; e.preventDefault(); }); | |
| window.addEventListener("pointermove", (e) => { | |
| if (!dragging) return; | |
| const stage = $("after-stage").getBoundingClientRect(); | |
| const pct = Math.min(97, Math.max(3, ((e.clientX - stage.left) / stage.width) * 100)); | |
| divider.style.left = pct + "%"; | |
| $("split-source").style.clipPath = `inset(0 ${100 - pct}% 0 0)`; | |
| }); | |
| window.addEventListener("pointerup", () => { dragging = false; }); | |
| })(); | |
| // ---------- zoom ---------- | |
| document.querySelectorAll(".doc figcaption").forEach((caption) => { | |
| const btn = document.createElement("button"); | |
| btn.className = "zoom-btn"; | |
| btn.textContent = "FIT"; | |
| btn.onclick = () => { | |
| const img = caption.parentElement.querySelector("img"); | |
| const stage = caption.parentElement.querySelector(".stage"); | |
| const zoomed = img.classList.toggle("zoomed"); | |
| stage.classList.toggle("scrollable", zoomed); | |
| btn.textContent = zoomed ? "1:1" : "FIT"; | |
| placeMask(); | |
| }; | |
| caption.appendChild(btn); | |
| }); | |
| // ---------- collapsible panels ---------- | |
| document.querySelectorAll(".panel .panel-title").forEach((title) => { | |
| const body = document.createElement("div"); | |
| body.className = "panel-body"; | |
| [...title.parentElement.children].forEach((child) => { | |
| if (child !== title) body.appendChild(child); | |
| }); | |
| title.parentElement.appendChild(body); | |
| title.onclick = () => title.parentElement.classList.toggle("collapsed"); | |
| }); | |
| // ---------- examples: photo + mask + instruction from the model card ---------- | |
| CONFIG.examples.forEach((ex) => { | |
| const btn = document.createElement("button"); | |
| btn.className = "ex-thumb"; | |
| btn.title = ex.prompt; | |
| const img = document.createElement("img"); | |
| img.src = `/examples/${ex.id}_photo.jpg`; | |
| img.loading = "lazy"; | |
| img.alt = ex.label; | |
| const label = document.createElement("span"); | |
| label.textContent = ex.label; | |
| btn.appendChild(img); | |
| btn.appendChild(label); | |
| btn.onclick = async () => { | |
| setMode("remove"); | |
| $("prompt").value = ex.prompt; | |
| setStatus("Loading example…", true); | |
| try { | |
| const blob = await (await fetch(`/examples/${ex.id}_photo.jpg`)).blob(); | |
| await loadPhoto(new File([blob], `${ex.id}.jpg`, { type: blob.type || "image/jpeg" }), `/examples/${ex.id}_mask.png`); | |
| } catch (err) { | |
| setStatus(String(err)); | |
| } | |
| }; | |
| $("examples").appendChild(btn); | |
| }); | |
| // ---------- run ---------- | |
| function unwrap(result) { | |
| const data = Array.isArray(result?.data) ? result.data[0] : result?.data; | |
| return data ?? {}; | |
| } | |
| $("run-btn").onclick = async () => { | |
| const prompt = $("prompt").value.trim(); | |
| if (!state.sourcePath) { setStatus("Upload a photo or pick an example first."); return; } | |
| if (!state.maskPainted && !prompt) { setStatus("Paint over the object to remove, or describe it."); return; } | |
| const run = { | |
| prompt, variant: $("variant").value, sourceUrl: state.sourceUrl, sourcePath: state.sourcePath, | |
| }; | |
| const autoMask = CONFIG.textMask && !state.maskPainted; | |
| setStatus(autoMask ? "Finding the object and removing it…" : "Removing…", true); | |
| try { | |
| const app = await getApp(); | |
| const result = await app.predict("/remove", { | |
| source: run.sourcePath, | |
| mask: await uploadMask(), | |
| instruction: prompt, | |
| variant: run.variant, | |
| seed: parseInt($("seed").value, 10) || 0, | |
| randomize: $("randomize").checked, | |
| auto_mask: autoMask, | |
| }); | |
| const payload = unwrap(result); | |
| if (typeof payload.image_data !== "string") { setStatus("Run finished but no image came back (see console)."); return; } | |
| if (payload.seed != null) $("seed-out").textContent = "seed " + payload.seed; | |
| if (state.sourceUrl === run.sourceUrl) { // the photo may have changed while it ran | |
| $("result-meta").textContent = payload.info || "Done"; | |
| showResultImage(payload.image_data); | |
| // Show the mask SAM 3 found, so the next run can reuse or refine it. | |
| if (typeof payload.mask_data === "string" && !state.maskPainted) await loadMask(payload.mask_data); | |
| } | |
| history.unshift({ ...run, uri: payload.image_data, seed: payload.seed, info: payload.info || "" }); | |
| if (history.length > 8) history.pop(); | |
| renderHistory(); | |
| setStatus("Done"); | |
| } catch (err) { | |
| setStatus(err?.message || String(err)); | |
| } | |
| }; | |
| setMode("remove"); | |
| </script> | |
| </body> | |
| </html> | |