akhaliq's picture
akhaliq HF Staff
Video modal: show loading spinner during generation, make it closable
57b1995
Raw
History Blame Contribute Delete
41.4 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pro Realism Edit Studio</title>
<style>
:root {
--bg: #1e1e22;
--panel: #2b2b30;
--panel-2: #32323a;
--panel-3: #3a3a44;
--border: #41414a;
--border-light: #4a4a55;
--text: #e6e6ea;
--text-dim: #9a9aa6;
--text-faint: #6c6c78;
--accent: #5b8cff;
--accent-2: #7a6cff;
--accent-grad: linear-gradient(135deg, #5b8cff 0%, #7a6cff 100%);
--ok: #4ade80;
--warn: #fbbf24;
--err: #f87171;
--shadow: 0 8px 24px rgba(0,0,0,.45);
--radius: 8px;
--mono: "SF Mono", "JetBrains Mono", "Fira Code", ui-monospace, Menlo, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, Helvetica, Arial, sans-serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; overflow: hidden; }
body { font-family: var(--sans); background: var(--bg); color: var(--text); font-size: 13px; user-select: none; -webkit-font-smoothing: antialiased; }
/* ===== App layout ===== */
.app { display: grid; grid-template-rows: 44px 1fr 26px; height: 100vh; }
.app-main { display: grid; grid-template-columns: 52px 1fr 320px; overflow: hidden; }
/* ===== Menubar ===== */
.menubar { display: flex; align-items: center; gap: 0; background: linear-gradient(180deg, #2a2a30, #232328); border-bottom: 1px solid var(--border); padding: 0 12px; }
.brand { display: flex; align-items: center; gap: 10px; padding-right: 18px; }
.brand-mark { width: 26px; height: 26px; border-radius: 6px; background: var(--accent-grad); display: grid; place-items: center; font-size: 14px; color: #fff; box-shadow: 0 2px 8px rgba(91,140,255,.4); }
.brand-name { font-weight: 600; letter-spacing: .2px; font-size: 13px; }
.brand-sub { color: var(--text-faint); font-size: 11px; font-weight: 500; }
.menu-items { display: flex; gap: 2px; }
.menu-items button { background: none; border: none; color: var(--text-dim); padding: 6px 10px; border-radius: 5px; cursor: pointer; font-size: 12px; }
.menu-items button:hover { background: var(--panel-3); color: var(--text); }
.menubar-spacer { flex: 1; }
.menubar-right { display: flex; align-items: center; gap: 10px; }
.pill { display: flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 999px; font-size: 11px; background: var(--panel-2); border: 1px solid var(--border); color: var(--text-dim); }
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--text-faint); }
.dot.live { background: var(--ok); box-shadow: 0 0 6px var(--ok); }
.dot.busy { background: var(--warn); box-shadow: 0 0 6px var(--warn); animation: pulse 1.2s infinite; }
.dot.offline { background: var(--err); }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.35} }
/* ===== Left toolbar ===== */
.toolbar { background: var(--panel); border-right: 1px solid var(--border); display: flex; flex-direction: column; align-items: center; padding: 8px 0; gap: 4px; }
.tool { width: 38px; height: 38px; border-radius: 8px; display: grid; place-items: center; cursor: pointer; color: var(--text-dim); border: 1px solid transparent; position: relative; font-size: 16px; transition: background .12s, color .12s, border-color .12s; }
.tool:hover { background: var(--panel-2); color: var(--text); }
.tool.active { background: var(--panel-3); color: var(--accent); border-color: var(--accent); }
.tool .tip { position: absolute; left: 46px; top: 50%; transform: translateY(-50%); background: #000; color: #fff; padding: 4px 8px; border-radius: 4px; font-size: 11px; white-space: nowrap; opacity: 0; pointer-events: none; transition: opacity .12s; z-index: 50; border: 1px solid var(--border); }
.tool:hover .tip { opacity: 1; }
.tool-sep { width: 26px; height: 1px; background: var(--border); margin: 4px 0; }
/* ===== Center viewport ===== */
.viewport { display: flex; flex-direction: column; overflow: hidden; background: #26262b; }
.vp-canvas { flex: 1; overflow: auto; position: relative; display: grid; place-items: center; padding: 32px;
background: linear-gradient(45deg, #2c2c31 25%, transparent 25%), linear-gradient(-45deg, #2c2c31 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #2c2c31 75%), linear-gradient(-45deg, transparent 75%, #2c2c31 75%);
background-size: 20px 20px; background-position: 0 0, 0 10px, 10px -10px, -10px 0; background-color: #26262b; }
.canvas-frame { background: #000; border-radius: 4px; box-shadow: var(--shadow); display: flex; align-items: center; justify-content: center; min-width: 320px; min-height: 320px; position: relative; }
.canvas-empty { color: var(--text-faint); text-align: center; padding: 60px 40px; }
.canvas-empty .big { font-size: 42px; margin-bottom: 12px; opacity: .5; }
.canvas-empty p { font-size: 13px; margin-bottom: 4px; color: var(--text-dim); }
.canvas-empty .sub { font-size: 11px; color: var(--text-faint); }
#preview-img { max-width: 100%; max-height: 72vh; display: block; border-radius: 2px; }
/* progress overlay */
.progress-card { position: absolute; inset: 0; display: grid; place-items: center; background: rgba(20,20,24,.7); backdrop-filter: blur(3px); border-radius: 4px; z-index: 5; }
.progress-inner { text-align: center; width: min(80%, 360px); }
.progress-inner .stage { font-size: 12px; color: var(--text); margin-bottom: 10px; font-weight: 500; }
.progress-bar { height: 6px; background: var(--panel-3); border-radius: 3px; overflow: hidden; }
.progress-bar .fill { height: 100%; width: 0%; background: var(--accent-grad); border-radius: 3px; transition: width .2s; }
.progress-inner .pct { font-size: 11px; color: var(--text-dim); margin-top: 6px; font-family: var(--mono); }
.indeterminate .fill { width: 30% !important; animation: slide 1.1s ease-in-out infinite; }
@keyframes slide { 0%{margin-left:-30%} 100%{margin-left:100%} }
/* results strip */
.results { border-top: 1px solid var(--border); background: var(--panel); padding: 10px 12px; max-height: 168px; overflow: hidden; }
.results-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.results-head h4 { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--text-dim); font-weight: 600; }
.results-strip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; }
.results-strip::-webkit-scrollbar { height: 8px; }
.results-strip::-webkit-scrollbar-thumb { background: var(--panel-3); border-radius: 4px; }
.thumb { width: 120px; height: 120px; border-radius: 6px; overflow: hidden; border: 1px solid var(--border); cursor: pointer; position: relative; flex: 0 0 auto; background: #000; }
.thumb.active { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(91,140,255,.35); }
.thumb img { width: 100%; height: 100%; object-fit: cover; }
.thumb .badge { position: absolute; bottom: 4px; left: 4px; padding: 1px 6px; background: rgba(0,0,0,.7); border-radius: 4px; font-size: 10px; color: #fff; font-family: var(--mono); }
.thumb .actions { position: absolute; top: 4px; right: 4px; display: none; gap: 4px; }
.thumb:hover .actions { display: flex; }
.thumb .actions button { width: 22px; height: 22px; border-radius: 5px; border: none; background: rgba(0,0,0,.7); color: #fff; cursor: pointer; font-size: 11px; }
.thumb .actions button:hover { background: var(--accent); }
.results-empty { color: var(--text-faint); font-size: 11px; padding: 36px 0; text-align: center; }
/* ===== Right properties ===== */
.props { background: var(--panel); border-left: 1px solid var(--border); overflow-y: auto; display: flex; flex-direction: column; }
.props::-webkit-scrollbar { width: 8px; }
.props::-webkit-scrollbar-thumb { background: var(--panel-3); border-radius: 4px; }
.panel-group { border-bottom: 1px solid var(--border); }
.panel-group > summary { list-style: none; cursor: pointer; padding: 10px 12px; display: flex; align-items: center; gap: 8px; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--text-dim); font-weight: 600; background: var(--panel); }
.panel-group > summary::-webkit-details-marker { display: none; }
.panel-group > summary:hover { color: var(--text); }
.panel-group > summary .chev { margin-left: auto; transition: transform .15s; color: var(--text-faint); }
.panel-group[open] > summary .chev { transform: rotate(90deg); }
.panel-group[open] > summary { border-bottom: 1px solid var(--border); }
.panel-body { padding: 12px; display: flex; flex-direction: column; gap: 12px; }
.slots { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.slot { aspect-ratio: 1; border-radius: 6px; border: 1px dashed var(--border-light); background: var(--panel-2); position: relative; overflow: hidden; cursor: pointer; display: grid; place-items: center; transition: border-color .12s, background .12s; }
.slot:hover { border-color: var(--accent); }
.slot.drag { border-color: var(--accent); background: rgba(91,140,255,.12); }
.slot.has-img { border-style: solid; }
.slot img { width: 100%; height: 100%; object-fit: cover; }
.slot .ph { text-align: center; color: var(--text-faint); }
.slot .ph .ic { font-size: 22px; margin-bottom: 4px; }
.slot .ph .lbl { font-size: 10px; }
.slot .clear { position: absolute; top: 4px; right: 4px; width: 20px; height: 20px; border-radius: 5px; border: none; background: rgba(0,0,0,.6); color: #fff; cursor: pointer; display: none; font-size: 11px; }
.slot.has-img .clear { display: block; }
.slot .tag { position: absolute; bottom: 4px; left: 4px; padding: 1px 6px; background: rgba(0,0,0,.65); border-radius: 4px; font-size: 9px; color: #fff; }
textarea#prompt { width: 100%; min-height: 78px; resize: vertical; max-height: 220px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 6px; color: var(--text); padding: 9px 10px; font-family: var(--sans); font-size: 12px; line-height: 1.5; outline: none; user-select: text; }
textarea#prompt:focus { border-color: var(--accent); }
.prompt-hint { margin-top: 4px; font-size: 10px; color: var(--text-faint); display: flex; justify-content: space-between; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.field label { font-size: 11px; color: var(--text-dim); font-weight: 500; }
.field .val { font-size: 11px; color: var(--text); font-family: var(--mono); background: var(--panel-2); padding: 1px 6px; border-radius: 4px; }
input[type=range] { -webkit-appearance: none; width: 100%; height: 4px; background: var(--panel-3); border-radius: 2px; outline: none; cursor: pointer; }
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: var(--accent); border: 2px solid #fff; cursor: pointer; box-shadow: 0 1px 3px rgba(0,0,0,.4); }
input[type=range]::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; background: var(--accent); border: 2px solid #fff; cursor: pointer; }
input[type=number] { background: var(--panel-2); border: 1px solid var(--border); border-radius: 5px; color: var(--text); padding: 5px 8px; font-size: 11px; font-family: var(--mono); width: 100%; outline: none; user-select: text; }
input[type=number]:focus { border-color: var(--accent); }
input[type=number]:disabled { opacity: .5; }
.switch { position: relative; display: inline-flex; align-items: center; cursor: pointer; gap: 8px; }
.switch input { display: none; }
.track { width: 32px; height: 18px; background: var(--panel-3); border-radius: 999px; position: relative; transition: background .15s; }
.track::after { content: ""; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; background: #fff; border-radius: 50%; transition: transform .15s; }
.switch input:checked + .track { background: var(--accent); }
.switch input:checked + .track::after { transform: translateX(14px); }
.switch span { font-size: 11px; color: var(--text-dim); }
.seg { display: flex; border-radius: 6px; overflow: hidden; border: 1px solid var(--border); }
.seg button { flex: 1; padding: 5px 8px; background: var(--panel-2); border: none; color: var(--text-dim); cursor: pointer; font-size: 11px; }
.seg button.active { background: var(--accent); color: #fff; }
.actions { padding: 12px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 8px; background: var(--panel); }
.btn { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 9px 14px; border-radius: 7px; border: 1px solid var(--border); background: var(--panel-2); color: var(--text); cursor: pointer; font-size: 12px; font-weight: 500; transition: filter .12s, background .12s; }
.btn:hover { background: var(--panel-3); }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.btn.primary { background: var(--accent-grad); border: none; color: #fff; font-weight: 600; box-shadow: 0 4px 14px rgba(91,140,255,.35); }
.btn.primary:hover { filter: brightness(1.08); }
.btn.ghost { background: transparent; }
.btn.sm { padding: 6px 10px; font-size: 11px; }
.btn-row { display: flex; gap: 8px; }
.btn-row .btn { flex: 1; }
/* ===== Status bar ===== */
.statusbar { background: var(--panel); border-top: 1px solid var(--border); display: flex; align-items: center; gap: 16px; padding: 0 12px; font-size: 11px; color: var(--text-dim); font-family: var(--mono); }
.statusbar .sep { width: 1px; height: 14px; background: var(--border); }
.statusbar .right { margin-left: auto; display: flex; align-items: center; gap: 16px; }
.toast-wrap { position: fixed; bottom: 40px; right: 16px; display: flex; flex-direction: column; gap: 8px; z-index: 100; }
.toast { background: var(--panel-3); border: 1px solid var(--border-light); color: var(--text); padding: 10px 14px; border-radius: 7px; box-shadow: var(--shadow); font-size: 12px; max-width: 340px; animation: toastin .2s ease; border-left: 3px solid var(--accent); }
.toast.err { border-left-color: var(--err); }
.toast.ok { border-left-color: var(--ok); }
@keyframes toastin { from{opacity:0; transform: translateY(8px)} to{opacity:1; transform: none} }
.modal { position: fixed; inset: 0; background: rgba(0,0,0,.7); z-index: 200; display: none; align-items: center; justify-content: center; padding: 40px; }
.modal.open { display: flex; }
.modal-card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; max-width: 800px; width: 100%; box-shadow: var(--shadow); overflow: hidden; }
.modal-head { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--border); }
.modal-head h3 { font-size: 13px; }
.modal-head button { background: none; border: none; color: var(--text-dim); font-size: 18px; cursor: pointer; }
.modal-body { padding: 16px; min-height: 200px; }
.modal-body video { width: 100%; border-radius: 6px; background: #000; max-height: 70vh; }
.video-loading { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; padding: 40px 20px; text-align: center; min-height: 200px; }
.video-loading .vstage { font-size: 13px; color: var(--text); font-weight: 500; }
.video-loading .vhint { font-size: 11px; color: var(--text-faint); max-width: 360px; line-height: 1.5; }
.spinner { width: 38px; height: 38px; border-radius: 50%; border: 3px solid var(--panel-3); border-top-color: var(--accent); animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.hidden { display: none !important; }
</style>
</head>
<body>
<div class="app">
<!-- ===== Menubar ===== -->
<div class="menubar">
<div class="brand">
<div class="brand-mark">🎨</div>
<div>
<div class="brand-name">Pro Realism Edit Studio</div>
<div class="brand-sub">Qwen-Image-Edit-2511 · Rapid-AIO v18 · 4-step</div>
</div>
</div>
<div class="menu-items">
<button id="m-download">Download Result</button>
</div>
<div class="menubar-spacer"></div>
<div class="menubar-right">
<div class="pill"><span class="dot" id="conn-dot"></span><span id="conn-text">connecting…</span></div>
<div class="pill" id="queue-pill" style="display:none">queue: <span id="queue-pos"></span></div>
</div>
</div>
<div class="app-main">
<!-- ===== Left toolbar ===== -->
<div class="toolbar">
<div class="tool" id="tool-add1"><span>🖼️</span><span class="tip">Set Image 1</span></div>
<div class="tool" id="tool-add2"><span>🎥</span><span class="tip">Set Image 2 (optional)</span></div>
</div>
<!-- ===== Center viewport ===== -->
<div class="viewport">
<div class="vp-canvas" id="vp-canvas">
<div class="canvas-frame" id="canvas-frame">
<div class="canvas-empty" id="canvas-empty">
<div class="big">🪄</div>
<p>No preview yet</p>
<div class="sub">Upload an image, write a prompt, and hit Generate.</div>
</div>
<img id="preview-img" class="hidden" />
<div class="progress-card hidden" id="progress-card">
<div class="progress-inner">
<div class="stage" id="prog-stage">Starting…</div>
<div class="progress-bar" id="prog-bar-wrap"><div class="fill" id="prog-fill"></div></div>
<div class="pct" id="prog-pct"></div>
</div>
</div>
</div>
</div>
<div class="results">
<div class="results-head">
<h4>Result</h4>
<span style="font-size:11px;color:var(--text-faint)" id="results-count">0 images</span>
</div>
<div class="results-strip" id="results-strip">
<div class="results-empty">Outputs will appear here.</div>
</div>
</div>
</div>
<!-- ===== Right properties ===== -->
<div class="props">
<details class="panel-group" open>
<summary>Inputs <span class="chev"></span></summary>
<div class="panel-body">
<div class="slots">
<div class="slot" id="slot-1" data-slot="1">
<div class="ph"><div class="ic"></div><div class="lbl">IMAGE 1</div></div>
<img class="hidden" /><span class="tag">main</span>
<button class="clear"></button>
</div>
<div class="slot" id="slot-2" data-slot="2">
<div class="ph"><div class="ic"></div><div class="lbl">IMAGE 2</div></div>
<img class="hidden" /><span class="tag">ref</span>
<button class="clear"></button>
</div>
</div>
<input type="file" id="file-1" accept="image/*" class="hidden" />
<input type="file" id="file-2" accept="image/*" class="hidden" />
<div style="font-size:10px;color:var(--text-faint);line-height:1.5">
Drop, click, or use the toolbar. Image 2 is optional (reference/secondary).
</div>
</div>
</details>
<details class="panel-group" open>
<summary>Prompt <span class="chev"></span></summary>
<div class="panel-body">
<textarea id="prompt" placeholder="Describe the edit… e.g. 'Camera dollies forward, revealing more of the scene'"></textarea>
<div class="prompt-hint"><span>Passed verbatim to the model · ⌘/Ctrl+Enter to run</span><span id="pcount">0</span></div>
</div>
</details>
<details class="panel-group" open>
<summary>Generation <span class="chev"></span></summary>
<div class="panel-body">
<div class="field">
<div class="field-row"><label>Inference steps</label><span class="val" id="v-steps">4</span></div>
<input type="range" id="steps" min="1" max="40" step="1" value="4" />
</div>
<div class="field">
<div class="field-row"><label>True guidance</label><span class="val" id="v-cfg">1.0</span></div>
<input type="range" id="cfg" min="1.0" max="10.0" step="0.1" value="1.0" />
</div>
<label class="switch"><input type="checkbox" id="randseed" checked /><span class="track"></span><span>Randomize seed</span></label>
</div>
</details>
<details class="panel-group">
<summary>Output size <span class="chev"></span></summary>
<div class="panel-body">
<div class="seg" id="size-mode">
<button data-mode="auto" class="active">Auto (aspect)</button>
<button data-mode="custom">Custom</button>
</div>
<div class="field-row" id="size-custom" style="display:none">
<div class="field" style="flex:1"><label>Width</label><input type="number" id="width" min="256" max="2048" step="8" value="1024" /></div>
<div class="field" style="flex:1"><label>Height</label><input type="number" id="height" min="256" max="2048" step="8" value="1024" /></div>
</div>
<div style="font-size:10px;color:var(--text-faint);line-height:1.5">
Auto derives size from the first input image. Custom must be divisible by 16.
</div>
</div>
</details>
<details class="panel-group">
<summary>Seed <span class="chev"></span></summary>
<div class="panel-body">
<div class="field">
<label>Seed value</label>
<input type="number" id="seed" min="0" max="2147483647" value="0" disabled />
</div>
</div>
</details>
<details class="panel-group">
<summary>History <span class="chev"></span></summary>
<div class="panel-body">
<div class="results-strip" id="history-strip" style="max-height:240px;flex-wrap:wrap">
<div class="results-empty">No history yet.</div>
</div>
<button class="btn sm ghost" id="clear-history">🗑 Clear history</button>
</div>
</details>
<div class="actions">
<button class="btn primary" id="generate">⚡ Generate</button>
<div class="btn-row">
<button class="btn sm" id="use-input" disabled>↗ Use as input</button>
<button class="btn sm" id="make-video" disabled>🎬 Turn into Video</button>
</div>
</div>
</div>
</div>
<!-- ===== Status bar ===== -->
<div class="statusbar">
<span id="st-ready">Ready</span>
<span class="sep"></span>
<span id="st-size"></span>
<span class="sep"></span>
<span id="st-seed">seed: —</span>
<div class="right">
<span id="st-model">Qwen-Image-Edit-2511</span>
<span class="sep"></span>
<span>4-step · bf16</span>
</div>
</div>
</div>
<!-- video modal -->
<div class="modal" id="video-modal">
<div class="modal-card">
<div class="modal-head"><h3 id="vm-title">Cinematic transition video</h3><button id="vm-close" title="Close"></button></div>
<div class="modal-body">
<div id="video-loading" class="video-loading">
<div class="spinner"></div>
<div class="vstage" id="video-stage">Connecting to Wan space…</div>
<div class="vhint">Generating your cinematic transition — this runs on a remote model and can take a minute or two.</div>
</div>
<video id="video-el" controls autoplay loop class="hidden"></video>
</div>
</div>
</div>
<div class="toast-wrap" id="toast-wrap"></div>
<script type="module">
import { client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
const HISTORY_KEY = "prestudio_history_v1";
const HISTORY_MAX = 20;
const state = {
app: null,
connected: false,
busy: false,
files: { 1: null, 2: null },
fileURLs: { 1: null, 2: null },
results: [],
history: [],
activeResult: null,
lastSeed: null,
};
// ---------- init ----------
(async function init() {
try {
state.app = await client(window.location.origin);
state.connected = true;
setConn("live", "ready");
} catch (e) {
state.connected = false;
setConn("offline", "backend offline");
toast("Could not connect to backend. Is the server running?", "err");
}
loadHistory();
renderHistory();
bindUI();
})();
function setConn(kind, text) {
document.getElementById("conn-dot").className = "dot " + kind;
document.getElementById("conn-text").textContent = text;
}
function toast(msg, kind = "") {
const wrap = document.getElementById("toast-wrap");
const el = document.createElement("div");
el.className = "toast " + kind;
el.textContent = msg;
wrap.appendChild(el);
setTimeout(() => { el.style.opacity = "0"; el.style.transition = "opacity .25s"; setTimeout(() => el.remove(), 260); }, 3800);
}
// ---------- image slots ----------
function setSlot(n, file) {
if (state.fileURLs[n]) URL.revokeObjectURL(state.fileURLs[n]);
state.files[n] = file || null;
state.fileURLs[n] = file ? URL.createObjectURL(file) : null;
const slot = document.getElementById("slot-" + n);
const img = slot.querySelector("img");
const ph = slot.querySelector(".ph");
if (file) {
img.src = state.fileURLs[n];
img.classList.remove("hidden");
ph.classList.add("hidden");
slot.classList.add("has-img");
} else {
img.classList.add("hidden");
ph.classList.remove("hidden");
slot.classList.remove("has-img");
}
}
function bindSlots() {
for (const n of [1, 2]) {
const slot = document.getElementById("slot-" + n);
const fileInput = document.getElementById("file-" + n);
slot.addEventListener("click", (e) => {
if (e.target.classList.contains("clear")) return;
fileInput.click();
});
slot.querySelector(".clear").addEventListener("click", (e) => {
e.stopPropagation();
setSlot(n, null);
});
fileInput.addEventListener("change", () => {
if (fileInput.files[0]) setSlot(n, fileInput.files[0]);
});
["dragenter", "dragover"].forEach(ev => slot.addEventListener(ev, e => { e.preventDefault(); slot.classList.add("drag"); }));
["dragleave", "drop"].forEach(ev => slot.addEventListener(ev, e => { e.preventDefault(); slot.classList.remove("drag"); }));
slot.addEventListener("drop", e => {
const f = e.dataTransfer.files[0];
if (f && f.type.startsWith("image/")) setSlot(n, f);
});
}
document.getElementById("tool-add1").addEventListener("click", () => document.getElementById("file-1").click());
document.getElementById("tool-add2").addEventListener("click", () => document.getElementById("file-2").click());
}
// ---------- preview ----------
function renderPreview() {
const img = document.getElementById("preview-img");
const empty = document.getElementById("canvas-empty");
if (state.activeResult) {
console.log("[renderPreview] loading:", state.activeResult);
img.onerror = () => {
console.error("[renderPreview] image FAILED to load:", state.activeResult);
toast("Image URL failed to load — check console.", "err");
};
img.src = state.activeResult;
img.classList.remove("hidden");
empty.classList.add("hidden");
const im = new Image();
im.onload = () => { document.getElementById("st-size").textContent = `output: ${im.naturalWidth}×${im.naturalHeight}`; };
im.onerror = () => { console.error("[renderPreview] size-probe image FAILED:", state.activeResult); };
im.src = state.activeResult;
} else {
img.classList.add("hidden");
empty.classList.remove("hidden");
document.getElementById("st-size").textContent = "—";
}
}
// Resolve a possibly-relative Gradio file URL/path to an absolute URL the <img>
// can load. Gradio may return a path like "/file=outputs/foo.png" or a bare path;
// prefix with the current origin when there's no scheme.
function absURL(u) {
if (!u) return u;
if (/^https?:\/\//i.test(u) || u.startsWith("data:") || u.startsWith("blob:")) return u;
if (u.startsWith("http")) return u;
return window.location.origin + (u.startsWith("/") ? "" : "/") + u;
}
// ---------- progress ----------
function showProgress(show) { document.getElementById("progress-card").classList.toggle("hidden", !show); }
function setProgress(stage, frac) {
document.getElementById("prog-stage").textContent = stage || "Working…";
const wrap = document.getElementById("prog-bar-wrap");
const fill = document.getElementById("prog-fill");
if (frac == null) {
wrap.classList.add("indeterminate"); fill.style.width = ""; document.getElementById("prog-pct").textContent = "—";
} else {
wrap.classList.remove("indeterminate");
const pct = Math.round(frac * 100);
fill.style.width = pct + "%";
document.getElementById("prog-pct").textContent = pct + "%";
}
}
function setStatus(text) { document.getElementById("st-ready").textContent = text; }
// ---------- sliders ----------
function bindControls() {
const steps = document.getElementById("steps");
steps.addEventListener("input", () => { document.getElementById("v-steps").textContent = steps.value; });
const cfg = document.getElementById("cfg");
cfg.addEventListener("input", () => { document.getElementById("v-cfg").textContent = parseFloat(cfg.value).toFixed(1); });
const prompt = document.getElementById("prompt");
prompt.addEventListener("input", () => { document.getElementById("pcount").textContent = prompt.value.length; });
const rand = document.getElementById("randseed");
rand.addEventListener("change", () => { document.getElementById("seed").disabled = rand.checked; });
document.querySelectorAll("#size-mode button").forEach(b => b.addEventListener("click", () => {
document.querySelectorAll("#size-mode button").forEach(x => x.classList.remove("active"));
b.classList.add("active");
document.getElementById("size-custom").style.display = b.dataset.mode === "custom" ? "flex" : "none";
}));
}
// ---------- payload ----------
function buildPayload() {
const p = {
prompt: document.getElementById("prompt").value,
seed: parseInt(document.getElementById("seed").value) || 0,
randomize_seed: document.getElementById("randseed").checked,
true_guidance_scale: parseFloat(document.getElementById("cfg").value),
num_inference_steps: parseInt(document.getElementById("steps").value),
};
const mode = document.querySelector("#size-mode button.active").dataset.mode;
if (mode === "custom") {
p.height = parseInt(document.getElementById("height").value) || null;
p.width = parseInt(document.getElementById("width").value) || null;
} else {
p.height = null; p.width = null;
}
if (state.files[1]) p.image_1 = handle_file(state.files[1]);
if (state.files[2]) p.image_2 = handle_file(state.files[2]);
return p;
}
function validate() {
const p = document.getElementById("prompt").value.trim();
if (!p) { toast("Enter a prompt first.", "err"); return false; }
if (!state.files[1] && !state.files[2]) { toast("Add at least one input image.", "err"); return false; }
return true;
}
// ---------- generate ----------
async function generate() {
if (state.busy) return;
if (!state.connected) { toast("Backend not connected.", "err"); return; }
if (!validate()) return;
state.busy = true;
setConn("busy", "generating…");
setStatus("Generating…");
document.getElementById("generate").disabled = true;
showProgress(true);
setProgress("Submitting to queue…", null);
const payload = buildPayload();
try {
// predict() still routes through Gradio's queue (concurrency, SSE).
// No live step fraction in this client build -> indeterminate bar + eta.
setProgress("Generating…", null);
const final = await state.app.predict("/infer", payload);
console.log("[infer] raw predict() result:", JSON.stringify(final, null, 2));
const data = (final && Array.isArray(final.data)) ? final.data : final;
// data could be [images, seed] or {images, seed}; normalize.
let images = null, seed = null;
if (Array.isArray(data)) { [images, seed] = data; }
else if (data && typeof data === "object") { images = data.images || data.output || null; seed = data.seed; }
if (seed == null && Array.isArray(images)) seed = images[0]?.seed;
if (!images || !images.length) throw new Error("No images returned. (see console for raw result)");
state.lastSeed = seed;
document.getElementById("st-seed").textContent = "seed: " + seed;
const newResults = images.map((fd) => ({ url: absURL(fd.url || fd.path), fd, seed }));
console.log("[infer] parsed results:", newResults);
state.results = newResults.concat(state.results);
state.activeResult = newResults[0].url;
renderResults();
renderPreview();
for (const r of newResults) addHistory(r);
setStatus("Done");
toast(`Generated ${newResults.length} image${newResults.length > 1 ? "s" : ""} · seed ${seed}`, "ok");
} catch (e) {
console.error(e);
toast("Generation failed: " + ((e && e.message) ? e.message : e), "err");
setStatus("Error");
} finally {
state.busy = false;
showProgress(false);
document.getElementById("generate").disabled = false;
document.getElementById("queue-pill").style.display = "none";
setConn(state.connected ? "live" : "offline", state.connected ? "ready" : "offline");
}
}
// ---------- results strip ----------
function renderResults() {
const strip = document.getElementById("results-strip");
document.getElementById("results-count").textContent = state.results.length + " image" + (state.results.length === 1 ? "" : "s");
if (!state.results.length) {
strip.innerHTML = '<div class="results-empty">Outputs will appear here.</div>';
document.getElementById("use-input").disabled = true;
document.getElementById("make-video").disabled = true;
return;
}
strip.innerHTML = "";
state.results.forEach((r) => {
const t = document.createElement("div");
t.className = "thumb" + (r.url === state.activeResult ? " active" : "");
t.innerHTML = `<img src="${r.url}" /><span class="badge">seed ${r.seed}</span>
<div class="actions">
<button title="Use as input" data-act="use">↗</button>
<button title="Download" data-act="dl">⬇</button>
</div>`;
t.addEventListener("click", () => { state.activeResult = r.url; renderResults(); renderPreview(); });
t.querySelector('[data-act="dl"]').addEventListener("click", e => { e.stopPropagation(); downloadURL(r.url, `edit_${r.seed}.png`); });
t.querySelector('[data-act="use"]').addEventListener("click", e => { e.stopPropagation(); useAsInput(r.url, 1); });
strip.appendChild(t);
});
document.getElementById("use-input").disabled = false;
document.getElementById("make-video").disabled = false;
}
// ---------- history ----------
function loadHistory() {
try { state.history = JSON.parse(localStorage.getItem(HISTORY_KEY) || "[]"); } catch { state.history = []; }
}
function saveHistory() {
try { localStorage.setItem(HISTORY_KEY, JSON.stringify(state.history.slice(0, HISTORY_MAX))); } catch {}
}
function addHistory(r) {
state.history = state.history.filter(h => h.url !== r.url);
state.history.unshift(r);
state.history = state.history.slice(0, HISTORY_MAX);
saveHistory();
renderHistory();
}
function renderHistory() {
const strip = document.getElementById("history-strip");
if (!state.history.length) {
strip.innerHTML = '<div class="results-empty">No history yet.</div>';
return;
}
strip.innerHTML = "";
state.history.forEach(r => {
const t = document.createElement("div");
t.className = "thumb";
t.innerHTML = `<img src="${r.url}" /><span class="badge">${r.seed ?? ""}</span>`;
t.addEventListener("click", () => useAsInput(r.url, 1));
strip.appendChild(t);
});
}
// ---------- use as input ----------
async function useAsInput(url, slot) {
try {
setStatus("Loading image…");
const res = await fetch(url);
const blob = await res.blob();
const file = new File([blob], "from-output.png", { type: blob.type || "image/png" });
setSlot(slot, file);
toast(`Loaded into Image ${slot}.`, "ok");
setStatus("Ready");
} catch (e) {
toast("Could not load that image.", "err");
setStatus("Error");
}
}
function downloadURL(url, name) {
const a = document.createElement("a");
a.href = url; a.download = name || "result.png";
document.body.appendChild(a); a.click(); a.remove();
}
// ---------- video (Image 1 + first result + prompt) ----------
function videoLoading(show, stage) {
document.getElementById("video-loading").classList.toggle("hidden", !show);
document.getElementById("video-el").classList.toggle("hidden", show);
if (stage) document.getElementById("video-stage").textContent = stage;
}
function closeVideoModal() {
const m = document.getElementById("video-modal");
m.classList.remove("open");
const v = document.getElementById("video-el");
v.pause();
// reset to loading state so a next open starts clean
videoLoading(true, "Connecting to Wan space…");
v.src = "";
}
async function makeVideo() {
if (state.busy) return;
if (!state.files[1]) { toast("Set Image 1 first.", "err"); return; }
if (!state.results.length) { toast("Generate a result first.", "err"); return; }
const prompt = document.getElementById("prompt").value || "smooth cinematic transition";
const modal = document.getElementById("video-modal");
const video = document.getElementById("video-el");
// open modal in loading state
videoLoading(true, "Preparing images…");
video.src = "";
modal.classList.add("open");
setStatus("Generating video…");
setConn("busy", "rendering video…");
state.busy = true;
let done = false;
// long-running remote call — surface that it's still working every ~12s
const heartbeat = setInterval(() => {
if (done) return;
const cur = document.getElementById("video-stage").textContent;
if (cur.includes("Generating")) {
const dots = ".".repeat(((cur.match(/\./g) || []).length % 3) + 1);
document.getElementById("video-stage").textContent = "Generating video" + dots + " (still working on the remote model)";
}
}, 12000);
try {
const startFD = handle_file(state.files[1]);
videoLoading(true, "Fetching end frame…");
const endBlob = await (await fetch(state.results[0].url)).blob();
const endFD = handle_file(new File([endBlob], "end.png", { type: "image/png" }));
videoLoading(true, "Connecting to Wan space…");
const final = await state.app.predict("/turn_into_video", { start_image: startFD, end_image: endFD, prompt });
console.log("[video] raw predict() result:", JSON.stringify(final, null, 2));
const fd = (final && Array.isArray(final.data)) ? final.data[0] : final;
const vurl = absURL((fd && (fd.url || fd.path)) || (typeof fd === "string" ? fd : null));
console.log("[video] resolved URL:", vurl);
if (!vurl) throw new Error("No video URL returned (see console)");
videoLoading(true, "Finalizing…");
video.src = vurl;
video.load();
videoLoading(false);
document.getElementById("vm-title").textContent = "Cinematic transition video — ready";
setStatus("Video ready");
toast("Video generated.", "ok");
} catch (e) {
console.error(e);
toast("Video generation failed: " + ((e && e.message) ? e.message : e), "err");
setStatus("Error");
closeVideoModal(); // don't leave an empty box open on failure
} finally {
done = true;
clearInterval(heartbeat);
state.busy = false;
setConn(state.connected ? "live" : "offline", state.connected ? "ready" : "offline");
}
}
// ---------- bind all ----------
function bindUI() {
bindSlots();
bindControls();
document.getElementById("generate").addEventListener("click", generate);
document.getElementById("prompt").addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); generate(); }
});
document.getElementById("use-input").addEventListener("click", () => { if (state.activeResult) useAsInput(state.activeResult, 1); });
document.getElementById("make-video").addEventListener("click", makeVideo);
document.getElementById("m-download").addEventListener("click", () => {
if (state.activeResult) downloadURL(state.activeResult, "result.png");
else toast("Nothing to download yet.", "err");
});
document.getElementById("clear-history").addEventListener("click", () => {
state.history = []; saveHistory(); renderHistory(); toast("History cleared.", "");
});
document.getElementById("vm-close").addEventListener("click", closeVideoModal);
// click the dimmed backdrop (not the card) to close
document.getElementById("video-modal").addEventListener("click", (e) => {
if (e.target.id === "video-modal") closeVideoModal();
});
// Escape closes the modal (and any open modal)
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && document.getElementById("video-modal").classList.contains("open")) {
closeVideoModal();
}
});
renderResults();
renderPreview();
}
</script>
</body>
</html>