| <!DOCTYPE html> |
| <html lang="ko"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Local Image Studio · SD-Turbo</title> |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"> |
| <script src="https://unpkg.com/lucide@latest"></script> |
| <style> |
| :root { |
| --bg-color: #0f172a; |
| --card-bg: rgba(30, 41, 59, 0.7); |
| --accent: #3b82f6; |
| --accent-2: #a78bfa; |
| --text-color: #f8fafc; |
| --muted: #94a3b8; |
| --glass-border: rgba(255, 255, 255, 0.1); |
| --danger: #ef4444; |
| } |
| |
| * { box-sizing: border-box; } |
| |
| body { |
| margin: 0; |
| font-family: 'Inter', sans-serif; |
| background: var(--bg-color); |
| background-image: |
| radial-gradient(at 0% 0%, rgba(59, 130, 246, 0.15) 0px, transparent 50%), |
| radial-gradient(at 100% 100%, rgba(139, 92, 246, 0.15) 0px, transparent 50%); |
| color: var(--text-color); |
| min-height: 100vh; |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| } |
| |
| header { |
| width: 100%; |
| max-width: 1000px; |
| padding: 20px 24px; |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| backdrop-filter: blur(12px); |
| border-bottom: 1px solid var(--glass-border); |
| } |
| |
| .logo-container { display: flex; align-items: center; gap: 12px; } |
| .logo { |
| font-size: 1.4rem; |
| font-weight: 700; |
| background: linear-gradient(to right, #60a5fa, #a78bfa); |
| -webkit-background-clip: text; |
| -webkit-text-fill-color: transparent; |
| } |
| |
| .badge { |
| font-size: 0.75rem; |
| font-weight: 500; |
| padding: 6px 12px; |
| border-radius: 999px; |
| background: rgba(59, 130, 246, 0.15); |
| border: 1px solid var(--glass-border); |
| color: var(--muted); |
| } |
| |
| main { |
| flex: 1; |
| width: 100%; |
| max-width: 1000px; |
| padding: 24px; |
| display: flex; |
| flex-direction: column; |
| gap: 20px; |
| } |
| |
| .preview { |
| background: var(--card-bg); |
| border: 1px solid var(--glass-border); |
| border-radius: 24px; |
| backdrop-filter: blur(12px); |
| min-height: 520px; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| overflow: hidden; |
| position: relative; |
| box-shadow: 0 10px 30px rgba(0,0,0,0.3); |
| } |
| |
| .preview img { |
| max-width: 100%; |
| max-height: 720px; |
| border-radius: 16px; |
| animation: fadeIn 0.4s cubic-bezier(0.16, 1, 0.3, 1); |
| } |
| |
| .preview .empty { |
| color: var(--muted); |
| text-align: center; |
| padding: 40px; |
| } |
| |
| .preview .empty .icon-wrap { |
| width: 64px; |
| height: 64px; |
| border-radius: 50%; |
| background: rgba(59, 130, 246, 0.15); |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| margin: 0 auto 16px; |
| } |
| |
| .loader { |
| display: none; |
| flex-direction: column; |
| align-items: center; |
| gap: 16px; |
| } |
| |
| .spinner { |
| width: 40px; |
| height: 40px; |
| border: 3px solid rgba(255,255,255,0.1); |
| border-top-color: var(--accent); |
| border-radius: 50%; |
| animation: spin 0.8s linear infinite; |
| } |
| |
| @keyframes spin { to { transform: rotate(360deg); } } |
| @keyframes fadeIn { from { opacity: 0; transform: scale(0.98); } to { opacity: 1; transform: scale(1); } } |
| |
| .controls-row { |
| display: flex; |
| gap: 16px; |
| flex-wrap: wrap; |
| } |
| |
| .control { |
| flex: 1; |
| min-width: 180px; |
| background: var(--card-bg); |
| border: 1px solid var(--glass-border); |
| border-radius: 16px; |
| padding: 14px 18px; |
| backdrop-filter: blur(12px); |
| } |
| |
| .control label { |
| font-size: 0.75rem; |
| color: var(--muted); |
| text-transform: uppercase; |
| letter-spacing: 0.05em; |
| display: block; |
| margin-bottom: 6px; |
| } |
| |
| .control .value { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| gap: 12px; |
| } |
| |
| .control input[type="range"] { |
| flex: 1; |
| accent-color: var(--accent); |
| } |
| |
| .control input[type="number"] { |
| background: rgba(15, 23, 42, 0.8); |
| color: white; |
| border: 1px solid var(--glass-border); |
| border-radius: 8px; |
| padding: 6px 10px; |
| font-family: 'Inter', sans-serif; |
| width: 100%; |
| outline: none; |
| } |
| |
| .control input[type="number"]:focus { |
| border-color: var(--accent); |
| } |
| |
| .control .num { |
| font-weight: 600; |
| color: var(--text-color); |
| min-width: 24px; |
| text-align: right; |
| } |
| |
| .prompt-wrapper { |
| display: flex; |
| background: var(--card-bg); |
| border: 1px solid var(--glass-border); |
| border-radius: 20px; |
| padding: 8px 8px 8px 16px; |
| backdrop-filter: blur(12px); |
| box-shadow: 0 10px 30px rgba(0,0,0,0.3); |
| transition: border-color 0.2s, box-shadow 0.2s; |
| align-items: flex-end; |
| gap: 8px; |
| } |
| |
| .prompt-wrapper:focus-within { |
| border-color: var(--accent); |
| box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2); |
| } |
| |
| textarea { |
| flex: 1; |
| background: transparent; |
| border: none; |
| color: white; |
| padding: 12px 4px; |
| font-size: 1rem; |
| font-family: 'Inter', sans-serif; |
| outline: none; |
| resize: none; |
| min-height: 24px; |
| max-height: 160px; |
| } |
| |
| textarea::placeholder { color: #64748b; } |
| |
| button.send { |
| background: var(--accent); |
| color: white; |
| border: none; |
| border-radius: 14px; |
| width: 48px; |
| height: 48px; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| cursor: pointer; |
| transition: all 0.2s; |
| flex-shrink: 0; |
| } |
| |
| button.send:hover:not(:disabled) { |
| transform: scale(1.05); |
| box-shadow: 0 0 15px rgba(59, 130, 246, 0.6); |
| } |
| |
| button.send:disabled { |
| background: #475569; |
| cursor: not-allowed; |
| opacity: 0.5; |
| } |
| |
| .error { |
| color: var(--danger); |
| font-size: 0.9rem; |
| padding: 12px 16px; |
| background: rgba(239, 68, 68, 0.1); |
| border: 1px solid rgba(239, 68, 68, 0.3); |
| border-radius: 12px; |
| display: none; |
| } |
| |
| .meta { |
| font-size: 0.8rem; |
| color: var(--muted); |
| text-align: center; |
| padding-bottom: 24px; |
| } |
| </style> |
| </head> |
| <body> |
| <header> |
| <div class="logo-container"> |
| <i data-lucide="sparkles" style="color: #60a5fa;"></i> |
| <div class="logo">Local Image Studio</div> |
| </div> |
| <div class="badge" id="model-badge">백엔드 URL 설정 필요</div> |
| </header> |
|
|
| <main> |
| <div class="preview" id="preview"> |
| <div class="empty" id="empty-state"> |
| <div class="icon-wrap"><i data-lucide="image" color="#60a5fa" size="28"></i></div> |
| <div>프롬프트를 입력하면 여기에 이미지가 생성됩니다</div> |
| </div> |
| <div class="loader" id="loader"> |
| <div class="spinner"></div> |
| <div style="color: var(--muted);">생성 중...</div> |
| </div> |
| </div> |
|
|
| <div class="error" id="error"></div> |
|
|
| <div class="controls-row"> |
| <div class="control" style="flex: 2; min-width: 280px;"> |
| <label>Backend URL (로컬 서버 + Cloudflare Tunnel 등)</label> |
| <input type="text" id="backend-url" placeholder="https://xxxxx.trycloudflare.com" style="width: 100%; background: rgba(15, 23, 42, 0.8); color: white; border: 1px solid var(--glass-border); border-radius: 8px; padding: 6px 10px; font-family: 'Inter', sans-serif; outline: none;"> |
| </div> |
| </div> |
|
|
| <div class="controls-row"> |
| <div class="control"> |
| <label>Steps</label> |
| <div class="value"> |
| <input type="range" id="steps" min="1" max="8" value="2"> |
| <div class="num" id="steps-num">2</div> |
| </div> |
| </div> |
| <div class="control"> |
| <label>Seed (비워두면 랜덤)</label> |
| <input type="number" id="seed" placeholder="예: 42"> |
| </div> |
| </div> |
|
|
| <form class="prompt-wrapper" id="gen-form"> |
| <textarea id="prompt" placeholder="예: a cinematic photo of a fox in autumn forest, golden hour, 35mm" required></textarea> |
| <button type="submit" class="send" id="send-btn"> |
| <i data-lucide="send" size="20"></i> |
| </button> |
| </form> |
|
|
| <div class="meta" id="meta"></div> |
| </main> |
|
|
| <script> |
| lucide.createIcons(); |
| |
| const preview = document.getElementById('preview'); |
| const emptyState = document.getElementById('empty-state'); |
| const loader = document.getElementById('loader'); |
| const errorBox = document.getElementById('error'); |
| const form = document.getElementById('gen-form'); |
| const promptInput = document.getElementById('prompt'); |
| const sendBtn = document.getElementById('send-btn'); |
| const stepsInput = document.getElementById('steps'); |
| const stepsNum = document.getElementById('steps-num'); |
| const seedInput = document.getElementById('seed'); |
| const badge = document.getElementById('model-badge'); |
| const meta = document.getElementById('meta'); |
| const backendInput = document.getElementById('backend-url'); |
| |
| const BACKEND_KEY = 'sdxl_backend_url'; |
| |
| function getBackend() { |
| const saved = localStorage.getItem(BACKEND_KEY) || ''; |
| return saved.replace(/\/+$/, ''); |
| } |
| |
| function api(path) { |
| const base = getBackend(); |
| return base ? base + path : path; |
| } |
| |
| backendInput.value = localStorage.getItem(BACKEND_KEY) || ''; |
| backendInput.addEventListener('change', () => { |
| localStorage.setItem(BACKEND_KEY, backendInput.value.trim()); |
| loadInfo(); |
| }); |
| |
| stepsInput.addEventListener('input', () => { stepsNum.textContent = stepsInput.value; }); |
| |
| promptInput.addEventListener('input', () => { |
| promptInput.style.height = 'auto'; |
| promptInput.style.height = Math.min(promptInput.scrollHeight, 160) + 'px'; |
| }); |
| |
| promptInput.addEventListener('keydown', (e) => { |
| if (e.key === 'Enter' && !e.shiftKey) { |
| e.preventDefault(); |
| form.requestSubmit(); |
| } |
| }); |
| |
| async function loadInfo() { |
| if (!getBackend()) { |
| badge.textContent = '백엔드 URL 설정 필요'; |
| return; |
| } |
| try { |
| const r = await fetch(api('/api/info')); |
| const data = await r.json(); |
| badge.textContent = `${data.model.split('/').pop()} · ${data.device}`; |
| } catch { |
| badge.textContent = '백엔드 연결 실패'; |
| } |
| } |
| |
| function setLoading(on) { |
| sendBtn.disabled = on; |
| emptyState.style.display = on ? 'none' : (preview.querySelector('img') ? 'none' : 'block'); |
| loader.style.display = on ? 'flex' : 'none'; |
| } |
| |
| function showError(msg) { |
| errorBox.textContent = msg; |
| errorBox.style.display = 'block'; |
| } |
| |
| function clearError() { |
| errorBox.style.display = 'none'; |
| } |
| |
| form.addEventListener('submit', async (e) => { |
| e.preventDefault(); |
| const prompt = promptInput.value.trim(); |
| if (!prompt) return; |
| |
| if (!getBackend()) { |
| showError('Backend URL을 먼저 입력하세요 (예: https://xxxxx.trycloudflare.com)'); |
| return; |
| } |
| |
| clearError(); |
| setLoading(true); |
| |
| const body = { |
| prompt, |
| steps: parseInt(stepsInput.value, 10), |
| }; |
| const seed = seedInput.value.trim(); |
| if (seed !== '') body.seed = parseInt(seed, 10); |
| |
| const t0 = performance.now(); |
| try { |
| const r = await fetch(api('/api/generate'), { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify(body), |
| }); |
| if (!r.ok) { |
| const err = await r.json().catch(() => ({})); |
| throw new Error(err.detail || `HTTP ${r.status}`); |
| } |
| const data = await r.json(); |
| const elapsed = ((performance.now() - t0) / 1000).toFixed(1); |
| |
| const oldImg = preview.querySelector('img'); |
| if (oldImg) oldImg.remove(); |
| emptyState.style.display = 'none'; |
| |
| const img = document.createElement('img'); |
| img.src = `data:image/png;base64,${data.image}`; |
| img.alt = data.prompt; |
| preview.appendChild(img); |
| |
| meta.textContent = `${elapsed}s · steps ${data.steps}${data.seed != null ? ` · seed ${data.seed}` : ''}`; |
| } catch (err) { |
| showError(`생성 실패: ${err.message}`); |
| } finally { |
| setLoading(false); |
| } |
| }); |
| |
| loadInfo(); |
| </script> |
| </body> |
| </html> |
|
|