endless-edit-tailsteer / studio.html
akhaliq's picture
akhaliq HF Staff
Header: ML Intern badge + Build-your-own-app CTA, drop API link
bda9ba8 verified
Raw History Blame Contribute Delete
19 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Endless Edit — TailSteer</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: #a78bfa; --accent2: #ec4899; --accent-soft: rgba(167,139,250,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.95rem; white-space: nowrap; }
.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;
}
.mode-tabs button.active { background: var(--raised); color: var(--text); }
.spacer { flex: 1; }
.link { color: var(--muted); text-decoration: none; font-size: 0.8rem; }
.link:hover { color: var(--text); }
.ml-badge { height: 20px; display: block; }
.ml-cta {
color: #fff; text-decoration: none; font-size: 0.8rem; font-weight: 600;
padding: 6px 14px; border-radius: 999px; white-space: nowrap;
background: linear-gradient(90deg, var(--accent), var(--accent2));
transition: filter 0.15s;
}
.ml-cta:hover { filter: brightness(1.15); }
.main { display: flex; flex: 1; min-height: 0; }
.viewport { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; position: relative; }
.workflow-view { position: absolute; inset: 0; padding: 12px; }
.workflow-view iframe { width: 100%; height: 100%; border: none; border-radius: 10px; background: #0c0d10; }
.stage {
flex: 1; min-height: 0; margin: 16px 16px 8px; display: flex; align-items: center; justify-content: center;
background-color: #0b0b0d; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden;
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;
position: relative;
}
.stage img { max-width: 100%; max-height: 100%; object-fit: contain; display: block; }
.drop-hint { color: var(--dim); font-size: 0.9rem; text-align: center; padding: 20px; position: absolute; }
.drop-hint b { color: var(--muted); display: block; margin-bottom: 6px; font-size: 1.05rem; }
.chain-strip {
display: flex; gap: 8px; padding: 8px 16px; overflow-x: auto; flex-shrink: 0;
background: var(--panel); border-top: 1px solid var(--border); align-items: center; min-height: 76px;
}
.chain-strip .clabel { font-size: 0.62rem; font-weight: 650; letter-spacing: 0.14em; color: var(--dim); text-transform: uppercase; white-space: nowrap; }
.chip {
width: 56px; height: 56px; border-radius: 6px; border: 1px solid var(--border);
overflow: hidden; cursor: pointer; flex-shrink: 0; position: relative;
}
.chip:hover, .chip.current { border-color: var(--accent); }
.chip img { width: 100%; height: 100%; object-fit: cover; }
.chip .num {
position: absolute; bottom: 0; left: 0; right: 0; background: rgba(0,0,0,0.65);
color: #ddd; font-size: 0.55rem; padding: 1px 3px; text-align: center;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.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; }
.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="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;
}
textarea:focus, input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.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; cursor: pointer; }
input[type="range"]::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; background: #fff; border: none; cursor: pointer; }
.switch { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; }
.switch input { display: none; }
.switch .sw { width: 34px; height: 20px; border-radius: 999px; background: var(--raised); border: 1px solid var(--border); position: relative; flex-shrink: 0; }
.switch .sw::after { content: ""; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: var(--muted); }
.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); }
.idea { display: inline-block; margin: 0 6px 6px 0; padding: 4px 10px; border: 1px solid var(--border); border-radius: 999px; background: var(--raised); color: var(--muted); font-size: 0.72rem; cursor: pointer; }
.idea:hover { border-color: var(--accent); color: var(--text); }
#apply-btn {
margin-top: 4px; padding: 12px; border: none; border-radius: 8px; cursor: pointer;
background: linear-gradient(90deg, var(--accent), var(--accent2)); color: white;
font-weight: 600; font-size: 0.9rem;
}
#apply-btn:hover { filter: brightness(1.12); }
#apply-btn:disabled { opacity: 0.5; cursor: wait; }
.btn-row { display: flex; gap: 8px; margin-top: 8px; }
.btn-row button {
flex: 1; padding: 8px; border: 1px solid var(--border); border-radius: 8px;
background: transparent; color: var(--muted); cursor: pointer; font-size: 0.8rem;
}
.btn-row button:hover { color: var(--text); border-color: var(--accent); }
#gif-preview { width: 100%; border-radius: 8px; margin-top: 8px; border: 1px solid var(--border); }
.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) {
.topbar .ml-badge { display: none; }
html, body { height: auto !important; overflow: visible !important; }
body { display: block; }
.main { display: block; }
.viewport { display: block; height: auto; }
.workflow-view { position: static; height: 78vh; padding: 0 0 10px; }
.stage { min-height: 260px; margin: 10px; }
.panels { width: 100%; border-left: none; border-top: 1px solid var(--border); }
.statusbar { position: sticky; bottom: 0; }
}
</style>
</head>
<body>
<header class="topbar">
<div class="brand">♾️ Endless Edit</div>
<div class="mode-tabs">
<button id="mode-studio" class="active">Studio</button>
<button id="mode-workflow">Workflow</button>
</div>
<div class="spacer"></div>
<a class="link" href="https://huggingface.co/Lyricccco/Qwen-Image-2.1-TailSteer" target="_blank" rel="noopener">Model</a>
<a class="link" href="https://arxiv.org/abs/2611.06741" target="_blank" rel="noopener">Paper</a>
<a href="https://hf.co/chat?mode=ml-intern" target="_blank" rel="noopener"><img class="ml-badge" src="https://hf.co/datasets/huggingface/badges/resolve/main/built-with-ml-intern-sm.svg" alt="Built with ML Intern" /></a>
<a class="ml-cta" href="https://hf.co/chat?mode=ml-intern&prompt=Build%20a%20gr.Server%20studio%20and%20gr.Workflow%20Space%20for%20my%20model" target="_blank" rel="noopener">Build your own app ↗</a>
</header>
<div class="main">
<div class="viewport">
<div class="workflow-view" id="workflow-view" hidden>
<iframe src="/workflow/?ui=canvas" title="Workflow canvas"></iframe>
</div>
<div class="stage" id="stage">
<img id="frame-img" alt="" hidden />
<div class="drop-hint" id="drop-hint"><b>♾️ How many edits can a photo survive?</b>Drop an image here, or use the upload button — then keep piling on edits.</div>
</div>
<div class="chain-strip" id="chain-strip"><span class="clabel">Chain</span></div>
</div>
<aside class="panels">
<section class="panel">
<div class="panel-title">1 · Photo</div>
<button class="idea" id="upload-btn" style="width:100%;padding:10px">⬆ Upload / replace photo</button>
</section>
<section class="panel">
<div class="panel-title">2 · Next edit</div>
<textarea id="prompt" rows="3" placeholder="e.g. add a corgi sitting next to me"></textarea>
<div style="margin-top:8px">
<span class="idea" data-p="make it sunset">sunset</span>
<span class="idea" data-p="add a corgi sitting next to me">+ corgi</span>
<span class="idea" data-p="change my jacket to a red leather one">red jacket</span>
<span class="idea" data-p="make it snow">snow</span>
<span class="idea" data-p="turn the background into a cyberpunk city">cyberpunk</span>
<span class="idea" data-p="give me sunglasses">sunglasses</span>
</div>
</section>
<section class="panel">
<div class="panel-title">Advanced</div>
<label>Steps <span id="steps-val">28</span> · 28 = fast, 40 = paper default</label>
<input type="range" id="steps" min="10" max="50" value="28" />
<div class="row" style="margin-top:10px">
<div><label for="seed">Seed</label><input type="number" id="seed" value="42" min="0" /></div>
<label class="switch"><input type="checkbox" id="lock" /><span class="sw"></span><span class="sw-label">Lock</span></label>
</div>
</section>
<button id="apply-btn">✨ Apply edit</button>
<div class="btn-row">
<button id="undo-btn">↩ Undo</button>
<button id="reset-btn">🗑 Reset</button>
</div>
<button id="gif-btn" class="idea" style="width:100%;padding:10px;text-align:center" hidden>🎞 Export chain as GIF</button>
<img id="gif-preview" alt="" hidden />
</aside>
</div>
<footer class="statusbar" id="statusbar">
<div class="pulse"></div>
<span id="status">Upload a photo to start your chain.</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 = { app: null, frames: [], viewIndex: -1 };
async function getApp() {
if (!state.app) state.app = await client(location.origin);
return state.app;
}
// The workflow canvas is nested in this page's iframe, so gradio's own token
// handshake never reaches it. Every queued call we make carries the visitor's
// ZeroGPU token, and the server stashes it for the canvas — keep it fresh.
async function handshake() {
try { (await getApp()).predict("/handshake", {}); } catch (e) { /* best effort */ }
}
handshake();
setInterval(handshake, 60000);
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;
}
async function dataUriToPath(uri) {
const blob = await (await fetch(uri)).blob();
return uploadFile(new File([blob], "frame.jpg", { type: "image/jpeg" }));
}
function setStatus(text, busy = false) {
$("status").textContent = text;
$("statusbar").classList.toggle("busy", busy);
$("apply-btn").disabled = busy;
}
function setMode(mode) {
$("mode-studio").classList.toggle("active", mode === "studio");
$("mode-workflow").classList.toggle("active", mode === "workflow");
const wf = mode === "workflow";
$("workflow-view").hidden = !wf;
$("stage").style.display = wf ? "none" : "";
$("chain-strip").style.display = wf ? "none" : "";
document.querySelector(".panels").style.display = wf ? "none" : "";
}
$("mode-studio").onclick = () => setMode("studio");
$("mode-workflow").onclick = () => setMode("workflow");
// ---------- chain ----------
function showFrame(i) {
if (i < 0 || i >= state.frames.length) return;
state.viewIndex = i;
$("frame-img").src = state.frames[i].uri;
$("frame-img").hidden = false;
$("drop-hint").style.display = "none";
renderChain();
}
function renderChain() {
const strip = $("chain-strip");
strip.innerHTML = '<span class="clabel">Chain</span>';
state.frames.forEach((f, i) => {
const chip = document.createElement("div");
chip.className = "chip" + (i === state.viewIndex ? " current" : "");
chip.title = f.prompt || "original";
const img = document.createElement("img");
img.src = f.uri;
const num = document.createElement("div");
num.className = "num";
num.textContent = i === 0 ? "#0" : `#${i} ${f.prompt}`;
chip.appendChild(img);
chip.appendChild(num);
chip.onclick = () => showFrame(i);
strip.appendChild(chip);
});
$("gif-btn").hidden = state.frames.length < 2;
}
async function addPhoto(file) {
setStatus("Uploading…", true);
try {
const path = await uploadFile(file);
state.frames = [{ uri: URL.createObjectURL(file), path, prompt: "", seed: null }];
showFrame(0);
setStatus("Chain started. Now describe edit #1.");
$("prompt").focus();
} catch (err) {
setStatus(String(err));
}
}
$("upload-btn").onclick = () => $("file-input").click();
$("file-input").onchange = (e) => {
const file = e.target.files[0];
e.target.value = "";
if (file) addPhoto(file);
};
$("stage").addEventListener("dragover", (e) => e.preventDefault());
$("stage").addEventListener("drop", (e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file) addPhoto(file);
});
document.querySelectorAll(".idea[data-p]").forEach((el) => {
el.onclick = () => { $("prompt").value = el.dataset.p; $("prompt").focus(); };
});
$("steps").oninput = () => ($("steps-val").textContent = $("steps").value);
$("prompt").addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); $("apply-btn").click(); }
});
function unwrap(result) {
return (Array.isArray(result?.data) ? result.data[0] : result?.data) ?? {};
}
$("apply-btn").onclick = async () => {
if (!state.frames.length) { setStatus("Upload a photo first."); return; }
const prompt = $("prompt").value.trim();
if (!prompt) { setStatus("Describe the edit you want."); return; }
const app = await getApp();
const lock = $("lock").checked;
const seed = parseInt($("seed").value, 10) || 0;
const steps = parseInt($("steps").value, 10);
setStatus("Editing… (the first call also loads the model)", true);
try {
const result = await app.predict("/edit", {
source: state.frames[state.frames.length - 1].path,
instruction: prompt,
steps, seed, randomize: !lock,
});
const payload = unwrap(result);
if (!payload.image_data) { setStatus("Run finished but no image came back."); return; }
const path = await dataUriToPath(payload.image_data);
state.frames.push({ uri: payload.image_data, path, prompt, seed: payload.seed });
showFrame(state.frames.length - 1);
$("seed-out").textContent = "seed " + payload.seed;
$("prompt").value = "";
setStatus(`Turn ${state.frames.length - 1} done — the chain is ${state.frames.length} frames long. Keep going.`);
} catch (err) {
setStatus(err?.message || String(err));
}
};
$("undo-btn").onclick = () => {
if (state.frames.length <= 1) { setStatus("Nothing to undo — only the original is left."); return; }
state.frames.pop();
showFrame(state.frames.length - 1);
setStatus(`Back to turn ${state.frames.length - 1}.`);
};
$("reset-btn").onclick = () => {
state.frames = [];
state.viewIndex = -1;
$("frame-img").hidden = true;
$("drop-hint").style.display = "";
$("gif-preview").hidden = true;
renderChain();
setStatus("Upload a photo to start a new chain.");
};
$("gif-btn").onclick = async () => {
if (state.frames.length < 2) return;
const app = await getApp();
setStatus("Building GIF…", true);
try {
const result = await app.predict("/gif", { frames: state.frames.map((f) => f.path), duration: 900 });
const payload = unwrap(result);
if (!payload.gif_data) { setStatus("GIF build returned nothing."); return; }
$("gif-preview").src = payload.gif_data;
$("gif-preview").hidden = false;
const a = document.createElement("a");
a.href = payload.gif_data;
a.download = "endless_edit_chain.gif";
a.click();
setStatus("GIF ready — downloaded (preview below).");
} catch (err) {
setStatus(err?.message || String(err));
}
};
</script>
</body>
</html>