Compare commits
5 Commits
b6748ecd27
...
53e9938903
| Author | SHA1 | Date | |
|---|---|---|---|
| 53e9938903 | |||
| 522b7695aa | |||
| 9ffd7889e7 | |||
| 5bba880c23 | |||
| 4498124514 |
17
src/api.js
17
src/api.js
@@ -653,3 +653,20 @@ export const getCompileJob = (id) => apiGet(`/api/music/compile/${id}`);
|
||||
export const deleteCompileJob = (id) => apiDelete(`/api/music/compile/${id}`);
|
||||
export const exportCompileJob = (id) => apiGet(`/api/music/compile/${id}/export`);
|
||||
|
||||
// --- Music Pipeline ---
|
||||
export const listPipelines = (status='all') => apiGet(`/api/music/pipeline?status=${status}`);
|
||||
export const getPipeline = (id) => apiGet(`/api/music/pipeline/${id}`);
|
||||
export const createPipeline = (track_id) => apiPost('/api/music/pipeline', { track_id });
|
||||
export const startPipeline = (id) => apiPost(`/api/music/pipeline/${id}/start`);
|
||||
export const cancelPipeline = (id) => apiPost(`/api/music/pipeline/${id}/cancel`);
|
||||
export const publishPipeline = (id) => apiPost(`/api/music/pipeline/${id}/publish`);
|
||||
|
||||
// --- Music Setup ---
|
||||
export const getMusicSetup = () => apiGet('/api/music/setup');
|
||||
export const updateMusicSetup = (payload) => apiPut('/api/music/setup', payload);
|
||||
|
||||
// --- YouTube OAuth ---
|
||||
export const getYoutubeAuthUrl = () => apiGet('/api/music/youtube/auth-url');
|
||||
export const getYoutubeStatus = () => apiGet('/api/music/youtube/status');
|
||||
export const disconnectYoutube = () => apiPost('/api/music/youtube/disconnect');
|
||||
|
||||
|
||||
@@ -3180,3 +3180,140 @@
|
||||
width: 100%;
|
||||
accent-color: #22c55e;
|
||||
}
|
||||
|
||||
/* === SetupTab === */
|
||||
.setup-container { display:flex; flex-direction:column; gap:16px; padding:16px; }
|
||||
.setup-card {
|
||||
background: rgba(0,0,0,.3);
|
||||
border: 1px solid var(--ms-line, #2a2a3a);
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
}
|
||||
.setup-card h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 15px;
|
||||
color: var(--ms-text, #f0f0f5);
|
||||
font-family: var(--ms-ff-disp, inherit);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.setup-card label {
|
||||
display: block;
|
||||
margin: 8px 0;
|
||||
font-size: 12px;
|
||||
color: var(--ms-muted, #a0a0b0);
|
||||
}
|
||||
.setup-card input,
|
||||
.setup-card textarea,
|
||||
.setup-card select {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
margin-top: 4px;
|
||||
background: rgba(255,255,255,.04);
|
||||
border: 1px solid var(--ms-line, #2a2a3a);
|
||||
border-radius: 8px;
|
||||
color: var(--ms-text, #f0f0f5);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.setup-card input[type="range"] {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
accent-color: var(--ms-accent, #f5a623);
|
||||
}
|
||||
.setup-card button {
|
||||
padding: 6px 14px;
|
||||
margin-top: 8px;
|
||||
background: rgba(245, 166, 35, 0.15);
|
||||
color: var(--ms-accent, #bae6fd);
|
||||
border: 1px solid rgba(245, 166, 35, 0.4);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.setup-card button:hover {
|
||||
background: rgba(245, 166, 35, 0.25);
|
||||
}
|
||||
.setup-channel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.setup-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.setup-prompt-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 6px 0;
|
||||
align-items: center;
|
||||
}
|
||||
.setup-prompt-genre {
|
||||
width: 80px;
|
||||
font-size: 12px;
|
||||
color: var(--ms-muted, #a0a0b0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.setup-saving {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
background: #222;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255,255,255,.1);
|
||||
font-size: 12px;
|
||||
color: var(--ms-text, #f0f0f5);
|
||||
z-index: 100;
|
||||
}
|
||||
.ms-loading,
|
||||
.ms-error {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--ms-muted, #a0a0b0);
|
||||
}
|
||||
.ms-error {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
/* === PipelineTab === */
|
||||
.pipeline-container { padding:16px; }
|
||||
.pipeline-toolbar { display:flex; gap:12px; margin-bottom:16px; align-items:center; }
|
||||
.pipeline-toolbar select { padding:6px 10px; background:rgba(255,255,255,.04);
|
||||
border:1px solid var(--ms-line, #2a2a3a); border-radius:8px; color:var(--ms-text, #f0f0f5); font-size:13px; }
|
||||
.pipeline-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(320px, 1fr)); gap:16px; }
|
||||
.pipeline-card { background:rgba(0,0,0,.3); border:1px solid var(--ms-line, #2a2a3a);
|
||||
border-radius:14px; padding:16px; display:flex; flex-direction:column; gap:8px; }
|
||||
.pipeline-card__head { display:flex; justify-content:space-between; align-items:center; }
|
||||
.pipeline-card__head h4 { margin:0; font-size:14px; color:var(--ms-text, #f0f0f5); }
|
||||
.pipeline-card__head button { padding:4px 10px; background:rgba(248,113,113,.15); color:#fca5a5;
|
||||
border:1px solid rgba(248,113,113,.3); border-radius:6px; cursor:pointer; font-size:11px; }
|
||||
.pipeline-progress { display:flex; gap:6px; margin:8px 0; }
|
||||
.pipeline-dot { flex:1; text-align:center; padding:6px 0; border-radius:8px;
|
||||
background:rgba(255,255,255,.05); font-size:11px; color:var(--ms-muted, #a0a0b0); }
|
||||
.pipeline-dot.is-done { background:rgba(56,189,248,.2); color:#bae6fd; }
|
||||
.pipeline-dot.is-current { box-shadow:0 0 8px rgba(56,189,248,.6); }
|
||||
.pipeline-state { font-size:13px; color:var(--ms-text, #f0f0f5); }
|
||||
.pipeline-review { font-size:12px; color:var(--ms-muted, #a0a0b0); }
|
||||
.pipeline-review strong { color:#bae6fd; }
|
||||
.pipeline-feedback { margin-top:8px; font-size:12px; color:var(--ms-muted, #a0a0b0); }
|
||||
.pipeline-feedback summary { cursor:pointer; }
|
||||
.pipeline-card a { color:#bae6fd; font-size:12px; }
|
||||
.ms-empty { padding:32px; text-align:center; color:var(--ms-muted, #a0a0b0); grid-column:1/-1; }
|
||||
|
||||
/* Modal — shared */
|
||||
.modal-overlay { position:fixed; inset:0; background:rgba(0,0,0,.6);
|
||||
display:flex; align-items:center; justify-content:center; z-index:1000; }
|
||||
.modal-body { background:#1a1a2e; padding:24px; border-radius:14px; min-width:320px;
|
||||
border:1px solid var(--ms-line, #2a2a3a); }
|
||||
.modal-body h3 { margin:0 0 12px; font-size:15px; color:var(--ms-text, #f0f0f5); }
|
||||
.modal-body select { width:100%; padding:8px; background:rgba(255,255,255,.04);
|
||||
border:1px solid var(--ms-line, #2a2a3a); border-radius:8px; color:var(--ms-text, #f0f0f5); font-size:13px; }
|
||||
.modal-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:16px; }
|
||||
.modal-actions button { padding:6px 14px; background:rgba(255,255,255,.05); color:var(--ms-text, #f0f0f5);
|
||||
border:1px solid var(--ms-line, #2a2a3a); border-radius:8px; cursor:pointer; font-size:13px; }
|
||||
.modal-actions .button.primary { background:rgba(56,189,248,.2); color:#bae6fd; border-color:rgba(56,189,248,.4); }
|
||||
|
||||
@@ -338,7 +338,7 @@ const TrackResult = ({ track, onDownload, onNew }) => {
|
||||
/* ─────────────────────────────────────────────
|
||||
Library Card
|
||||
───────────────────────────────────────────── */
|
||||
const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, onVideoProject, isGenerating }) => {
|
||||
const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, onVideoProject, onVideoPipeline, isGenerating }) => {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const genre = GENRES.find((g) => g.id === track.genre);
|
||||
const totalSec = track.duration_sec ?? null;
|
||||
@@ -429,6 +429,9 @@ const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemo
|
||||
<button type="button" onClick={() => { onVideoProject(track); setMenuOpen(false); }}>
|
||||
🎯 YouTube 프로젝트
|
||||
</button>
|
||||
<button type="button" onClick={() => { onVideoPipeline(track); setMenuOpen(false); }}>
|
||||
🎬 영상 파이프라인
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -439,6 +442,10 @@ const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemo
|
||||
<a href={track.audio_url} download className="ms-btn ms-btn--ghost ms-btn--sm">
|
||||
↓ Download
|
||||
</a>
|
||||
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm"
|
||||
onClick={() => onVideoPipeline(track)}>
|
||||
🎬 영상 파이프라인
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<p className="ms-lib-card__date">
|
||||
@@ -451,7 +458,7 @@ const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemo
|
||||
/* ─────────────────────────────────────────────
|
||||
Library Section
|
||||
───────────────────────────────────────────── */
|
||||
const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, onVideoProject, isGenerating, loading }) => {
|
||||
const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, onVideoProject, onVideoPipeline, isGenerating, loading }) => {
|
||||
const [playingId, setPlayingId] = useState(null);
|
||||
|
||||
const handlePlay = (track) => {
|
||||
@@ -506,6 +513,7 @@ const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCove
|
||||
onSyncedLyrics={onSyncedLyrics}
|
||||
onVideoGenerate={onVideoGenerate}
|
||||
onVideoProject={onVideoProject}
|
||||
onVideoPipeline={onVideoPipeline}
|
||||
isGenerating={isGenerating}
|
||||
/>
|
||||
))}
|
||||
@@ -521,6 +529,7 @@ export default function MusicStudio() {
|
||||
/* ── 탭 ── */
|
||||
const [tab, setTab] = useState('create');
|
||||
const [initialTrackId, setInitialTrackId] = useState(null);
|
||||
const [openPipelineFor, setOpenPipelineFor] = useState(null);
|
||||
|
||||
/* ── Provider 상태 ── */
|
||||
const [providers, setProviders] = useState([]);
|
||||
@@ -1070,6 +1079,11 @@ export default function MusicStudio() {
|
||||
setTab('youtube');
|
||||
};
|
||||
|
||||
const handleVideoPipeline = (track) => {
|
||||
setOpenPipelineFor(track.id);
|
||||
setTab('youtube');
|
||||
};
|
||||
|
||||
const handleNewTrack = () => {
|
||||
setTrack(null);
|
||||
setGenProgress(0);
|
||||
@@ -1159,6 +1173,7 @@ export default function MusicStudio() {
|
||||
onSyncedLyrics={handleSyncedLyrics}
|
||||
onVideoGenerate={handleVideoGenerate}
|
||||
onVideoProject={handleVideoProject}
|
||||
onVideoPipeline={handleVideoPipeline}
|
||||
isGenerating={isGenerating}
|
||||
/>
|
||||
</PullToRefresh>
|
||||
@@ -1193,6 +1208,7 @@ export default function MusicStudio() {
|
||||
library={library}
|
||||
initialTrackId={initialTrackId}
|
||||
onClearInitialTrack={() => setInitialTrackId(null)}
|
||||
openPipelineFor={openPipelineFor}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
63
src/pages/music/components/PipelineCard.jsx
Normal file
63
src/pages/music/components/PipelineCard.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { cancelPipeline, publishPipeline } from '../../../api';
|
||||
|
||||
const STEP_LABELS = ['커버','영상','썸네','메타','검토','발행'];
|
||||
|
||||
function stepIndex(state) {
|
||||
if (state.startsWith('cover')) return 0;
|
||||
if (state.startsWith('video')) return 1;
|
||||
if (state.startsWith('thumb')) return 2;
|
||||
if (state.startsWith('meta')) return 3;
|
||||
if (state.startsWith('ai_review') || state.startsWith('publish_pending')) return 4;
|
||||
if (state.startsWith('publish')) return 5;
|
||||
if (state === 'published') return 6;
|
||||
return -1;
|
||||
}
|
||||
|
||||
export default function PipelineCard({ pipeline, onChanged }) {
|
||||
const i = stepIndex(pipeline.state);
|
||||
return (
|
||||
<div className="pipeline-card">
|
||||
<div className="pipeline-card__head">
|
||||
<h4>{pipeline.track_title || `Track #${pipeline.track_id}`}</h4>
|
||||
{!['published','cancelled','failed'].includes(pipeline.state) && (
|
||||
<button onClick={async () => { await cancelPipeline(pipeline.id); onChanged(); }}>
|
||||
취소
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="pipeline-progress">
|
||||
{STEP_LABELS.map((lbl, idx) => (
|
||||
<div key={lbl} className={`pipeline-dot ${idx <= i ? 'is-done' : ''} ${idx === i ? 'is-current' : ''}`}>
|
||||
<span>{lbl}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="pipeline-state">현재: {pipeline.state}</div>
|
||||
{pipeline.review && (
|
||||
<div className="pipeline-review">
|
||||
AI 검토: <strong>{pipeline.review.verdict}</strong>
|
||||
({pipeline.review.weighted_total}/100)
|
||||
</div>
|
||||
)}
|
||||
{pipeline.state === 'publish_pending' && (
|
||||
<button className="button primary"
|
||||
onClick={async () => { await publishPipeline(pipeline.id); onChanged(); }}>
|
||||
YouTube 업로드
|
||||
</button>
|
||||
)}
|
||||
{pipeline.youtube_video_id && (
|
||||
<a href={`https://youtu.be/${pipeline.youtube_video_id}`} target="_blank" rel="noreferrer">
|
||||
유튜브에서 보기
|
||||
</a>
|
||||
)}
|
||||
{pipeline.feedback && pipeline.feedback.length > 0 && (
|
||||
<details className="pipeline-feedback">
|
||||
<summary>피드백 히스토리 ({pipeline.feedback.length})</summary>
|
||||
{pipeline.feedback.map(f => (
|
||||
<div key={f.id}>• [{f.step}] {f.feedback_text}</div>
|
||||
))}
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
src/pages/music/components/PipelineStartModal.jsx
Normal file
33
src/pages/music/components/PipelineStartModal.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useState } from 'react';
|
||||
import { createPipeline, startPipeline } from '../../../api';
|
||||
|
||||
export default function PipelineStartModal({ library, initialTrackId, onClose, onCreated }) {
|
||||
const [tid, setTid] = useState(initialTrackId || library?.[0]?.id || '');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
const p = await createPipeline(parseInt(tid));
|
||||
await startPipeline(p.id);
|
||||
onCreated(p);
|
||||
} catch (e) { setError(String(e)); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-body" onClick={e => e.stopPropagation()}>
|
||||
<h3>새 파이프라인 시작</h3>
|
||||
<select value={tid} onChange={e => setTid(e.target.value)}>
|
||||
{(library || []).map(t => (
|
||||
<option key={t.id} value={t.id}>{t.title} ({t.genre})</option>
|
||||
))}
|
||||
</select>
|
||||
{error && <div className="ms-error">{error}</div>}
|
||||
<div className="modal-actions">
|
||||
<button onClick={onClose}>취소</button>
|
||||
<button className="button primary" onClick={submit}>시작</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
src/pages/music/components/PipelineTab.jsx
Normal file
55
src/pages/music/components/PipelineTab.jsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { listPipelines } from '../../../api';
|
||||
import PipelineCard from './PipelineCard';
|
||||
import PipelineStartModal from './PipelineStartModal';
|
||||
|
||||
export default function PipelineTab({ library, initialTrackId }) {
|
||||
const [pipelines, setPipelines] = useState([]);
|
||||
const [filter, setFilter] = useState('active');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const timer = useRef(null);
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const r = await listPipelines(filter);
|
||||
setPipelines(r.pipelines || []);
|
||||
} catch { /* swallow */ }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
timer.current = setInterval(load, 5000);
|
||||
return () => clearInterval(timer.current);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialTrackId) setModalOpen(true);
|
||||
}, [initialTrackId]);
|
||||
|
||||
return (
|
||||
<div className="pipeline-container">
|
||||
<div className="pipeline-toolbar">
|
||||
<button className="button primary" onClick={() => setModalOpen(true)}>+ 새 파이프라인</button>
|
||||
<select value={filter} onChange={e => setFilter(e.target.value)}>
|
||||
<option value="active">진행 중</option>
|
||||
<option value="all">전체</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="pipeline-grid">
|
||||
{pipelines.map(p => (
|
||||
<PipelineCard key={p.id} pipeline={p} onChanged={load} />
|
||||
))}
|
||||
{pipelines.length === 0 && <p className="ms-empty">진행 중인 파이프라인이 없습니다</p>}
|
||||
</div>
|
||||
{modalOpen && (
|
||||
<PipelineStartModal
|
||||
library={library}
|
||||
initialTrackId={initialTrackId}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onCreated={() => { setModalOpen(false); load(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
src/pages/music/components/SetupTab.jsx
Normal file
148
src/pages/music/components/SetupTab.jsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
getMusicSetup, updateMusicSetup,
|
||||
getYoutubeAuthUrl, getYoutubeStatus, disconnectYoutube,
|
||||
} from '../../../api';
|
||||
|
||||
export default function SetupTab() {
|
||||
const [setup, setSetup] = useState(null);
|
||||
const [yt, setYt] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([getMusicSetup(), getYoutubeStatus()])
|
||||
.then(([s, y]) => { setSetup(s); setYt(y); })
|
||||
.catch(e => setError(String(e)));
|
||||
}, []);
|
||||
|
||||
if (!setup) return <p className="ms-loading">Loading…</p>;
|
||||
|
||||
const save = async (patch) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const next = await updateMusicSetup(patch);
|
||||
setSetup(next);
|
||||
} catch (e) { setError(String(e)); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const connectYoutube = async () => {
|
||||
const { url } = await getYoutubeAuthUrl();
|
||||
window.location.href = url;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="setup-container">
|
||||
{error && <div className="ms-error">{error}</div>}
|
||||
|
||||
<section className="setup-card">
|
||||
<h3>YouTube 채널 연동</h3>
|
||||
{yt && yt.channel_id ? (
|
||||
<div className="setup-channel">
|
||||
{yt.avatar_url && <img src={yt.avatar_url} alt="" className="setup-avatar" />}
|
||||
<span>{yt.channel_title}</span>
|
||||
<button onClick={async () => { await disconnectYoutube(); setYt({}); }}>
|
||||
연결 해제
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button className="button primary" onClick={connectYoutube}>
|
||||
Google 계정 연결
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="setup-card">
|
||||
<h3>메타데이터 템플릿</h3>
|
||||
<label>제목 패턴
|
||||
<input
|
||||
value={setup.metadata_template.title}
|
||||
onChange={e => setSetup(s => ({...s, metadata_template: {...s.metadata_template, title: e.target.value}}))}
|
||||
/>
|
||||
</label>
|
||||
<label>설명 템플릿
|
||||
<textarea
|
||||
rows={6}
|
||||
value={setup.metadata_template.description}
|
||||
onChange={e => setSetup(s => ({...s, metadata_template: {...s.metadata_template, description: e.target.value}}))}
|
||||
/>
|
||||
</label>
|
||||
<label>기본 태그 (쉼표 구분)
|
||||
<input
|
||||
value={(setup.metadata_template.tags || []).join(', ')}
|
||||
onChange={e => setSetup(s => ({...s, metadata_template: {...s.metadata_template,
|
||||
tags: e.target.value.split(',').map(t => t.trim()).filter(Boolean)}}))}
|
||||
/>
|
||||
</label>
|
||||
<button onClick={() => save({ metadata_template: setup.metadata_template })}>저장</button>
|
||||
</section>
|
||||
|
||||
<section className="setup-card">
|
||||
<h3>AI 커버 prompt (장르별)</h3>
|
||||
{Object.entries(setup.cover_prompts).map(([g, p]) => (
|
||||
<div key={g} className="setup-prompt-row">
|
||||
<span className="setup-prompt-genre">{g}</span>
|
||||
<input
|
||||
value={p}
|
||||
onChange={e => setSetup(s => ({...s, cover_prompts: {...s.cover_prompts, [g]: e.target.value}}))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={() => save({ cover_prompts: setup.cover_prompts })}>저장</button>
|
||||
</section>
|
||||
|
||||
<section className="setup-card">
|
||||
<h3>AI 최종 검토 기준</h3>
|
||||
{['meta','policy','viewer','trend'].map(k => (
|
||||
<label key={k}>
|
||||
{k} 가중치 ({setup.review_weights[k]})
|
||||
<input type="range" min="0" max="100"
|
||||
value={setup.review_weights[k]}
|
||||
onChange={e => setSetup(s => ({...s, review_weights: {...s.review_weights, [k]: parseInt(e.target.value)}}))}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<label>임계값 ({setup.review_threshold})
|
||||
<input type="range" min="0" max="100" value={setup.review_threshold}
|
||||
onChange={e => setSetup(s => ({...s, review_threshold: parseInt(e.target.value)}))}
|
||||
/>
|
||||
</label>
|
||||
<button onClick={() => save({ review_weights: setup.review_weights, review_threshold: setup.review_threshold })}>저장</button>
|
||||
</section>
|
||||
|
||||
<section className="setup-card">
|
||||
<h3>영상 비주얼 기본값</h3>
|
||||
<label>해상도
|
||||
<select value={setup.visual_defaults.resolution}
|
||||
onChange={e => setSetup(s => ({...s, visual_defaults: {...s.visual_defaults, resolution: e.target.value}}))}>
|
||||
<option value="1920x1080">1920×1080 (가로)</option>
|
||||
<option value="1080x1920">1080×1920 (세로/Shorts)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>스타일
|
||||
<select value={setup.visual_defaults.style}
|
||||
onChange={e => setSetup(s => ({...s, visual_defaults: {...s.visual_defaults, style: e.target.value}}))}>
|
||||
<option value="visualizer">Visualizer (파형)</option>
|
||||
</select>
|
||||
</label>
|
||||
<button onClick={() => save({ visual_defaults: setup.visual_defaults })}>저장</button>
|
||||
</section>
|
||||
|
||||
<section className="setup-card">
|
||||
<h3>발행 정책</h3>
|
||||
<label>privacy
|
||||
<select value={setup.publish_policy.privacy}
|
||||
onChange={e => setSetup(s => ({...s, publish_policy: {...s.publish_policy, privacy: e.target.value}}))}>
|
||||
<option value="private">Private (비공개)</option>
|
||||
<option value="unlisted">Unlisted</option>
|
||||
<option value="public">Public</option>
|
||||
</select>
|
||||
</label>
|
||||
<button onClick={() => save({ publish_policy: setup.publish_policy })}>저장</button>
|
||||
</section>
|
||||
|
||||
{saving && <div className="setup-saving">저장 중...</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,48 +3,45 @@ import VideoProjectsTab from './VideoProjectsTab';
|
||||
import RevenueTab from './RevenueTab';
|
||||
import TrendsTab from './TrendsTab';
|
||||
import CompileTab from './CompileTab';
|
||||
import PipelineTab from './PipelineTab';
|
||||
import SetupTab from './SetupTab';
|
||||
|
||||
export default function YoutubeTab({ library, initialTrackId, onClearInitialTrack }) {
|
||||
const [subtab, setSubtab] = useState('video');
|
||||
export default function YoutubeTab({ library, initialTrackId, onClearInitialTrack, openPipelineFor }) {
|
||||
const [subtab, setSubtab] = useState('pipeline');
|
||||
|
||||
// initialTrackId가 들어오면 video 서브탭으로 전환
|
||||
useEffect(() => {
|
||||
if (initialTrackId) setSubtab('video');
|
||||
}, [initialTrackId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (openPipelineFor) setSubtab('pipeline');
|
||||
}, [openPipelineFor]);
|
||||
|
||||
const tabs = [
|
||||
['pipeline', '🚀 진행'],
|
||||
['video', '🎬 영상 제작'],
|
||||
['compile', '🎵 컴파일'],
|
||||
['trends', '📊 시장 트렌드'],
|
||||
['revenue', '💰 수익 추적'],
|
||||
['setup', '⚙️ 구성'],
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="yt-container">
|
||||
<nav className="yt-subtabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`yt-subtab ${subtab === 'video' ? 'is-active' : ''}`}
|
||||
onClick={() => setSubtab('video')}
|
||||
>
|
||||
🎬 영상 제작
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`yt-subtab ${subtab === 'revenue' ? 'is-active' : ''}`}
|
||||
onClick={() => setSubtab('revenue')}
|
||||
>
|
||||
💰 수익 추적
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`yt-subtab ${subtab === 'trends' ? 'is-active' : ''}`}
|
||||
onClick={() => setSubtab('trends')}
|
||||
>
|
||||
📊 시장 트렌드
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`yt-subtab ${subtab === 'compile' ? 'is-active' : ''}`}
|
||||
onClick={() => setSubtab('compile')}
|
||||
>
|
||||
🎵 컴파일
|
||||
</button>
|
||||
{tabs.map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={`yt-subtab ${subtab === key ? 'is-active' : ''}`}
|
||||
onClick={() => setSubtab(key)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{subtab === 'pipeline' && <PipelineTab library={library} initialTrackId={openPipelineFor} />}
|
||||
{subtab === 'video' && (
|
||||
<VideoProjectsTab
|
||||
library={library}
|
||||
@@ -52,9 +49,10 @@ export default function YoutubeTab({ library, initialTrackId, onClearInitialTrac
|
||||
onClearInitialTrack={onClearInitialTrack}
|
||||
/>
|
||||
)}
|
||||
{subtab === 'revenue' && <RevenueTab />}
|
||||
{subtab === 'trends' && <TrendsTab />}
|
||||
{subtab === 'compile' && <CompileTab library={library} />}
|
||||
{subtab === 'trends' && <TrendsTab />}
|
||||
{subtab === 'revenue' && <RevenueTab />}
|
||||
{subtab === 'setup' && <SetupTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user