Last active
February 12, 2026 07:34
-
-
Save OmerFarukOruc/b5eb390098ef95f047667355b4304c17 to your computer and use it in GitHub Desktop.
Gemini Image Studio - Text-to-Image & Image-to-Image with AI prompt enhancement & queue
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Gemini Image Studio</title> | |
| <style> | |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } | |
| :root { | |
| --bg: #0a0a0a; | |
| --surface: #141414; | |
| --surface-hover: #1a1a1a; | |
| --border: #2a2a2a; | |
| --border-focus: #5b5bf7; | |
| --text: #e4e4e7; | |
| --text-dim: #71717a; | |
| --accent: #5b5bf7; | |
| --accent-hover: #4a4ae6; | |
| --danger: #ef4444; | |
| --success: #22c55e; | |
| --warning: #f59e0b; | |
| --radius: 12px; | |
| } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; | |
| background: var(--bg); | |
| color: var(--text); | |
| min-height: 100vh; | |
| padding: 24px; | |
| } | |
| .container { max-width: 960px; margin: 0 auto; } | |
| h1 { font-size: 28px; font-weight: 700; margin-bottom: 8px; letter-spacing: -0.5px; } | |
| .subtitle { color: var(--text-dim); font-size: 14px; margin-bottom: 32px; } | |
| .mode-toggle { | |
| display: flex; gap: 4px; background: var(--surface); border: 1px solid var(--border); | |
| border-radius: var(--radius); padding: 4px; margin-bottom: 24px; width: fit-content; | |
| } | |
| .mode-btn { | |
| padding: 10px 24px; border: none; border-radius: 8px; background: transparent; | |
| color: var(--text-dim); font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s; | |
| } | |
| .mode-btn.active { background: var(--accent); color: #fff; } | |
| .mode-btn:hover:not(.active) { color: var(--text); background: var(--surface-hover); } | |
| .panel { | |
| background: var(--surface); border: 1px solid var(--border); | |
| border-radius: var(--radius); padding: 24px; margin-bottom: 24px; | |
| } | |
| .form-group { margin-bottom: 20px; } | |
| label { | |
| display: block; font-size: 13px; font-weight: 600; color: var(--text-dim); | |
| text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; | |
| } | |
| textarea { | |
| width: 100%; min-height: 80px; padding: 12px 16px; background: var(--bg); | |
| border: 1px solid var(--border); border-radius: 8px; color: var(--text); | |
| font-size: 15px; font-family: inherit; resize: vertical; transition: border-color 0.2s; | |
| } | |
| textarea:focus { outline: none; border-color: var(--border-focus); } | |
| input[type="text"], input[type="password"], select { | |
| width: 100%; padding: 10px 16px; background: var(--bg); border: 1px solid var(--border); | |
| border-radius: 8px; color: var(--text); font-size: 14px; font-family: inherit; transition: border-color 0.2s; | |
| } | |
| input[type="text"]:focus, input[type="password"]:focus, select:focus { outline: none; border-color: var(--border-focus); } | |
| select { cursor: pointer; } | |
| .row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } | |
| .dropzone { | |
| border: 2px dashed var(--border); border-radius: var(--radius); padding: 32px; | |
| text-align: center; cursor: pointer; transition: all 0.2s; position: relative; | |
| min-height: 160px; display: flex; flex-direction: column; align-items: center; justify-content: center; | |
| } | |
| .dropzone:hover, .dropzone.dragover { border-color: var(--accent); background: rgba(91, 91, 247, 0.05); } | |
| .dropzone.has-image { padding: 8px; border-style: solid; border-color: var(--success); } | |
| .dropzone input[type="file"] { position: absolute; inset: 0; opacity: 0; cursor: pointer; } | |
| .dropzone-icon { font-size: 32px; margin-bottom: 8px; opacity: 0.5; } | |
| .dropzone-text { color: var(--text-dim); font-size: 14px; } | |
| .dropzone-text strong { color: var(--accent); } | |
| .dropzone .preview { max-width: 100%; max-height: 200px; border-radius: 8px; object-fit: contain; } | |
| .dropzone .remove-btn { | |
| position: absolute; top: 8px; right: 8px; width: 28px; height: 28px; | |
| border-radius: 50%; border: none; background: var(--danger); color: white; | |
| font-size: 16px; cursor: pointer; display: flex; align-items: center; justify-content: center; z-index: 2; | |
| } | |
| .btn-row { display: flex; gap: 12px; } | |
| .btn-row > * { flex: 1; } | |
| .generate-btn, .queue-add-btn, .queue-run-btn { | |
| padding: 14px; border: none; border-radius: var(--radius); | |
| font-size: 15px; font-weight: 600; cursor: pointer; transition: all 0.2s; letter-spacing: 0.3px; | |
| } | |
| .generate-btn { background: var(--accent); color: #fff; } | |
| .generate-btn:hover:not(:disabled) { background: var(--accent-hover); transform: translateY(-1px); } | |
| .generate-btn:disabled { opacity: 0.5; cursor: not-allowed; } | |
| .queue-add-btn { background: transparent; color: var(--text); border: 1px solid var(--border); } | |
| .queue-add-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); } | |
| .queue-run-btn { background: var(--success); color: #fff; } | |
| .queue-run-btn:hover:not(:disabled) { opacity: 0.9; } | |
| .queue-run-btn:disabled { opacity: 0.5; cursor: not-allowed; } | |
| .enhance-row { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; } | |
| .toggle { position: relative; width: 44px; height: 24px; flex-shrink: 0; } | |
| .toggle input { opacity: 0; width: 0; height: 0; } | |
| .toggle-slider { | |
| position: absolute; inset: 0; background: var(--border); border-radius: 12px; cursor: pointer; transition: 0.2s; | |
| } | |
| .toggle-slider::before { | |
| content: ''; position: absolute; width: 18px; height: 18px; | |
| left: 3px; bottom: 3px; background: #fff; border-radius: 50%; transition: 0.2s; | |
| } | |
| .toggle input:checked + .toggle-slider { background: var(--accent); } | |
| .toggle input:checked + .toggle-slider::before { transform: translateX(20px); } | |
| .enhance-label { | |
| font-size: 14px; font-weight: 500; color: var(--text); text-transform: none; | |
| letter-spacing: 0; margin-bottom: 0; cursor: pointer; | |
| } | |
| .enhance-model-select { width: auto; padding: 6px 12px; font-size: 13px; flex-shrink: 0; } | |
| .enhanced-preview { | |
| background: var(--bg); border: 1px solid var(--warning); border-radius: 8px; | |
| padding: 12px 16px; margin-bottom: 16px; display: none; | |
| } | |
| .enhanced-preview.visible { display: block; } | |
| .enhanced-preview-header { | |
| font-size: 11px; font-weight: 700; color: var(--warning); | |
| text-transform: uppercase; letter-spacing: 1px; margin-bottom: 6px; | |
| } | |
| .enhanced-preview-text { font-size: 14px; color: var(--text); line-height: 1.5; white-space: pre-wrap; } | |
| .status { text-align: center; padding: 16px; font-size: 14px; color: var(--text-dim); display: none; } | |
| .status.visible { display: block; } | |
| .status.error { color: var(--danger); } | |
| .spinner { | |
| display: inline-block; width: 16px; height: 16px; border: 2px solid var(--border); | |
| border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; | |
| margin-right: 8px; vertical-align: middle; | |
| } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| .result-panel { display: none; } | |
| .result-panel.visible { display: block; } | |
| .result-image { width: 100%; border-radius: var(--radius); border: 1px solid var(--border); } | |
| .result-actions { display: flex; gap: 12px; margin-top: 16px; } | |
| .result-actions button { | |
| flex: 1; padding: 10px; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s; | |
| } | |
| .download-btn { background: var(--success); color: #fff; border: none; } | |
| .download-btn:hover { opacity: 0.9; } | |
| .copy-btn { background: transparent; color: var(--text); border: 1px solid var(--border); } | |
| .copy-btn:hover { border-color: var(--text-dim); } | |
| .settings-row { display: flex; gap: 12px; align-items: end; flex-wrap: wrap; } | |
| .settings-row .form-group { flex: 1; margin-bottom: 0; min-width: 140px; } | |
| .hidden { display: none !important; } | |
| .result-meta { font-size: 12px; color: var(--text-dim); margin-top: 8px; text-align: right; } | |
| .queue-panel { margin-bottom: 24px; } | |
| .queue-panel h3 { font-size: 14px; font-weight: 600; margin-bottom: 12px; display: flex; align-items: center; gap: 8px; } | |
| .queue-badge { | |
| background: var(--accent); color: #fff; font-size: 11px; font-weight: 700; | |
| padding: 2px 8px; border-radius: 10px; min-width: 20px; text-align: center; | |
| } | |
| .queue-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; } | |
| .queue-item { | |
| display: flex; align-items: center; gap: 12px; background: var(--bg); | |
| border: 1px solid var(--border); border-radius: 8px; padding: 12px 16px; | |
| font-size: 13px; transition: border-color 0.2s; | |
| } | |
| .queue-item.running { border-color: var(--accent); } | |
| .queue-item.done { border-color: var(--success); opacity: 0.7; } | |
| .queue-item.failed { border-color: var(--danger); opacity: 0.7; } | |
| .queue-item-num { font-weight: 700; color: var(--text-dim); font-size: 12px; width: 24px; text-align: center; flex-shrink: 0; } | |
| .queue-item-info { flex: 1; min-width: 0; } | |
| .queue-item-prompt { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); margin-bottom: 2px; } | |
| .queue-item-meta { font-size: 11px; color: var(--text-dim); } | |
| .queue-item-status { flex-shrink: 0; font-size: 12px; font-weight: 600; } | |
| .queue-item-status.pending { color: var(--text-dim); } | |
| .queue-item-status.running { color: var(--accent); } | |
| .queue-item-status.done { color: var(--success); } | |
| .queue-item-status.failed { color: var(--danger); } | |
| .queue-item-remove { | |
| flex-shrink: 0; width: 24px; height: 24px; border-radius: 50%; border: none; | |
| background: transparent; color: var(--text-dim); font-size: 16px; cursor: pointer; | |
| display: flex; align-items: center; justify-content: center; | |
| } | |
| .queue-item-remove:hover { color: var(--danger); background: rgba(239,68,68,0.1); } | |
| .queue-actions { display: flex; gap: 12px; } | |
| .queue-clear-btn { | |
| padding: 10px 20px; border-radius: 8px; font-size: 13px; font-weight: 500; cursor: pointer; | |
| background: transparent; color: var(--text-dim); border: 1px solid var(--border); transition: all 0.2s; | |
| } | |
| .queue-clear-btn:hover { border-color: var(--danger); color: var(--danger); } | |
| .results-gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 16px; } | |
| .gallery-item { | |
| background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); | |
| overflow: hidden; cursor: pointer; transition: transform 0.2s; | |
| } | |
| .gallery-item:hover { transform: translateY(-2px); } | |
| .gallery-item img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; } | |
| .gallery-item-info { padding: 10px 12px; font-size: 12px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } | |
| .lightbox { | |
| display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.9); | |
| z-index: 100; align-items: center; justify-content: center; padding: 40px; | |
| } | |
| .lightbox.visible { display: flex; } | |
| .lightbox img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 8px; } | |
| .lightbox-close { | |
| position: absolute; top: 20px; right: 20px; width: 40px; height: 40px; border-radius: 50%; | |
| border: none; background: rgba(255,255,255,0.1); color: #fff; font-size: 24px; cursor: pointer; | |
| display: flex; align-items: center; justify-content: center; | |
| } | |
| .lightbox-actions { | |
| position: absolute; bottom: 30px; left: 50%; transform: translateX(-50%); display: flex; gap: 12px; | |
| } | |
| .lightbox-actions button { | |
| padding: 10px 24px; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; border: none; | |
| } | |
| .api-key-row { display: flex; gap: 8px; align-items: end; } | |
| .api-key-row .form-group { flex: 1; margin-bottom: 0; } | |
| .toggle-vis-btn { | |
| padding: 10px 14px; border: 1px solid var(--border); border-radius: 8px; | |
| background: var(--bg); color: var(--text-dim); cursor: pointer; font-size: 14px; | |
| transition: border-color 0.2s; flex-shrink: 0; height: 40px; | |
| } | |
| .toggle-vis-btn:hover { border-color: var(--accent); color: var(--text); } | |
| .save-indicator { | |
| font-size: 11px; color: var(--success); opacity: 0; transition: opacity 0.3s; | |
| margin-left: 8px; | |
| } | |
| .save-indicator.visible { opacity: 1; } | |
| .info-box { | |
| background: rgba(91, 91, 247, 0.08); border: 1px solid rgba(91, 91, 247, 0.2); | |
| border-radius: 8px; padding: 12px 16px; margin-bottom: 20px; font-size: 13px; | |
| color: var(--text-dim); line-height: 1.5; | |
| } | |
| .info-box strong { color: var(--text); } | |
| .i2i-settings-row { display: flex; gap: 12px; align-items: end; flex-wrap: wrap; margin-bottom: 20px; } | |
| .i2i-settings-row .form-group { flex: 1; margin-bottom: 0; min-width: 120px; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>Gemini Image Studio</h1> | |
| <p class="subtitle">Text-to-Image & Image-to-Image with AI prompt enhancement & queue</p> | |
| <div class="mode-toggle"> | |
| <button class="mode-btn active" data-mode="t2i">Text to Image</button> | |
| <button class="mode-btn" data-mode="i2i">Image to Image</button> | |
| </div> | |
| <div class="panel"> | |
| <div class="info-box"> | |
| <strong>Setup:</strong> Enter your OpenAI-compatible API base URL and API key below. | |
| Your credentials are stored only in your browser's local storage and never sent anywhere except the API endpoint you configure. | |
| </div> | |
| <div class="form-group"> | |
| <label>API Base URL</label> | |
| <input type="text" id="apiBase" placeholder="http://localhost:8045/v1" /> | |
| </div> | |
| <div class="form-group"> | |
| <label>API Key <span class="save-indicator" id="saveIndicator">Saved</span></label> | |
| <div class="api-key-row"> | |
| <div class="form-group"> | |
| <input type="password" id="apiKey" placeholder="Enter your API key" /> | |
| </div> | |
| <button class="toggle-vis-btn" id="toggleKeyVis" title="Show/hide API key">👁</button> | |
| </div> | |
| </div> | |
| <div class="settings-row form-group"> | |
| <div class="form-group"> | |
| <label>Model</label> | |
| <input type="text" id="model" value="gemini-3-pro-image" placeholder="gemini-3-pro-image" /> | |
| </div> | |
| <div class="form-group" id="aspectGroup"> | |
| <label>Aspect Ratio</label> | |
| <select id="aspectRatio"> | |
| <option value="1:1">1:1 — Square</option> | |
| <option value="16:9">16:9 — Widescreen</option> | |
| <option value="9:16">9:16 — Mobile / Vertical</option> | |
| <option value="4:3">4:3 — Traditional</option> | |
| <option value="3:4">3:4 — Portrait</option> | |
| <option value="3:2">3:2 — DSLR</option> | |
| <option value="2:3">2:3 — Portrait Photo</option> | |
| <option value="21:9">21:9 — Ultra-wide</option> | |
| <option value="5:4">5:4 — Large Format</option> | |
| <option value="4:5">4:5 — Social Media</option> | |
| </select> | |
| </div> | |
| <div class="form-group" id="qualityGroup"> | |
| <label>Quality</label> | |
| <select id="quality"> | |
| <option value="standard">Standard (1K)</option> | |
| <option value="medium">Medium (2K)</option> | |
| <option value="hd">HD (4K)</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div id="imageInputs" class="hidden"> | |
| <div class="row form-group"> | |
| <div> | |
| <label>Image 1</label> | |
| <div class="dropzone" id="drop1"> | |
| <input type="file" accept="image/*" /> | |
| <div class="dropzone-icon">🖼</div> | |
| <p class="dropzone-text">Drop image or <strong>browse</strong></p> | |
| </div> | |
| </div> | |
| <div> | |
| <label>Image 2 (optional)</label> | |
| <div class="dropzone" id="drop2"> | |
| <input type="file" accept="image/*" /> | |
| <div class="dropzone-icon">🖼</div> | |
| <p class="dropzone-text">Drop image or <strong>browse</strong></p> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="i2i-settings-row" id="i2iSettings"> | |
| <div class="form-group"> | |
| <label>Aspect Ratio (override)</label> | |
| <select id="i2iAspectRatio"> | |
| <option value="">Auto (from image)</option> | |
| <option value="1:1">1:1 — Square</option> | |
| <option value="16:9">16:9 — Widescreen</option> | |
| <option value="9:16">9:16 — Vertical</option> | |
| <option value="4:3">4:3 — Traditional</option> | |
| <option value="3:4">3:4 — Portrait</option> | |
| <option value="3:2">3:2 — DSLR</option> | |
| <option value="2:3">2:3 — Portrait Photo</option> | |
| <option value="21:9">21:9 — Ultra-wide</option> | |
| <option value="5:4">5:4 — Large Format</option> | |
| <option value="4:5">4:5 — Social Media</option> | |
| </select> | |
| </div> | |
| <div class="form-group"> | |
| <label>Quality</label> | |
| <select id="i2iQuality"> | |
| <option value="standard">Standard (1K)</option> | |
| <option value="medium">Medium (2K)</option> | |
| <option value="hd">HD (4K)</option> | |
| </select> | |
| </div> | |
| <div class="form-group"> | |
| <label>Style (optional)</label> | |
| <input type="text" id="i2iStyle" placeholder="e.g. watercolor, cyberpunk" /> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="form-group"> | |
| <label>Prompt</label> | |
| <textarea id="prompt" placeholder="Describe the image you want to generate..."></textarea> | |
| </div> | |
| <div class="enhance-row"> | |
| <label class="toggle"> | |
| <input type="checkbox" id="enhanceToggle" checked /> | |
| <span class="toggle-slider"></span> | |
| </label> | |
| <span class="enhance-label" onclick="document.getElementById('enhanceToggle').click()">Enhance prompt with AI</span> | |
| <select id="enhanceModel" class="enhance-model-select"> | |
| <option value="gemini-3-flash-preview">Gemini 3 Flash</option> | |
| <option value="gemini-2.5-flash">Gemini 2.5 Flash</option> | |
| <option value="gemini-2.0-flash">Gemini 2.0 Flash</option> | |
| </select> | |
| </div> | |
| <div class="enhanced-preview" id="enhancedPreview"> | |
| <div class="enhanced-preview-header">Enhanced Prompt</div> | |
| <div class="enhanced-preview-text" id="enhancedText"></div> | |
| </div> | |
| <div class="btn-row"> | |
| <button class="generate-btn" id="generateBtn">Generate</button> | |
| <button class="queue-add-btn" id="queueAddBtn">+ Add to Queue</button> | |
| </div> | |
| </div> | |
| <div class="status" id="status"></div> | |
| <div class="panel result-panel" id="resultPanel"> | |
| <img class="result-image" id="resultImage" /> | |
| <div class="result-meta" id="resultMeta"></div> | |
| <div class="result-actions"> | |
| <button class="download-btn" id="downloadBtn">Download</button> | |
| <button class="copy-btn" id="copyBtn">Copy Base64</button> | |
| </div> | |
| </div> | |
| <div class="panel queue-panel hidden" id="queuePanel"> | |
| <h3>Queue <span class="queue-badge" id="queueBadge">0</span></h3> | |
| <div class="queue-list" id="queueList"></div> | |
| <div class="queue-actions"> | |
| <button class="queue-run-btn" id="queueRunBtn">Run Queue</button> | |
| <button class="queue-clear-btn" id="queueClearBtn">Clear</button> | |
| </div> | |
| </div> | |
| <div class="panel hidden" id="galleryPanel"> | |
| <h3 style="font-size:14px;font-weight:600;margin-bottom:16px;">Results</h3> | |
| <div class="results-gallery" id="resultsGallery"></div> | |
| </div> | |
| </div> | |
| <div class="lightbox" id="lightbox"> | |
| <button class="lightbox-close" onclick="closeLightbox()">×</button> | |
| <img id="lightboxImg" /> | |
| <div class="lightbox-actions"> | |
| <button class="download-btn" id="lightboxDownload">Download</button> | |
| <button class="copy-btn" id="lightboxCopy">Copy Base64</button> | |
| </div> | |
| </div> | |
| <script> | |
| const STORAGE_KEY = 'gemini-studio-settings'; | |
| let currentMode = 't2i'; | |
| let images = [null, null]; | |
| let imageFiles = [null, null]; | |
| let lastResultB64 = null; | |
| let queue = []; | |
| let queueRunning = false; | |
| const $ = (s) => document.querySelector(s); | |
| const $$ = (s) => document.querySelectorAll(s); | |
| async function safeParseJSON(res) { | |
| const text = await res.text(); | |
| try { | |
| return JSON.parse(text); | |
| } catch (_) { | |
| throw new Error(text || ('HTTP ' + res.status)); | |
| } | |
| } | |
| // Persist settings in localStorage | |
| function loadSettings() { | |
| try { | |
| var saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); | |
| if (saved.apiBase) $('#apiBase').value = saved.apiBase; | |
| if (saved.apiKey) $('#apiKey').value = saved.apiKey; | |
| if (saved.model) $('#model').value = saved.model; | |
| if (saved.aspectRatio) $('#aspectRatio').value = saved.aspectRatio; | |
| if (saved.quality) $('#quality').value = saved.quality; | |
| if (saved.enhanceModel) $('#enhanceModel').value = saved.enhanceModel; | |
| if (saved.enhanceEnabled !== undefined) $('#enhanceToggle').checked = saved.enhanceEnabled; | |
| } catch (_) {} | |
| } | |
| function saveSettings() { | |
| try { | |
| localStorage.setItem(STORAGE_KEY, JSON.stringify({ | |
| apiBase: $('#apiBase').value, | |
| apiKey: $('#apiKey').value, | |
| model: $('#model').value, | |
| aspectRatio: $('#aspectRatio').value, | |
| quality: $('#quality').value, | |
| enhanceModel: $('#enhanceModel').value, | |
| enhanceEnabled: $('#enhanceToggle').checked | |
| })); | |
| var indicator = $('#saveIndicator'); | |
| indicator.classList.add('visible'); | |
| setTimeout(function() { indicator.classList.remove('visible'); }, 1500); | |
| } catch (_) {} | |
| } | |
| loadSettings(); | |
| // Auto-save on change | |
| ['apiBase', 'apiKey', 'model', 'aspectRatio', 'quality', 'enhanceModel'].forEach(function(id) { | |
| $('#' + id).addEventListener('change', saveSettings); | |
| $('#' + id).addEventListener('input', saveSettings); | |
| }); | |
| $('#enhanceToggle').addEventListener('change', saveSettings); | |
| // Toggle API key visibility | |
| $('#toggleKeyVis').addEventListener('click', function() { | |
| var input = $('#apiKey'); | |
| input.type = input.type === 'password' ? 'text' : 'password'; | |
| }); | |
| function getApiBase() { | |
| var base = $('#apiBase').value.trim(); | |
| if (!base) throw new Error('API Base URL is required. Enter your OpenAI-compatible API endpoint.'); | |
| return base.replace(/\/+$/, ''); | |
| } | |
| const ENHANCE_SYSTEM_PROMPTS = { | |
| pro: `You are an expert image generation prompt engineer for Gemini Pro Image models. | |
| Transform the user's basic prompt into a highly detailed, vivid prompt optimized for Gemini Pro image generation. | |
| Rules: | |
| - Output ONLY the enhanced prompt text. No explanations, no labels, no markdown. | |
| - Keep the user's core intent and subject intact. | |
| - Add rich visual details: lighting, composition, camera angle, color palette, atmosphere, textures, materials. | |
| - Specify art style or photographic style if not provided (cinematic photography, digital art, oil painting, etc). | |
| - Add depth: foreground, midground, background elements where appropriate. | |
| - Include quality boosters: highly detailed, professional, 8K resolution, masterful composition. | |
| - Pro models handle complexity well — add multiple visual layers and nuanced descriptions. | |
| - Keep under 300 words. | |
| - No negative prompts, only positive description.`, | |
| flash: `You are an image generation prompt engineer for Gemini Flash Image models. | |
| Transform the user's basic prompt into a clear, effective prompt optimized for Flash image generation. | |
| Rules: | |
| - Output ONLY the enhanced prompt text. No explanations, no labels, no markdown. | |
| - Keep the user's core intent and subject intact. | |
| - Be concise but descriptive — Flash models work best with clear, focused prompts. | |
| - Add key visual details: main subject, style, lighting, and color mood. | |
| - Avoid overly complex multi-layered scenes — keep composition focused. | |
| - Include a clear art/photo style reference. | |
| - Keep under 150 words. | |
| - No negative prompts, only positive description.`, | |
| nano: `You are an image generation prompt engineer for Gemini Nano Image models. | |
| Transform the user's basic prompt into a simple, direct prompt optimized for Nano image generation. | |
| Rules: | |
| - Output ONLY the enhanced prompt text. No explanations, no labels, no markdown. | |
| - Keep the user's core intent and subject intact. | |
| - Be SHORT and DIRECT — Nano models work best with simple, unambiguous prompts. | |
| - Focus on: main subject, one clear style, basic mood/lighting. | |
| - Avoid complex compositions, multiple subjects, or layered descriptions. | |
| - One sentence for subject, one for style. | |
| - Keep under 80 words. | |
| - No negative prompts.` | |
| }; | |
| function getModelTier(modelName) { | |
| if (modelName.includes('nano')) return 'nano'; | |
| if (modelName.includes('flash')) return 'flash'; | |
| return 'pro'; | |
| } | |
| // Mode toggle | |
| $$('.mode-btn').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| $$('.mode-btn').forEach(b => b.classList.remove('active')); | |
| btn.classList.add('active'); | |
| currentMode = btn.dataset.mode; | |
| $('#imageInputs').classList.toggle('hidden', currentMode === 't2i'); | |
| $('#aspectGroup').classList.toggle('hidden', currentMode === 'i2i'); | |
| $('#qualityGroup').classList.toggle('hidden', currentMode === 'i2i'); | |
| }); | |
| }); | |
| // Dropzone | |
| function setupDropzone(dropEl, index) { | |
| const fileInput = dropEl.querySelector('input[type="file"]'); | |
| ['dragenter', 'dragover'].forEach(e => | |
| dropEl.addEventListener(e, (ev) => { ev.preventDefault(); dropEl.classList.add('dragover'); }) | |
| ); | |
| ['dragleave', 'drop'].forEach(e => | |
| dropEl.addEventListener(e, () => dropEl.classList.remove('dragover')) | |
| ); | |
| dropEl.addEventListener('drop', (ev) => { | |
| ev.preventDefault(); | |
| if (ev.dataTransfer.files.length) loadFile(ev.dataTransfer.files[0], dropEl, index); | |
| }); | |
| fileInput.addEventListener('change', (ev) => { | |
| if (ev.target.files.length) loadFile(ev.target.files[0], dropEl, index); | |
| }); | |
| } | |
| function loadFile(file, dropEl, index) { | |
| imageFiles[index] = file; | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| const b64 = e.target.result; | |
| images[index] = b64; | |
| dropEl.classList.add('has-image'); | |
| dropEl.innerHTML = | |
| '<button class="remove-btn" onclick="removeImage(' + index + ', this.parentElement)">×</button>' + | |
| '<img class="preview" src="' + b64 + '" />' + | |
| '<input type="file" accept="image/*" />'; | |
| setupDropzone(dropEl, index); | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| window.removeImage = function(index, dropEl) { | |
| images[index] = null; | |
| imageFiles[index] = null; | |
| dropEl.classList.remove('has-image'); | |
| dropEl.innerHTML = | |
| '<input type="file" accept="image/*" />' + | |
| '<div class="dropzone-icon">🖼</div>' + | |
| '<p class="dropzone-text">Drop image or <strong>browse</strong></p>'; | |
| setupDropzone(dropEl, index); | |
| }; | |
| setupDropzone($('#drop1'), 0); | |
| setupDropzone($('#drop2'), 1); | |
| // Status | |
| function showStatus(msg, isError) { | |
| const el = $('#status'); | |
| el.className = 'status visible' + (isError ? ' error' : ''); | |
| el.innerHTML = isError ? msg : '<span class="spinner"></span>' + msg; | |
| } | |
| function hideStatus() { $('#status').className = 'status'; } | |
| // Prompt Enhancement | |
| async function enhancePrompt(userPrompt, imageModel, imgs) { | |
| const apiBase = getApiBase(); | |
| const tier = getModelTier(imageModel); | |
| const systemPrompt = ENHANCE_SYSTEM_PROMPTS[tier]; | |
| const enhanceModel = $('#enhanceModel').value; | |
| var userContent; | |
| var hasImages = imgs && imgs.filter(Boolean).length > 0; | |
| if (hasImages) { | |
| userContent = []; | |
| for (var i = 0; i < imgs.length; i++) { | |
| if (imgs[i]) userContent.push({ type: 'image_url', image_url: { url: imgs[i] } }); | |
| } | |
| userContent.push({ type: 'text', text: userPrompt }); | |
| } else { | |
| userContent = userPrompt; | |
| } | |
| const res = await fetch(apiBase + '/chat/completions', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': 'Bearer ' + $('#apiKey').value | |
| }, | |
| body: JSON.stringify({ | |
| model: enhanceModel, | |
| messages: [ | |
| { role: 'system', content: systemPrompt }, | |
| { role: 'user', content: userContent } | |
| ], | |
| temperature: 0.7, | |
| max_tokens: 1024 | |
| }) | |
| }); | |
| const data = await safeParseJSON(res); | |
| if (data.error) throw new Error('Enhancement failed: ' + (data.error.message || JSON.stringify(data.error))); | |
| const enhanced = data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content; | |
| if (!enhanced) throw new Error('Empty enhancement response'); | |
| return enhanced.trim(); | |
| } | |
| // Text-to-Image: POST /v1/images/generations (JSON) | |
| async function generateTextToImage(prompt, model, aspectRatio, quality) { | |
| const apiBase = getApiBase(); | |
| const res = await fetch(apiBase + '/images/generations', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': 'Bearer ' + $('#apiKey').value | |
| }, | |
| body: JSON.stringify({ | |
| model: model, | |
| prompt: prompt, | |
| n: 1, | |
| response_format: 'b64_json', | |
| size: aspectRatio, | |
| quality: quality | |
| }) | |
| }); | |
| const data = await safeParseJSON(res); | |
| if (data.error) throw new Error(data.error.message || JSON.stringify(data.error)); | |
| if (!data.data || !data.data[0] || !data.data[0].b64_json) throw new Error('No image in response'); | |
| return data.data[0].b64_json; | |
| } | |
| // Image-to-Image: POST /v1/images/edits (multipart/form-data) | |
| async function generateImageToImage(prompt, model, files, aspectRatio, quality, style) { | |
| const apiBase = getApiBase(); | |
| const formData = new FormData(); | |
| formData.append('prompt', prompt); | |
| formData.append('model', model); | |
| formData.append('n', '1'); | |
| formData.append('response_format', 'b64_json'); | |
| // Append image files | |
| for (var i = 0; i < files.length; i++) { | |
| if (files[i]) { | |
| formData.append('image' + (i + 1), files[i], files[i].name); | |
| } | |
| } | |
| // Also send first image as "image" for OpenAI compat | |
| if (files[0]) { | |
| formData.append('image', files[0], files[0].name); | |
| } | |
| if (aspectRatio) formData.append('aspect_ratio', aspectRatio); | |
| if (quality) formData.append('image_size', quality === 'hd' ? '4K' : quality === 'medium' ? '2K' : '1K'); | |
| if (style) formData.append('style', style); | |
| const res = await fetch(apiBase + '/images/edits', { | |
| method: 'POST', | |
| headers: { | |
| 'Authorization': 'Bearer ' + $('#apiKey').value | |
| }, | |
| body: formData | |
| }); | |
| const data = await res.json(); | |
| if (data.error) throw new Error(data.error.message || JSON.stringify(data.error)); | |
| if (data.data && data.data[0] && data.data[0].b64_json) { | |
| return data.data[0].b64_json; | |
| } | |
| // Fallback: try extracting from choices (chat completions compat) | |
| var rc = data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content; | |
| if (rc) { | |
| var match = rc.match(/data:image\/[a-z]+;base64,([A-Za-z0-9+\/=]+)/); | |
| if (match) return match[1]; | |
| } | |
| throw new Error('No image found in response'); | |
| } | |
| // Full pipeline | |
| async function runGeneration(job) { | |
| var finalPrompt = job.prompt; | |
| if (job.enhance) { | |
| showStatus('Enhancing prompt with ' + $('#enhanceModel').value + '...'); | |
| finalPrompt = await enhancePrompt(job.prompt, job.model, job.mode === 'i2i' ? job.imgs : null); | |
| $('#enhancedText').textContent = finalPrompt; | |
| $('#enhancedPreview').classList.add('visible'); | |
| } | |
| showStatus(job.mode === 't2i' ? 'Generating image...' : 'Generating from images...'); | |
| if (job.mode === 't2i') { | |
| return await generateTextToImage(finalPrompt, job.model, job.aspectRatio, job.quality); | |
| } else { | |
| return await generateImageToImage(finalPrompt, job.model, job.files, job.i2iAspectRatio, job.i2iQuality, job.i2iStyle); | |
| } | |
| } | |
| function getCurrentJob() { | |
| var prompt = $('#prompt').value.trim(); | |
| if (!prompt) throw new Error('Prompt is required'); | |
| if (!$('#apiKey').value.trim()) throw new Error('API Key is required'); | |
| getApiBase(); | |
| if (currentMode === 'i2i' && !imageFiles[0]) throw new Error('At least one image is required'); | |
| return { | |
| prompt: prompt, | |
| model: $('#model').value, | |
| aspectRatio: $('#aspectRatio').value, | |
| quality: $('#quality').value, | |
| mode: currentMode, | |
| imgs: [images[0], images[1]], | |
| files: [imageFiles[0], imageFiles[1]], | |
| i2iAspectRatio: $('#i2iAspectRatio').value, | |
| i2iQuality: $('#i2iQuality').value, | |
| i2iStyle: $('#i2iStyle').value.trim(), | |
| enhance: $('#enhanceToggle').checked | |
| }; | |
| } | |
| // Single generate | |
| function showResult(b64Data) { | |
| lastResultB64 = b64Data; | |
| $('#resultImage').src = 'data:image/png;base64,' + b64Data; | |
| $('#resultPanel').classList.add('visible'); | |
| var sizeKB = Math.round((b64Data.length * 3) / 4 / 1024); | |
| $('#resultMeta').textContent = '~' + sizeKB + ' KB'; | |
| $('#resultImage').onload = function() { | |
| $('#resultMeta').textContent = this.naturalWidth + 'x' + this.naturalHeight + ' · ~' + sizeKB + ' KB'; | |
| }; | |
| } | |
| $('#generateBtn').addEventListener('click', async () => { | |
| var btn = $('#generateBtn'); | |
| btn.disabled = true; | |
| btn.textContent = 'Generating...'; | |
| $('#resultPanel').classList.remove('visible'); | |
| $('#enhancedPreview').classList.remove('visible'); | |
| try { | |
| var job = getCurrentJob(); | |
| var b64 = await runGeneration(job); | |
| hideStatus(); | |
| showResult(b64); | |
| } catch (err) { | |
| showStatus(err.message, true); | |
| } finally { | |
| btn.disabled = false; | |
| btn.textContent = 'Generate'; | |
| } | |
| }); | |
| $('#downloadBtn').addEventListener('click', () => { if (lastResultB64) downloadB64(lastResultB64); }); | |
| $('#copyBtn').addEventListener('click', () => { if (lastResultB64) copyB64(lastResultB64, $('#copyBtn')); }); | |
| function downloadB64(b64) { | |
| var a = document.createElement('a'); | |
| a.href = 'data:image/png;base64,' + b64; | |
| a.download = 'gemini-' + Date.now() + '.png'; | |
| a.click(); | |
| } | |
| function copyB64(b64, btn) { | |
| navigator.clipboard.writeText(b64).then(() => { | |
| var orig = btn.textContent; | |
| btn.textContent = 'Copied!'; | |
| setTimeout(() => { btn.textContent = orig; }, 2000); | |
| }); | |
| } | |
| // Queue | |
| function escHtml(s) { | |
| return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); | |
| } | |
| $('#queueAddBtn').addEventListener('click', () => { | |
| try { | |
| var job = getCurrentJob(); | |
| job.id = Date.now() + Math.random(); | |
| job.status = 'pending'; | |
| queue.push(job); | |
| renderQueue(); | |
| } catch (err) { | |
| showStatus(err.message, true); | |
| } | |
| }); | |
| function renderQueue() { | |
| var panel = $('#queuePanel'); | |
| var list = $('#queueList'); | |
| if (queue.length === 0) { panel.classList.add('hidden'); return; } | |
| panel.classList.remove('hidden'); | |
| $('#queueBadge').textContent = queue.length; | |
| var html = ''; | |
| for (var i = 0; i < queue.length; i++) { | |
| var job = queue[i]; | |
| var statusLabel = job.status === 'pending' ? 'Pending' : job.status === 'running' ? 'Running...' : job.status === 'done' ? 'Done' : 'Failed'; | |
| var modelShort = job.model.includes('pro') ? 'Pro' : 'Flash'; | |
| var removeBtn = job.status === 'pending' ? '<button class="queue-item-remove" onclick="removeQueueItem(' + i + ')">×</button>' : ''; | |
| var metaStr = job.mode.toUpperCase() + ' · ' + modelShort + ' · ' + (job.enhance ? 'Enhanced' : 'Raw'); | |
| if (job.mode === 't2i') { | |
| metaStr += ' · ' + job.aspectRatio + ' · ' + job.quality; | |
| } | |
| html += '<div class="queue-item ' + job.status + '">' + | |
| '<span class="queue-item-num">#' + (i + 1) + '</span>' + | |
| '<div class="queue-item-info">' + | |
| '<div class="queue-item-prompt">' + escHtml(job.prompt) + '</div>' + | |
| '<div class="queue-item-meta">' + metaStr + '</div>' + | |
| '</div>' + | |
| '<span class="queue-item-status ' + job.status + '">' + statusLabel + '</span>' + | |
| removeBtn + | |
| '</div>'; | |
| } | |
| list.innerHTML = html; | |
| } | |
| window.removeQueueItem = function(i) { | |
| if (queue[i] && queue[i].status === 'pending') { | |
| queue.splice(i, 1); | |
| renderQueue(); | |
| } | |
| }; | |
| $('#queueClearBtn').addEventListener('click', () => { | |
| queue = queue.filter(j => j.status === 'running'); | |
| renderQueue(); | |
| if (queue.length === 0) $('#queuePanel').classList.add('hidden'); | |
| }); | |
| $('#queueRunBtn').addEventListener('click', runQueue); | |
| async function runQueue() { | |
| if (queueRunning) return; | |
| queueRunning = true; | |
| $('#queueRunBtn').disabled = true; | |
| $('#queueAddBtn').disabled = true; | |
| $('#generateBtn').disabled = true; | |
| var gallery = $('#resultsGallery'); | |
| $('#galleryPanel').classList.remove('hidden'); | |
| for (var i = 0; i < queue.length; i++) { | |
| var job = queue[i]; | |
| if (job.status !== 'pending') continue; | |
| job.status = 'running'; | |
| renderQueue(); | |
| try { | |
| showStatus('Queue ' + (i + 1) + '/' + queue.length + ': ' + (job.enhance ? 'Enhancing & generating...' : 'Generating...')); | |
| var b64 = await runGeneration(job); | |
| job.status = 'done'; | |
| job.result = b64; | |
| var item = document.createElement('div'); | |
| item.className = 'gallery-item'; | |
| item.innerHTML = '<img src="data:image/png;base64,' + b64 + '" />' + | |
| '<div class="gallery-item-info">' + escHtml(job.prompt.substring(0, 60)) + '</div>'; | |
| (function(capturedB64) { | |
| item.addEventListener('click', function() { openLightbox(capturedB64); }); | |
| })(b64); | |
| gallery.appendChild(item); | |
| } catch (err) { | |
| job.status = 'failed'; | |
| job.error = err.message; | |
| } | |
| renderQueue(); | |
| } | |
| hideStatus(); | |
| queueRunning = false; | |
| $('#queueRunBtn').disabled = false; | |
| $('#queueAddBtn').disabled = false; | |
| $('#generateBtn').disabled = false; | |
| } | |
| // Lightbox | |
| var lightboxB64 = null; | |
| function openLightbox(b64) { | |
| lightboxB64 = b64; | |
| $('#lightboxImg').src = 'data:image/png;base64,' + b64; | |
| $('#lightbox').classList.add('visible'); | |
| } | |
| window.closeLightbox = function() { $('#lightbox').classList.remove('visible'); }; | |
| $('#lightbox').addEventListener('click', function(e) { | |
| if (e.target === $('#lightbox')) closeLightbox(); | |
| }); | |
| $('#lightboxDownload').addEventListener('click', () => { if (lightboxB64) downloadB64(lightboxB64); }); | |
| $('#lightboxCopy').addEventListener('click', () => { if (lightboxB64) copyB64(lightboxB64, $('#lightboxCopy')); }); | |
| document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeLightbox(); }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment