Files
homeclaw/web-ui/html/studio-app.html
T
kimandClaude Sonnet 5 02247e4ee3 feat: 스튜디오 앱 사진→동영상(LTX Image-to-Video)
studio-app.html에 시작 이미지 업로드/제거 UI + strength 슬라이더 추가,
imagegen.ts에 ltxImageToVideoWorkflow (ComfyUI 공식 ltxv_image_to_video.json
템플릿 기반 LoadImage→LTXVImgToVideo→LTXVConditioning 그래프) 추가.
실제 생성 테스트로 프레임0(원본 이미지 일치)·프레임15(모션 발생) 확인 완료된
작업을 커밋만 안 하고 남겨뒀던 것.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 13:13:58 +09:00

1270 lines
70 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🎨 이미지·동영상 스튜디오</title>
<link rel="icon" type="image/png" sizes="64x64" href="/homeclaw_logo.png">
<link rel="stylesheet" href="../css/styles.css">
<script>try{var _t=localStorage.getItem('homeclaw_theme')||'dark';document.documentElement.setAttribute('data-theme',_t);}catch(e){}</script>
<style>
:root{--brand:#a78bfa;}
html,body{height:100%;margin:0;padding:0;}
body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;font-weight:500;display:flex;flex-direction:column;overflow:hidden;height:100dvh;}
.st-hdr{display:flex;align-items:center;gap:8px;padding:6px 12px;background:var(--panel);border-bottom:1px solid var(--line);flex-shrink:0;}
.st-hdr h1{font-size:13px;font-weight:700;margin:0;}
.st-hdr-sp{flex:1;}
.hdr-btn{background:none;border:1.5px solid var(--line);border-radius:6px;padding:3px 9px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;transition:.15s;}
.hdr-btn:hover{border-color:var(--brand);color:var(--brand);}
.st-tabs{display:flex;gap:4px;padding:8px 12px 0;flex-shrink:0;}
.st-tab{background:none;border:none;border-bottom:2px solid transparent;padding:8px 14px;font-size:13px;font-weight:700;color:var(--muted);cursor:pointer;font-family:inherit;}
.st-tab.active{color:var(--brand);border-bottom-color:var(--brand);}
.st-layout{flex:1;display:flex;min-height:0;overflow:hidden;}
.st-comfy-layout{flex:1;display:flex;flex-direction:row;min-height:0;overflow:hidden;}
.st-comfy-note{flex-shrink:0;padding:8px 14px;font-size:12px;color:var(--muted);background:var(--bg-soft);border-bottom:1px solid var(--line);}
.st-comfy-assist{width:280px;flex-shrink:0;display:flex;flex-direction:column;gap:6px;padding:10px;border-left:1px solid var(--line);background:var(--panel-2);overflow:hidden;}
.st-form{width:320px;flex-shrink:0;display:flex;flex-direction:column;gap:10px;padding:14px;border-right:1px solid var(--line);background:var(--panel);overflow-y:auto;}
.st-field{display:flex;flex-direction:column;gap:4px;}
.st-field label{font-size:11px;font-weight:700;color:var(--muted);}
.st-field textarea,.st-field input,.st-field select{background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:7px 8px;font-size:12px;color:var(--text);font-family:inherit;outline:none;resize:vertical;}
.st-field input[type=range]{padding:0;background:none;border:none;accent-color:var(--brand);cursor:pointer;}
.st-field textarea:focus,.st-field input:focus,.st-field select:focus{border-color:var(--brand);}
.st-row{display:flex;gap:8px;}
.st-row .st-field{flex:1;}
.st-gen-btn{background:var(--brand);border:none;border-radius:8px;padding:10px;font-size:13px;font-weight:700;color:#1e1b2e;cursor:pointer;font-family:inherit;margin-top:4px;transition:.15s;}
.st-gen-btn:hover{opacity:.9;}
.st-gen-btn:disabled{opacity:.5;cursor:not-allowed;}
.st-hint{font-size:10px;color:var(--muted);line-height:1.4;}
.st-main{flex:1;display:flex;flex-direction:column;min-width:0;overflow:hidden;}
.st-result{flex:1;display:flex;align-items:center;justify-content:center;padding:20px;overflow:auto;min-height:0;position:relative;}
.st-result img,.st-result video,.st-result-wrap img,.st-result-wrap video{max-width:100%;max-height:100%;object-fit:contain;border-radius:10px;box-shadow:0 4px 24px rgba(0,0,0,.25);transition:transform .15s;}
.st-placeholder{color:var(--muted);font-size:13px;text-align:center;}
.st-spinner{width:32px;height:32px;border:3px solid var(--line);border-top-color:var(--brand);border-radius:50%;animation:st-spin 0.8s linear infinite;margin:0 auto 10px;}
@keyframes st-spin{to{transform:rotate(360deg);}}
.st-status{font-size:12px;color:var(--muted);margin-top:6px;}
.st-progress-track{width:220px;height:6px;border-radius:3px;background:var(--line);margin:10px auto 0;overflow:hidden;}
.st-progress-fill{height:100%;background:var(--brand);border-radius:3px;width:0%;transition:width .4s linear;}
.st-error{color:#ef4444;font-size:12px;}
.st-gallery{flex-shrink:0;border-top:1px solid var(--line);background:var(--panel);padding:8px 12px;max-height:130px;overflow-y:auto;}
.st-gallery-hdr{font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px;display:flex;align-items:center;gap:8px;}
.st-gallery-grid{display:flex;gap:8px;flex-wrap:wrap;}
.st-gitem{position:relative;width:76px;height:76px;border-radius:6px;overflow:hidden;border:1px solid var(--line);cursor:pointer;flex-shrink:0;background:var(--panel-2);}
.st-gitem img,.st-gitem video{width:100%;height:100%;object-fit:cover;}
.st-gitem .st-gtype{position:absolute;bottom:2px;left:2px;font-size:9px;background:rgba(0,0,0,.6);color:#fff;padding:1px 4px;border-radius:3px;}
.st-gitem .st-gchk{position:absolute;top:3px;left:3px;width:15px;height:15px;cursor:pointer;z-index:2;accent-color:var(--brand);}
.st-gitem .st-gdel{position:absolute;top:2px;right:2px;background:rgba(0,0,0,.6);color:#fff;border:none;border-radius:3px;font-size:10px;width:16px;height:16px;line-height:1;cursor:pointer;}
.st-gitem .st-gdl{position:absolute;bottom:2px;right:2px;background:rgba(0,0,0,.6);color:#fff;border-radius:3px;font-size:10px;width:16px;height:16px;line-height:16px;text-align:center;text-decoration:none;}
.st-toolbar{display:flex;align-items:center;gap:6px;text-transform:none;letter-spacing:normal;font-weight:400;}
.st-toolbar label{display:flex;align-items:center;gap:3px;cursor:pointer;}
.st-toolbar input[type=checkbox]{accent-color:var(--brand);cursor:pointer;}
.st-toolbar-sp{flex:1;}
.st-toolbar button{background:none;border:1px solid var(--line);border-radius:5px;padding:2px 7px;font-size:10px;color:var(--muted);cursor:pointer;font-family:inherit;}
.st-toolbar button:hover:not(:disabled){border-color:#ef4444;color:#ef4444;}
.st-toolbar button:disabled{opacity:.4;cursor:not-allowed;}
.st-sel-count{font-size:10px;color:var(--muted);}
.st-dropzone{border:1.5px dashed var(--line);border-radius:8px;padding:14px;text-align:center;font-size:11px;color:var(--muted);cursor:pointer;transition:.15s;}
.st-dropzone:hover{border-color:var(--brand);color:var(--brand);}
.st-dropzone img{max-width:100%;max-height:140px;border-radius:6px;display:block;margin:0 auto;}
.st-result img,.st-result video,.st-result-wrap img,.st-result-wrap video{cursor:zoom-in;}
.st-zoom-modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.9);z-index:1000;align-items:center;justify-content:center;cursor:zoom-out;}
.st-zoom-modal.show{display:flex;}
.st-zoom-modal img,.st-zoom-modal video{max-width:96vw;max-height:96vh;border-radius:8px;box-shadow:0 4px 24px rgba(0,0,0,.5);}
.st-sticker-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:14px;width:100%;padding:4px;}
.st-sticker-tile{position:relative;border:1px solid var(--line);border-radius:10px;overflow:hidden;background:
linear-gradient(45deg,#8886 25%,transparent 25%) 0 0/16px 16px,
linear-gradient(-45deg,#8886 25%,transparent 25%) 0 0/16px 16px,
linear-gradient(45deg,transparent 75%,#8886 75%) 0 0/16px 16px,
linear-gradient(-45deg,transparent 75%,#8886 75%) 0 0/16px 16px,
var(--panel-2);}
.st-sticker-tile img{width:100%;aspect-ratio:1/1;object-fit:contain;display:block;cursor:zoom-in;}
.st-sticker-cap{font-size:11px;text-align:center;padding:5px 4px;color:var(--muted);border-top:1px solid var(--line);background:var(--panel);}
.st-sticker-dl{position:absolute;top:6px;right:6px;background:rgba(0,0,0,.6);color:#fff;border-radius:5px;width:22px;height:22px;line-height:22px;text-align:center;text-decoration:none;font-size:12px;}
.st-result-wrap{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center;}
.st-result-dl{position:absolute;bottom:10px;right:10px;background:rgba(0,0,0,.65);color:#fff;padding:6px 12px;border-radius:20px;font-size:12px;text-decoration:none;backdrop-filter:blur(4px);}
.st-result-dl:hover{background:rgba(0,0,0,.85);}
.st-video-meta{position:absolute;top:10px;left:10px;max-width:calc(100% - 70px);background:rgba(0,0,0,.65);color:#fff;padding:6px 12px;border-radius:12px;font-size:12px;line-height:1.4;white-space:normal;backdrop-filter:blur(4px);z-index:5;}
.st-zoom-ctl{position:absolute;top:10px;right:10px;display:flex;align-items:center;gap:2px;background:rgba(0,0,0,.6);border-radius:20px;padding:3px;backdrop-filter:blur(4px);z-index:5;}
.st-zoom-ctl button{background:none;border:none;color:#fff;font-size:14px;font-family:inherit;cursor:pointer;width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;}
.st-zoom-ctl button:hover{background:rgba(255,255,255,.18);}
.st-zoom-ctl span{color:#fff;font-size:10px;min-width:34px;text-align:center;user-select:none;}
.st-edit-ctl{position:absolute;top:10px;left:10px;display:flex;flex-wrap:wrap;align-items:center;gap:2px;background:rgba(0,0,0,.6);border-radius:16px;padding:3px;backdrop-filter:blur(4px);max-width:calc(100% - 20px);z-index:5;}
.st-edit-ctl button{position:relative;background:none;border:none;color:#fff;font-size:15px;font-family:inherit;cursor:pointer;width:26px;height:26px;border-radius:50%;display:flex;align-items:center;justify-content:center;}
.st-edit-ctl button:hover{background:rgba(255,255,255,.18);}
.st-edit-ctl button.active{background:var(--brand);color:#1e1b2e;}
.st-edit-ctl button:disabled{opacity:.35;cursor:wait;}
/* Native title-attribute tooltips render just below the cursor and get hidden behind it on
these small, closely-packed icon buttons — custom tooltip above the button instead. */
.st-edit-ctl button[data-tip]::after{content:attr(data-tip);position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);background:rgba(0,0,0,.9);color:#fff;font-size:11px;line-height:1;padding:4px 8px;border-radius:5px;white-space:nowrap;opacity:0;pointer-events:none;transition:opacity .12s;z-index:20;}
.st-edit-ctl button[data-tip]:hover::after{opacity:1;}
.st-edit-panel{position:absolute;top:44px;left:10px;background:rgba(20,20,24,.92);border-radius:10px;padding:10px;backdrop-filter:blur(4px);display:none;flex-direction:column;gap:8px;width:220px;z-index:6;color:#fff;font-size:11px;}
.st-edit-panel.show{display:flex;}
.st-edit-panel-row{display:flex;align-items:center;gap:6px;}
.st-edit-panel-row label{flex-shrink:0;width:44px;color:#ccc;}
.st-edit-panel-row input[type=range]{flex:1;accent-color:var(--brand);}
.st-edit-panel-row span{width:28px;text-align:right;color:#ccc;font-size:10px;}
.st-edit-panel .st-filter-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:5px;}
.st-edit-panel .st-filter-grid button{background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.15);border-radius:6px;color:#fff;font-size:10px;padding:6px 2px;cursor:pointer;font-family:inherit;width:auto;height:auto;}
.st-edit-panel .st-filter-grid button:hover{background:rgba(255,255,255,.2);}
.st-edit-panel input[type=text]{background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.2);border-radius:6px;padding:5px 7px;color:#fff;font-size:11px;font-family:inherit;outline:none;}
.st-edit-panel select{background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.2);border-radius:6px;padding:4px 6px;color:#fff;font-size:11px;font-family:inherit;}
.st-edit-panel .st-apply-btn{background:var(--brand);border:none;border-radius:6px;color:#1e1b2e;font-size:11px;font-weight:700;padding:6px;cursor:pointer;font-family:inherit;width:auto;height:auto;}
.st-edit-panel .st-text-mode{display:flex;gap:4px;}
.st-edit-panel .st-text-mode button{flex:1;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.15);border-radius:6px;color:#fff;font-size:10px;padding:5px;cursor:pointer;font-family:inherit;width:auto;height:auto;}
.st-edit-panel .st-text-mode button.active{background:var(--brand);color:#1e1b2e;}
.st-crop-box{position:absolute;border:2px dashed #fff;background:rgba(255,255,255,.1);box-shadow:0 0 0 2000px rgba(0,0,0,.45);cursor:move;touch-action:none;}
.st-crop-hint{position:absolute;bottom:10px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,.7);color:#fff;font-size:11px;padding:6px 12px;border-radius:16px;white-space:nowrap;z-index:6;}
/* ── Mobile ── */
@media(max-width:1024px){
.st-layout{flex-direction:column;}
.st-form{width:100%;max-height:40vh;border-right:none;border-bottom:1px solid var(--line);}
.st-main{width:100%;min-height:0;}
textarea,input,select{font-size:16px !important;}
}
</style>
</head>
<body>
<header class="st-hdr">
<div style="font-weight:700;font-size:13px">🎨 이미지·동영상 스튜디오</div>
<div class="st-hdr-sp"></div>
<button class="hdr-btn" onclick="toggleTheme()">🌓</button>
<button class="hdr-btn" onclick="window.close()">닫기</button>
</header>
<div class="st-tabs">
<button class="st-tab active" id="tab-image" onclick="switchTab('image')">🖼️ 이미지</button>
<button class="st-tab" id="tab-video" onclick="switchTab('video')">🎬 동영상</button>
<button class="st-tab" id="tab-edit" onclick="switchTab('edit')">📷 사진 편집</button>
<button class="st-tab" id="tab-sticker" onclick="switchTab('sticker')">😊 이모티콘 세트</button>
<button class="st-tab" id="tab-comfy" onclick="switchTab('comfy')">🔧 ComfyUI</button>
</div>
<div class="st-comfy-layout" id="comfy-layout" style="display:none">
<div style="display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden">
<div class="st-comfy-note" id="comfy-note">
ComfyUI 원본 화면입니다 — 노드 그래프를 직접 짜서 실행할 수 있습니다.
</div>
<iframe id="comfy-frame" style="width:100%;flex:1;border:none;background:#1a1a1a"></iframe>
</div>
<div class="st-comfy-assist" id="comfy-assist-panel">
<div style="font-size:11px;font-weight:700;color:var(--muted);padding-bottom:4px">💬 프롬프트 도우미</div>
<div id="comfy-assist-messages" style="flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:6px;font-size:12px"></div>
<div style="display:flex;gap:6px;flex-shrink:0">
<input id="comfy-assist-input" placeholder="예: 빨간 팬더가 책 읽는 장면" style="flex:1;font-size:12px" onkeydown="if(event.key==='Enter'){event.preventDefault();sendComfyAssist();}">
<button type="button" class="hdr-btn" style="font-size:11px" onclick="sendComfyAssist()">전송</button>
</div>
<div class="st-hint" style="margin:0">노드 그래프에는 자동으로 안 들어가서, 만든 프롬프트를 복사해서 CLIPTextEncode 노드에 직접 붙여넣으세요.</div>
</div>
</div>
<div class="st-layout">
<div class="st-form">
<div id="common-fields" style="display:contents">
<div class="st-field">
<div style="display:flex;align-items:center;justify-content:space-between">
<label style="margin:0">프롬프트</label>
<button type="button" class="hdr-btn" style="font-size:11px;padding:2px 8px" onclick="togglePromptAssist()">💬 프롬프트 도우미</button>
</div>
<textarea id="f-prompt" rows="4" placeholder="예: a red panda reading a book, cozy lighting"></textarea>
</div>
<div id="prompt-assist-panel" style="display:none;flex-direction:column;gap:6px;border:1px solid var(--border);border-radius:8px;background:var(--panel-2);padding:8px;margin:-4px 0 4px">
<div id="prompt-assist-messages" style="max-height:220px;overflow-y:auto;display:flex;flex-direction:column;gap:6px;font-size:12px"></div>
<div style="display:flex;gap:6px">
<input id="prompt-assist-input" placeholder="예: 빨간 팬더가 책 읽는 장면, 아늑한 조명" style="flex:1;font-size:12px" onkeydown="if(event.key==='Enter'){event.preventDefault();sendPromptAssist();}">
<button type="button" class="hdr-btn" style="font-size:11px" onclick="sendPromptAssist()">전송</button>
</div>
</div>
<div class="st-field">
<label>네거티브 프롬프트 (선택)</label>
<textarea id="f-neg" rows="2" placeholder="blurry, low quality, deformed"></textarea>
</div>
<div class="st-row">
<div class="st-field">
<label>가로</label>
<input type="number" id="f-width" value="1024" step="8">
</div>
<div class="st-field">
<label>세로</label>
<input type="number" id="f-height" value="768" step="8">
</div>
</div>
<div class="st-row">
<div class="st-field">
<label>스텝수 (품질 ↔ 속도)</label>
<input type="number" id="f-steps" value="30" min="10" max="50">
</div>
<div class="st-field">
<label>가이던스 스케일</label>
<input type="number" id="f-guidance" value="7.0" step="0.5">
</div>
</div>
</div>
<div class="st-row" id="row-image-only">
<div class="st-field">
<label>품질</label>
<select id="f-quality" onchange="onQualityChange()">
<option value="fast">빠름 (SDXL · 10~20초)</option>
<option value="high">고품질 (FLUX.1 · 40~60초)</option>
</select>
</div>
<div class="st-field">
<label>시드 (선택)</label>
<input type="number" id="f-seed" placeholder="랜덤">
</div>
</div>
<div id="row-video-only" style="display:none;flex-direction:column;gap:10px;">
<div class="st-field">
<label>모델</label>
<select id="f-video-model" onchange="onVideoModelChange()">
<option value="ltx">LTX-Video (빠름 · 30~90초)</option>
<option value="ltx_hq">LTX-Video 고화질 (dev · 40~150초, VRAM 여유 적음)</option>
<option value="cogvideox">CogVideoX-2B (느림 · 1~3분, VRAM 여유 적음)</option>
</select>
</div>
<div class="st-row">
<div class="st-field">
<label>프레임수</label>
<input type="number" id="f-frames" value="65" step="8">
</div>
<div class="st-field">
<label>FPS</label>
<input type="number" id="f-fps" value="24">
</div>
</div>
<div class="st-field" id="video-image-field">
<label>시작 이미지 (선택 — 사진에서 동영상 만들기)</label>
<input type="file" id="f-video-image" accept="image/*" style="display:none" onchange="onVideoImageSelected(this.files[0])">
<div class="st-dropzone" id="video-image-dropzone" onclick="document.getElementById('f-video-image').click()">사진을 선택하려면 탭하세요 (없으면 텍스트만으로 생성)</div>
<div id="video-image-preview" style="display:none;align-items:center;gap:8px;margin-top:4px">
<img id="video-image-thumb" style="width:48px;height:48px;object-fit:cover;border-radius:6px">
<button type="button" class="hdr-btn" style="font-size:11px" onclick="clearVideoImage()">✕ 제거</button>
</div>
<div class="st-hint" id="video-image-cogvideox-warn" style="display:none;color:#ef4444">CogVideoX는 이미지 시작을 지원하지 않아요 — LTX/LTX고화질로 바꾸세요.</div>
</div>
<div class="st-field" id="video-strength-field" style="display:none">
<label>이미지 반영 강도: <span id="video-strength-val">0.5</span></label>
<input type="range" id="f-video-strength" min="0" max="1" step="0.05" value="0.5" oninput="document.getElementById('video-strength-val').textContent=this.value">
<div class="st-hint" style="margin:0">낮음 = 시작 이미지에 거의 고정(움직임 적음) · 높음 = 프롬프트 쪽으로 더 많이 변형</div>
</div>
</div>
<div id="row-edit-only" style="display:none;flex-direction:column;gap:10px;">
<div class="st-field">
<label>사진 업로드</label>
<input type="file" id="f-photo" accept="image/*" style="display:none" onchange="onPhotoSelected(this.files[0])">
<div class="st-dropzone" id="photo-dropzone" onclick="document.getElementById('f-photo').click()">사진을 선택하려면 탭하세요</div>
</div>
<div class="st-field">
<div class="st-toolbar">
<span style="flex-shrink:0">저장된 사진</span>
<div class="st-toolbar-sp"></div>
<label><input type="checkbox" id="up1-selall" onchange="toggleSelectAll('uploads-grid','up1-selall',uploadsSelected1,'up1')"> 전체선택</label>
<span class="st-sel-count" id="up1-count"></span>
<button type="button" id="up1-delbtn" disabled onclick="deleteSelected(uploadsSelected1,n=>'/api/imagegen/uploads/'+encodeURIComponent(n),()=>loadUploadsGrid('1'),'up1-selall')">삭제</button>
</div>
<div class="st-gallery-grid" id="uploads-grid"></div>
</div>
<div class="st-field">
<label>스타일</label>
<select id="f-style" onchange="toggleStrengthField('f-style','strength-field')">
<option value="anime">🎨 애니메이션 (인물)</option>
<option value="anime_group">🎨 애니메이션 (단체/풍경)</option>
<option value="anime_celeba">🎨 애니메이션 (만화체)</option>
<option value="cartoon">😄 카툰 / 캐리커쳐</option>
<option value="sketch">✏️ 스케치</option>
<option value="sketch_color">🖍️ 컬러 스케치</option>
<option value="watercolor">🖌️ 수채화</option>
<option value="bw">⚫ 흑백 사진</option>
</select>
</div>
<div class="st-field" id="strength-field" style="display:none">
<label>카툰 강도: <span id="strength-val">0.45</span></label>
<input type="range" id="f-strength" min="0.2" max="0.8" step="0.05" value="0.45" oninput="document.getElementById('strength-val').textContent=this.value">
<div class="st-hint" style="margin:0">낮음 = 원본 표정·자세에 가까움 · 높음 = 더 과장된 캐리커쳐</div>
</div>
</div>
<div id="row-sticker-only" style="display:none;flex-direction:column;gap:10px;">
<div class="st-field">
<label>사진 업로드</label>
<input type="file" id="f-photo2" accept="image/*" style="display:none" onchange="onStickerPhotoSelected(this.files[0])">
<div class="st-dropzone" id="sticker-dropzone" onclick="document.getElementById('f-photo2').click()">사진을 선택하려면 탭하세요</div>
</div>
<div class="st-field">
<div class="st-toolbar">
<span style="flex-shrink:0">저장된 사진</span>
<div class="st-toolbar-sp"></div>
<label><input type="checkbox" id="up2-selall" onchange="toggleSelectAll('uploads-grid2','up2-selall',uploadsSelected2,'up2')"> 전체선택</label>
<span class="st-sel-count" id="up2-count"></span>
<button type="button" id="up2-delbtn" disabled onclick="deleteSelected(uploadsSelected2,n=>'/api/imagegen/uploads/'+encodeURIComponent(n),()=>loadUploadsGrid('2'),'up2-selall')">삭제</button>
</div>
<div class="st-gallery-grid" id="uploads-grid2"></div>
</div>
<div class="st-field">
<label>스타일</label>
<select id="f-style2" onchange="toggleStrengthField('f-style2','strength-field2')">
<option value="anime">🎨 애니메이션 (인물)</option>
<option value="anime_group">🎨 애니메이션 (단체/풍경)</option>
<option value="anime_celeba">🎨 애니메이션 (만화체)</option>
<option value="cartoon">😄 카툰 / 캐리커쳐</option>
<option value="sketch">✏️ 스케치</option>
<option value="sketch_color">🖍️ 컬러 스케치</option>
<option value="watercolor">🖌️ 수채화</option>
<option value="bw">⚫ 흑백 사진</option>
</select>
</div>
<div class="st-field" id="strength-field2" style="display:none">
<label>카툰 강도: <span id="strength-val2">0.45</span></label>
<input type="range" id="f-strength2" min="0.2" max="0.8" step="0.05" value="0.45" oninput="document.getElementById('strength-val2').textContent=this.value">
<div class="st-hint" style="margin:0">낮음 = 원본 표정·자세에 가까움 · 높음 = 더 과장된 캐리커쳐</div>
</div>
<div class="st-field">
<label>문구 (한 줄에 하나씩, 최대 12개)</label>
<textarea id="f-phrases" rows="8">안녕!
ㅋㅋㅋ
오키오키
굿모닝
고마워!
화이팅
미안ㅠㅠ
사랑해</textarea>
</div>
</div>
<button class="st-gen-btn" id="gen-btn" onclick="runGenerate()">생성</button>
<div class="st-hint" id="hint-text">SDXL 로컬 생성 · 보통 10~20초</div>
</div>
<div class="st-main">
<div class="st-result" id="result-area">
<div class="st-placeholder">프롬프트를 입력하고 생성 버튼을 눌러주세요</div>
</div>
<div class="st-gallery">
<div class="st-gallery-hdr st-toolbar">
<span style="flex-shrink:0">최근 생성 이력</span>
<div class="st-toolbar-sp"></div>
<label><input type="checkbox" id="gal-selall" onchange="toggleSelectAll('gallery-grid','gal-selall',gallerySelected,'gal')"> 전체선택</label>
<span class="st-sel-count" id="gal-count"></span>
<button type="button" id="gal-delbtn" disabled onclick="deleteSelected(gallerySelected,n=>'/api/imagegen/gallery/'+encodeURIComponent(n),loadGallery,'gal-selall')">삭제</button>
</div>
<div class="st-gallery-grid" id="gallery-grid"></div>
</div>
</div>
</div>
<div class="st-zoom-modal" id="zoom-modal" onclick="closeZoom()">
<img id="zoom-img" style="display:none">
<video id="zoom-video" style="display:none" controls></video>
</div>
<script>
const TOKEN_KEY='smallclaw_token';
function getToken(){try{return sessionStorage.getItem(TOKEN_KEY)||localStorage.getItem(TOKEN_KEY)||'';}catch{return '';}}
function authH(extra){const t=getToken();const h={...(extra||{})};if(t)h['Authorization']='Bearer '+t;return h;}
function escHtml(s){return String(s ?? '').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
// --- Prompt-writing assistant (chat sidebar) ---------------------------------
let promptAssistHistory=[];
function togglePromptAssist(){
const el=document.getElementById('prompt-assist-panel');
const showing = el.style.display!=='none';
el.style.display = showing ? 'none' : 'flex';
if(!showing) document.getElementById('prompt-assist-input').focus();
}
function extractPromptBlock(text){
const m=text.match(/PROMPT:\s*([^\n]*)\n?NEGATIVE:\s*([^\n]*)/);
if(!m) return null;
return {prompt:m[1].trim(), negative:m[2].trim()};
}
// Applies the CURRENT contents of the per-message editable textareas, not the
// original AI-generated text — the user may have tweaked a word or two first.
function applyPromptAssist(idx){
const p=document.getElementById(`pa-prompt-${idx}`);
const n=document.getElementById(`pa-neg-${idx}`);
if(!p) return;
document.getElementById('f-prompt').value=p.value;
if(n) document.getElementById('f-neg').value=n.value;
}
function renderPromptAssist(loading){
const el=document.getElementById('prompt-assist-messages');
el.innerHTML = promptAssistHistory.map((m,i)=>{
const isUser = m.role==='user';
const extracted = !isUser ? extractPromptBlock(m.content) : null;
const shown = extracted ? m.content.replace(/```[\s\S]*?```/,'').trim() : m.content;
const editable = extracted ? `
<div style="margin-top:6px;display:flex;flex-direction:column;gap:4px">
<label style="font-size:10px;opacity:0.7">프롬프트 (직접 수정 가능)</label>
<textarea id="pa-prompt-${i}" rows="3" style="font-size:11px;font-family:'JetBrains Mono',monospace;background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:6px 8px;color:var(--text);resize:vertical">${escHtml(extracted.prompt)}</textarea>
<label style="font-size:10px;opacity:0.7">네거티브 프롬프트</label>
<textarea id="pa-neg-${i}" rows="2" style="font-size:11px;font-family:'JetBrains Mono',monospace;background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:6px 8px;color:var(--text);resize:vertical">${escHtml(extracted.negative)}</textarea>
<button type="button" class="hdr-btn" style="font-size:10px;align-self:flex-start" onclick="applyPromptAssist(${i})">✓ 프롬프트에 적용</button>
</div>` : '';
return `<div style="align-self:${isUser?'flex-end':'flex-start'};max-width:90%;background:${isUser?'var(--brand)':'var(--bg3)'};color:${isUser?'#fff':'inherit'};border-radius:8px;padding:6px 9px;white-space:pre-wrap">${escHtml(shown)}${editable}</div>`;
}).join('') + (loading ? `<div style="align-self:flex-start;color:var(--muted);font-size:11px">생각 중...</div>` : '');
el.scrollTop = el.scrollHeight;
}
async function sendPromptAssist(){
const input=document.getElementById('prompt-assist-input');
const text=input.value.trim();
if(!text) return;
input.value='';
promptAssistHistory.push({role:'user', content:text});
renderPromptAssist(true);
try{
const mode = currentTab==='video' ? 'video' : 'image';
const r=await fetch('/api/studio/prompt-assist',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({messages:promptAssistHistory, mode})});
const data=await r.json();
if(!r.ok) throw new Error(data.error||'요청 실패');
promptAssistHistory.push({role:'assistant', content:data.reply});
}catch(e){
promptAssistHistory.push({role:'assistant', content:'오류: '+e.message});
}
renderPromptAssist(false);
}
// --- Same prompt-writing assistant, docked beside the raw ComfyUI iframe -----
// Can't reach into the iframe's node graph reliably (depends on ComfyUI's internal
// JS globals, which could break on a ComfyUI update), so this offers copy-to-clipboard
// instead of an auto-apply button — user pastes into whichever CLIPTextEncode node
// they're working on.
let comfyAssistHistory=[];
function renderComfyAssist(loading){
const el=document.getElementById('comfy-assist-messages');
el.innerHTML = comfyAssistHistory.map((m,i)=>{
const isUser = m.role==='user';
const extracted = !isUser ? extractPromptBlock(m.content) : null;
const shown = extracted ? m.content.replace(/```[\s\S]*?```/,'').trim() : m.content;
const editable = extracted ? `
<div style="margin-top:6px;display:flex;flex-direction:column;gap:4px">
<label style="font-size:10px;opacity:0.7">프롬프트</label>
<textarea id="ca-prompt-${i}" rows="3" style="font-size:11px;font-family:'JetBrains Mono',monospace;background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:6px 8px;color:var(--text);resize:vertical">${escHtml(extracted.prompt)}</textarea>
<button type="button" class="hdr-btn" style="font-size:10px;align-self:flex-start" onclick="copyComfyAssist(${i}, this)">📋 복사</button>
</div>` : '';
return `<div style="align-self:${isUser?'flex-end':'flex-start'};max-width:95%;background:${isUser?'var(--brand)':'var(--bg3)'};color:${isUser?'#fff':'inherit'};border-radius:8px;padding:6px 9px;white-space:pre-wrap">${escHtml(shown)}${editable}</div>`;
}).join('') + (loading ? `<div style="align-self:flex-start;color:var(--muted);font-size:11px">생각 중...</div>` : '');
el.scrollTop = el.scrollHeight;
}
async function copyComfyAssist(idx, btn){
const p=document.getElementById(`ca-prompt-${idx}`);
if(!p) return;
const orig=btn.textContent;
let ok=false;
try{
await navigator.clipboard.writeText(p.value);
ok=true;
}catch(e){
// Clipboard API can be gated behind a permission prompt in some contexts
// (embedded/automated browsers) — fall back to the older selection-based
// copy instead of alert()ing, which would just be another blocking dialog.
try{ p.select(); ok=document.execCommand('copy'); }catch(e2){}
}
btn.textContent = ok ? '✓ 복사됨' : '복사 실패 — 직접 선택해서 복사하세요';
setTimeout(()=>{btn.textContent=orig;},1500);
}
async function sendComfyAssist(){
const input=document.getElementById('comfy-assist-input');
const text=input.value.trim();
if(!text) return;
input.value='';
comfyAssistHistory.push({role:'user', content:text});
renderComfyAssist(true);
try{
const r=await fetch('/api/studio/prompt-assist',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({messages:comfyAssistHistory, mode:'image'})});
const data=await r.json();
if(!r.ok) throw new Error(data.error||'요청 실패');
comfyAssistHistory.push({role:'assistant', content:data.reply});
}catch(e){
comfyAssistHistory.push({role:'assistant', content:'오류: '+e.message});
}
renderComfyAssist(false);
}
async function checkAuth(){
if(!getToken()){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}
try{const r=await fetch('/api/auth/status',{headers:authH()});if(!r.ok){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}const d=await r.json();if(!d.authenticated){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}return true;}
catch{location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}
}
function toggleTheme(){const d=document.documentElement;const n=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',n);try{localStorage.setItem('homeclaw_theme',n);}catch{}}
// ── Selection / bulk-delete helpers (shared by the gallery grid and both saved-photo pickers) ──
let uploadsSelected1=new Set(), uploadsSelected2=new Set(), gallerySelected=new Set();
function updateGridToolbar(prefix, selectedSet){
const btn=document.getElementById(prefix+'-delbtn');
const cnt=document.getElementById(prefix+'-count');
if(btn) btn.disabled = selectedSet.size===0;
if(cnt) cnt.textContent = selectedSet.size ? selectedSet.size+'개 선택됨' : '';
}
function wireGridItem(el, name, selectedSet, prefix){
el.dataset.name = name;
const chk = el.querySelector('.st-gchk');
chk.addEventListener('click', ev=>{
ev.stopPropagation();
if(chk.checked) selectedSet.add(name); else selectedSet.delete(name);
updateGridToolbar(prefix, selectedSet);
});
}
function toggleSelectAll(gridId, selectAllId, selectedSet, prefix){
const checkAll = document.getElementById(selectAllId).checked;
selectedSet.clear();
document.getElementById(gridId).querySelectorAll('.st-gitem').forEach(el=>{
const chk = el.querySelector('.st-gchk');
chk.checked = checkAll;
if(checkAll) selectedSet.add(el.dataset.name);
});
updateGridToolbar(prefix, selectedSet);
}
async function deleteSelected(selectedSet, deleteUrlFn, reloadFn, selectAllId){
if(!selectedSet.size) return;
if(!confirm(selectedSet.size+'개 삭제할까요?')) return;
await Promise.all([...selectedSet].map(name => fetch(deleteUrlFn(name), {method:'DELETE', headers:authH()})));
selectedSet.clear();
const sa=document.getElementById(selectAllId); if(sa) sa.checked=false;
reloadFn();
}
function resultMediaHtml(url, type, filename, isChainedEdit, meta){
resultZoom=1;
cancelCropUI();
currentResultPath = type==='video' ? null : url.replace(/^\/api\/files\//, '');
if(!isChainedEdit) currentEditSession=null;
const media = type==='video'
? '<video src="'+url+'" controls autoplay loop></video>'
: '<img src="'+url+'" alt="result">';
const editToolbar = type==='video' ? '' :
'<div class="st-edit-ctl" onclick="event.stopPropagation()">'
+'<button type="button" id="edit-crop-btn" onclick="toggleCropMode()" data-tip="자르기">✂️</button>'
+'<button type="button" onclick="quickRotate()" data-tip="회전">🔄</button>'
+'<button type="button" onclick="quickFlip()" data-tip="좌우반전">↔️</button>'
+'<button type="button" onclick="quickAutoEnhance()" data-tip="자동보정">✨</button>'
+'<button type="button" onclick="quickRemoveBg()" data-tip="배경투명화">🪄</button>'
+'<button type="button" onclick="togglePanel(\'filter-panel\')" data-tip="필터">🎨</button>'
+'<button type="button" onclick="togglePanel(\'adjust-panel\')" data-tip="보정">🎚️</button>'
+'<button type="button" onclick="togglePanel(\'text-panel\')" data-tip="텍스트">📝</button>'
+'<button type="button" id="save-result-btn" onclick="saveResult()" data-tip="저장">💾</button>'
+'</div>'
+'<div class="st-edit-panel" id="filter-panel" onclick="event.stopPropagation()">'
+'<div class="st-filter-grid">'
+'<button onclick="applyFilter(\'warm\')">따뜻하게</button>'
+'<button onclick="applyFilter(\'cool\')">차갑게</button>'
+'<button onclick="applyFilter(\'vintage\')">빈티지</button>'
+'<button onclick="applyFilter(\'sepia\')">세피아</button>'
+'<button onclick="applyFilter(\'fade\')">페이드</button>'
+'<button onclick="applyFilter(\'dramatic\')">드라마틱</button>'
+'</div>'
+'<div class="st-edit-panel-row">'
+'<label>비네트</label>'
+'<input type="range" id="vignette-strength" min="0.3" max="0.8" step="0.05" value="0.5">'
+'<button class="st-apply-btn" onclick="applyVignette()">적용</button>'
+'</div>'
+'</div>'
+'<div class="st-edit-panel" id="adjust-panel" onclick="event.stopPropagation()">'
+'<div class="st-edit-panel-row"><label>밝기</label><input type="range" id="adj-brightness" min="0.5" max="2" step="0.05" value="1" oninput="document.getElementById(\'adj-brightness-val\').textContent=parseFloat(this.value).toFixed(2)"><span id="adj-brightness-val">1.00</span></div>'
+'<div class="st-edit-panel-row"><label>대비</label><input type="range" id="adj-contrast" min="0.5" max="2" step="0.05" value="1" oninput="document.getElementById(\'adj-contrast-val\').textContent=parseFloat(this.value).toFixed(2)"><span id="adj-contrast-val">1.00</span></div>'
+'<div class="st-edit-panel-row"><label>채도</label><input type="range" id="adj-saturation" min="0" max="2" step="0.05" value="1" oninput="document.getElementById(\'adj-saturation-val\').textContent=parseFloat(this.value).toFixed(2)"><span id="adj-saturation-val">1.00</span></div>'
+'<div class="st-edit-panel-row"><label>선명도</label><input type="range" id="adj-sharpen" min="1" max="3" step="0.1" value="1" oninput="document.getElementById(\'adj-sharpen-val\').textContent=parseFloat(this.value).toFixed(2)"><span id="adj-sharpen-val">1.00</span></div>'
+'<button class="st-apply-btn" onclick="applyAdjust()">적용</button>'
+'</div>'
+'<div class="st-edit-panel" id="text-panel" onclick="event.stopPropagation()">'
+'<div class="st-text-mode">'
+'<button type="button" class="active" id="text-mode-watermark" onclick="setTextMode(\'watermark\')">워터마크</button>'
+'<button type="button" id="text-mode-bubble" onclick="setTextMode(\'speech_bubble\')">말풍선</button>'
+'</div>'
+'<input type="text" id="text-content" placeholder="텍스트 입력">'
+'<select id="text-position">'
+'<option value="bottom-right">우하단</option>'
+'<option value="bottom-left">좌하단</option>'
+'<option value="top-right">우상단</option>'
+'<option value="top-left">좌상단</option>'
+'<option value="center">중앙(워터마크 전용)</option>'
+'</select>'
+'<button class="st-apply-btn" onclick="applyText()">적용</button>'
+'</div>';
return '<div class="st-result-wrap">'+media
+'<div class="st-zoom-ctl" onclick="event.stopPropagation()">'
+'<button type="button" onclick="adjustResultZoom(-0.25)" title="축소">−</button>'
+'<span id="zoom-pct">100%</span>'
+'<button type="button" onclick="adjustResultZoom(0.25)" title="확대">+</button>'
+'<button type="button" onclick="resetResultZoom()" title="원래크기">⟲</button>'
+'</div>'
+editToolbar
+(meta && type==='video' ? '<div class="st-video-meta">'+meta.width+'×'+meta.height+' · '+meta.num_frames+'프레임 · '+meta.fps+'fps ('+(meta.num_frames/meta.fps).toFixed(1)+'초)</div>' : '')
+'<a class="st-result-dl" href="'+url+'" download="'+(filename||'')+'" title="다운로드" onclick="event.stopPropagation()">⬇ 다운로드</a></div>';
}
let resultZoom=1;
function applyResultZoom(){
const el=document.querySelector('.st-result-wrap img, .st-result-wrap video');
if(el) el.style.transform='scale('+resultZoom+')';
const pct=document.getElementById('zoom-pct');
if(pct) pct.textContent=Math.round(resultZoom*100)+'%';
}
function adjustResultZoom(delta){
resultZoom=Math.max(0.25, Math.min(4, +(resultZoom+delta).toFixed(2)));
applyResultZoom();
}
function resetResultZoom(){
resultZoom=1;
applyResultZoom();
}
// ── Quick photo editor (result-area toolbar: crop/rotate/flip/adjust/filter/text/remove-bg) ──
let currentResultPath=null;
let currentEditSession=null;
let editBusy=false;
async function quickEdit(operation, extraParams, statusMsg){
if(editBusy) return;
if(!currentResultPath){alert('편집할 이미지가 없습니다');return;}
if(!currentEditSession) currentEditSession=Date.now().toString(36)+Math.random().toString(36).slice(2,8);
const wrap=document.querySelector('.st-result-wrap');
let ov=null;
if(wrap){
ov=document.createElement('div');
ov.style.cssText='position:absolute;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;z-index:10;border-radius:10px;';
ov.innerHTML='<div style="text-align:center;color:#fff"><div class="st-spinner"></div><div style="font-size:11px;margin-top:4px">'+(statusMsg||'적용 중...')+'</div></div>';
wrap.appendChild(ov);
}
editBusy=true;
try{
const body=Object.assign({path:currentResultPath, operation, session:currentEditSession}, extraParams||{});
const r=await fetch('/api/imagegen/quick-edit',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify(body)});
const d=await r.json();
if(!r.ok || !d.success){
alert('편집 실패: '+(d.error||'알 수 없는 오류'));
return;
}
document.getElementById('result-area').innerHTML=resultMediaHtml(d.url, 'image', 'edited_'+Date.now()+'.png', true);
}catch(e){
alert('요청 실패: '+e.message);
}finally{
editBusy=false;
ov?.remove();
}
}
function quickRotate(){ quickEdit('rotate', {degrees:90}, '회전 중...'); }
function quickFlip(){ quickEdit('flip', {direction:'horizontal'}, '반전 중...'); }
function quickAutoEnhance(){ quickEdit('auto_enhance', {}, '자동보정 중...'); }
function quickRemoveBg(){ quickEdit('remove_bg', {}, '배경 제거 중... (20~30초 걸려요)'); }
async function saveResult(){
if(!currentResultPath){alert('저장할 이미지가 없습니다');return;}
const btn=document.getElementById('save-result-btn');
if(btn){btn.disabled=true; btn.textContent='⏳';}
try{
const r=await fetch('/api/imagegen/save-result',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({path:currentResultPath, save_to_uploads:true})});
const d=await r.json();
if(!r.ok || !d.success){
alert('저장 실패: '+(d.error||'알 수 없는 오류'));
return;
}
// The session dir (if any) was just deleted server-side, so currentResultPath
// would now point at a gone file — repoint the view at the saved copy instead
// of just leaving stale state for the next edit to trip over.
document.getElementById('result-area').innerHTML=resultMediaHtml(d.gallery_url, 'image', 'saved_'+Date.now()+'.png');
loadGallery();
loadUploadsGrid('1');
loadUploadsGrid('2');
const btn2=document.getElementById('save-result-btn');
if(btn2){
btn2.textContent='✅';
setTimeout(()=>{ if(btn2){btn2.textContent='💾'; btn2.disabled=false;} }, 1200);
}
}catch(e){
alert('요청 실패: '+e.message);
if(btn){btn.textContent='💾'; btn.disabled=false;}
}
}
function togglePanel(id){
const panel=document.getElementById(id);
const wasShown=panel && panel.classList.contains('show');
closeAllPanels();
if(panel && !wasShown) panel.classList.add('show');
}
function closeAllPanels(){
document.querySelectorAll('.st-edit-panel').forEach(p=>p.classList.remove('show'));
}
function applyFilter(preset){
closeAllPanels();
quickEdit('filter', {preset}, '필터 적용 중...');
}
function applyVignette(){
const v=parseFloat(document.getElementById('vignette-strength').value);
closeAllPanels();
quickEdit('vignette', {strength:v}, '비네트 적용 중...');
}
async function applyAdjust(){
const ops=[
['brightness', parseFloat(document.getElementById('adj-brightness').value)],
['contrast', parseFloat(document.getElementById('adj-contrast').value)],
['saturation', parseFloat(document.getElementById('adj-saturation').value)],
['sharpen', parseFloat(document.getElementById('adj-sharpen').value)],
].filter(([op,val])=>Math.abs(val-1)>0.001);
if(!ops.length){alert('변경된 값이 없습니다');return;}
closeAllPanels();
for(const [op,val] of ops){
await quickEdit(op, {value:val}, op+' 적용 중...');
}
}
let textMode='watermark';
function setTextMode(mode){
textMode=mode;
document.getElementById('text-mode-watermark').classList.toggle('active', mode==='watermark');
document.getElementById('text-mode-bubble').classList.toggle('active', mode==='speech_bubble');
}
function applyText(){
const text=document.getElementById('text-content').value.trim();
if(!text){alert('텍스트를 입력해주세요');return;}
const position=document.getElementById('text-position').value;
closeAllPanels();
quickEdit(textMode, {text, position}, '텍스트 적용 중...');
}
let cropState=null;
function toggleCropMode(){
if(cropState){ cancelCropUI(); return; }
closeAllPanels();
const wrap=document.querySelector('.st-result-wrap');
const img=wrap?.querySelector('img');
if(!img){alert('자를 이미지가 없습니다');return;}
const hint=document.createElement('div');
hint.className='st-crop-hint';
hint.id='crop-hint';
hint.textContent='드래그해서 자를 영역을 선택하세요';
wrap.appendChild(hint);
document.getElementById('edit-crop-btn')?.classList.add('active');
cropState={img};
img.addEventListener('pointerdown', onCropStart);
}
function onCropStart(ev){
ev.preventDefault();
const wrap=document.querySelector('.st-result-wrap');
const img=ev.currentTarget;
const rect=img.getBoundingClientRect();
const startX=Math.min(Math.max(ev.clientX-rect.left,0),rect.width);
const startY=Math.min(Math.max(ev.clientY-rect.top,0),rect.height);
document.getElementById('crop-box')?.remove();
const box=document.createElement('div');
box.className='st-crop-box';
box.id='crop-box';
wrap.appendChild(box);
cropState={img, rect, startX, startY};
updateCropBox(startX, startY);
const onMove=(mv)=>{
const x=Math.min(Math.max(mv.clientX-rect.left,0),rect.width);
const y=Math.min(Math.max(mv.clientY-rect.top,0),rect.height);
updateCropBox(x,y);
};
const onUp=()=>{
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
finishCropSelection();
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
}
function updateCropBox(curX, curY){
const box=document.getElementById('crop-box');
if(!box || !cropState) return;
const x=Math.min(cropState.startX, curX), y=Math.min(cropState.startY, curY);
const w=Math.abs(curX-cropState.startX), h=Math.abs(curY-cropState.startY);
box.style.left=x+'px'; box.style.top=y+'px'; box.style.width=w+'px'; box.style.height=h+'px';
cropState.sel={x,y,w,h};
}
function finishCropSelection(){
if(!cropState?.sel || cropState.sel.w<10 || cropState.sel.h<10){
document.getElementById('crop-box')?.remove();
return;
}
const hint=document.getElementById('crop-hint');
if(hint) hint.innerHTML='영역을 확인하고 적용하세요 '
+'<button onclick="confirmCrop()" style="margin-left:6px;background:var(--brand);color:#1e1b2e;border:none;border-radius:10px;padding:3px 10px;cursor:pointer;font-family:inherit;font-size:11px;">적용</button> '
+'<button onclick="cancelCropUI()" style="margin-left:4px;background:rgba(255,255,255,.2);color:#fff;border:none;border-radius:10px;padding:3px 10px;cursor:pointer;font-family:inherit;font-size:11px;">취소</button>';
}
function confirmCrop(){
const img=cropState?.img, sel=cropState?.sel, rect=cropState?.rect;
if(!img || !sel || !rect) return;
const fitScale=Math.min(rect.width/img.naturalWidth, rect.height/img.naturalHeight);
const renderedW=img.naturalWidth*fitScale, renderedH=img.naturalHeight*fitScale;
const offsetX=(rect.width-renderedW)/2, offsetY=(rect.height-renderedH)/2;
const x=Math.max(0, Math.round((sel.x-offsetX)/fitScale));
const y=Math.max(0, Math.round((sel.y-offsetY)/fitScale));
const w=Math.round(sel.w/fitScale);
const h=Math.round(sel.h/fitScale);
cancelCropUI();
quickEdit('crop', {x, y, width:w, height:h}, '자르는 중...');
}
function cancelCropUI(){
document.getElementById('crop-box')?.remove();
document.getElementById('crop-hint')?.remove();
document.getElementById('edit-crop-btn')?.classList.remove('active');
if(cropState?.img) cropState.img.removeEventListener('pointerdown', onCropStart);
cropState=null;
}
let currentTab='image';
function genVerb(){
return currentTab==='edit' ? '변환' : currentTab==='sticker' ? '세트 만들기' : '생성';
}
function switchTab(tab){
currentTab=tab;
document.getElementById('tab-image').classList.toggle('active', tab==='image');
document.getElementById('tab-video').classList.toggle('active', tab==='video');
document.getElementById('tab-edit').classList.toggle('active', tab==='edit');
document.getElementById('tab-sticker').classList.toggle('active', tab==='sticker');
document.getElementById('tab-comfy').classList.toggle('active', tab==='comfy');
document.querySelector('.st-layout').style.display = tab==='comfy' ? 'none' : 'flex';
document.getElementById('comfy-layout').style.display = tab==='comfy' ? 'flex' : 'none';
if(tab==='comfy'){
// Lazy-load: ComfyUI's own frontend bundle is heavy, no need to fetch it
// unless this tab is actually opened. Proxied through our own gateway
// (routes-comfyui.ts) so this works both on LAN and through the public
// domain — trailing slash matters (ComfyUI derives its own API base path
// from location.pathname minus the last segment) and the one-time ?token=
// becomes a scoped cookie server-side so every later request from inside
// the iframe (assets, API, the /ws upgrade) authenticates automatically.
const frame = document.getElementById('comfy-frame');
if(!frame.src) frame.src = '/api/comfyui/?token='+encodeURIComponent(getToken());
return;
}
document.getElementById('common-fields').style.display = (tab==='edit'||tab==='sticker') ? 'none' : 'contents';
document.getElementById('row-image-only').style.display = tab==='image' ? 'flex' : 'none';
document.getElementById('row-video-only').style.display = tab==='video' ? 'flex' : 'none';
document.getElementById('row-edit-only').style.display = tab==='edit' ? 'flex' : 'none';
document.getElementById('row-sticker-only').style.display = tab==='sticker' ? 'flex' : 'none';
document.getElementById('gen-btn').textContent = genVerb();
if(tab==='image'){
document.getElementById('f-width').value=1024; document.getElementById('f-height').value=768;
document.getElementById('f-quality').value='fast';
onQualityChange();
} else if(tab==='video'){
document.getElementById('f-width').value=704; document.getElementById('f-height').value=480;
document.getElementById('f-steps').value=40; document.getElementById('f-guidance').value=3.0;
document.getElementById('hint-text').textContent='LTX-Video 로컬 생성 · 보통 30~90초, 시간이 걸립니다';
} else if(tab==='edit'){
document.getElementById('hint-text').textContent='사진을 업로드하고 스타일을 골라주세요 · 대부분 2~10초, 카툰/캐리커쳐는 SDXL로 그려서 15~40초 정도 걸려요';
} else {
document.getElementById('hint-text').textContent='사진 업로드 → 스타일 변환 → 배경 제거(정교한 모델이라 20~30초) → 문구별 스티커 생성까지 한 번에 · 보통 40~70초, 카툰/캐리커쳐는 SDXL이라 더 걸려요';
}
}
function onQualityChange(){
const q=document.getElementById('f-quality').value;
const stepsEl=document.getElementById('f-steps');
if(q==='high'){
stepsEl.min=1; stepsEl.max=8; stepsEl.value=4;
document.getElementById('f-guidance').disabled=true;
document.getElementById('hint-text').textContent='FLUX.1-schnell 로컬 생성(4비트 양자화) · 보통 40~60초, 시간이 걸립니다';
} else {
stepsEl.min=10; stepsEl.max=50; stepsEl.value=30;
document.getElementById('f-guidance').disabled=false;
document.getElementById('f-guidance').value=7.0;
document.getElementById('hint-text').textContent='SDXL 로컬 생성 · 보통 10~20초';
}
}
let uploadedVideoImagePath=null;
async function onVideoImageSelected(file){
if(!file) return;
const form=new FormData();
form.append('image', file);
uploadedVideoImagePath=null;
document.getElementById('video-image-dropzone').textContent='업로드 중...';
try{
const r=await fetch('/api/upload/image',{method:'POST',headers:authH(),body:form});
const d=await r.json();
if(!r.ok) throw new Error(d.error||'업로드 실패');
uploadedVideoImagePath=d.path;
document.getElementById('video-image-thumb').src=d.url || URL.createObjectURL(file);
document.getElementById('video-image-preview').style.display='flex';
document.getElementById('video-image-dropzone').style.display='none';
document.getElementById('video-strength-field').style.display='flex';
}catch(e){
document.getElementById('video-image-dropzone').textContent='업로드 실패: '+e.message;
}
}
function clearVideoImage(){
uploadedVideoImagePath=null;
document.getElementById('f-video-image').value='';
document.getElementById('video-image-preview').style.display='none';
document.getElementById('video-image-dropzone').style.display='block';
document.getElementById('video-image-dropzone').textContent='사진을 선택하려면 탭하세요 (없으면 텍스트만으로 생성)';
document.getElementById('video-strength-field').style.display='none';
}
function onVideoModelChange(){
const m=document.getElementById('f-video-model').value;
const guidanceEl=document.getElementById('f-guidance');
const isCogVideoX = m==='cogvideox';
document.getElementById('video-image-cogvideox-warn').style.display = isCogVideoX ? 'block' : 'none';
document.getElementById('video-image-dropzone').style.pointerEvents = isCogVideoX ? 'none' : 'auto';
document.getElementById('video-image-dropzone').style.opacity = isCogVideoX ? '0.5' : '1';
if(isCogVideoX) clearVideoImage();
if(m==='cogvideox'){
document.getElementById('f-frames').value=49;
document.getElementById('f-fps').value=8;
document.getElementById('f-steps').value=25;
guidanceEl.value=6.0;
document.getElementById('hint-text').textContent='CogVideoX-2B 로컬 생성 · 보통 3~5분, VRAM 여유가 적어서 해상도/프레임수/스텝수를 기본값보다 많이 올리면 실패하거나 너무 오래 걸릴 수 있어요';
} else if(m==='ltx_hq'){
document.getElementById('f-frames').value=65;
document.getElementById('f-fps').value=24;
document.getElementById('f-steps').value=40;
guidanceEl.value=3.0;
document.getElementById('hint-text').textContent='LTX-Video 고화질(dev 체크포인트) 로컬 생성 · 보통 40~150초, 배포판보다 느리지만 움직임/디테일이 더 자연스러워요. VRAM 여유가 적어서 동시에 다른 이미지 생성을 돌리면 실패할 수 있어요';
} else {
document.getElementById('f-frames').value=65;
document.getElementById('f-fps').value=24;
document.getElementById('f-steps').value=40;
guidanceEl.value=3.0;
document.getElementById('hint-text').textContent='LTX-Video 로컬 생성 · 보통 30~90초, 시간이 걸립니다';
}
}
let genStartTime=0, genTimer=null;
// Rough expected durations per mode, used to animate an estimated (not real — ComfyUI
// only exposes true step-by-step progress over a websocket, not worth the backend
// rework for this) progress bar. Capped at 95% until the actual response lands, so it
// never lies about being "done" before the result is really back.
function estimatedGenMs(){
if(currentTab==='video'){
const m=document.getElementById('f-video-model').value;
return m==='ltx_hq' ? 95000 : m==='cogvideox' ? 120000 : 60000;
}
if(currentTab==='edit') return 15000;
if(currentTab==='sticker'){
const n=document.getElementById('f-phrases').value.split('\n').map(s=>s.trim()).filter(Boolean).length;
return 15000 + Math.max(1,n)*3000;
}
return document.getElementById('f-quality').value==='high' ? 50000 : 15000;
}
function startTimer(){
genStartTime=Date.now();
const btn=document.getElementById('gen-btn');
btn.disabled=true;
const verb = genVerb();
const estMs = estimatedGenMs();
genTimer=setInterval(()=>{
const elapsed = Date.now()-genStartTime;
const s=(elapsed/1000).toFixed(0);
btn.textContent=verb+' 중... ('+s+'초)';
const fill=document.getElementById('gen-progress-fill');
if(fill) fill.style.width=Math.min(95, (elapsed/estMs)*95).toFixed(0)+'%';
},500);
}
function stopTimer(){
clearInterval(genTimer);
document.getElementById('gen-btn').disabled=false;
document.getElementById('gen-btn').textContent = genVerb();
}
function runGenerate(){
if(currentTab==='edit') runEdit();
else if(currentTab==='sticker') runStickerSet();
else generate();
}
// tabSuffix '1' = 사진편집 탭 (uploads-grid / uploadedPhotoPath), '2' = 이모티콘 탭 (uploads-grid2 / uploadedStickerPath)
async function loadUploadsGrid(tabSuffix){
const gridId = tabSuffix==='1' ? 'uploads-grid' : 'uploads-grid2';
const selected = tabSuffix==='1' ? uploadsSelected1 : uploadsSelected2;
const prefix = 'up'+tabSuffix;
try{
const r=await fetch('/api/imagegen/uploads',{headers:authH()});
const d=await r.json();
const grid=document.getElementById(gridId);
grid.innerHTML='';
selected.clear();
const sa=document.getElementById(prefix+'-selall'); if(sa) sa.checked=false;
updateGridToolbar(prefix, selected);
for(const item of (d.items||[])){
const el=document.createElement('div');
el.className='st-gitem';
el.innerHTML='<input type="checkbox" class="st-gchk">'
+'<img src="'+item.url+'">'
+'<a class="st-gdl" href="'+item.url+'" download="'+item.name+'" title="다운로드">⬇</a>'
+'<button class="st-gdel" title="삭제">✕</button>';
wireGridItem(el, item.name, selected, prefix);
el.querySelector('.st-gdl').addEventListener('click', ev=>ev.stopPropagation());
el.querySelector('img').addEventListener('click', ()=>{
if(tabSuffix==='1'){
uploadedPhotoPath=item.path;
document.getElementById('photo-dropzone').innerHTML='<img src="'+item.url+'">';
} else {
uploadedStickerPath=item.path;
document.getElementById('sticker-dropzone').innerHTML='<img src="'+item.url+'">';
}
});
el.querySelector('.st-gdel').addEventListener('click', async (ev)=>{
ev.stopPropagation();
if(!confirm(item.name+' 삭제할까요?'))return;
await fetch('/api/imagegen/uploads/'+encodeURIComponent(item.name),{method:'DELETE',headers:authH()});
loadUploadsGrid(tabSuffix);
});
grid.appendChild(el);
}
}catch(e){}
}
let uploadedPhotoPath=null;
async function onPhotoSelected(file){
if(!file) return;
const zone=document.getElementById('photo-dropzone');
zone.textContent='업로드 중...';
uploadedPhotoPath=null;
try{
const form=new FormData();
form.append('image', file);
const r=await fetch('/api/upload/image',{method:'POST',headers:authH(),body:form});
const d=await r.json();
if(!r.ok || !d.success){
zone.textContent='업로드 실패: '+(d.error||'알 수 없는 오류');
return;
}
uploadedPhotoPath=d.path;
zone.innerHTML='<img src="'+d.url+'">';
loadUploadsGrid('1');
}catch(e){
zone.textContent='업로드 실패: '+e.message;
}
}
function toggleStrengthField(styleSelectId, fieldId){
const isCartoon = document.getElementById(styleSelectId).value==='cartoon';
document.getElementById(fieldId).style.display = isCartoon ? 'flex' : 'none';
}
async function runEdit(){
if(!uploadedPhotoPath){alert('먼저 사진을 업로드해주세요');return;}
const style=document.getElementById('f-style').value;
const body={path:uploadedPhotoPath, style};
if(style==='cartoon') body.strength=parseFloat(document.getElementById('f-strength').value);
const resultArea=document.getElementById('result-area');
resultArea.innerHTML='<div style="text-align:center"><div class="st-spinner"></div><div class="st-status">스타일 변환 중...</div><div class="st-progress-track"><div class="st-progress-fill" id="gen-progress-fill"></div></div></div>';
startTimer();
try{
const r=await fetch('/api/imagegen/edit-image',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify(body)});
const d=await r.json();
stopTimer();
if(!r.ok || !d.success){
resultArea.innerHTML='<div class="st-error">변환 실패: '+(d.error||'알 수 없는 오류')+'</div>';
return;
}
resultArea.innerHTML=resultMediaHtml(d.url, 'image', 'edited_'+Date.now()+'.png');
loadGallery();
}catch(e){
stopTimer();
resultArea.innerHTML='<div class="st-error">요청 실패: '+e.message+'</div>';
}
}
function escapeHtml(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML;}
let uploadedStickerPath=null;
async function onStickerPhotoSelected(file){
if(!file) return;
const zone=document.getElementById('sticker-dropzone');
zone.textContent='업로드 중...';
uploadedStickerPath=null;
try{
const form=new FormData();
form.append('image', file);
const r=await fetch('/api/upload/image',{method:'POST',headers:authH(),body:form});
const d=await r.json();
if(!r.ok || !d.success){
zone.textContent='업로드 실패: '+(d.error||'알 수 없는 오류');
return;
}
uploadedStickerPath=d.path;
zone.innerHTML='<img src="'+d.url+'">';
loadUploadsGrid('2');
}catch(e){
zone.textContent='업로드 실패: '+e.message;
}
}
async function runStickerSet(){
if(!uploadedStickerPath){alert('먼저 사진을 업로드해주세요');return;}
const style=document.getElementById('f-style2').value;
const phrases=document.getElementById('f-phrases').value.split('\n').map(s=>s.trim()).filter(Boolean).slice(0,12);
if(!phrases.length){alert('문구를 한 줄 이상 입력해주세요');return;}
const body={path:uploadedStickerPath, style, phrases};
if(style==='cartoon') body.strength=parseFloat(document.getElementById('f-strength2').value);
const resultArea=document.getElementById('result-area');
resultArea.innerHTML='<div style="text-align:center"><div class="st-spinner"></div><div class="st-status">이모티콘 세트 만드는 중... (스타일 변환 → 배경 제거 → 문구 '+phrases.length+'개)</div><div class="st-progress-track"><div class="st-progress-fill" id="gen-progress-fill"></div></div></div>';
startTimer();
try{
const r=await fetch('/api/imagegen/make-sticker-set',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify(body)});
const d=await r.json();
stopTimer();
if(!r.ok || !d.success){
resultArea.innerHTML='<div class="st-error">생성 실패: '+(d.error||'알 수 없는 오류')+'</div>';
return;
}
resultArea.innerHTML='<div class="st-sticker-grid">'+d.stickers.map((s,i)=>
'<div class="st-sticker-tile"><img src="'+s.url+'">'
+'<a class="st-sticker-dl" href="'+s.url+'" download="sticker_'+(i+1)+'.png" title="다운로드" onclick="event.stopPropagation()">⬇</a>'
+'<div class="st-sticker-cap">'+escapeHtml(s.phrase)+'</div></div>'
).join('')+'</div>';
}catch(e){
stopTimer();
resultArea.innerHTML='<div class="st-error">요청 실패: '+e.message+'</div>';
}
}
async function generate(){
const prompt=document.getElementById('f-prompt').value.trim();
if(!prompt){alert('프롬프트를 입력해주세요');return;}
const resultArea=document.getElementById('result-area');
const isHighQuality = currentTab==='image' && document.getElementById('f-quality').value==='high';
const statusMsg = currentTab==='video' ? '동영상 생성 중... 최대 1~2분 정도 걸릴 수 있어요'
: isHighQuality ? '고품질(FLUX.1) 이미지 생성 중... 최대 1분 정도 걸릴 수 있어요'
: '이미지 생성 중...';
resultArea.innerHTML='<div style="text-align:center"><div class="st-spinner"></div><div class="st-status">'+statusMsg+'</div><div class="st-progress-track"><div class="st-progress-fill" id="gen-progress-fill"></div></div></div>';
startTimer();
const body={
prompt,
negative_prompt: document.getElementById('f-neg').value.trim(),
width: parseInt(document.getElementById('f-width').value)||undefined,
height: parseInt(document.getElementById('f-height').value)||undefined,
steps: parseInt(document.getElementById('f-steps').value)||undefined,
guidance_scale: parseFloat(document.getElementById('f-guidance').value)||undefined,
};
if(currentTab==='image'){
body.quality=document.getElementById('f-quality').value;
const seed=document.getElementById('f-seed').value;
if(seed) body.seed=parseInt(seed);
} else {
body.model=document.getElementById('f-video-model').value;
body.num_frames=parseInt(document.getElementById('f-frames').value)||undefined;
body.fps=parseInt(document.getElementById('f-fps').value)||undefined;
if(uploadedVideoImagePath && body.model!=='cogvideox'){
body.image=uploadedVideoImagePath;
body.strength=parseFloat(document.getElementById('f-video-strength').value)||0.5;
}
}
try{
const endpoint = currentTab==='image' ? '/api/imagegen/generate-image' : '/api/imagegen/generate-video';
const r=await fetch(endpoint,{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify(body)});
const d=await r.json();
stopTimer();
if(!r.ok || !d.success){
resultArea.innerHTML='<div class="st-error">생성 실패: '+(d.error||'알 수 없는 오류')+'</div>';
return;
}
if(currentTab==='image'){
resultArea.innerHTML=resultMediaHtml(d.url, 'image', 'generated_'+Date.now()+'.png');
} else {
resultArea.innerHTML=resultMediaHtml(d.url, 'video', 'generated_'+Date.now()+'.mp4', false, {width:d.width, height:d.height, num_frames:d.num_frames, fps:d.fps});
}
loadGallery();
}catch(e){
stopTimer();
resultArea.innerHTML='<div class="st-error">요청 실패: '+e.message+'</div>';
}
}
async function loadGallery(){
try{
const r=await fetch('/api/imagegen/gallery',{headers:authH()});
const d=await r.json();
const grid=document.getElementById('gallery-grid');
grid.innerHTML='';
gallerySelected.clear();
const sa=document.getElementById('gal-selall'); if(sa) sa.checked=false;
updateGridToolbar('gal', gallerySelected);
for(const item of (d.items||[])){
const el=document.createElement('div');
el.className='st-gitem';
const media = item.type==='image'
? '<img src="'+item.url+'">'
: '<video src="'+item.url+'" muted></video>';
el.innerHTML='<input type="checkbox" class="st-gchk">'+media
+'<span class="st-gtype">'+(item.type==='image'?'🖼️':'🎬')+'</span>'
+'<a class="st-gdl" href="'+item.url+'" download="'+item.name+'" title="다운로드">⬇</a>'
+'<button class="st-gdel" title="삭제">✕</button>';
wireGridItem(el, item.name, gallerySelected, 'gal');
el.querySelector('.st-gdl').addEventListener('click', ev=>ev.stopPropagation());
el.querySelector('.st-gitem > img, .st-gitem > video')?.addEventListener('click',()=>{
document.getElementById('result-area').innerHTML = resultMediaHtml(item.url, item.type, item.name);
});
el.addEventListener('click',(ev)=>{ if(['BUTTON','INPUT','A'].includes(ev.target.tagName))return;
document.getElementById('result-area').innerHTML = resultMediaHtml(item.url, item.type, item.name);
});
el.querySelector('.st-gdel').addEventListener('click', async (ev)=>{
ev.stopPropagation();
if(!confirm(item.name+' 삭제할까요?'))return;
await fetch('/api/imagegen/gallery/'+encodeURIComponent(item.name),{method:'DELETE',headers:authH()});
loadGallery();
});
grid.appendChild(el);
}
}catch(e){}
}
function openZoom(el){
const modal=document.getElementById('zoom-modal');
const img=document.getElementById('zoom-img');
const vid=document.getElementById('zoom-video');
if(el.tagName==='VIDEO'){
vid.src=el.currentSrc||el.src; vid.style.display='block'; img.style.display='none';
vid.play().catch(()=>{});
} else {
img.src=el.currentSrc||el.src; img.style.display='block'; vid.style.display='none'; vid.pause();
}
modal.classList.add('show');
}
function closeZoom(){
document.getElementById('zoom-modal').classList.remove('show');
document.getElementById('zoom-video').pause();
}
let lastResultTap=0;
function handleResultDblTap(ev){
const t=ev.target;
if(t.tagName!=='IMG'&&t.tagName!=='VIDEO')return;
const now=Date.now();
if(now-lastResultTap<350){
ev.preventDefault();
openZoom(t);
lastResultTap=0;
} else {
lastResultTap=now;
}
}
document.getElementById('result-area').addEventListener('touchend', handleResultDblTap);
document.getElementById('result-area').addEventListener('dblclick', (ev)=>{
const t=ev.target;
if(t.tagName==='IMG'||t.tagName==='VIDEO') openZoom(t);
});
(async function init(){
const ok=await checkAuth();if(!ok)return;
loadGallery();
loadUploadsGrid('1');
loadUploadsGrid('2');
})();
</script>
</body>
</html>