# Qwen-Image-Layered로 포스터 자동 레이어 분해 (7/10): 웹 인터페이스 구현 ## 인터페이스 디자인 목표 사용자가 기술적 지식 없이도 쉽게 사용할 수 있는 UI를 만든다. **핵심 원칙**: 1. **단순성** - 3번의 클릭으로 완료 2. **피드백** - 모든 단계에서 진행 상황 표시 3. **시각성** - 결과를 즉시 미리보기 4. **접근성** - 모바일에서도 사용 가능 ## 전체 플로우 ``` [1단계] 이미지 드래그 앤 드롭 ↓ [2단계] 레이어 수 선택 (AI 추천 옵션) ↓ [3단계] 처리 중... (진행률 표시) ↓ [4단계] 결과 확인 및 다운로드 ``` ## HTML 구조 `frontend/index.html`: ```html Poster Layer Decomposer - AI로 포스터 분해

🎨 Poster Layer Decomposer

AI가 포스터를 편집 가능한 레이어로 자동 분해합니다

1. 포스터 이미지 업로드

이미지를 드래그하거나 클릭하여 업로드

JPG, PNG, WebP (최대 10MB)

2. 레이어 수 설정

3. AI 처리 중...

0% 초기화 중...

처리 로그

⏱ 예상 소요 시간: 30초 ~ 2분

✅ 분해 완료!

