Spaces:
Running on Zero
Running on Zero
Download studio.html from akhaliq/endless-edit-tailsteer: direct link, hf CLI and curl.
- Browser
- Download file 19 kB
-
https://huggingface.co/spaces/akhaliq/endless-edit-tailsteer/resolve/main/studio.html
- Command line
-
hf download hf://spaces/akhaliq/endless-edit-tailsteer/studio.html
-
curl -L -o studio.html https://huggingface.co/spaces/akhaliq/endless-edit-tailsteer/resolve/main/studio.html
19 kB
| <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 ; } | |
| 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 ; overflow: visible ; } | |
| 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> | |