v4.1.9: 스튜디오 이미지 편집·저장 플로우 + 음성엔진 정리 + GPU 게이지 분리

- 스튜디오 앱: 사진 편집(스타일 변환/이모티콘 세트) + 결과 뷰어 내 크롭/회전/필터/보정/텍스트 오버레이 편집 툴바 + 저장 플로우(중간 편집은 갤러리에 안 남기고 최종본만 저장+업로드 목록에 재사용 등록)
- image_style_transform: SDXL img2img + IP-Adapter-FaceID로 cartoon 스타일 추가 (watercolor/sketch는 identity drift로 재검토 후 제외)
- 음성엔진 provider 값 xtts_gpu → omnivoice_gpu로 리네이밍 (실제 로딩 모델과 이름 일치, XTTS는 이미 OmniVoice로 교체된 지 오래)
- 메인창 사이드바 GPU 게이지를 GPU0/GPU1로 분리 표시
- 단종된 gemini-3-flash-preview를 기본 모델값에서 전부 제거, kimi-k2.6:cloud로 교체
- vault 백업을 cron→systemd timer(Persistent=true)로 전환, /DATA 전체 동기화 스크립트 추가

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-07-15 18:16:20 +09:00
co-authored by Claude Sonnet 5
parent 88bad1e7d9
commit 2228aedbf7
19 changed files with 1366 additions and 96 deletions
+17 -4
View File
@@ -4736,10 +4736,21 @@ async function loadSystemStats() {
// 사이드바 리소스 바
setText('sb-sys-cpu', fmtPercent(cpu));
setText('sb-sys-ram', fmtPercent(ram));
setText('sb-sys-vram', gpuAvailable ? fmtPercent(vram) : 'N/A');
setMeter('sb-sys-cpu-meter', cpu);
setMeter('sb-sys-ram-meter', ram);
setMeter('sb-sys-vram-meter', gpuAvailable ? vram : 0);
// GPU별 VRAM — 카드가 2개 이상이면 GPU0/GPU1 분리 표시, 1개면 GPU1 행 숨김
const gpuList = Array.isArray(stats?.gpu?.gpus) ? stats.gpu.gpus : [];
const gpu0 = gpuList[0];
const gpu1 = gpuList[1];
setText('sb-sys-vram0', gpu0 ? fmtPercent(gpu0.vram_used_percent) : (gpuAvailable ? fmtPercent(vram) : 'N/A'));
setMeter('sb-sys-vram0-meter', gpu0 ? gpu0.vram_used_percent : (gpuAvailable ? vram : 0));
const gpu1Row = document.getElementById('sb-gpu1-row');
if (gpu1Row) gpu1Row.style.display = gpu1 ? 'flex' : 'none';
if (gpu1) {
setText('sb-sys-vram1', fmtPercent(gpu1.vram_used_percent));
setMeter('sb-sys-vram1-meter', gpu1.vram_used_percent);
}
const rxBps = stats?.network?.rx_bps || 0;
const txBps = stats?.network?.tx_bps || 0;
setText('sb-sys-rx', fmtNetSpeed(rxBps));
@@ -4800,10 +4811,12 @@ async function loadSystemStats() {
setMeter('sys-vram-meter', 0);
setText('sb-sys-cpu', '--%');
setText('sb-sys-ram', '--%');
setText('sb-sys-vram', 'N/A');
setText('sb-sys-vram0', 'N/A');
setText('sb-sys-vram1', 'N/A');
setMeter('sb-sys-cpu-meter', 0);
setMeter('sb-sys-ram-meter', 0);
setMeter('sb-sys-vram-meter', 0);
setMeter('sb-sys-vram0-meter', 0);
setMeter('sb-sys-vram1-meter', 0);
setText('sb-sys-rx', '0 B/s');
setText('sb-sys-tx', '0 B/s');
setMeter('sb-sys-rx-meter', 0);
+10 -5
View File
@@ -153,10 +153,15 @@
<div class="sys-meter-track"><i id="sb-sys-ram-meter"></i></div>
<span id="sb-sys-ram" style="font-size:11px;font-weight:700;font-family:'IBM Plex Mono',monospace;color:var(--text);width:36px;text-align:right;flex-shrink:0">--%</span>
</div>
<div style="display:flex;align-items:center;gap:8px">
<span style="font-size:11px;font-weight:700;color:var(--muted);width:32px;flex-shrink:0">GPU</span>
<div class="sys-meter-track"><i id="sb-sys-vram-meter"></i></div>
<span id="sb-sys-vram" style="font-size:11px;font-weight:700;font-family:'IBM Plex Mono',monospace;color:var(--text);width:36px;text-align:right;flex-shrink:0">--%</span>
<div id="sb-gpu0-row" style="display:flex;align-items:center;gap:8px">
<span style="font-size:11px;font-weight:700;color:var(--muted);width:32px;flex-shrink:0">GPU0</span>
<div class="sys-meter-track"><i id="sb-sys-vram0-meter"></i></div>
<span id="sb-sys-vram0" style="font-size:11px;font-weight:700;font-family:'IBM Plex Mono',monospace;color:var(--text);width:36px;text-align:right;flex-shrink:0">--%</span>
</div>
<div id="sb-gpu1-row" style="display:none;align-items:center;gap:8px">
<span style="font-size:11px;font-weight:700;color:var(--muted);width:32px;flex-shrink:0">GPU1</span>
<div class="sys-meter-track"><i id="sb-sys-vram1-meter"></i></div>
<span id="sb-sys-vram1" style="font-size:11px;font-weight:700;font-family:'IBM Plex Mono',monospace;color:var(--text);width:36px;text-align:right;flex-shrink:0">--%</span>
</div>
</div>
</div>
@@ -829,7 +834,7 @@
</select>
<div id="search-ollama-model-row" style="display:none;margin-top:8px">
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:4px">Ollama 검색 모델</label>
<input id="settings-search-ollama-model" type="text" placeholder="예: gemini-3-flash-preview:cloud" style="width:100%;border:1px solid var(--line);border-radius:8px;padding:7px 9px;font-size:12px;box-sizing:border-box">
<input id="settings-search-ollama-model" type="text" placeholder="예: kimi-k2.6:cloud" style="width:100%;border:1px solid var(--line);border-radius:8px;padding:7px 9px;font-size:12px;box-sizing:border-box">
</div>
<div id="search-searxng-url-row" style="display:none;margin-top:8px">
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:4px">SearXNG 인스턴스 URL</label>
+746 -42
View File
@@ -27,6 +27,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;fon
.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;}
@@ -36,8 +37,8 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;fon
.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;}
.st-result img,.st-result video{max-width:100%;max-height:100%;border-radius:10px;box-shadow:0 4px 24px rgba(0,0,0,.25);}
.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);}}
@@ -45,13 +46,85 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;fon
.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;}
.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;top:2px;left:2px;font-size:9px;background:rgba(0,0,0,.6);color:#fff;padding:1px 4px;border-radius:3px;}
.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;display:none;}
.st-gitem:hover .st-gdel{display:block;}
.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-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{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;}
.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>
@@ -66,36 +139,40 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;fon
<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>
</div>
<div class="st-layout">
<div class="st-form">
<div class="st-field">
<label>프롬프트</label>
<textarea id="f-prompt" rows="4" placeholder="예: a red panda reading a book, cozy lighting"></textarea>
</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 id="common-fields" style="display:contents">
<div class="st-field">
<label>가로</label>
<input type="number" id="f-width" value="1024" step="8">
<label>프롬프트</label>
<textarea id="f-prompt" rows="4" placeholder="예: a red panda reading a book, cozy lighting"></textarea>
</div>
<div class="st-field">
<label>세로</label>
<input type="number" id="f-height" value="1024" step="8">
<label>네거티브 프롬프트 (선택)</label>
<textarea id="f-neg" rows="2" placeholder="blurry, low quality, deformed"></textarea>
</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 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-field">
<label>가이던스 스케일</label>
<input type="number" id="f-guidance" value="7.0" step="0.5">
<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">
@@ -121,7 +198,88 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;fon
<input type="number" id="f-fps" value="24">
</div>
</div>
<button class="st-gen-btn" id="gen-btn" onclick="generate()">생성</button>
<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>
@@ -130,12 +288,23 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;fon
<div class="st-placeholder">프롬프트를 입력하고 생성 버튼을 눌러주세요</div>
</div>
<div class="st-gallery">
<div class="st-gallery-hdr">최근 생성 이력</div>
<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 '';}}
@@ -147,21 +316,357 @@ async function checkAuth(){
}
function toggleTheme(){const d=document.documentElement;const n=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',n);try{localStorage.setItem('cherryclaw_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){
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()" title="자르기">✂️</button>'
+'<button type="button" onclick="quickRotate()" title="회전">🔄</button>'
+'<button type="button" onclick="quickFlip()" title="좌우반전">↔️</button>'
+'<button type="button" onclick="quickAutoEnhance()" title="자동보정">✨</button>'
+'<button type="button" onclick="quickRemoveBg()" title="배경투명화">🪄</button>'
+'<button type="button" onclick="togglePanel(\'filter-panel\')" title="필터">🎨</button>'
+'<button type="button" onclick="togglePanel(\'adjust-panel\')" title="보정">🎚️</button>'
+'<button type="button" onclick="togglePanel(\'text-panel\')" title="텍스트">📝</button>'
+'<button type="button" id="save-result-btn" onclick="saveResult()" title="저장">💾</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
+'<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('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=1024;
document.getElementById('f-width').value=1024; document.getElementById('f-height').value=768;
document.getElementById('f-quality').value='fast';
onQualityChange();
} else {
} 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이라 더 걸려요';
}
}
@@ -185,15 +690,170 @@ function startTimer(){
genStartTime=Date.now();
const btn=document.getElementById('gen-btn');
btn.disabled=true;
const verb = genVerb();
genTimer=setInterval(()=>{
const s=((Date.now()-genStartTime)/1000).toFixed(0);
btn.textContent='생성 중... ('+s+'초)';
btn.textContent=verb+' 중... ('+s+'초)';
},500);
}
function stopTimer(){
clearInterval(genTimer);
document.getElementById('gen-btn').disabled=false;
document.getElementById('gen-btn').textContent='생성';
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>';
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>';
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(){
@@ -234,9 +894,9 @@ async function generate(){
return;
}
if(currentTab==='image'){
resultArea.innerHTML='<img src="'+d.url+'" alt="generated">';
resultArea.innerHTML=resultMediaHtml(d.url, 'image', 'generated_'+Date.now()+'.png');
} else {
resultArea.innerHTML='<video src="'+d.url+'" controls autoplay loop></video>';
resultArea.innerHTML=resultMediaHtml(d.url, 'video', 'generated_'+Date.now()+'.mp4');
}
loadGallery();
}catch(e){
@@ -251,21 +911,27 @@ async function loadGallery(){
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=media+'<span class="st-gtype">'+(item.type==='image'?'🖼️':'🎬')+'</span><button class="st-gdel" title="삭제">✕</button>';
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 = item.type==='image'
? '<img src="'+item.url+'">' : '<video src="'+item.url+'" controls autoplay loop></video>';
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.addEventListener('click',(ev)=>{ if(ev.target.tagName!=='BUTTON'){
document.getElementById('result-area').innerHTML = item.type==='image'
? '<img src="'+item.url+'">' : '<video src="'+item.url+'" controls autoplay loop></video>';
}});
el.querySelector('.st-gdel').addEventListener('click', async (ev)=>{
ev.stopPropagation();
if(!confirm(item.name+' 삭제할까요?'))return;
@@ -277,9 +943,47 @@ async function loadGallery(){
}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>
File diff suppressed because one or more lines are too long