Fibo-2.0-editing / studio.html
tingled's picture
gradio server + workflow app (#3)
e99af70
Raw History Blame Contribute Delete
35.3 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>FIBO 2.0 Edit Turbo Studio</title>
<style>
:root {
--bg: #0e0e10; --panel: #16171a; --panel2: #101114; --raised: #1e2024;
--border: rgba(255,255,255,0.07);
--text: rgba(255,255,255,0.87); --muted: rgba(255,255,255,0.5); --dim: rgba(255,255,255,0.35);
--accent: #7c5cff; --accent2: #8a3dff; --accent-soft: rgba(124,92,255,0.14);
--radius: 10px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
[hidden] { display: none !important; }
body {
font-family: "Inter", ui-sans-serif, system-ui, sans-serif;
background: var(--bg); 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: 16px; padding: 8px 16px;
background: var(--panel); border-bottom: 1px solid var(--border); flex-shrink: 0;
}
.brand { font-weight: 700; font-size: 0.92rem; white-space: nowrap; letter-spacing: -0.01em; }
.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;
}
.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); }
.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); }
.ml-cta {
color: var(--accent); text-decoration: none; font-size: 0.8rem; font-weight: 600;
padding: 5px 13px; border: 1px solid var(--accent); border-radius: 999px;
background: var(--accent-soft); transition: filter 0.15s; white-space: nowrap;
}
.ml-cta:hover { filter: brightness(1.2); }
.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(--border);
}
.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(--accent); 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: #0c0d10; }
.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(--border); 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: #0b0b0d;
background-image:
linear-gradient(45deg, #141419 25%, transparent 25%),
linear-gradient(-45deg, #141419 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #141419 75%),
linear-gradient(-45deg, transparent 75%, #141419 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; }
#mask-canvas { position: absolute; inset: 0; cursor: crosshair; display: none; }
#mask-canvas.active { display: block; }
.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;
}
.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(--border); 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;
}
.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(--accent); }
.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(124,92,255,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(--border); 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;
}
.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(--border);
}
.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"] {
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;
}
textarea:focus, input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
textarea.code { font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 0.75rem; }
.row { display: flex; gap: 8px; align-items: center; }
.row > * { flex: 1; }
input[type="range"] {
-webkit-appearance: none; appearance: none; width: 100%; height: 3px;
border-radius: 2px; background: var(--raised); outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none; width: 14px; height: 14px; border-radius: 50%;
background: #fff; border: none; box-shadow: 0 1px 4px rgba(0,0,0,0.55); cursor: pointer;
transition: transform 0.12s;
}
input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.2); }
input[type="range"]::-moz-range-thumb {
width: 14px; height: 14px; border-radius: 50%; background: #fff; border: none;
box-shadow: 0 1px 4px rgba(0,0,0,0.55); cursor: pointer;
}
.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); cursor: pointer;
}
.layer-row:hover { border-color: var(--accent); }
.layer-row .thumb {
width: 40px; height: 40px; border-radius: 4px; object-fit: cover; flex-shrink: 0;
background: #101014; 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; }
.layer-row .ldel {
border: none; background: transparent; color: var(--dim); cursor: pointer;
font-size: 0.9rem; padding: 2px 6px; border-radius: 4px;
}
.layer-row .ldel:hover { color: #ff6f91; background: rgba(255,111,145,0.1); }
.layer-row.mask-layer .thumb { border-color: var(--accent); }
.ref-slot {
height: 72px; border: 1px dashed var(--border); border-radius: 6px; margin-top: 8px;
display: flex; align-items: center; justify-content: center; cursor: pointer; overflow: hidden;
color: var(--dim); font-size: 0.72rem; background: var(--raised);
}
.ref-slot:hover { border-color: var(--accent); }
.ref-slot img { width: 100%; height: 100%; object-fit: cover; }
.hint { font-size: 0.68rem; color: var(--dim); margin-top: 8px; line-height: 1.4; }
.ex-row { display: flex; gap: 8px; overflow-x: auto; }
.ex-chip {
flex: 1; min-width: 76px; border: 1px solid var(--border); border-radius: 8px;
overflow: hidden; cursor: pointer; background: var(--raised); padding: 0;
text-align: left; transition: border-color 0.15s;
}
.ex-chip:hover { border-color: var(--accent); }
.ex-chip img { width: 100%; height: 64px; object-fit: cover; display: block; }
.ex-chip span { display: block; padding: 5px 7px; font-size: 0.62rem; color: var(--muted); line-height: 1.25; }
#run-btn {
margin-top: 4px; padding: 12px; border: none; border-radius: 8px; cursor: pointer;
background: var(--accent); color: white;
font-weight: 600; font-size: 0.9rem; letter-spacing: 0.01em;
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(--border);
font-size: 0.75rem; color: var(--muted);
}
.statusbar .pulse { width: 8px; height: 8px; border-radius: 50%; background: #4fd1a5; }
.statusbar.busy .pulse { background: var(--accent); animation: pulse 1s infinite alternate; }
@keyframes pulse { from { opacity: 0.4; } to { opacity: 1; } }
#file-input { display: none; }
@media (max-width: 860px) {
html, body { height: auto !important; overflow: visible !important; }
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(--border);
}
.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(--border);
padding: 10px 12px; overflow-y: visible;
}
.mode-tabs button { padding: 6px 13px; font-size: 0.8rem; }
.statusbar { position: sticky; bottom: 0; }
}
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><img src="/bria_logo.png" alt="BRIA" style="height:22px;vertical-align:middle;margin-right:8px" />FIBO 2.0 Edit Turbo</div>
<div class="mode-tabs">
<button id="mode-edit" class="active">Edit</button>
<button id="mode-workflow">Workflow</button>
</div>
<div class="spacer"></div>
<a class="ml-badge" href="https://huggingface.co/briaai/Fibo-2.0-edit-turbo" target="_blank" rel="noopener">Model</a>
<a class="ml-cta" href="https://arxiv.org/abs/2511.06876" target="_blank" rel="noopener">arXiv</a>
</header>
<div class="main">
<div class="rail">
<button id="tool-upload" title="Upload source image"><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-brush" title="Brush mask (single reference)"><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="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.07"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg></button>
<button id="tool-clear-mask" title="Clear 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"><path d="M20 20H8L3 15a2 2 0 0 1 0-2.83l7.17-7.17a2 2 0 0 1 2.83 0l5.66 5.66a2 2 0 0 1 0 2.83L13 19"/><line x1="18" y1="11" x2="9" y2="20"/></svg></button>
<button id="tool-clear-doc" title="Clear document"><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 id="before-caption">Source</figcaption>
<div class="stage" id="before-stage">
<img id="source-img" alt="" hidden />
<canvas id="mask-canvas"></canvas>
<div class="drop-hint" id="source-hint"><b>No source image</b>Use the upload tool or drop a file here</div>
</div>
</figure>
<figure class="doc" id="doc-after">
<figcaption id="after-caption">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"><span class="hlabel">History</span></div>
</div>
<aside class="panels">
<section class="panel">
<div class="panel-title">Examples</div>
<div class="ex-row">
<button class="ex-chip" data-img="/example_images/edit_cat.jpg" data-instruction="Put a small party hat on the cat">
<img src="/example_images/edit_cat.jpg" alt="" /><span>Party hat on the cat</span>
</button>
<button class="ex-chip" data-img="/example_images/edit_bicycle.jpg" data-instruction="Change the bicycle color to bright red">
<img src="/example_images/edit_bicycle.jpg" alt="" /><span>Red bicycle</span>
</button>
<button class="ex-chip" data-img="/example_images/edit_portrait.jpg" data-instruction="Add round glasses">
<img src="/example_images/edit_portrait.jpg" alt="" /><span>Round glasses</span>
</button>
</div>
</section>
<section class="panel">
<div class="panel-title">Instruction</div>
<label for="instruction">What should change?</label>
<textarea id="instruction" rows="3" placeholder="make the sky dramatic and stormy"></textarea>
<label for="json-instruction">JSON override (optional; auto-filled by the VGL prompt writer)</label>
<textarea id="json-instruction" class="code" rows="5" placeholder='{"edit_instruction": "…"}'></textarea>
</section>
<section class="panel">
<div class="panel-title">Layers</div>
<div class="layer-row" id="layer-source">
<div class="thumb" id="layer-source-thumb">BG</div>
<div class="lname">Background<span class="lsub">source image</span></div>
</div>
<div class="layer-row mask-layer" id="layer-mask" hidden>
<div class="thumb" id="layer-mask-thumb"></div>
<div class="lname">Mask<span class="lsub">repaint region</span></div>
<button class="ldel" id="layer-mask-del" title="Clear mask">✕</button>
</div>
<div class="ref-slot" data-ref="2">+ Reference 2</div>
<div class="ref-slot" data-ref="3">+ Reference 3</div>
<div class="ref-slot" data-ref="4">+ Reference 4</div>
<div class="hint">Slots map to &lt;image_1&gt;…&lt;image_4&gt; in the instruction. A mask only works with a single reference.</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="1" /></div>
<label class="switch"><input type="checkbox" id="randomize" checked /><span class="sw"></span><span class="sw-label">Randomize</span></label>
</div>
<div class="row" style="margin-top:10px">
<div><label>Steps <span id="steps-val">4</span></label><input type="range" id="steps" min="1" max="8" value="4" /></div>
</div>
<div class="hint">Edit Turbo is distilled for 4 guidance-free steps.</div>
</section>
<button id="run-btn">Generate Edit</button>
</aside>
</div>
<footer class="statusbar" id="statusbar">
<div class="pulse"></div>
<span id="status">Idle</span>
<div class="spacer"></div>
<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 state = { mode: "edit", sourcePath: null, refPaths: { 2: null, 3: null, 4: null }, app: null };
const docState = { source: null, result: null, history: [] };
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;
}
function setMode(mode) {
state.mode = mode;
$("mode-edit").classList.toggle("active", mode === "edit");
$("mode-workflow").classList.toggle("active", mode === "workflow");
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" : "";
document.getElementById("history-strip").style.display = workflow ? "none" : "";
}
$("mode-edit").onclick = () => setMode("edit");
$("mode-workflow").onclick = () => setMode("workflow");
// ---------- uploads ----------
let uploadTarget = "source";
function pickFile(target) {
uploadTarget = target;
$("file-input").click();
}
$("tool-upload").onclick = () => pickFile("source");
document.querySelectorAll(".ref-slot").forEach((slot) => {
slot.onclick = () => pickFile(slot.dataset.ref);
});
$("file-input").onchange = async (e) => {
const file = e.target.files[0];
e.target.value = "";
if (!file) return;
setStatus("Uploading…", true);
try {
const path = await uploadFile(file);
if (uploadTarget === "source") {
state.sourcePath = path;
showSource(file);
} else {
state.refPaths[uploadTarget] = path;
const slot = document.querySelector(`.ref-slot[data-ref="${uploadTarget}"]`);
slot.innerHTML = "";
const img = document.createElement("img");
img.src = URL.createObjectURL(file);
slot.appendChild(img);
}
setStatus("Idle");
} catch (err) {
setStatus(String(err));
}
};
$("before-stage").addEventListener("dragover", (e) => e.preventDefault());
$("before-stage").addEventListener("drop", async (e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (!file) return;
setStatus("Uploading…", true);
try {
state.sourcePath = await uploadFile(file);
showSource(file);
setStatus("Idle");
} catch (err) {
setStatus(String(err));
}
});
function showSource(file) {
const img = $("source-img");
img.src = URL.createObjectURL(file);
img.hidden = false;
$("source-hint").style.display = "none";
img.onload = () => sizeMaskCanvas();
clearMask();
$("json-instruction").value = "";
docState.source = img.src;
const st = $("layer-source-thumb");
st.innerHTML = "";
const stImg = document.createElement("img");
stImg.src = img.src;
st.appendChild(stImg);
}
// ---------- mask brush ----------
const maskCanvas = $("mask-canvas");
const mctx = maskCanvas.getContext("2d");
let strokes = [];
let drawing = false;
function sizeMaskCanvas() {
const stage = $("before-stage");
const img = $("source-img");
if (!img.src || img.hidden) return;
const box = img.getBoundingClientRect();
const stageBox = stage.getBoundingClientRect();
maskCanvas.width = box.width;
maskCanvas.height = box.height;
maskCanvas.style.left = box.left - stageBox.left + "px";
maskCanvas.style.top = box.top - stageBox.top + "px";
maskCanvas.style.width = box.width + "px";
maskCanvas.style.height = box.height + "px";
mctx.fillStyle = "#000";
mctx.fillRect(0, 0, maskCanvas.width, maskCanvas.height);
}
function brushPos(e) {
const box = maskCanvas.getBoundingClientRect();
return { x: e.clientX - box.left, y: e.clientY - box.top };
}
maskCanvas.addEventListener("pointerdown", (e) => { drawing = true; strokes.push([brushPos(e)]); });
maskCanvas.addEventListener("pointermove", (e) => {
if (!drawing) return;
strokes[strokes.length - 1].push(brushPos(e));
redrawMask();
});
window.addEventListener("pointerup", () => {
drawing = false;
if (state.brushOn && strokes.length) {
$("layer-mask").hidden = false;
const mt = $("layer-mask-thumb");
mt.innerHTML = "";
const t = document.createElement("img");
t.src = maskCanvas.toDataURL();
mt.appendChild(t);
}
});
function redrawMask() {
mctx.fillStyle = "#000";
mctx.fillRect(0, 0, maskCanvas.width, maskCanvas.height);
mctx.strokeStyle = "#fff";
mctx.lineWidth = Math.max(16, maskCanvas.width / 40);
mctx.lineCap = "round";
mctx.lineJoin = "round";
for (const stroke of strokes) {
mctx.beginPath();
stroke.forEach((p, i) => (i ? mctx.lineTo(p.x, p.y) : mctx.moveTo(p.x, p.y)));
mctx.stroke();
}
mctx.save();
mctx.globalCompositeOperation = "source-atop";
mctx.fillStyle = "rgba(124,92,255,0.35)";
mctx.fillRect(0, 0, maskCanvas.width, maskCanvas.height);
mctx.restore();
}
function clearMask() {
strokes = [];
maskCanvas.classList.remove("active");
$("tool-brush").classList.remove("active");
mctx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
$("layer-mask").hidden = true;
}
state.brushOn = false;
$("tool-brush").onclick = () => {
if (state.sourcePath == null) { setStatus("Upload a source image first."); return; }
state.brushOn = !state.brushOn;
maskCanvas.classList.toggle("active", state.brushOn);
$("tool-brush").classList.toggle("active", state.brushOn);
if (state.brushOn) { sizeMaskCanvas(); redrawMask(); setStatus("Brush the area to repaint."); }
else setStatus("Idle");
};
$("tool-clear-mask").onclick = clearMask;
$("layer-mask-del").onclick = (e) => { e.stopPropagation(); clearMask(); };
$("tool-clear-doc").onclick = () => {
state.sourcePath = null;
docState.source = null;
docState.result = null;
$("source-img").hidden = true;
$("source-hint").style.display = "";
$("result-img").hidden = true;
$("result-hint").style.display = "";
$("layer-source-thumb").textContent = "BG";
clearMask();
};
async function maskPath() {
if (!strokes.length) return null;
const img = $("source-img");
const out = document.createElement("canvas");
out.width = img.naturalWidth;
out.height = img.naturalHeight;
const octx = out.getContext("2d");
octx.fillStyle = "#000";
octx.fillRect(0, 0, out.width, out.height);
const sx = out.width / maskCanvas.width;
const sy = out.height / maskCanvas.height;
octx.strokeStyle = "#fff";
octx.lineWidth = Math.max(16, maskCanvas.width / 40) * sx;
octx.lineCap = "round";
octx.lineJoin = "round";
for (const stroke of strokes) {
octx.beginPath();
stroke.forEach((p, i) => (i ? octx.lineTo(p.x * sx, p.y * sy) : octx.moveTo(p.x * sx, p.y * sy)));
octx.stroke();
}
const blob = await new Promise((r) => out.toBlob(r, "image/png"));
return uploadFile(blob);
}
// ---------- history ----------
function renderHistory() {
const strip = $("history-strip");
strip.innerHTML = '<span class="hlabel">History</span>';
docState.history.forEach((h) => {
const chip = document.createElement("div");
chip.className = "history-chip";
chip.title = h.instruction || "";
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 = () => {
$("instruction").value = h.instruction;
$("seed").value = h.seed ?? 42;
$("randomize").checked = false;
docState.result = h.uri;
$("result-img").src = h.uri;
$("result-img").hidden = false;
$("result-hint").style.display = "none";
$("split-toggle").hidden = false;
};
strip.appendChild(chip);
});
}
function pushHistory(uri, seed, instruction) {
docState.history.unshift({ uri, seed, instruction });
if (docState.history.length > 8) docState.history.pop();
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 && !$("source-img").hidden) $("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";
};
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");
});
// Changing the instruction or the source makes a previously written JSON stale,
// like the classic app: clear it so the next run regenerates it.
$("instruction").addEventListener("input", () => { $("json-instruction").value = ""; });
// ---------- examples ----------
document.querySelectorAll(".ex-chip").forEach((chip) => {
chip.onclick = async () => {
setStatus("Loading example…", true);
try {
const blob = await (await fetch(chip.dataset.img)).blob();
const file = new File([blob], "example.jpg", { type: "image/jpeg" });
state.sourcePath = await uploadFile(file);
showSource(file);
$("instruction").value = chip.dataset.instruction;
setMode("edit");
setStatus("Example loaded — hit Generate Edit");
} catch (err) {
setStatus(String(err));
}
};
});
// ---------- result handling ----------
function unwrap(result) {
const data = Array.isArray(result?.data) ? result.data[0] : result?.data;
return data ?? {};
}
function showResult(result) {
const payload = unwrap(result);
const url = typeof payload.image_data === "string" ? payload.image_data : null;
if (!url) { setStatus("Run finished but no image came back (see console)."); return false; }
const img = $("result-img");
img.src = url;
img.hidden = false;
$("result-hint").style.display = "none";
docState.result = url;
if (payload.seed != null) $("seed-out").textContent = "seed " + payload.seed;
$("split-toggle").hidden = false;
$("result-meta").textContent = "Edited";
const instruction = ($("json-instruction").value.trim() || $("instruction").value);
pushHistory(url, payload.seed, instruction);
return true;
}
$("steps").oninput = () => ($("steps-val").textContent = $("steps").value);
$("run-btn").onclick = async () => {
const app = await getApp();
if (!state.sourcePath) { setStatus("Upload a source image first."); return; }
const seed = parseInt($("seed").value, 10) || 42;
const randomize = $("randomize").checked;
const steps = parseInt($("steps").value, 10);
try {
setStatus("Preparing mask…", true);
const mask = await maskPath();
setStatus($("json-instruction").value.trim() ? "Editing…" : "Writing the instruction (VGL)…", true);
const result = await app.predict("/edit", {
source: state.sourcePath,
instruction: $("instruction").value,
json_instruction: $("json-instruction").value,
ref2: state.refPaths[2],
ref3: state.refPaths[3],
ref4: state.refPaths[4],
mask,
seed, randomize, steps,
});
const payload = unwrap(result);
if (payload.json_instruction) $("json-instruction").value = payload.json_instruction;
if (showResult({ data: [payload] })) setStatus("Done");
} catch (err) {
setStatus(err?.message || String(err));
}
};
</script>
</body>
</html>