feat(web-ui): Create 탭 배치 생성 섹션 + BatchProgress 폴링
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
48
src/pages/music/components/BatchProgress.jsx
Normal file
48
src/pages/music/components/BatchProgress.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user