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>
1270 lines
70 KiB
HTML
1270 lines
70 KiB
HTML
<!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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|