품질 점수: -
``` ## CSS 스타일 `frontend/style.css`: ```css :root { --primary: #6366f1; --success: #10b981; --warning: #f59e0b; --danger: #ef4444; --gray-50: #f9fafb; --gray-200: #e5e7eb; --gray-700: #374151; --gray-900: #111827; } * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; padding: 20px; } .container { max-width: 1200px; margin: 0 auto; } .header { text-align: center; color: white; margin-bottom: 40px; } .header h1 { font-size: 2.5rem; margin-bottom: 10px; } .subtitle { font-size: 1.1rem; opacity: 0.9; } .section { display: none; } .section.active { display: block; } .card { background: white; border-radius: 16px; padding: 40px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); } .card h2 { color: var(--gray-900); margin-bottom: 20px; font-size: 1.5rem; } /* Upload Zone */ .upload-zone { border: 3px dashed var(--gray-200); border-radius: 12px; padding: 60px 20px; text-align: center; cursor: pointer; transition: all 0.3s; } .upload-zone:hover, .upload-zone.drag-over { border-color: var(--primary); background: var(--gray-50); } .upload-icon { fill: var(--gray-700); margin-bottom: 20px; } .upload-text { font-size: 1.1rem; color: var(--gray-700); margin-bottom: 10px; } .upload-hint { color: var(--gray-700); opacity: 0.7; font-size: 0.9rem; } /* Preview */ .preview-container { position: relative; margin-top: 20px; } .preview-container img { width: 100%; max-height: 400px; object-fit: contain; border-radius: 8px; } .btn-icon { position: absolute; top: 10px; right: 10px; background: white; border: none; border-radius: 50%; width: 32px; height: 32px; cursor: pointer; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); } /* Config */ .config-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin-bottom: 20px; } .config-item label { display: block; margin-bottom: 8px; font-weight: 600; color: var(--gray-700); } .input { width: 100%; padding: 12px; border: 2px solid var(--gray-200); border-radius: 8px; font-size: 1rem; } .ai-suggestion { background: var(--gray-50); padding: 15px; border-radius: 8px; margin: 20px 0; } /* Progress */ .progress-container { margin: 30px 0; } .progress-bar { width: 100%; height: 24px; background: var(--gray-200); border-radius: 12px; overflow: hidden; } .progress-fill { height: 100%; background: linear-gradient(90deg, var(--primary), var(--success)); transition: width 0.3s; width: 0; } .progress-text { text-align: center; margin-top: 10px; color: var(--gray-700); } .log-container { margin-top: 30px; } .log-output { background: var(--gray-900); color: #0f0; padding: 15px; border-radius: 8px; font-family: 'Courier New', monospace; font-size: 0.9rem; max-height: 200px; overflow-y: auto; } /* Layer Grid */ .quality-badge { background: var(--success); color: white; padding: 10px 20px; border-radius: 8px; display: inline-block; margin-bottom: 20px; } .layer-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 20px; margin-bottom: 30px; } .layer-card { border: 2px solid var(--gray-200); border-radius: 12px; padding: 15px; text-align: center; } .layer-card img { width: 100%; height: 150px; object-fit: contain; margin-bottom: 10px; background: repeating-linear-gradient(45deg, #f0f0f0, #f0f0f0 10px, #e0e0e0 10px, #e0e0e0 20px); } .layer-card h4 { margin-bottom: 5px; color: var(--gray-900); } .layer-card p { font-size: 0.9rem; color: var(--gray-700); margin-bottom: 10px; } /* Buttons */ .btn-primary, .btn-secondary, .btn-success { padding: 14px 28px; border: none; border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.3s; } .btn-primary { background: var(--primary); color: white; } .btn-primary:hover:not(:disabled) { background: #4f46e5; transform: translateY(-2px); box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4); } .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } .btn-secondary { background: var(--gray-200); color: var(--gray-700); } .btn-success { background: var(--success); color: white; } .actions { display: flex; gap: 15px; flex-wrap: wrap; } .hint { color: var(--gray-700); opacity: 0.8; font-size: 0.9rem; margin-top: 10px; } .footer { text-align: center; color: white; margin-top: 40px; opacity: 0.8; } ``` ## JavaScript 로직 `frontend/app.js`: ```javascript class PosterDecomposer { constructor() { this.apiBase = 'http://localhost:8000'; this.fileId = null; this.jobId = null; this.ws = null; this.init(); } init() { // 파일 업로드 const dropZone = document.getElementById('dropZone'); const fileInput = document.getElementById('fileInput'); dropZone.addEventListener('click', () => fileInput.click()); dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('drag-over'); }); dropZone.addEventListener('dragleave', () => { dropZone.classList.remove('drag-over'); }); dropZone.addEventListener('drop', async (e) => { e.preventDefault(); dropZone.classList.remove('drag-over'); const file = e.dataTransfer.files[0]; await this.handleFileUpload(file); }); fileInput.addEventListener('change', async (e) => { const file = e.target.files[0]; await this.handleFileUpload(file); }); // AI 추천 document.getElementById('ai-suggest-btn').addEventListener('click', () => this.suggestLayerCount() ); // 분해 시작 document.getElementById('start-decompose-btn').addEventListener('click', () => this.startDecompose() ); // 재시작 document.getElementById('restart-btn').addEventListener('click', () => location.reload() ); } async handleFileUpload(file) { if (!file || !file.type.startsWith('image/')) { alert('이미지 파일만 업로드 가능합니다'); return; } const formData = new FormData(); formData.append('file', file); try { const response = await fetch(`${this.apiBase}/api/upload`, { method: 'POST', body: formData }); if (!response.ok) throw new Error('업로드 실패'); const data = await response.json(); this.fileId = data.file_id; // 미리보기 표시 const reader = new FileReader(); reader.onload = (e) => { document.getElementById('preview-image').src = e.target.result; document.getElementById('preview-container').style.display = 'block'; document.getElementById('dropZone').style.display = 'none'; }; reader.readAsDataURL(file); // 다음 섹션 활성화 this.showSection('config'); document.getElementById('start-decompose-btn').disabled = false; } catch (error) { console.error(error); alert('업로드 실패: ' + error.message); } } async suggestLayerCount() { const btn = document.getElementById('ai-suggest-btn'); btn.disabled = true; btn.textContent = '🤖 AI 분석 중...'; try { const fileInput = document.getElementById('fileInput'); const formData = new FormData(); formData.append('file', fileInput.files[0]); const response = await fetch(`${this.apiBase}/api/analyze/suggest-layers`, { method: 'POST', body: formData }); const data = await response.json(); // 추천 표시 document.getElementById('suggested-layers').textContent = data.recommended_layers; document.getElementById('suggestion-reason').textContent = data.reason; document.getElementById('ai-suggestion').style.display = 'block'; // 자동 선택 document.getElementById('num-layers').value = data.recommended_layers; } catch (error) { console.error(error); } finally { btn.disabled = false; btn.textContent = '🤖 AI 추천 레이어 수 분석'; } } async startDecompose() { this.showSection('processing'); const numLayers = parseInt(document.getElementById('num-layers').value); const resolution = parseInt(document.getElementById('resolution').value); try { // 작업 생성 const response = await fetch(`${this.apiBase}/api/decompose`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ file_id: this.fileId, num_layers: numLayers, resolution: resolution }) }); const data = await response.json(); this.jobId = data.job_id; // WebSocket 연결 this.connectWebSocket(); } catch (error) { console.error(error); alert('처리 실패: ' + error.message); } } connectWebSocket() { this.ws = new WebSocket(`ws://localhost:8000/ws/status/${this.jobId}`); this.ws.onmessage = (event) => { const data = JSON.parse(event.data); // 진행률 업데이트 document.getElementById('progress-fill').style.width = `${data.progress}%`; document.getElementById('progress-percent').textContent = `${data.progress}%`; document.getElementById('progress-message').textContent = data.message; // 로그 추가 this.appendLog(`[${new Date().toLocaleTimeString()}] ${data.message}`); // 완료 시 if (data.status === 'completed') { this.showResults(); } else if (data.status === 'failed') { alert('처리 실패'); } }; } async showResults() { this.showSection('results'); // 결과 조회 const response = await fetch(`${this.apiBase}/api/status/${this.jobId}`); const data = await response.json(); // 품질 점수 document.getElementById('quality-score').textContent = `${data.quality_score || 95}/100`; // 레이어 그리드 const grid = document.getElementById('layer-grid'); grid.innerHTML = ''; data.layers.forEach((layer) => { const card = document.createElement('div'); card.className = 'layer-card'; card.innerHTML = ` Layer ${layer.index}

Layer ${layer.index}

${layer.description || '레이어 ' + layer.index}

${layer.size_kb} KB

`; grid.appendChild(card); }); // ZIP 다운로드 document.getElementById('download-all-btn').onclick = () => { window.location.href = `${this.apiBase}/api/download/${this.jobId}`; }; } downloadLayer(url, filename) { const a = document.createElement('a'); a.href = this.apiBase + url; a.download = filename; a.click(); } showSection(section) { document.querySelectorAll('.section').forEach(s => s.classList.remove('active')); document.getElementById(`${section}-section`).classList.add('active'); } appendLog(message) { const log = document.getElementById('log-output'); log.textContent += message + '\n'; log.scrollTop = log.scrollHeight; } } // 앱 시작 const app = new PosterDecomposer(); ``` ## 모바일 반응형 ```css @media (max-width: 768px) { .header h1 { font-size: 1.8rem; } .card { padding: 20px; } .config-grid { grid-template-columns: 1fr; } .layer-grid { grid-template-columns: 1fr; } .actions { flex-direction: column; } .actions button { width: 100%; } } ``` ## 다음 단계 v8에서는 **후처리 및 최적화**를 다룬다: - Alpha matting 품질 향상 - 레이어 경계선 smoothing - GPU 메모리 최적화 - 배치 처리 UI가 완성되었으니, 품질과 성능을 극대화하자. --- **이전 글**: [Vertex AI 통합 (6/10)](./qwen-image-layered-v6.md) **다음 글**: [후처리 및 최적화 (8/10)](./qwen-image-layered-v8.md)