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>Image Editor</title> | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap'); | |
| *{box-sizing:border-box;margin:0;padding:0} | |
| :root{ | |
| --bg:#0a0a0c;--panel:#131316;--panel2:#0e0e11;--canvas:#08080a; | |
| --border:#2a2323;--border2:#3f2f2f; | |
| --text:#e4e4e7;--muted:#a1a1aa;--dim:#6b6b75;--faint:#4a4a52; | |
| --accent:#FF3333;--accent2:#FF5555;--accent3:#FF8080; | |
| --menubar-h:48px;--statusbar-h:30px;--rail-w:52px;--inspector-w:360px;--filmstrip-h:132px; | |
| } | |
| html,body{height:100%} | |
| body{ | |
| background:var(--bg);font-family:'Inter',system-ui,-apple-system,sans-serif; | |
| font-size:13px;color:var(--text);overflow:hidden; | |
| } | |
| /* ══ Layout grid ══ */ | |
| .studio{ | |
| display:grid;height:100vh; | |
| grid-template-rows:var(--menubar-h) 1fr var(--statusbar-h); | |
| grid-template-columns:var(--rail-w) 1fr var(--inspector-w); | |
| grid-template-areas: | |
| "menubar menubar menubar" | |
| "rail workspace inspector" | |
| "statusbar statusbar statusbar"; | |
| } | |
| /* ══ Menu bar ══ */ | |
| .menubar{ | |
| grid-area:menubar;display:flex;align-items:center;gap:14px; | |
| background:var(--panel);border-bottom:1px solid var(--border); | |
| padding:0 16px;z-index:50; | |
| } | |
| .menubar-logo{ | |
| width:28px;height:28px;background:linear-gradient(135deg,#FF0000,#FF3333,#FF8080); | |
| border-radius:8px;display:flex;align-items:center;justify-content:center; | |
| box-shadow:0 2px 8px rgba(255,0,0,.35);flex-shrink:0; | |
| } | |
| .menubar-logo svg{width:15px;height:15px;fill:#fff} | |
| .menubar-title{font-size:14px;font-weight:700;letter-spacing:-.2px;white-space:nowrap} | |
| .menubar-title span{color:var(--dim);font-weight:500} | |
| .menubar-badge{ | |
| font-size:10px;font-weight:700;padding:2px 8px;border-radius:12px; | |
| background:rgba(255,0,0,.14);color:var(--accent2);border:1px solid rgba(255,0,0,.25); | |
| letter-spacing:.4px;white-space:nowrap; | |
| } | |
| .menubar-badge.fast{background:rgba(34,197,94,.12);color:#4ade80;border-color:rgba(34,197,94,.25)} | |
| .menubar-spacer{flex:1} | |
| .menubar-status{ | |
| display:flex;align-items:center;gap:7px;font-family:'JetBrains Mono',monospace; | |
| font-size:11px;color:var(--dim); | |
| } | |
| .menubar-status .dot{width:7px;height:7px;border-radius:50%;background:#4ade80;box-shadow:0 0 6px rgba(74,222,128,.6)} | |
| .menubar-status .dot.off{background:#f87171;box-shadow:0 0 6px rgba(248,113,113,.5)} | |
| .gh-btn{ | |
| display:inline-flex;align-items:center;gap:6px; | |
| padding:5px 13px;border-radius:7px;text-decoration:none; | |
| font-size:12px;font-weight:600;background:var(--accent);color:#fff; | |
| border:1px solid rgba(255,255,255,.15); | |
| transition:all .15s ease;flex-shrink:0; | |
| } | |
| .gh-btn:hover{background:var(--accent2)} | |
| .gh-btn svg{fill:#fff;width:13px;height:13px} | |
| /* ══ Left icon rail ══ */ | |
| .rail{ | |
| grid-area:rail;background:var(--panel);border-right:1px solid var(--border); | |
| display:flex;flex-direction:column;align-items:center;padding:10px 0;gap:4px; | |
| overflow-y:auto;z-index:40; | |
| } | |
| .rail-btn{ | |
| width:38px;height:38px;border-radius:9px;border:1px solid transparent; | |
| background:transparent;color:var(--dim);cursor:pointer; | |
| display:flex;align-items:center;justify-content:center; | |
| transition:all .15s ease;position:relative; | |
| } | |
| .rail-btn:hover:not(:disabled){background:rgba(255,0,0,.12);color:var(--accent3);border-color:rgba(255,0,0,.25)} | |
| .rail-btn.active{background:rgba(255,0,0,.2);color:var(--accent2);border-color:rgba(255,0,0,.4)} | |
| .rail-btn:disabled{opacity:.3;cursor:not-allowed} | |
| .rail-btn svg{width:17px;height:17px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round} | |
| .rail-btn svg.fill{fill:currentColor;stroke:none} | |
| .rail-sep{width:24px;height:1px;background:var(--border);margin:6px 0} | |
| .rail-label{ | |
| font-size:8px;font-weight:700;color:var(--faint);letter-spacing:1px; | |
| text-transform:uppercase;margin:8px 0 2px;user-select:none; | |
| } | |
| /* ══ Workspace ══ */ | |
| .workspace{ | |
| grid-area:workspace;display:flex;flex-direction:column;min-width:0;min-height:0; | |
| background:var(--canvas); | |
| } | |
| .canvas-head{ | |
| display:flex;align-items:center;gap:10px;padding:8px 16px; | |
| border-bottom:1px solid var(--border);background:var(--panel2);flex-shrink:0; | |
| } | |
| .seg-control{ | |
| display:inline-flex;background:var(--panel);border:1px solid var(--border); | |
| border-radius:8px;padding:2px;gap:2px; | |
| } | |
| .seg-btn{ | |
| padding:4px 14px;font-size:11px;font-weight:600;color:var(--dim); | |
| background:transparent;border:none;border-radius:6px;cursor:pointer; | |
| font-family:'Inter',sans-serif;transition:all .15s;letter-spacing:.2px; | |
| } | |
| .seg-btn:hover:not(.active){color:var(--muted)} | |
| .seg-btn.active{background:rgba(255,0,0,.18);color:var(--accent2)} | |
| .canvas-meta{ | |
| font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--faint); | |
| overflow:hidden;white-space:nowrap;text-overflow:ellipsis;flex:1; | |
| } | |
| .canvas-actions{display:flex;align-items:center;gap:6px} | |
| .out-seed-chip{ | |
| display:none;font-family:'JetBrains Mono',monospace;font-size:10px;font-weight:500; | |
| color:var(--dim);background:var(--panel);border:1px solid var(--border); | |
| border-radius:6px;padding:4px 9px;cursor:pointer;transition:all .15s; | |
| } | |
| .out-seed-chip.visible{display:inline-flex} | |
| .out-seed-chip:hover{color:var(--accent3);border-color:rgba(255,0,0,.35)} | |
| .canvas-stage{ | |
| flex:1;position:relative;overflow:hidden;min-height:0; | |
| display:flex;align-items:center;justify-content:center; | |
| background: | |
| repeating-conic-gradient(#0c0c0f 0% 25%, #08080a 0% 50%) 0 0/24px 24px; | |
| } | |
| .canvas-stage img.modern-out-img{ | |
| max-width:calc(100% - 48px);max-height:calc(100% - 48px); | |
| box-shadow:0 12px 48px rgba(0,0,0,.7),0 0 0 1px var(--border); | |
| border-radius:4px;cursor:zoom-in;image-rendering:auto; | |
| animation:canvasIn .3s ease; | |
| } | |
| @keyframes canvasIn{from{opacity:0;transform:scale(.985)}to{opacity:1;transform:scale(1)}} | |
| .canvas-placeholder{ | |
| text-align:center;color:var(--faint);user-select:none; | |
| display:flex;flex-direction:column;align-items:center;gap:14px;padding:24px; | |
| } | |
| .canvas-placeholder svg{width:72px;height:72px;opacity:.5} | |
| .canvas-placeholder .cp-title{font-size:15px;font-weight:600;color:var(--dim)} | |
| .canvas-placeholder .cp-sub{font-size:12px;line-height:1.7;max-width:340px} | |
| .canvas-placeholder kbd{ | |
| padding:1px 6px;background:var(--panel);border:1px solid var(--border2); | |
| border-radius:4px;font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--muted); | |
| } | |
| /* Compare slider */ | |
| .compare-wrap{ | |
| position:relative;max-width:calc(100% - 48px);max-height:calc(100% - 48px);overflow:hidden; | |
| display:none;user-select:none;touch-action:none;cursor:ew-resize; | |
| box-shadow:0 12px 48px rgba(0,0,0,.7),0 0 0 1px var(--border);border-radius:4px; | |
| } | |
| .compare-wrap.visible{display:block} | |
| .compare-wrap img{display:block;max-width:100%;max-height:100%;pointer-events:none} | |
| .compare-top{position:absolute;top:0;left:0;width:100%;height:100%;overflow:hidden} | |
| .compare-top img{position:absolute;top:0;left:0;height:100%;width:auto;max-width:none!important;max-height:none!important} | |
| .compare-divider{position:absolute;top:0;bottom:0;width:2px;background:#fff;box-shadow:0 0 8px rgba(0,0,0,.6);pointer-events:none} | |
| .compare-handle{ | |
| position:absolute;top:50%;transform:translate(-50%,-50%); | |
| width:32px;height:32px;border-radius:50%;background:#fff; | |
| display:flex;align-items:center;justify-content:center; | |
| box-shadow:0 2px 8px rgba(0,0,0,.5);color:#111;font-size:13px;font-weight:800; | |
| } | |
| .compare-label{ | |
| position:absolute;top:8px;padding:2px 8px;border-radius:4px; | |
| background:rgba(0,0,0,.65);color:#fff;font-size:10px;font-weight:600; | |
| font-family:'JetBrains Mono',monospace;pointer-events:none; | |
| } | |
| .compare-label.before{left:8px} | |
| .compare-label.after{right:8px} | |
| /* Loader */ | |
| .modern-loader{ | |
| display:none;position:absolute;inset:0;background:rgba(8,8,10,.9); | |
| z-index:15;flex-direction:column;align-items:center;justify-content:center;gap:16px;backdrop-filter:blur(5px); | |
| } | |
| .modern-loader.active{display:flex} | |
| .loader-spinner{ | |
| width:38px;height:38px;border:3px solid var(--border);border-top-color:var(--accent); | |
| border-radius:50%;animation:spin .8s linear infinite; | |
| } | |
| @keyframes spin{to{transform:rotate(360deg)}} | |
| .loader-text{font-size:13px;color:var(--muted);font-weight:500;text-align:center;padding:0 16px} | |
| .loader-bar-track{width:220px;height:4px;background:var(--border);border-radius:2px;overflow:hidden} | |
| .loader-bar-fill{ | |
| height:100%;width:100%;background:linear-gradient(90deg,#FF0000,#FF3333,#FF0000); | |
| background-size:200% 100%;animation:shimmer 1.5s ease-in-out infinite;border-radius:2px; | |
| transition:width .3s ease; | |
| } | |
| .loader-bar-fill.determinate{animation:none;background:var(--accent)} | |
| @keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}} | |
| /* ══ Filmstrip ══ */ | |
| .filmstrip{ | |
| height:var(--filmstrip-h);flex-shrink:0;background:var(--panel); | |
| border-top:1px solid var(--border);display:flex;min-height:0;overflow:hidden; | |
| } | |
| .filmstrip-section{ | |
| display:flex;flex-direction:column;min-width:0;padding:8px 0 8px 14px; | |
| } | |
| .filmstrip-section.inputs{flex:1.2;border-right:1px solid var(--border)} | |
| .filmstrip-section.history{flex:1} | |
| .filmstrip-label{ | |
| font-size:9px;font-weight:700;color:var(--faint);letter-spacing:1.2px; | |
| text-transform:uppercase;margin-bottom:6px;display:flex;align-items:center;gap:8px; | |
| user-select:none;flex-shrink:0; | |
| } | |
| .filmstrip-label .count{ | |
| font-family:'JetBrains Mono',monospace;font-weight:500;color:var(--dim); | |
| background:var(--panel2);border:1px solid var(--border);border-radius:4px;padding:0 5px;font-size:9px; | |
| } | |
| .filmstrip-row{ | |
| display:flex;gap:8px;overflow-x:auto;overflow-y:hidden;flex:1; | |
| align-items:flex-start;padding-bottom:4px;padding-right:14px; | |
| } | |
| .filmstrip-row::-webkit-scrollbar{height:5px} | |
| .filmstrip-row::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px} | |
| .fs-thumb{ | |
| position:relative;flex-shrink:0;width:76px;height:76px;border-radius:8px;overflow:hidden; | |
| border:2px solid var(--border);cursor:pointer;transition:all .15s;background:var(--panel2); | |
| animation:thumbIn .25s ease; | |
| } | |
| @keyframes thumbIn{from{opacity:0;transform:scale(.92)}to{opacity:1;transform:scale(1)}} | |
| .fs-thumb:hover{border-color:var(--border2);transform:translateY(-2px)} | |
| .fs-thumb.selected{border-color:var(--accent);box-shadow:0 0 0 2px rgba(255,0,0,.25)} | |
| .fs-thumb img{width:100%;height:100%;object-fit:cover} | |
| .fs-badge{ | |
| position:absolute;bottom:4px;left:4px;background:rgba(0,0,0,.75);color:#fff; | |
| padding:1px 6px;border-radius:4px;font-family:'JetBrains Mono',monospace;font-size:9px;font-weight:600; | |
| } | |
| .fs-remove{ | |
| position:absolute;top:4px;right:4px;width:20px;height:20px;background:rgba(0,0,0,.8); | |
| color:#fff;border:1px solid rgba(255,255,255,.2);border-radius:50%;cursor:pointer; | |
| display:none;align-items:center;justify-content:center;font-size:10px;transition:all .15s;line-height:1; | |
| } | |
| .fs-thumb:hover .fs-remove{display:flex} | |
| .fs-remove:hover{background:#ef4444;border-color:#ef4444} | |
| .fs-add{ | |
| flex-shrink:0;width:76px;height:76px;border-radius:8px;border:2px dashed var(--border2); | |
| display:flex;flex-direction:column;align-items:center;justify-content:center; | |
| cursor:pointer;transition:all .2s;background:rgba(255,0,0,.03);gap:3px; | |
| } | |
| .fs-add:hover{border-color:var(--accent);background:rgba(255,0,0,.08)} | |
| .fs-add .add-icon{font-size:22px;color:var(--dim);font-weight:300} | |
| .fs-add .add-text{font-size:9px;color:var(--dim);font-weight:600;text-transform:uppercase;letter-spacing:.5px} | |
| .fs-empty{ | |
| flex-shrink:0;display:flex;align-items:center;height:76px; | |
| color:var(--faint);font-size:11px;font-style:italic;padding-right:12px; | |
| } | |
| /* ══ Inspector ══ */ | |
| .inspector{ | |
| grid-area:inspector;background:var(--panel);border-left:1px solid var(--border); | |
| display:flex;flex-direction:column;min-height:0;z-index:40; | |
| } | |
| .inspector-tabs{ | |
| display:flex;border-bottom:1px solid var(--border);flex-shrink:0; | |
| } | |
| .insp-tab{ | |
| flex:1;padding:11px 8px;font-size:11px;font-weight:700;color:var(--dim); | |
| background:transparent;border:none;border-bottom:2px solid transparent; | |
| cursor:pointer;font-family:'Inter',sans-serif;text-transform:uppercase;letter-spacing:.8px; | |
| transition:all .15s; | |
| } | |
| .insp-tab:hover:not(.active){color:var(--muted)} | |
| .insp-tab.active{color:var(--accent2);border-bottom-color:var(--accent)} | |
| .insp-page{display:none;flex:1;overflow-y:auto;min-height:0;flex-direction:column} | |
| .insp-page.active{display:flex} | |
| .insp-section{border-bottom:1px solid var(--border);padding:14px 18px} | |
| .insp-section-title{ | |
| font-size:10px;font-weight:700;color:var(--dim);text-transform:uppercase; | |
| letter-spacing:1px;margin-bottom:10px;display:flex;align-items:center;justify-content:space-between; | |
| } | |
| .char-count{font-size:9px;font-weight:500;color:var(--faint);font-family:'JetBrains Mono',monospace;text-transform:none;letter-spacing:0} | |
| .modern-textarea{ | |
| width:100%;background:var(--panel2);border:1px solid var(--border);border-radius:8px; | |
| padding:10px 13px;font-family:'Inter',sans-serif;font-size:13px;color:var(--text); | |
| resize:vertical;outline:none;min-height:64px;transition:border-color .2s;line-height:1.55; | |
| } | |
| .modern-textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(255,0,0,.15)} | |
| .modern-textarea::placeholder{color:var(--faint)} | |
| .modern-textarea.error-flash{ | |
| border-color:#ef4444!important;box-shadow:0 0 0 3px rgba(239,68,68,.2)!important;animation:shake .4s ease; | |
| } | |
| @keyframes shake{0%,100%{transform:translateX(0)}20%,60%{transform:translateX(-4px)}40%,80%{transform:translateX(4px)}} | |
| .suggestions-wrap{display:flex;flex-wrap:wrap;gap:5px} | |
| .suggestion-chip{ | |
| display:inline-flex;align-items:center;padding:4px 11px; | |
| background:rgba(255,0,0,.07);border:1px solid rgba(255,0,0,.18);border-radius:14px; | |
| color:var(--accent3);font-size:11px;font-weight:500;font-family:'Inter',sans-serif; | |
| cursor:pointer;transition:all .15s;white-space:nowrap; | |
| } | |
| .suggestion-chip:hover{background:rgba(255,0,0,.15);border-color:rgba(255,0,0,.35);color:var(--accent2)} | |
| .suggestion-chip.active{background:rgba(255,0,0,.25);border-color:var(--accent);color:#fff} | |
| .btn-run{ | |
| display:flex;align-items:center;justify-content:center;gap:8px;width:100%; | |
| background:linear-gradient(135deg,#FF0000,#CC0000);border:none;border-radius:9px; | |
| padding:12px 24px;cursor:pointer;font-size:14px;font-weight:700;font-family:'Inter',sans-serif; | |
| color:#fff;transition:all .2s ease;letter-spacing:-.2px; | |
| box-shadow:0 4px 16px rgba(255,0,0,.3),inset 0 1px 0 rgba(255,255,255,.1); | |
| } | |
| .btn-run:hover:not(:disabled){ | |
| background:linear-gradient(135deg,#FF3333,#FF0000);transform:translateY(-1px); | |
| box-shadow:0 6px 24px rgba(255,0,0,.45),inset 0 1px 0 rgba(255,255,255,.15); | |
| } | |
| .btn-run:active:not(:disabled){transform:translateY(0)} | |
| .btn-run:disabled{opacity:.55;cursor:not-allowed} | |
| .btn-run svg{width:16px;height:16px;fill:#fff} | |
| .btn-cancel{ | |
| display:flex;align-items:center;justify-content:center;gap:6px;width:100%; | |
| margin-top:8px;background:transparent;border:1px solid var(--border2);border-radius:9px; | |
| padding:8px 24px;cursor:pointer;font-size:12px;font-weight:600;font-family:'Inter',sans-serif; | |
| color:var(--muted);transition:all .15s; | |
| } | |
| .btn-cancel:hover{border-color:#ef4444;color:#ef4444;background:rgba(239,68,68,.08)} | |
| .run-hint{ | |
| text-align:center;font-size:10px;color:var(--faint);margin-top:8px; | |
| font-family:'JetBrains Mono',monospace; | |
| } | |
| .slider-row{display:flex;align-items:center;gap:9px;min-height:26px;margin-bottom:10px} | |
| .slider-row:last-child{margin-bottom:0} | |
| .slider-row label{font-size:12px;font-weight:500;color:var(--muted);min-width:64px;flex-shrink:0} | |
| .slider-row input[type="range"]{ | |
| flex:1;-webkit-appearance:none;appearance:none;height:5px;background:var(--border); | |
| border-radius:3px;outline:none;min-width:0; | |
| } | |
| .slider-row input[type="range"]::-webkit-slider-thumb{ | |
| -webkit-appearance:none;width:14px;height:14px;background:linear-gradient(135deg,#FF0000,#CC0000); | |
| border-radius:50%;cursor:pointer;box-shadow:0 2px 6px rgba(255,0,0,.4);transition:transform .15s; | |
| } | |
| .slider-row input[type="range"]::-webkit-slider-thumb:hover{transform:scale(1.2)} | |
| .slider-row input[type="range"]::-moz-range-thumb{ | |
| width:14px;height:14px;background:linear-gradient(135deg,#FF0000,#CC0000); | |
| border-radius:50%;cursor:pointer;border:none;box-shadow:0 2px 6px rgba(255,0,0,.4); | |
| } | |
| .slider-val{ | |
| min-width:48px;text-align:right;font-family:'JetBrains Mono',monospace;font-size:11px; | |
| font-weight:500;padding:2px 7px;background:var(--panel2);border:1px solid var(--border); | |
| border-radius:5px;color:var(--muted);flex-shrink:0; | |
| } | |
| .dice-btn{ | |
| display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px; | |
| background:var(--panel2);border:1px solid var(--border);border-radius:5px;cursor:pointer; | |
| color:var(--dim);transition:all .15s;flex-shrink:0;padding:0; | |
| } | |
| .dice-btn:hover{border-color:var(--accent);color:var(--accent3)} | |
| .dice-btn svg{width:13px;height:13px;fill:currentColor} | |
| .checkbox-row{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--muted);margin-bottom:10px} | |
| .checkbox-row input[type="checkbox"]{accent-color:var(--accent);width:15px;height:15px;cursor:pointer} | |
| .checkbox-row label{color:var(--muted);font-size:12px;cursor:pointer} | |
| /* Examples page */ | |
| .examples-list{padding:12px 14px;display:flex;flex-direction:column;gap:10px} | |
| .example-card{ | |
| background:var(--panel2);border:1px solid var(--border); | |
| border-radius:10px;overflow:hidden;cursor:pointer;transition:all .2s ease;flex-shrink:0; | |
| display:flex;align-items:stretch; | |
| } | |
| .example-card:hover{border-color:var(--accent);box-shadow:0 4px 14px rgba(255,0,0,.15)} | |
| .example-card.loading{opacity:.5;pointer-events:none} | |
| .example-thumb-wrap{width:96px;flex-shrink:0;background:var(--panel);overflow:hidden;display:flex} | |
| .example-thumb-wrap img{width:100%;height:100%;object-fit:cover} | |
| .example-thumb-placeholder{ | |
| width:100%;height:100%;display:flex;align-items:center;justify-content:center; | |
| background:var(--panel);color:var(--border2);font-size:10px; | |
| } | |
| .example-meta{padding:6px 10px 2px;display:flex;align-items:center;gap:5px;flex-wrap:wrap} | |
| .example-badge{ | |
| display:inline-flex;padding:2px 7px;background:rgba(255,0,0,.1);border-radius:4px; | |
| font-size:9px;font-weight:600;color:var(--accent2);font-family:'JetBrains Mono',monospace; | |
| white-space:nowrap;border:1px solid rgba(255,0,0,.25); | |
| } | |
| .example-body{flex:1;display:flex;flex-direction:column;justify-content:center;min-width:0} | |
| .example-prompt-text{ | |
| padding:4px 10px 9px;font-size:11px;color:var(--muted);line-height:1.45; | |
| display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden; | |
| } | |
| /* ══ Status bar ══ */ | |
| .statusbar{ | |
| grid-area:statusbar;background:var(--panel);border-top:1px solid var(--border); | |
| display:flex;align-items:center;gap:4px;padding:0 14px;font-size:11px;z-index:50; | |
| } | |
| .statusbar .sb-section{ | |
| padding:0 10px;display:flex;align-items:center;font-family:'JetBrains Mono',monospace; | |
| font-size:11px;color:var(--faint);overflow:hidden;white-space:nowrap;gap:6px; | |
| } | |
| .statusbar .sb-right{margin-left:auto;display:flex;align-items:center;gap:10px} | |
| .sb-pill{ | |
| padding:2px 12px;border-radius:5px;font-weight:600;font-size:10px; | |
| background:rgba(255,0,0,.1);color:var(--accent2); | |
| } | |
| .sb-pill.error{background:rgba(239,68,68,.12);color:#f87171} | |
| .sb-pill.done{background:rgba(34,197,94,.12);color:#4ade80} | |
| .sb-link{color:var(--faint);text-decoration:none;font-family:'JetBrains Mono',monospace;font-size:10px} | |
| .sb-link:hover{color:var(--accent3)} | |
| /* ══ Toast ══ */ | |
| .toast-notification{ | |
| position:fixed;top:60px;left:50%;transform:translateX(-50%) translateY(-140%); | |
| z-index:9999;padding:10px 22px;border-radius:9px;font-family:'Inter',sans-serif; | |
| font-size:13px;font-weight:600;display:flex;align-items:center;gap:8px; | |
| box-shadow:0 8px 24px rgba(0,0,0,.5); | |
| transition:transform .35s cubic-bezier(.34,1.56,.64,1),opacity .35s ease;opacity:0;pointer-events:none; | |
| } | |
| .toast-notification.visible{transform:translateX(-50%) translateY(0);opacity:1;pointer-events:auto} | |
| .toast-notification.error{background:linear-gradient(135deg,#dc2626,#b91c1c);color:#fff;border:1px solid rgba(255,255,255,.15)} | |
| .toast-notification.warning{background:linear-gradient(135deg,#d97706,#b45309);color:#fff;border:1px solid rgba(255,255,255,.15)} | |
| .toast-notification.info{background:linear-gradient(135deg,#FF0000,#CC0000);color:#fff;border:1px solid rgba(255,255,255,.15)} | |
| .toast-notification .toast-icon{font-size:15px;line-height:1} | |
| /* ══ Lightbox ══ */ | |
| .lightbox{ | |
| position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:10000; | |
| display:none;align-items:center;justify-content:center;cursor:zoom-out; | |
| backdrop-filter:blur(6px);animation:fadeIn .2s ease; | |
| } | |
| .lightbox.visible{display:flex} | |
| .lightbox img{max-width:94vw;max-height:94vh;border-radius:6px;box-shadow:0 20px 60px rgba(0,0,0,.8)} | |
| @keyframes fadeIn{from{opacity:0}to{opacity:1}} | |
| /* ══ Drop overlay ══ */ | |
| .drop-overlay{ | |
| position:fixed;inset:0;z-index:9000;background:rgba(8,8,10,.85); | |
| display:none;align-items:center;justify-content:center;pointer-events:none; | |
| backdrop-filter:blur(3px); | |
| } | |
| .drop-overlay.visible{display:flex} | |
| .drop-overlay-inner{ | |
| border:3px dashed var(--accent);border-radius:22px;padding:56px 76px; | |
| font-size:19px;font-weight:700;color:var(--accent2);background:rgba(255,0,0,.06); | |
| } | |
| /* ══ Scrollbars ══ */ | |
| ::-webkit-scrollbar{width:8px;height:8px} | |
| ::-webkit-scrollbar-track{background:transparent} | |
| ::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px} | |
| ::-webkit-scrollbar-thumb:hover{background:var(--border2)} | |
| /* ══ Touch devices ══ */ | |
| @media (pointer:coarse){ | |
| .fs-remove{display:flex} | |
| .rail-btn{width:44px;height:44px} | |
| .rail-btn svg{width:19px;height:19px} | |
| } | |
| /* ══ Responsive ══ */ | |
| @media(max-width:960px){ | |
| html,body{height:auto} | |
| body{overflow:auto!important} | |
| .studio{ | |
| display:flex;flex-direction:column;height:auto;min-height:100vh; | |
| } | |
| .menubar{ | |
| flex-wrap:wrap;gap:8px;padding:10px 12px;min-height:var(--menubar-h); | |
| position:sticky;top:0; | |
| } | |
| .menubar-title{font-size:13px} | |
| .menubar-status{display:none} | |
| .menubar-spacer{flex:1} | |
| .gh-btn{padding:6px 10px} | |
| .gh-btn span{display:none} | |
| .rail{ | |
| flex-direction:row;justify-content:flex-start;flex-wrap:nowrap; | |
| overflow-x:auto;overflow-y:hidden;border-right:none; | |
| border-bottom:1px solid var(--border);padding:6px 10px;gap:6px; | |
| } | |
| .rail-sep{width:1px;height:26px;margin:0 4px;flex-shrink:0} | |
| .rail-label{display:none} | |
| .rail-btn{flex-shrink:0} | |
| .workspace{min-height:0} | |
| .canvas-head{flex-wrap:wrap;gap:8px;padding:8px 12px} | |
| .canvas-meta{flex-basis:100%;order:3;font-size:10px} | |
| .canvas-stage{min-height:42vh} | |
| .canvas-stage img.modern-out-img{max-width:calc(100% - 20px);max-height:52vh} | |
| .compare-wrap{max-width:calc(100% - 20px);max-height:52vh} | |
| .canvas-placeholder .cp-sub{font-size:11px;max-width:280px} | |
| .filmstrip{flex-direction:column;height:auto;max-height:none} | |
| .filmstrip-section.inputs{border-right:none;border-bottom:1px solid var(--border)} | |
| .filmstrip-section{padding:8px 0 8px 12px} | |
| .fs-thumb{width:68px;height:68px} | |
| .fs-add{width:68px;height:68px} | |
| .fs-empty{height:68px} | |
| .inspector{border-left:none;border-top:1px solid var(--border)} | |
| .inspector-tabs{position:sticky;top:0;background:var(--panel);z-index:30} | |
| .insp-tab{padding:13px 8px;font-size:12px} | |
| .insp-page{overflow-y:visible} | |
| .insp-section{padding:14px 16px} | |
| .modern-textarea{font-size:16px} | |
| .btn-run{padding:14px 24px;font-size:15px} | |
| .suggestion-chip{padding:6px 13px;font-size:12px} | |
| .statusbar{flex-wrap:wrap;height:auto;min-height:var(--statusbar-h);padding:4px 10px;gap:2px} | |
| .sb-link{display:none} | |
| .toast-notification{max-width:calc(100vw - 32px);font-size:12px;padding:10px 16px} | |
| .drop-overlay-inner{padding:32px 28px;font-size:15px;border-radius:16px;margin:0 16px} | |
| .example-card{flex-direction:column} | |
| .example-thumb-wrap{width:100%;height:96px} | |
| } | |
| @media(max-width:400px){ | |
| .menubar-badge.fast{display:none} | |
| .seg-btn{padding:4px 12px} | |
| .canvas-stage{min-height:36vh} | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="studio"> | |
| <!-- ══ Menu bar ══ --> | |
| <div class="menubar"> | |
| <div class="menubar-logo"> | |
| <svg viewBox="0 0 24 24" fill="white" xmlns="http://www.w3.org/2000/svg"><path d="M12 23c-3.6 0-8-2.69-8-7.5 0-3.5 3-6.5 4.5-8 .27-.27.75-.08.75.28v2.44c0 .42.5.63.72.28C12.28 7.5 13 3 13 1c0-.42.48-.64.8-.35C18 4.5 20 9 20 12c0 5.5-3.5 11-8 11z"/></svg> | |
| </div> | |
| <div class="menubar-title">Image Editor</div> | |
| <span class="menubar-badge fast">FAST</span> | |
| <div class="menubar-spacer"></div> | |
| <div class="menubar-status"><span class="dot" id="conn-dot"></span><span id="conn-text">connecting…</span></div> | |
| </div> | |
| <!-- ══ Left icon rail ══ --> | |
| <div class="rail"> | |
| <div class="rail-label">Input</div> | |
| <button class="rail-btn" id="tb-upload" title="Upload images"> | |
| <svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> | |
| </button> | |
| <button class="rail-btn" id="tb-remove" title="Remove selected image" disabled> | |
| <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg> | |
| </button> | |
| <button class="rail-btn" id="tb-clear" title="Clear all images" disabled> | |
| <svg viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg> | |
| </button> | |
| <div class="rail-sep"></div> | |
| <div class="rail-label">Result</div> | |
| <button class="rail-btn" id="compare-btn" title="Compare before / after" disabled> | |
| <svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg> | |
| </button> | |
| <button class="rail-btn" id="use-as-input-btn" title="Use result as input (chain edits)" disabled> | |
| <svg viewBox="0 0 24 24"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 014-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 01-4 4H3"/></svg> | |
| </button> | |
| <button class="rail-btn" id="dl-btn-output" title="Download result" disabled> | |
| <svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> | |
| </button> | |
| <button class="rail-btn" id="zoom-btn" title="Zoom result" disabled> | |
| <svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg> | |
| </button> | |
| </div> | |
| <!-- ══ Workspace ══ --> | |
| <div class="workspace"> | |
| <div class="canvas-head"> | |
| <div class="seg-control"> | |
| <button class="seg-btn active" id="seg-result">Result</button> | |
| <button class="seg-btn" id="seg-input">Input</button> | |
| </div> | |
| <div class="canvas-meta" id="canvas-meta">No image loaded</div> | |
| <div class="canvas-actions"> | |
| <span id="out-seed-chip" class="out-seed-chip" title="Click to copy seed"></span> | |
| </div> | |
| </div> | |
| <div class="canvas-stage" id="output-image-container"> | |
| <div class="modern-loader" id="output-loader"> | |
| <div class="loader-spinner"></div> | |
| <div class="loader-text" id="loader-text">Processing image…</div> | |
| <div class="loader-bar-track"><div class="loader-bar-fill" id="loader-bar-fill"></div></div> | |
| </div> | |
| <div class="canvas-placeholder" id="output-placeholder"> | |
| <svg viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| <rect x="8" y="14" width="64" height="52" rx="6" fill="none" stroke="#3f2f2f" stroke-width="2" stroke-dasharray="4 3"/> | |
| <polygon points="12,62 30,40 42,50 54,34 68,62" fill="rgba(255,0,0,0.1)" stroke="#3f2f2f" stroke-width="1.5"/> | |
| <circle cx="28" cy="30" r="6" fill="rgba(255,0,0,0.12)" stroke="#3f2f2f" stroke-width="1.5"/> | |
| </svg> | |
| <div class="cp-title">Canvas is empty</div> | |
| <div class="cp-sub"> | |
| Drop images anywhere, paste with <kbd>⌘/Ctrl+V</kbd>, or use the rail to upload. | |
| Then write an instruction and press <kbd>⌘/Ctrl</kbd>+<kbd>↵</kbd>. | |
| </div> | |
| </div> | |
| <div class="compare-wrap" id="compare-wrap"> | |
| <img id="compare-after-img" alt="after"> | |
| <div class="compare-top" id="compare-top"><img id="compare-before-img" alt="before"></div> | |
| <div class="compare-divider" id="compare-divider"><div class="compare-handle">⇄</div></div> | |
| <span class="compare-label before">Before</span> | |
| <span class="compare-label after">After</span> | |
| </div> | |
| </div> | |
| <!-- Filmstrip --> | |
| <div class="filmstrip"> | |
| <div class="filmstrip-section inputs"> | |
| <div class="filmstrip-label">Inputs <span class="count" id="tb-image-count">0</span></div> | |
| <div class="filmstrip-row" id="image-gallery-grid"></div> | |
| </div> | |
| <div class="filmstrip-section history"> | |
| <div class="filmstrip-label">History <span class="count" id="history-count">0</span></div> | |
| <div class="filmstrip-row" id="history-strip"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ══ Inspector ══ --> | |
| <div class="inspector"> | |
| <div class="inspector-tabs"> | |
| <button class="insp-tab active" id="tab-edit">Edit</button> | |
| <button class="insp-tab" id="tab-examples">Examples</button> | |
| </div> | |
| <!-- Edit page --> | |
| <div class="insp-page active" id="page-edit"> | |
| <div class="insp-section"> | |
| <div class="insp-section-title"><span>Instruction</span><span class="char-count" id="char-count">0</span></div> | |
| <textarea id="custom-prompt-input" class="modern-textarea" rows="3" | |
| placeholder="e.g., transform into anime, upscale, change lighting…"></textarea> | |
| </div> | |
| <div class="insp-section"> | |
| <div class="insp-section-title"><span>Quick Prompts</span></div> | |
| <div class="suggestions-wrap" id="suggestions-wrap"></div> | |
| </div> | |
| <div class="insp-section"> | |
| <button id="custom-run-btn" class="btn-run"> | |
| <svg viewBox="0 0 24 24" fill="white" xmlns="http://www.w3.org/2000/svg"><path d="M12 23c-3.6 0-8-2.69-8-7.5 0-3.5 3-6.5 4.5-8 .27-.27.75-.08.75.28v2.44c0 .42.5.63.72.28C12.28 7.5 13 3 13 1c0-.42.48-.64.8-.35C18 4.5 20 9 20 12c0 5.5-3.5 11-8 11z"/></svg> | |
| <span id="run-btn-label">Edit Image</span> | |
| </button> | |
| <button id="custom-cancel-btn" class="btn-cancel" style="display:none;">Cancel</button> | |
| <div class="run-hint">⌘/Ctrl + Enter</div> | |
| </div> | |
| <div class="insp-section"> | |
| <div class="insp-section-title"><span>Advanced</span></div> | |
| <div class="slider-row"> | |
| <label>Seed</label> | |
| <input type="range" id="custom-seed" min="0" max="2147483647" step="1" value="0"> | |
| <span class="slider-val" id="custom-seed-val">0</span> | |
| <button class="dice-btn" id="dice-btn" title="Random seed"> | |
| <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM7.5 18c-.83 0-1.5-.67-1.5-1.5S6.67 15 7.5 15s1.5.67 1.5 1.5S8.33 18 7.5 18zm0-9C6.67 9 6 8.33 6 7.5S6.67 6 7.5 6 9 6.67 9 7.5 8.33 9 7.5 9zm4.5 4.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm4.5 4.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm0-9c-.83 0-1.5-.67-1.5-1.5S15.67 6 16.5 6s1.5.67 1.5 1.5S17.33 9 16.5 9z"/></svg> | |
| </button> | |
| </div> | |
| <div class="checkbox-row"> | |
| <input type="checkbox" id="custom-randomize" checked> | |
| <label for="custom-randomize">Randomize seed</label> | |
| </div> | |
| <div class="slider-row"> | |
| <label>Guidance</label> | |
| <input type="range" id="custom-guidance" min="1" max="10" step="0.1" value="1.0"> | |
| <span class="slider-val" id="custom-guidance-val">1.0</span> | |
| </div> | |
| <div class="slider-row"> | |
| <label>Steps</label> | |
| <input type="range" id="custom-steps" min="1" max="50" step="1" value="4"> | |
| <span class="slider-val" id="custom-steps-val">4</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Examples page --> | |
| <div class="insp-page" id="page-examples"> | |
| <div class="examples-list" id="examples-scroll"></div> | |
| </div> | |
| </div> | |
| <!-- ══ Status bar ══ --> | |
| <div class="statusbar"> | |
| <div class="sb-section" id="sb-image-count">No inputs</div> | |
| <div class="sb-right"> | |
| <span class="sb-link">Image Editor</span> | |
| <span class="sb-pill" id="sb-status">Ready</span> | |
| </div> | |
| </div> | |
| </div> | |
| <input id="custom-file-input" type="file" accept="image/*" multiple style="display:none;" /> | |
| <!-- Lightbox --> | |
| <div class="lightbox" id="lightbox"><img id="lightbox-img" alt="zoom"></div> | |
| <!-- Global drop overlay --> | |
| <div class="drop-overlay" id="drop-overlay"><div class="drop-overlay-inner">Drop images to upload</div></div> | |
| <script type="module"> | |
| import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js"; | |
| /* ── DOM refs ── */ | |
| const galleryGrid = document.getElementById('image-gallery-grid'); | |
| const fileInput = document.getElementById('custom-file-input'); | |
| const btnUpload = document.getElementById('tb-upload'); | |
| const btnRemove = document.getElementById('tb-remove'); | |
| const btnClear = document.getElementById('tb-clear'); | |
| const promptInput = document.getElementById('custom-prompt-input'); | |
| const charCount = document.getElementById('char-count'); | |
| const runBtn = document.getElementById('custom-run-btn'); | |
| const runBtnLabel = document.getElementById('run-btn-label'); | |
| const cancelBtn = document.getElementById('custom-cancel-btn'); | |
| const imgCountTb = document.getElementById('tb-image-count'); | |
| const imgCountSb = document.getElementById('sb-image-count'); | |
| const sbStatus = document.getElementById('sb-status'); | |
| const loader = document.getElementById('output-loader'); | |
| const loaderText = document.getElementById('loader-text'); | |
| const loaderFill = document.getElementById('loader-bar-fill'); | |
| const outBody = document.getElementById('output-image-container'); | |
| const outPh = document.getElementById('output-placeholder'); | |
| const dlBtn = document.getElementById('dl-btn-output'); | |
| const compareBtn = document.getElementById('compare-btn'); | |
| const useAsInputBtn= document.getElementById('use-as-input-btn'); | |
| const zoomBtn = document.getElementById('zoom-btn'); | |
| const seedChip = document.getElementById('out-seed-chip'); | |
| const historyStrip = document.getElementById('history-strip'); | |
| const historyCount = document.getElementById('history-count'); | |
| const seedSlider = document.getElementById('custom-seed'); | |
| const seedVal = document.getElementById('custom-seed-val'); | |
| const lightbox = document.getElementById('lightbox'); | |
| const lightboxImg = document.getElementById('lightbox-img'); | |
| const dropOverlay = document.getElementById('drop-overlay'); | |
| const compareWrap = document.getElementById('compare-wrap'); | |
| const compareTop = document.getElementById('compare-top'); | |
| const compareDivider = document.getElementById('compare-divider'); | |
| const compareBeforeImg = document.getElementById('compare-before-img'); | |
| const compareAfterImg = document.getElementById('compare-after-img'); | |
| const canvasMeta = document.getElementById('canvas-meta'); | |
| const segResult = document.getElementById('seg-result'); | |
| const segInput = document.getElementById('seg-input'); | |
| const connDot = document.getElementById('conn-dot'); | |
| const connText = document.getElementById('conn-text'); | |
| /* ── State (multi-image) ── */ | |
| let images = []; | |
| let selectedIdx = -1; | |
| let toastTimer = null; | |
| let running = false; | |
| let currentJob = null; | |
| let currentResult = null; // {b64, seed, prompt, beforeB64} | |
| let history = []; | |
| let historyIdx = -1; | |
| let compareMode = false; | |
| let viewMode = 'result'; // 'result' | 'input' | |
| const SUGGESTIONS = [ | |
| ['Cartoon', 'Transform the image into a dotted cartoon style.'], | |
| ['B&W', 'Convert it to black and white.'], | |
| ['Cinematic', 'Add cinematic lighting with warm orange tones and film grain.'], | |
| ['Anime', 'Transform into anime style illustration.'], | |
| ['Oil Paint', 'Apply oil painting effect with visible brush strokes.'], | |
| ['Enhance', 'Enhance and upscale with more detail and clarity.'], | |
| ['Watercolor', 'Make it look like a watercolor painting with soft edges.'], | |
| ['Sunset', 'Add dramatic sunset sky and warm lighting.'], | |
| ['Pencil', 'Convert to detailed pencil sketch with cross-hatching and shading.'], | |
| ['Pop Art', 'Apply pop art style with bold colors and halftone patterns.'], | |
| ['Retro', 'Apply a vintage retro film look with faded colors and light leaks.'], | |
| ['Neon', 'Add neon glow effects with vibrant colors against a dark background.'], | |
| ['Pixel', 'Convert to pixel art style with a retro 16-bit aesthetic.'], | |
| ['Minimalist', 'Simplify into a clean minimalist illustration with flat colors.'], | |
| ['Low Poly', 'Convert to low poly 3D geometric art style.'], | |
| ['Comic', 'Transform into comic book style with bold outlines and cel shading.'], | |
| ]; | |
| /* ── Toast ── */ | |
| function showToast(message, type) { | |
| let toast = document.getElementById('app-toast'); | |
| if (!toast) { | |
| toast = document.createElement('div'); | |
| toast.id = 'app-toast'; | |
| toast.className = 'toast-notification'; | |
| toast.innerHTML = '<span class="toast-icon"></span><span class="toast-text"></span>'; | |
| document.body.appendChild(toast); | |
| } | |
| const icon = toast.querySelector('.toast-icon'); | |
| const text = toast.querySelector('.toast-text'); | |
| toast.className = 'toast-notification ' + (type || 'error'); | |
| icon.textContent = type === 'warning' ? '⚠' : type === 'info' ? 'ℹ' : '✗'; | |
| text.textContent = message; | |
| if (toastTimer) clearTimeout(toastTimer); | |
| void toast.offsetWidth; | |
| toast.classList.add('visible'); | |
| toastTimer = setTimeout(() => toast.classList.remove('visible'), 3500); | |
| } | |
| function flashPromptError() { | |
| promptInput.classList.add('error-flash'); | |
| promptInput.focus(); | |
| setTimeout(() => promptInput.classList.remove('error-flash'), 800); | |
| } | |
| function setStatus(text, cls) { | |
| sbStatus.textContent = text; | |
| sbStatus.className = 'sb-pill' + (cls ? ' ' + cls : ''); | |
| } | |
| /* ── View mode ── */ | |
| function setViewMode(mode) { | |
| viewMode = mode; | |
| segResult.classList.toggle('active', mode === 'result'); | |
| segInput.classList.toggle('active', mode === 'input'); | |
| renderCanvas(); | |
| } | |
| segResult.addEventListener('click', () => setViewMode('result')); | |
| segInput.addEventListener('click', () => setViewMode('input')); | |
| function getCanvasImg() { return outBody.querySelector('img.modern-out-img'); } | |
| function renderCanvas() { | |
| exitCompareMode(); | |
| let img = getCanvasImg(); | |
| const showResult = viewMode === 'result' && currentResult; | |
| const showInput = viewMode === 'input' && images.length > 0; | |
| if (showResult || showInput) { | |
| outPh.style.display = 'none'; | |
| if (!img) { | |
| img = document.createElement('img'); | |
| img.className = 'modern-out-img'; | |
| img.addEventListener('click', () => { | |
| if (img.src) { lightboxImg.src = img.src; lightbox.classList.add('visible'); } | |
| }); | |
| outBody.appendChild(img); | |
| } | |
| img.style.display = ''; | |
| if (showResult) { | |
| img.src = currentResult.b64; | |
| canvasMeta.textContent = 'result · seed ' + currentResult.seed; | |
| } else { | |
| const i = selectedIdx >= 0 ? selectedIdx : 0; | |
| img.src = images[i].b64; | |
| canvasMeta.textContent = 'input #' + (i + 1) + ' · ' + (images[i].name || 'image'); | |
| } | |
| } else { | |
| if (img) img.style.display = 'none'; | |
| outPh.style.display = ''; | |
| canvasMeta.textContent = images.length > 0 | |
| ? images.length + ' input' + (images.length > 1 ? 's' : '') + ' ready — run an edit' | |
| : 'No image loaded'; | |
| } | |
| updateRailState(); | |
| } | |
| function updateRailState() { | |
| const hasResult = !!currentResult; | |
| dlBtn.disabled = !hasResult; | |
| zoomBtn.disabled = !hasResult && images.length === 0; | |
| useAsInputBtn.disabled = !hasResult; | |
| compareBtn.disabled = !(hasResult && currentResult.beforeB64); | |
| btnRemove.disabled = selectedIdx < 0; | |
| btnClear.disabled = images.length === 0; | |
| } | |
| /* ── Inputs filmstrip ── */ | |
| function updateCounts() { | |
| const n = images.length; | |
| imgCountTb.textContent = n; | |
| imgCountSb.textContent = n > 0 ? n + ' input' + (n > 1 ? 's' : '') + ' · ' + history.length + ' result' + (history.length === 1 ? '' : 's') : 'No inputs'; | |
| } | |
| function addImage(b64, name) { | |
| images.push({id: Date.now() + Math.random(), b64, name}); | |
| if (selectedIdx < 0) selectedIdx = 0; | |
| renderFilmstrip(); updateCounts(); | |
| if (!currentResult || viewMode === 'input') renderCanvas(); | |
| } | |
| function removeImage(idx) { | |
| images.splice(idx, 1); | |
| if (selectedIdx === idx) selectedIdx = images.length ? 0 : -1; | |
| else if (selectedIdx > idx) selectedIdx--; | |
| renderFilmstrip(); updateCounts(); renderCanvas(); | |
| } | |
| function clearAll() { | |
| images = []; selectedIdx = -1; | |
| renderFilmstrip(); updateCounts(); renderCanvas(); | |
| } | |
| function renderFilmstrip() { | |
| galleryGrid.innerHTML = ''; | |
| if (images.length === 0) { | |
| const empty = document.createElement('div'); | |
| empty.className = 'fs-empty'; | |
| empty.textContent = 'Drop or paste images…'; | |
| galleryGrid.appendChild(empty); | |
| } | |
| images.forEach((img, i) => { | |
| const t = document.createElement('div'); | |
| t.className = 'fs-thumb' + (i === selectedIdx ? ' selected' : ''); | |
| t.innerHTML = '<img src="' + img.b64 + '" alt="">' + | |
| '<span class="fs-badge">#' + (i + 1) + '</span>' + | |
| '<button class="fs-remove">✕</button>'; | |
| t.addEventListener('click', (e) => { | |
| if (e.target.closest('.fs-remove')) return; | |
| selectedIdx = i; | |
| renderFilmstrip(); | |
| setViewMode('input'); | |
| }); | |
| t.querySelector('.fs-remove').addEventListener('click', (e) => { | |
| e.stopPropagation(); removeImage(i); | |
| }); | |
| galleryGrid.appendChild(t); | |
| }); | |
| const add = document.createElement('div'); | |
| add.className = 'fs-add'; | |
| add.innerHTML = '<span class="add-icon">+</span><span class="add-text">Add</span>'; | |
| add.addEventListener('click', () => fileInput.click()); | |
| galleryGrid.appendChild(add); | |
| updateRailState(); | |
| } | |
| function processFiles(files) { | |
| let added = 0; | |
| Array.from(files).forEach(file => { | |
| if (!file.type.startsWith('image/')) return; | |
| added++; | |
| const reader = new FileReader(); | |
| reader.onload = (e) => addImage(e.target.result, file.name); | |
| reader.readAsDataURL(file); | |
| }); | |
| return added; | |
| } | |
| fileInput.addEventListener('change', (e) => { processFiles(e.target.files); e.target.value = ''; }); | |
| btnUpload.addEventListener('click', () => fileInput.click()); | |
| btnRemove.addEventListener('click', () => { if (selectedIdx >= 0) removeImage(selectedIdx); }); | |
| btnClear.addEventListener('click', clearAll); | |
| /* ── Drop anywhere ── */ | |
| let dragDepth = 0; | |
| window.addEventListener('dragenter', (e) => { | |
| if (!e.dataTransfer || !Array.from(e.dataTransfer.types).includes('Files')) return; | |
| e.preventDefault(); | |
| dragDepth++; | |
| dropOverlay.classList.add('visible'); | |
| }); | |
| window.addEventListener('dragleave', (e) => { | |
| if (!e.dataTransfer || !Array.from(e.dataTransfer.types).includes('Files')) return; | |
| dragDepth = Math.max(0, dragDepth - 1); | |
| if (dragDepth === 0) dropOverlay.classList.remove('visible'); | |
| }); | |
| window.addEventListener('dragover', (e) => { e.preventDefault(); }); | |
| window.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| dragDepth = 0; | |
| dropOverlay.classList.remove('visible'); | |
| if (e.dataTransfer.files.length) { | |
| const n = processFiles(e.dataTransfer.files); | |
| if (n > 0) showToast('Added ' + n + ' image' + (n > 1 ? 's' : ''), 'info'); | |
| } | |
| }); | |
| /* ── Paste ── */ | |
| document.addEventListener('paste', (e) => { | |
| if (e.target === promptInput) return; | |
| const items = e.clipboardData && e.clipboardData.items; | |
| if (!items) return; | |
| const files = []; | |
| for (const item of items) { | |
| if (item.kind === 'file' && item.type.startsWith('image/')) { | |
| const f = item.getAsFile(); | |
| if (f) files.push(f); | |
| } | |
| } | |
| if (files.length) { | |
| e.preventDefault(); | |
| processFiles(files); | |
| showToast('Pasted ' + files.length + ' image' + (files.length > 1 ? 's' : ''), 'info'); | |
| } | |
| }); | |
| /* ── Inspector tabs ── */ | |
| const tabEdit = document.getElementById('tab-edit'); | |
| const tabExamples = document.getElementById('tab-examples'); | |
| const pageEdit = document.getElementById('page-edit'); | |
| const pageExamples = document.getElementById('page-examples'); | |
| tabEdit.addEventListener('click', () => { | |
| tabEdit.classList.add('active'); tabExamples.classList.remove('active'); | |
| pageEdit.classList.add('active'); pageExamples.classList.remove('active'); | |
| }); | |
| tabExamples.addEventListener('click', () => { | |
| tabExamples.classList.add('active'); tabEdit.classList.remove('active'); | |
| pageExamples.classList.add('active'); pageEdit.classList.remove('active'); | |
| }); | |
| /* ── Suggestion chips ── */ | |
| const suggWrap = document.getElementById('suggestions-wrap'); | |
| SUGGESTIONS.forEach(([label, prompt]) => { | |
| const chip = document.createElement('button'); | |
| chip.className = 'suggestion-chip'; | |
| chip.textContent = label; | |
| chip.addEventListener('click', () => { | |
| promptInput.value = prompt; | |
| updateCharCount(); | |
| suggWrap.querySelectorAll('.suggestion-chip').forEach(c => c.classList.remove('active')); | |
| chip.classList.add('active'); | |
| promptInput.focus(); | |
| }); | |
| suggWrap.appendChild(chip); | |
| }); | |
| function updateCharCount() { charCount.textContent = promptInput.value.length; } | |
| promptInput.addEventListener('input', () => { | |
| updateCharCount(); | |
| suggWrap.querySelectorAll('.suggestion-chip').forEach(c => c.classList.remove('active')); | |
| }); | |
| updateCharCount(); | |
| /* ── Sliders ── */ | |
| function bindSlider(id) { | |
| const slider = document.getElementById(id); | |
| const valSpan = document.getElementById(id + '-val'); | |
| slider.addEventListener('input', () => { valSpan.textContent = slider.value; }); | |
| } | |
| bindSlider('custom-seed'); | |
| bindSlider('custom-guidance'); | |
| bindSlider('custom-steps'); | |
| document.getElementById('dice-btn').addEventListener('click', () => { | |
| const s = Math.floor(Math.random() * 2147483647); | |
| seedSlider.value = s; seedVal.textContent = s; | |
| }); | |
| /* ── Loader ── */ | |
| function showLoader(text) { | |
| loaderText.textContent = text || 'Processing image…'; | |
| loaderFill.classList.remove('determinate'); | |
| loaderFill.style.width = '100%'; | |
| loader.classList.add('active'); | |
| setStatus('Processing…'); | |
| } | |
| function setLoaderProgress(text, frac) { | |
| loaderText.textContent = text; | |
| if (frac != null && frac >= 0 && frac <= 1) { | |
| loaderFill.classList.add('determinate'); | |
| loaderFill.style.width = Math.max(4, Math.round(frac * 100)) + '%'; | |
| } | |
| } | |
| function hideLoader(statusText, cls) { | |
| loader.classList.remove('active'); | |
| setStatus(statusText || 'Done', cls || 'done'); | |
| } | |
| /* ── Result / history ── */ | |
| function showResult(entry) { | |
| currentResult = entry; | |
| setViewMode('result'); | |
| dlBtn.disabled = false; | |
| useAsInputBtn.disabled = false; | |
| zoomBtn.disabled = false; | |
| if (entry.beforeB64) compareBtn.disabled = false; | |
| if (entry.seed != null) { | |
| seedChip.textContent = 'seed ' + entry.seed; | |
| seedChip.classList.add('visible'); | |
| } | |
| } | |
| function renderHistory() { | |
| historyStrip.innerHTML = ''; | |
| historyCount.textContent = history.length; | |
| if (history.length === 0) { | |
| const empty = document.createElement('div'); | |
| empty.className = 'fs-empty'; | |
| empty.textContent = 'No results yet'; | |
| historyStrip.appendChild(empty); | |
| return; | |
| } | |
| history.forEach((entry, i) => { | |
| const t = document.createElement('div'); | |
| t.className = 'fs-thumb' + (i === historyIdx ? ' selected' : ''); | |
| t.title = 'seed ' + entry.seed; | |
| const im = document.createElement('img'); | |
| im.src = entry.b64; | |
| t.appendChild(im); | |
| t.addEventListener('click', () => { historyIdx = i; showResult(history[i]); renderHistory(); }); | |
| historyStrip.appendChild(t); | |
| }); | |
| } | |
| function pushHistory(entry) { | |
| history.push(entry); | |
| historyIdx = history.length - 1; | |
| renderHistory(); | |
| updateCounts(); | |
| } | |
| /* ── Compare slider ── */ | |
| function enterCompareMode() { | |
| if (!currentResult || !currentResult.beforeB64) return; | |
| compareMode = true; | |
| compareBtn.classList.add('active'); | |
| const img = getCanvasImg(); | |
| if (img) img.style.display = 'none'; | |
| compareAfterImg.src = currentResult.b64; | |
| compareBeforeImg.src = currentResult.beforeB64; | |
| compareWrap.classList.add('visible'); | |
| requestAnimationFrame(() => setComparePos(0.5)); | |
| } | |
| function exitCompareMode() { | |
| if (!compareMode) return; | |
| compareMode = false; | |
| compareBtn.classList.remove('active'); | |
| compareWrap.classList.remove('visible'); | |
| const img = getCanvasImg(); | |
| if (img && (viewMode === 'result' && currentResult || viewMode === 'input' && images.length)) img.style.display = ''; | |
| } | |
| function setComparePos(frac) { | |
| frac = Math.max(0, Math.min(1, frac)); | |
| const w = compareWrap.clientWidth; | |
| const x = frac * w; | |
| compareTop.style.clipPath = 'inset(0 ' + (w - x) + 'px 0 0)'; | |
| compareDivider.style.left = x + 'px'; | |
| } | |
| compareBtn.addEventListener('click', () => { compareMode ? exitCompareMode() : enterCompareMode(); }); | |
| let compareDragging = false; | |
| function compareMove(clientX) { | |
| const rect = compareWrap.getBoundingClientRect(); | |
| setComparePos((clientX - rect.left) / rect.width); | |
| } | |
| compareWrap.addEventListener('pointerdown', (e) => { | |
| compareDragging = true; | |
| compareWrap.setPointerCapture(e.pointerId); | |
| compareMove(e.clientX); | |
| }); | |
| compareWrap.addEventListener('pointermove', (e) => { if (compareDragging) compareMove(e.clientX); }); | |
| compareWrap.addEventListener('pointerup', () => { compareDragging = false; }); | |
| compareWrap.addEventListener('pointercancel', () => { compareDragging = false; }); | |
| /* ── Lightbox ── */ | |
| lightbox.addEventListener('click', () => lightbox.classList.remove('visible')); | |
| document.addEventListener('keydown', (e) => { if (e.key === 'Escape') lightbox.classList.remove('visible'); }); | |
| zoomBtn.addEventListener('click', () => { | |
| const img = getCanvasImg(); | |
| const src = (viewMode === 'result' && currentResult) ? currentResult.b64 | |
| : (images.length ? images[selectedIdx >= 0 ? selectedIdx : 0].b64 : null); | |
| if (src) { lightboxImg.src = src; lightbox.classList.add('visible'); } | |
| }); | |
| /* ── Download / chain / seed ── */ | |
| dlBtn.addEventListener('click', () => { | |
| if (currentResult && currentResult.b64) { | |
| const a = document.createElement('a'); | |
| a.href = currentResult.b64; | |
| a.download = 'edit_' + (currentResult.seed != null ? currentResult.seed : 'output') + '.png'; | |
| document.body.appendChild(a); a.click(); document.body.removeChild(a); | |
| } | |
| }); | |
| useAsInputBtn.addEventListener('click', () => { | |
| if (!currentResult) return; | |
| addImage(currentResult.b64, 'edit_seed_' + (currentResult.seed != null ? currentResult.seed : 'x') + '.png'); | |
| showToast('Result added as input image', 'info'); | |
| }); | |
| seedChip.addEventListener('click', () => { | |
| if (currentResult && currentResult.seed != null) { | |
| navigator.clipboard.writeText(String(currentResult.seed)).then( | |
| () => showToast('Seed copied: ' + currentResult.seed, 'info'), () => {}); | |
| } | |
| }); | |
| /* ── Backend connection ── */ | |
| let client = null; | |
| try { | |
| client = await Client.connect(window.location.origin); | |
| setStatus('Ready'); | |
| connDot.classList.remove('off'); | |
| connText.textContent = 'connected'; | |
| } catch (e) { | |
| console.error('Failed to connect to Gradio backend:', e); | |
| setStatus('Offline', 'error'); | |
| connDot.classList.add('off'); | |
| connText.textContent = 'offline'; | |
| showToast('Could not connect to the backend API', 'error'); | |
| } | |
| /* ── Config: examples ── */ | |
| function renderConfig(cfg) { | |
| const list = document.getElementById('examples-scroll'); | |
| list.innerHTML = ''; | |
| cfg.examples.forEach(ex => { | |
| const card = document.createElement('div'); | |
| card.className = 'example-card'; | |
| const thumbs = ex.thumbs.map(t => | |
| t ? '<img src="' + t + '" alt="">' : '<div class="example-thumb-placeholder">Preview</div>' | |
| ).join(''); | |
| const promptShort = ex.prompt.length > 90 ? ex.prompt.slice(0, 90) + '…' : ex.prompt; | |
| card.innerHTML = | |
| '<div class="example-thumb-wrap">' + thumbs + '</div>' | |
| + '<div class="example-body">' | |
| + '<div class="example-meta">' | |
| + '<span class="example-badge">' + ex.n_images + ' img' + (ex.n_images > 1 ? 's' : '') + '</span>' | |
| + '</div>' | |
| + '<div class="example-prompt-text"></div>' | |
| + '</div>'; | |
| card.querySelector('.example-prompt-text').textContent = promptShort; | |
| card.addEventListener('click', () => loadExample(card, ex.idx)); | |
| list.appendChild(card); | |
| }); | |
| } | |
| async function loadExample(card, idx) { | |
| if (!client) { showToast('Backend not connected', 'error'); return; } | |
| document.querySelectorAll('.example-card.loading').forEach(c => c.classList.remove('loading')); | |
| card.classList.add('loading'); | |
| showToast('Loading example…', 'info'); | |
| try { | |
| const result = await client.predict('/load_example', { idx: idx }); | |
| const data = result.data[0]; | |
| if (data && data.status === 'ok' && data.images && data.images.length > 0) { | |
| clearAll(); | |
| if (data.prompt) { promptInput.value = data.prompt; updateCharCount(); } | |
| data.images.forEach((b64, i) => { | |
| const name = (data.names && data.names[i]) ? data.names[i] : ('example_' + (i + 1) + '.jpg'); | |
| addImage(b64, name); | |
| }); | |
| tabEdit.click(); | |
| showToast('Example loaded — ' + data.images.length + ' image(s)', 'info'); | |
| } else { | |
| showToast('Could not load example images', 'error'); | |
| } | |
| } catch (e) { | |
| console.error('Example load error:', e); | |
| showToast('Could not load example images', 'error'); | |
| } finally { | |
| card.classList.remove('loading'); | |
| } | |
| } | |
| try { | |
| const cfg = await fetch('api/config').then(r => r.json()); | |
| renderConfig(cfg); | |
| } catch (e) { | |
| console.error('Failed to load config:', e); | |
| } | |
| /* ── Run ── */ | |
| function validateBeforeRun() { | |
| const promptVal = promptInput.value.trim(); | |
| const hasImages = images.length > 0; | |
| if (!hasImages && !promptVal) { showToast('Please upload an image and enter a prompt', 'error'); flashPromptError(); return false; } | |
| if (!hasImages) { showToast('Please upload at least one image', 'error'); return false; } | |
| if (!promptVal) { showToast('Please enter an edit prompt', 'warning'); flashPromptError(); return false; } | |
| return true; | |
| } | |
| function setRunningUI(on) { | |
| running = on; | |
| runBtn.disabled = on; | |
| runBtnLabel.textContent = on ? 'Editing…' : 'Edit Image'; | |
| cancelBtn.style.display = on ? 'flex' : 'none'; | |
| } | |
| async function runEdit() { | |
| if (running) return; | |
| if (!client) { showToast('Backend not connected', 'error'); return; } | |
| if (!validateBeforeRun()) return; | |
| setRunningUI(true); | |
| setViewMode('result'); | |
| showLoader('Submitting to queue…'); | |
| const beforeB64 = images[0] ? images[0].b64 : null; | |
| const usedPrompt = promptInput.value; | |
| const payload = { | |
| images_b64_json: JSON.stringify(images.map(img => img.b64)), | |
| prompt: usedPrompt, | |
| seed: parseInt(seedSlider.value) || 0, | |
| randomize_seed: document.getElementById('custom-randomize').checked, | |
| guidance_scale: parseFloat(document.getElementById('custom-guidance').value), | |
| steps: parseInt(document.getElementById('custom-steps').value), | |
| }; | |
| try { | |
| currentJob = client.submit('/edit_image', payload); | |
| for await (const msg of currentJob) { | |
| if (msg.type === 'status') { | |
| if (msg.status === 'pending') { | |
| const pos = (msg.position != null && msg.position >= 0) | |
| ? 'In queue — position ' + (msg.position + 1) + (msg.queue_size ? ' of ' + msg.queue_size : '') | |
| : 'Waiting in queue…'; | |
| setLoaderProgress(pos, null); | |
| setStatus('Queued'); | |
| } else if (msg.status === 'generating') { | |
| setStatus('Processing…'); | |
| const pd = msg.progress_data; | |
| if (pd && pd.length > 0) { | |
| const p = pd[pd.length - 1]; | |
| if (p.index != null && p.length) { | |
| const pct = Math.round((p.index / p.length) * 100); | |
| setLoaderProgress('Generating… ' + p.index + '/' + p.length + ' (' + pct + '%)', p.index / p.length); | |
| } else { | |
| setLoaderProgress('Generating…', null); | |
| } | |
| } else { | |
| setLoaderProgress('Generating image…', null); | |
| } | |
| } else if (msg.status === 'error') { | |
| throw new Error(msg.message || 'Generation failed'); | |
| } | |
| } else if (msg.type === 'data') { | |
| const out = msg.data && msg.data[0]; | |
| if (out && out.image) { | |
| const entry = { b64: out.image, seed: out.seed, prompt: usedPrompt, beforeB64 }; | |
| showResult(entry); | |
| pushHistory(entry); | |
| if (out.seed != null) { | |
| seedSlider.value = out.seed; | |
| seedVal.textContent = out.seed; | |
| } | |
| hideLoader('Done', 'done'); | |
| showToast('Image edited successfully', 'info'); | |
| } else if (out && out.status === 'blocked') { | |
| // NCII safety guard triggered | |
| hideLoader('Blocked', 'error'); | |
| showToast(out.message || 'Request blocked by safety guard', 'warning'); | |
| } else { | |
| hideLoader('Done', 'done'); | |
| } | |
| } | |
| } | |
| } catch (e) { | |
| console.error('Edit error:', e); | |
| hideLoader('Error', 'error'); | |
| const msg = (e && e.message) ? e.message : 'Generation failed'; | |
| showToast(msg.length > 120 ? msg.slice(0, 120) + '…' : msg, 'error'); | |
| } finally { | |
| setRunningUI(false); | |
| currentJob = null; | |
| } | |
| } | |
| runBtn.addEventListener('click', runEdit); | |
| cancelBtn.addEventListener('click', async () => { | |
| if (currentJob) { | |
| try { await currentJob.cancel(); } catch (e) { console.warn('Cancel error:', e); } | |
| currentJob = null; | |
| } | |
| setRunningUI(false); | |
| hideLoader('Cancelled'); | |
| showToast('Generation cancelled', 'warning'); | |
| }); | |
| document.addEventListener('keydown', (e) => { | |
| if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { | |
| e.preventDefault(); | |
| runEdit(); | |
| } | |
| }); | |
| renderFilmstrip(); | |
| renderHistory(); | |
| updateCounts(); | |
| renderCanvas(); | |
| </script> | |
| </body> | |
| </html> |