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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user