Spaces:
Running on Zero
Running on Zero
| <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% ; 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 ; } | |
| </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> |