v4.1.8: 이미지/동영상 생성 스튜디오 앱 + SDXL/FLUX 품질 개선

- 이미지·동영상 생성 전용 앱(studio-app.html) + API 라우트 추가, 갤러리 기능 포함
- SD1.5 → SDXL 교체 (프롬프트 반영력/해상도 개선), 한글 프롬프트 자동 영어 번역 추가
- image_generate에 quality="high" 옵션 추가 (FLUX.1-schnell 4비트 양자화, 게이트 없는 커뮤니티 미러 사용)
- video_generate에 guidance_scale 노출, 기본 steps 상향
- 생성기 서브프로세스가 결과 없이 죽는 경우의 크래시 방지 + 에러 로깅 강화
- 멀티 GPU 환경에서 시스템 통계 GPU 미터가 깨지던 nvidia-smi 파싱 버그 수정

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-07-15 02:38:58 +09:00
co-authored by Claude Sonnet 5
parent 2427aeeaba
commit 88bad1e7d9
7 changed files with 624 additions and 51 deletions
+1
View File
@@ -65,6 +65,7 @@
<button class="mode-btn" onclick="window.open('/pptx-wizard.html','_blank')" title="슬라이드 제작">📊 슬라이드</button>
<button class="mode-btn" onclick="window.open('/language-app.html','_blank')" title="우즈베크어 학습">🇺🇿 언어</button>
<button class="mode-btn" onclick="window.open('/music-app.html','_blank')" title="음악 스튜디오">🎵 음악</button>
<button class="mode-btn" onclick="window.open('/studio-app.html','_blank')" title="이미지·동영상 생성">🎨 스튜디오</button>
<button class="mode-btn" onclick="window.open('/accountant-app.html','_blank')" title="회계 어시스턴트">📊 회계</button>
<button class="mode-btn" onclick="window.open('/mind-app.html','_blank')" title="마음 상담실">🧠 상담</button>
<button class="mode-btn" onclick="window.open('/lawyer-app.html','_blank')" title="법률 어시스턴트">⚖️ 법률</button>
+286
View File
@@ -0,0 +1,286 @@
<!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="cherry_logo.png">
<link rel="stylesheet" href="styles.css">
<script>try{var _t=localStorage.getItem('cherryclaw_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-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 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;}
.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-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-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-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;}
</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>
</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 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="1024" 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 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 class="st-row" id="row-video-only" style="display:none">
<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>
<button class="st-gen-btn" id="gen-btn" onclick="generate()">생성</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">최근 생성 이력</div>
<div class="st-gallery-grid" id="gallery-grid"></div>
</div>
</div>
</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;}
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('cherryclaw_theme',n);}catch{}}
let currentTab='image';
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('row-image-only').style.display = tab==='image' ? 'flex' : 'none';
document.getElementById('row-video-only').style.display = tab==='video' ? 'flex' : 'none';
if(tab==='image'){
document.getElementById('f-width').value=1024; document.getElementById('f-height').value=1024;
document.getElementById('f-quality').value='fast';
onQualityChange();
} else {
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초, 시간이 걸립니다';
}
}
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 genStartTime=0, genTimer=null;
function startTimer(){
genStartTime=Date.now();
const btn=document.getElementById('gen-btn');
btn.disabled=true;
genTimer=setInterval(()=>{
const s=((Date.now()-genStartTime)/1000).toFixed(0);
btn.textContent='생성 중... ('+s+'초)';
},500);
}
function stopTimer(){
clearInterval(genTimer);
document.getElementById('gen-btn').disabled=false;
document.getElementById('gen-btn').textContent='생성';
}
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>';
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.num_frames=parseInt(document.getElementById('f-frames').value)||undefined;
body.fps=parseInt(document.getElementById('f-fps').value)||undefined;
}
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='<img src="'+d.url+'" alt="generated">';
} else {
resultArea.innerHTML='<video src="'+d.url+'" controls autoplay loop></video>';
}
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='';
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.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>';
});
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;
await fetch('/api/imagegen/gallery/'+encodeURIComponent(item.name),{method:'DELETE',headers:authH()});
loadGallery();
});
grid.appendChild(el);
}
}catch(e){}
}
(async function init(){
const ok=await checkAuth();if(!ok)return;
loadGallery();
})();
</script>
</body>
</html>