feat(web-ui): Create 탭 배치 생성 섹션 + BatchProgress 폴링

This commit is contained in:
2026-05-10 19:00:42 +09:00
parent 93d5f49cdb
commit a80b869878
4 changed files with 253 additions and 0 deletions

View File

@@ -3417,3 +3417,116 @@
.psm-keyword-main small {
display: block; color: var(--ms-muted, #a0a0b0); font-size: 11px; margin-top: 4px;
}
/* === Batch Generation Section === */
.ms-batch-section {
margin: 16px 0;
padding: 12px 16px;
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--ms-line, #2a2a3a);
border-radius: 12px;
}
.ms-batch-section summary {
cursor: pointer;
font-weight: 600;
color: var(--ms-text, #f0f0f5);
user-select: none;
padding: 4px 0;
}
.ms-batch-section[open] summary {
margin-bottom: 8px;
}
.ms-batch-form {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px 0;
}
.ms-batch-form label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
color: var(--ms-text, #f0f0f5);
}
.ms-batch-form select,
.ms-batch-form input[type="range"] {
padding: 6px 8px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--ms-line, #2a2a3a);
border-radius: 6px;
color: var(--ms-text, #f0f0f5);
}
.ms-batch-form input[type="range"] {
padding: 0;
background: transparent;
}
.ms-batch-checkbox {
flex-direction: row !important;
align-items: center;
gap: 8px;
}
.ms-batch-checkbox input {
width: auto;
margin: 0;
}
.ms-batch-estimate {
font-size: 12px;
color: var(--ms-muted, #a0a0b0);
margin: 4px 0;
}
.ms-batch-form button {
padding: 8px 16px;
background: rgba(56, 189, 248, 0.2);
color: #bae6fd;
border: 1px solid rgba(56, 189, 248, 0.4);
border-radius: 8px;
cursor: pointer;
font-size: 13px;
font-weight: bold;
align-self: flex-start;
}
.ms-batch-form button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ms-batch-progress {
margin-top: 12px;
padding: 12px;
background: rgba(0, 0, 0, 0.35);
border-radius: 8px;
border: 1px solid var(--ms-line, #2a2a3a);
}
.ms-batch-header {
font-size: 13px;
margin-bottom: 8px;
}
.ms-batch-status--generating { color: var(--ms-accent, #38bdf8); }
.ms-batch-status--compiling { color: #fb923c; }
.ms-batch-status--piped { color: #86efac; }
.ms-batch-status--failed { color: #fca5a5; }
.ms-batch-status--cancelled { color: var(--ms-muted, #a0a0b0); }
.ms-batch-tracks {
padding-left: 24px;
font-size: 12px;
margin: 8px 0;
}
.ms-batch-tracks li {
margin: 2px 0;
}
.ms-batch-tracks li.done {
color: #86efac;
}
.ms-batch-tracks li.current {
color: var(--ms-accent, #38bdf8);
font-weight: bold;
}
.ms-batch-tracks li.pending {
color: var(--ms-muted, #a0a0b0);
}
.ms-batch-link {
margin-top: 6px;
font-size: 12px;
color: var(--ms-muted, #a0a0b0);
}

View File

@@ -15,6 +15,8 @@ import {
getTimestampedLyrics,
generateStyleBoost,
generateVideo,
startBatchGen,
getBatchJob,
} from '../../api';
import PullToRefresh from '../../components/PullToRefresh';
import FAB from '../../components/FAB';
@@ -28,6 +30,7 @@ import StemModal from './components/StemModal';
import SyncedLyricsPlayer from './components/SyncedLyricsPlayer';
import RemixTab from './components/RemixTab';
import YoutubeTab from './components/YoutubeTab';
import BatchProgress from './components/BatchProgress';
/* ─────────────────────────────────────────────
데이터 상수
@@ -592,6 +595,16 @@ export default function MusicStudio() {
const pollRef = useRef(null);
const taskIdRef = useRef(null);
/* ── 배치 생성 상태 ── */
const [batchOpen, setBatchOpen] = useState(false);
const [batchGenre, setBatchGenre] = useState('lo-fi');
const [batchCount, setBatchCount] = useState(10);
const [batchDuration, setBatchDuration] = useState(180);
const [batchAutoPipe, setBatchAutoPipe] = useState(true);
const [currentBatch, setCurrentBatch] = useState(null);
const [batchPolling, setBatchPolling] = useState(false);
const batchPollRef = useRef(null);
const activeGenre = GENRES.find((g) => g.id === genre);
const accentColor = activeGenre?.color ?? '#f5a623';
@@ -651,6 +664,43 @@ export default function MusicStudio() {
/* ── 언마운트 시 폴링 정리 ── */
useEffect(() => () => clearInterval(pollRef.current), []);
/* ── 배치 생성 시작 ── */
const startBatch = async () => {
try {
const res = await startBatchGen({
genre: batchGenre,
count: batchCount,
target_duration_sec: batchDuration,
auto_pipeline: batchAutoPipe,
});
setCurrentBatch(res);
setBatchPolling(true);
} catch (e) {
alert(`배치 시작 실패: ${e.message || e}`);
}
};
/* ── 배치 폴링 ── */
useEffect(() => {
if (!batchPolling || !currentBatch?.id) return;
const tick = async () => {
try {
const j = await getBatchJob(currentBatch.id);
if (j) {
setCurrentBatch(j);
if (['piped', 'failed', 'cancelled'].includes(j.status)) {
setBatchPolling(false);
// library 갱신 (새 트랙들 표시되도록)
if (typeof loadLibrary === 'function') loadLibrary();
}
}
} catch { /* swallow */ }
};
batchPollRef.current = setInterval(tick, 5000);
return () => clearInterval(batchPollRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [batchPolling, currentBatch?.id]);
/* ── helpers ── */
const toggleMood = (id) =>
setMoods((prev) =>
@@ -1276,6 +1326,43 @@ export default function MusicStudio() {
</div>
)}
{/* Batch Generation Section */}
<details className="ms-batch-section" open={batchOpen} onToggle={(e) => setBatchOpen(e.currentTarget.open)}>
<summary>🎲 배치 생성 (장르 1-10트랙 + 자동 영상)</summary>
<div className="ms-batch-form">
<label>장르
<select value={batchGenre} onChange={e => setBatchGenre(e.target.value)}>
<option value="lo-fi">Lo-Fi</option>
<option value="phonk">Phonk</option>
<option value="ambient">Ambient</option>
<option value="pop">Pop</option>
</select>
</label>
<label>트랙 : <strong>{batchCount}</strong>
<input type="range" min={1} max={10} value={batchCount}
onChange={e => setBatchCount(parseInt(e.target.value))} />
</label>
<label>트랙당 길이: <strong>{batchDuration}</strong>
<input type="range" min={60} max={300} step={10} value={batchDuration}
onChange={e => setBatchDuration(parseInt(e.target.value))} />
</label>
<label className="ms-batch-checkbox">
<input type="checkbox" checked={batchAutoPipe}
onChange={e => setBatchAutoPipe(e.target.checked)} />
모든 트랙 생성 자동 영상 파이프라인 시작
</label>
<p className="ms-batch-estimate">
예상: {Math.ceil(batchCount * 1.5)}{batchCount * 2} ·
{' '}비용 ~${(batchCount * 0.005 + (batchAutoPipe ? 0.05 : 0)).toFixed(2)}
</p>
<button className="button primary" onClick={startBatch}
disabled={batchPolling}>
🎵 배치 생성 시작
</button>
</div>
{currentBatch && <BatchProgress batch={currentBatch} />}
</details>
{/* Step 1: Genre */}
<section className="ms-section">
<div className="ms-section__head">

View File

@@ -0,0 +1,48 @@
const STATUS_LABELS = {
queued: '대기 중',
generating: '음악 생성 중',
generated: '음악 완료, 컴파일 대기',
compiling: '컴파일 중',
piped: '영상 파이프라인 시작됨 — YouTube 탭 진행 탭에서 확인',
failed: '실패',
cancelled: '취소',
};
export default function BatchProgress({ batch }) {
if (!batch) return null;
const trackList = Array.from({ length: batch.count }, (_, i) => i + 1);
return (
<div className="ms-batch-progress">
<div className="ms-batch-header">
배치 #{batch.id} <strong>{batch.genre}</strong> ·{' '}
{batch.completed}/{batch.count} 완료 ·{' '}
상태: <strong className={`ms-batch-status ms-batch-status--${batch.status}`}>
{STATUS_LABELS[batch.status] || batch.status}
</strong>
</div>
{batch.error && <div className="ms-error">에러: {batch.error}</div>}
<ol className="ms-batch-tracks">
{trackList.map(n => {
const completed = n <= batch.completed;
const current = n === batch.current_track_index && batch.status === 'generating';
const tr = (batch.tracks || [])[n - 1];
return (
<li key={n} className={completed ? 'done' : current ? 'current' : 'pending'}>
{completed ? '✓' : current ? '⏳' : '○'}
{' '}Track {n}: {tr?.title || (current ? '생성 중...' : '대기')}
</li>
);
})}
</ol>
{batch.compile_job_id && (
<div className="ms-batch-link">📀 컴파일 #{batch.compile_job_id}</div>
)}
{batch.pipeline_id && (
<div className="ms-batch-link">
🎬 영상 파이프라인 #{batch.pipeline_id} {' '}
<em>YouTube 진행 탭에서 확인</em>
</div>
)}
</div>
);
}