Compare commits
4 Commits
53e9938903
...
3f5cd32c77
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f5cd32c77 | |||
| 120c39a3ef | |||
| 08fce2d4f6 | |||
| 9c12de4593 |
@@ -656,7 +656,11 @@ export const exportCompileJob = (id) => apiGet(`/api/music/compile/${id}/expo
|
|||||||
// --- Music Pipeline ---
|
// --- Music Pipeline ---
|
||||||
export const listPipelines = (status='all') => apiGet(`/api/music/pipeline?status=${status}`);
|
export const listPipelines = (status='all') => apiGet(`/api/music/pipeline?status=${status}`);
|
||||||
export const getPipeline = (id) => apiGet(`/api/music/pipeline/${id}`);
|
export const getPipeline = (id) => apiGet(`/api/music/pipeline/${id}`);
|
||||||
export const createPipeline = (track_id) => apiPost('/api/music/pipeline', { track_id });
|
export const createPipeline = (payload) => {
|
||||||
|
// 옛 호출 호환: createPipeline(13) → { track_id: 13 }
|
||||||
|
if (typeof payload === 'number') payload = { track_id: payload };
|
||||||
|
return apiPost('/api/music/pipeline', payload);
|
||||||
|
};
|
||||||
export const startPipeline = (id) => apiPost(`/api/music/pipeline/${id}/start`);
|
export const startPipeline = (id) => apiPost(`/api/music/pipeline/${id}/start`);
|
||||||
export const cancelPipeline = (id) => apiPost(`/api/music/pipeline/${id}/cancel`);
|
export const cancelPipeline = (id) => apiPost(`/api/music/pipeline/${id}/cancel`);
|
||||||
export const publishPipeline = (id) => apiPost(`/api/music/pipeline/${id}/publish`);
|
export const publishPipeline = (id) => apiPost(`/api/music/pipeline/${id}/publish`);
|
||||||
|
|||||||
@@ -3236,6 +3236,15 @@
|
|||||||
.setup-card button:hover {
|
.setup-card button:hover {
|
||||||
background: rgba(245, 166, 35, 0.25);
|
background: rgba(245, 166, 35, 0.25);
|
||||||
}
|
}
|
||||||
|
.setup-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.setup-checkbox input[type="checkbox"] {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
.setup-channel {
|
.setup-channel {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -3317,3 +3326,83 @@
|
|||||||
.modal-actions button { padding:6px 14px; background:rgba(255,255,255,.05); color:var(--ms-text, #f0f0f5);
|
.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; }
|
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); }
|
.modal-actions .button.primary { background:rgba(56,189,248,.2); color:#bae6fd; border-color:rgba(56,189,248,.4); }
|
||||||
|
|
||||||
|
/* ── CompileTab → Pipeline 영상 만들기 버튼 ─────────────────────── */
|
||||||
|
.cmp-btn-video {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: rgba(56, 189, 248, 0.15);
|
||||||
|
color: #bae6fd;
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.4);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
.cmp-btn-video:hover { background: rgba(56, 189, 248, 0.25); }
|
||||||
|
|
||||||
|
.psm-input-radio { border: 1px solid var(--ms-line, #2a2a3a); padding: 8px 12px;
|
||||||
|
border-radius: 8px; margin-bottom: 12px; }
|
||||||
|
.psm-input-radio legend { padding: 0 6px; font-size: 11px; color: var(--ms-muted, #a0a0b0); }
|
||||||
|
.psm-input-radio label { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; }
|
||||||
|
.psm-advanced { margin-top: 12px; padding: 8px 0; }
|
||||||
|
.psm-advanced summary { cursor: pointer; font-size: 12px; color: var(--ms-muted, #a0a0b0); user-select: none; }
|
||||||
|
.psm-advanced label { display: block; margin: 8px 0; font-size: 12px; }
|
||||||
|
.psm-advanced input, .psm-advanced select { width: 100%; padding: 6px 8px;
|
||||||
|
background: rgba(255,255,255,.04); border: 1px solid var(--ms-line, #2a2a3a);
|
||||||
|
border-radius: 6px; color: var(--ms-text, #f0f0f5); font-size: 12px; }
|
||||||
|
|
||||||
|
/* === Pipeline Detail Modal === */
|
||||||
|
.modal-body--lg { max-width: 720px; max-height: 90vh; overflow-y: auto; }
|
||||||
|
.pdm-header { display:flex; align-items:center; gap:12px; margin-bottom:16px; }
|
||||||
|
.pdm-header h3 { flex:1; margin:0; }
|
||||||
|
.pdm-badge { padding:2px 8px; background:rgba(56,189,248,.2); color:#bae6fd;
|
||||||
|
border-radius:6px; font-size:11px; }
|
||||||
|
.pdm-close { background:none; border:none; font-size:24px; cursor:pointer;
|
||||||
|
color:var(--ms-muted, #a0a0b0); padding:0 6px; }
|
||||||
|
|
||||||
|
.pdm-grid { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-bottom:16px; }
|
||||||
|
.pdm-figure { margin:0; }
|
||||||
|
.pdm-figure img { width:100%; border-radius:8px; display:block; }
|
||||||
|
.pdm-figure figcaption { font-size:11px; color:var(--ms-muted, #a0a0b0); text-align:center; margin-top:4px; }
|
||||||
|
|
||||||
|
.pdm-video { margin-bottom:16px; }
|
||||||
|
.pdm-video video { border-radius:8px; }
|
||||||
|
|
||||||
|
.pdm-section { margin-bottom:16px; padding-bottom:12px; border-bottom:1px solid var(--ms-line, #2a2a3a); }
|
||||||
|
.pdm-section:last-of-type { border-bottom:none; }
|
||||||
|
.pdm-section h4 { margin:0 0 8px; font-size:14px; }
|
||||||
|
.pdm-pre { background:rgba(0,0,0,.3); padding:8px; border-radius:6px; font-size:12px;
|
||||||
|
white-space:pre-wrap; overflow-x:auto; max-height:400px; }
|
||||||
|
|
||||||
|
.pdm-verdict { padding:2px 8px; margin-left:8px; border-radius:6px; font-size:12px; font-weight:bold; }
|
||||||
|
.pdm-verdict--pass { background:rgba(34,197,94,.2); color:#86efac; }
|
||||||
|
.pdm-verdict--fail { background:rgba(248,113,113,.2); color:#fca5a5; }
|
||||||
|
.pdm-score { color:var(--ms-muted, #a0a0b0); font-size:12px; margin-left:8px; font-weight:normal; }
|
||||||
|
.pdm-review-table { width:100%; border-collapse:collapse; font-size:13px; }
|
||||||
|
.pdm-review-table td { padding:4px 8px; border-bottom:1px solid var(--ms-line, #2a2a3a); }
|
||||||
|
.pdm-review-table td:nth-child(2) { text-align:right; font-weight:bold; }
|
||||||
|
.pdm-summary { font-size:12px; color:var(--ms-muted, #a0a0b0); margin-top:8px; }
|
||||||
|
|
||||||
|
.pdm-tracks { padding-left:24px; }
|
||||||
|
.pdm-tracks li { margin-bottom:4px; font-size:13px; }
|
||||||
|
.pdm-track-time { color:var(--ms-accent, #38bdf8); font-family:monospace; }
|
||||||
|
.pdm-track-dur { color:var(--ms-muted, #a0a0b0); font-size:11px; }
|
||||||
|
|
||||||
|
.pdm-feedback { padding-left:0; list-style:none; }
|
||||||
|
.pdm-feedback li { padding:6px 8px; background:rgba(0,0,0,.2); border-radius:6px;
|
||||||
|
margin-bottom:4px; font-size:12px; }
|
||||||
|
.pdm-feedback code { color:#fb923c; font-size:11px; }
|
||||||
|
.pdm-feedback small { display:block; color:var(--ms-muted, #a0a0b0); margin-top:2px; }
|
||||||
|
|
||||||
|
.pdm-youtube { display:inline-block; padding:8px 16px; background:#ff0000; color:white;
|
||||||
|
border-radius:8px; text-decoration:none; font-weight:bold; }
|
||||||
|
|
||||||
|
/* PipelineCard mini previews + style badge */
|
||||||
|
.pipeline-previews { display:flex; gap:8px; margin:8px 0; align-items:center; }
|
||||||
|
.pipeline-preview-mini { width:64px; height:64px; object-fit:cover; border-radius:6px;
|
||||||
|
border:1px solid var(--ms-line, #2a2a3a); }
|
||||||
|
.pipeline-video-icon { font-size:24px; color:var(--ms-accent, #38bdf8); margin-left:4px; }
|
||||||
|
.pipeline-style-badge { padding:1px 6px; background:rgba(56,189,248,.15); color:#bae6fd;
|
||||||
|
border-radius:4px; font-size:10px; }
|
||||||
|
.pipeline-card { cursor:pointer; }
|
||||||
|
.pipeline-card:hover { background:rgba(255,255,255,.02); }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
createCompileJob, getCompileJobs, deleteCompileJob, exportCompileJob,
|
createCompileJob, getCompileJobs, deleteCompileJob, exportCompileJob,
|
||||||
|
createPipeline, startPipeline,
|
||||||
} from '../../../api';
|
} from '../../../api';
|
||||||
|
|
||||||
const fmtDuration = (sec) => {
|
const fmtDuration = (sec) => {
|
||||||
@@ -10,7 +11,7 @@ const fmtDuration = (sec) => {
|
|||||||
return `${m}분 ${s}초`;
|
return `${m}분 ${s}초`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CompileTab({ library }) {
|
export default function CompileTab({ library, onSwitchToPipeline }) {
|
||||||
const [jobs, setJobs] = useState([]);
|
const [jobs, setJobs] = useState([]);
|
||||||
const [selected, setSelected] = useState([]); // [{id, title, audio_url}] in order
|
const [selected, setSelected] = useState([]); // [{id, title, audio_url}] in order
|
||||||
const [crossfade, setCrossfade] = useState(3);
|
const [crossfade, setCrossfade] = useState(3);
|
||||||
@@ -89,6 +90,19 @@ export default function CompileTab({ library }) {
|
|||||||
if (exportData && exportData.id === jobId) setExportData(null);
|
if (exportData && exportData.id === jobId) setExportData(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleVideoFromCompile = async (jobId) => {
|
||||||
|
if (!window.confirm('이 mix로 영상 파이프라인을 시작할까요?')) return;
|
||||||
|
try {
|
||||||
|
const p = await createPipeline({ compile_job_id: jobId });
|
||||||
|
await startPipeline(p.id);
|
||||||
|
if (onSwitchToPipeline) {
|
||||||
|
onSwitchToPipeline(p.id);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert(`파이프라인 시작 실패: ${e.message || e}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const totalMin = selected.length > 0
|
const totalMin = selected.length > 0
|
||||||
? Math.round(selected.reduce((acc, t) => {
|
? Math.round(selected.reduce((acc, t) => {
|
||||||
const match = library.find(l => l.id === t.id);
|
const match = library.find(l => l.id === t.id);
|
||||||
@@ -215,6 +229,12 @@ export default function CompileTab({ library }) {
|
|||||||
>
|
>
|
||||||
{exportingId === j.id ? '...' : '↓ 내보내기'}
|
{exportingId === j.id ? '...' : '↓ 내보내기'}
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
className="cmp-btn-video"
|
||||||
|
onClick={() => handleVideoFromCompile(j.id)}
|
||||||
|
>
|
||||||
|
🎬 영상 만들기
|
||||||
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{j.status === 'failed' && (
|
{j.status === 'failed' && (
|
||||||
|
|||||||
@@ -1,63 +1,90 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { cancelPipeline, publishPipeline } from '../../../api';
|
import { cancelPipeline, publishPipeline } from '../../../api';
|
||||||
|
import PipelineDetailModal from './PipelineDetailModal';
|
||||||
|
|
||||||
const STEP_LABELS = ['커버','영상','썸네','메타','검토','발행'];
|
const STEP_LABELS = ['커버','영상','썸네','메타','검토','발행'];
|
||||||
|
|
||||||
function stepIndex(state) {
|
function stepIndex(state) {
|
||||||
|
if (!state) return -1;
|
||||||
if (state.startsWith('cover')) return 0;
|
if (state.startsWith('cover')) return 0;
|
||||||
if (state.startsWith('video')) return 1;
|
if (state.startsWith('video')) return 1;
|
||||||
if (state.startsWith('thumb')) return 2;
|
if (state.startsWith('thumb')) return 2;
|
||||||
if (state.startsWith('meta')) return 3;
|
if (state.startsWith('meta')) return 3;
|
||||||
if (state.startsWith('ai_review') || state.startsWith('publish_pending')) return 4;
|
if (state.startsWith('ai_review') || state === 'publish_pending') return 4;
|
||||||
if (state.startsWith('publish')) return 5;
|
if (state.startsWith('publish')) return 5;
|
||||||
if (state === 'published') return 6;
|
if (state === 'published') return 6;
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PipelineCard({ pipeline, onChanged }) {
|
export default function PipelineCard({ pipeline, onChanged }) {
|
||||||
|
const [showDetail, setShowDetail] = useState(false);
|
||||||
const i = stepIndex(pipeline.state);
|
const i = stepIndex(pipeline.state);
|
||||||
|
const title = pipeline.compile_title || pipeline.track_title || `Pipeline #${pipeline.id}`;
|
||||||
|
|
||||||
|
const handleCardClick = (e) => {
|
||||||
|
if (e.target.closest('button') || e.target.closest('a')) return;
|
||||||
|
setShowDetail(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pipeline-card">
|
<>
|
||||||
<div className="pipeline-card__head">
|
<div className="pipeline-card" onClick={handleCardClick}>
|
||||||
<h4>{pipeline.track_title || `Track #${pipeline.track_id}`}</h4>
|
<div className="pipeline-card__head">
|
||||||
{!['published','cancelled','failed'].includes(pipeline.state) && (
|
<h4>{title}</h4>
|
||||||
<button onClick={async () => { await cancelPipeline(pipeline.id); onChanged(); }}>
|
{pipeline.visual_style && (
|
||||||
취소
|
<span className="pipeline-style-badge">{pipeline.visual_style}</span>
|
||||||
|
)}
|
||||||
|
{!['published','cancelled','failed'].includes(pipeline.state) && (
|
||||||
|
<button onClick={async () => { await cancelPipeline(pipeline.id); onChanged(); }}>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pipeline-previews">
|
||||||
|
{pipeline.cover_url && (
|
||||||
|
<img src={pipeline.cover_url} alt="" className="pipeline-preview-mini" />
|
||||||
|
)}
|
||||||
|
{pipeline.thumbnail_url && (
|
||||||
|
<img src={pipeline.thumbnail_url} alt="" className="pipeline-preview-mini" />
|
||||||
|
)}
|
||||||
|
{pipeline.video_url && <span className="pipeline-video-icon">▶</span>}
|
||||||
|
</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>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{pipeline.youtube_video_id && (
|
||||||
|
<a href={`https://youtu.be/${pipeline.youtube_video_id}`}
|
||||||
|
target="_blank" rel="noreferrer">
|
||||||
|
유튜브에서 보기
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="pipeline-progress">
|
|
||||||
{STEP_LABELS.map((lbl, idx) => (
|
{showDetail && <PipelineDetailModal pipeline={pipeline} onClose={() => setShowDetail(false)} />}
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
117
src/pages/music/components/PipelineDetailModal.jsx
Normal file
117
src/pages/music/components/PipelineDetailModal.jsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
const fmtTimestamp = (sec) => {
|
||||||
|
if (sec == null) return '';
|
||||||
|
const total = Math.floor(sec);
|
||||||
|
const h = Math.floor(total / 3600);
|
||||||
|
const m = Math.floor((total % 3600) / 60);
|
||||||
|
const s = total % 60;
|
||||||
|
if (h) return `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
|
||||||
|
return `${m}:${String(s).padStart(2,'0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PipelineDetailModal({ pipeline, onClose }) {
|
||||||
|
if (!pipeline) return null;
|
||||||
|
const meta = pipeline.metadata || {};
|
||||||
|
const review = pipeline.review || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
|
<div className="modal-body modal-body--lg" onClick={e => e.stopPropagation()}>
|
||||||
|
<header className="pdm-header">
|
||||||
|
<h3>{pipeline.compile_title || pipeline.track_title || `Pipeline #${pipeline.id}`}</h3>
|
||||||
|
<span className="pdm-badge">{pipeline.visual_style || 'essential'}</span>
|
||||||
|
<button onClick={onClose} className="pdm-close" aria-label="close">×</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="pdm-grid">
|
||||||
|
{pipeline.cover_url && (
|
||||||
|
<figure className="pdm-figure">
|
||||||
|
<img src={pipeline.cover_url} alt="cover" />
|
||||||
|
<figcaption>커버 (배경)</figcaption>
|
||||||
|
</figure>
|
||||||
|
)}
|
||||||
|
{pipeline.thumbnail_url && (
|
||||||
|
<figure className="pdm-figure">
|
||||||
|
<img src={pipeline.thumbnail_url} alt="thumbnail" />
|
||||||
|
<figcaption>썸네일</figcaption>
|
||||||
|
</figure>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pipeline.video_url && (
|
||||||
|
<div className="pdm-video">
|
||||||
|
<video src={pipeline.video_url} controls preload="metadata" width="100%" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{meta.title && (
|
||||||
|
<section className="pdm-section">
|
||||||
|
<h4>메타데이터</h4>
|
||||||
|
<p><strong>제목:</strong> {meta.title}</p>
|
||||||
|
<details>
|
||||||
|
<summary>설명 ({(meta.description || '').length}자)</summary>
|
||||||
|
<pre className="pdm-pre">{meta.description}</pre>
|
||||||
|
</details>
|
||||||
|
<p><strong>태그:</strong> {(meta.tags || []).join(', ')}</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{review.weighted_total != null && (
|
||||||
|
<section className="pdm-section">
|
||||||
|
<h4>
|
||||||
|
AI 검토
|
||||||
|
<span className={`pdm-verdict pdm-verdict--${review.verdict}`}>
|
||||||
|
{review.verdict}
|
||||||
|
</span>
|
||||||
|
<span className="pdm-score">({review.weighted_total}/100)</span>
|
||||||
|
</h4>
|
||||||
|
<table className="pdm-review-table">
|
||||||
|
<tbody>
|
||||||
|
<tr><td>메타데이터 품질</td><td>{review.metadata_quality?.score}</td></tr>
|
||||||
|
<tr><td>콘텐츠 정책</td><td>{review.policy_compliance?.score}</td></tr>
|
||||||
|
<tr><td>시청 경험</td><td>{review.viewer_experience?.score}</td></tr>
|
||||||
|
<tr><td>트렌드 정렬</td><td>{review.trend_alignment?.score}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{review.summary && <p className="pdm-summary"><em>{review.summary}</em></p>}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pipeline.tracks && pipeline.tracks.length > 1 && (
|
||||||
|
<section className="pdm-section">
|
||||||
|
<h4>트랙 리스트 ({pipeline.tracks.length})</h4>
|
||||||
|
<ol className="pdm-tracks">
|
||||||
|
{pipeline.tracks.map(t => (
|
||||||
|
<li key={t.id}>
|
||||||
|
<span className="pdm-track-time">[{fmtTimestamp(t.start_offset_sec)}]</span>
|
||||||
|
{' '}{t.title}
|
||||||
|
<span className="pdm-track-dur"> ({fmtTimestamp(t.duration_sec)})</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pipeline.feedback && pipeline.feedback.length > 0 && (
|
||||||
|
<section className="pdm-section">
|
||||||
|
<h4>피드백 히스토리 ({pipeline.feedback.length})</h4>
|
||||||
|
<ul className="pdm-feedback">
|
||||||
|
{pipeline.feedback.map(f => (
|
||||||
|
<li key={f.id}>
|
||||||
|
<code>[{f.step}]</code> {f.feedback_text}
|
||||||
|
<small> {(f.received_at || '').replace('T', ' ')}</small>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pipeline.youtube_video_id && (
|
||||||
|
<a href={`https://youtu.be/${pipeline.youtube_video_id}`}
|
||||||
|
target="_blank" rel="noreferrer" className="pdm-youtube">
|
||||||
|
🎬 YouTube에서 보기
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,31 +1,134 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { createPipeline, startPipeline } from '../../../api';
|
import { createPipeline, startPipeline, getCompileJobs } from '../../../api';
|
||||||
|
|
||||||
|
const fmtDur = (s) => {
|
||||||
|
if (!s) return '0:00';
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
const sec = Math.round(s % 60);
|
||||||
|
return `${m}:${String(sec).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
export default function PipelineStartModal({ library, initialTrackId, onClose, onCreated }) {
|
export default function PipelineStartModal({ library, initialTrackId, onClose, onCreated }) {
|
||||||
|
const [inputType, setInputType] = useState('track'); // 'track' | 'compile'
|
||||||
const [tid, setTid] = useState(initialTrackId || library?.[0]?.id || '');
|
const [tid, setTid] = useState(initialTrackId || library?.[0]?.id || '');
|
||||||
|
const [cid, setCid] = useState('');
|
||||||
|
const [compileJobs, setCompileJobs] = useState([]);
|
||||||
|
const [advanced, setAdvanced] = useState(false);
|
||||||
|
const [visualStyle, setVisualStyle] = useState('');
|
||||||
|
const [bgMode, setBgMode] = useState('');
|
||||||
|
const [bgKeyword, setBgKeyword] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (inputType === 'compile') {
|
||||||
|
getCompileJobs()
|
||||||
|
.then(r => {
|
||||||
|
const list = (r.jobs || r || []);
|
||||||
|
const completed = list.filter(j => j.status === 'done' || j.status === 'succeeded');
|
||||||
|
setCompileJobs(completed);
|
||||||
|
if (completed.length && !cid) setCid(completed[0].id);
|
||||||
|
})
|
||||||
|
.catch(e => setError(String(e)));
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [inputType]);
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
try {
|
try {
|
||||||
const p = await createPipeline(parseInt(tid));
|
const payload = {};
|
||||||
|
if (inputType === 'track') {
|
||||||
|
payload.track_id = parseInt(tid);
|
||||||
|
} else {
|
||||||
|
if (!cid) {
|
||||||
|
setError('완료된 Mix를 선택해주세요');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
payload.compile_job_id = parseInt(cid);
|
||||||
|
}
|
||||||
|
if (visualStyle) payload.visual_style = visualStyle;
|
||||||
|
if (bgMode) payload.background_mode = bgMode;
|
||||||
|
if (bgKeyword) payload.background_keyword = bgKeyword;
|
||||||
|
|
||||||
|
const p = await createPipeline(payload);
|
||||||
await startPipeline(p.id);
|
await startPipeline(p.id);
|
||||||
onCreated(p);
|
onCreated(p);
|
||||||
} catch (e) { setError(String(e)); }
|
} catch (e) {
|
||||||
|
setError(e.message || String(e));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
<div className="modal-body" onClick={e => e.stopPropagation()}>
|
<div className="modal-body" onClick={e => e.stopPropagation()}>
|
||||||
<h3>새 파이프라인 시작</h3>
|
<h3>새 파이프라인 시작</h3>
|
||||||
<select value={tid} onChange={e => setTid(e.target.value)}>
|
|
||||||
{(library || []).map(t => (
|
<fieldset className="psm-input-radio">
|
||||||
<option key={t.id} value={t.id}>{t.title} ({t.genre})</option>
|
<legend>입력</legend>
|
||||||
))}
|
<label>
|
||||||
</select>
|
<input type="radio" checked={inputType === 'track'}
|
||||||
|
onChange={() => setInputType('track')} />
|
||||||
|
{' '}단일 트랙
|
||||||
|
</label>
|
||||||
|
<label style={{ marginLeft: 12 }}>
|
||||||
|
<input type="radio" checked={inputType === 'compile'}
|
||||||
|
onChange={() => setInputType('compile')} />
|
||||||
|
{' '}Mix (컴파일 결과)
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{inputType === 'track' ? (
|
||||||
|
<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>
|
||||||
|
) : (
|
||||||
|
<select value={cid} onChange={e => setCid(e.target.value)}>
|
||||||
|
{compileJobs.length === 0 && <option value="">완료된 Mix가 없습니다</option>}
|
||||||
|
{compileJobs.map(j => (
|
||||||
|
<option key={j.id} value={j.id}>
|
||||||
|
{j.title || `Mix #${j.id}`}
|
||||||
|
{' '}({fmtDur(j.duration_sec || 0)},{' '}
|
||||||
|
{j.tracks_count || (j.track_ids && j.track_ids.length) || '?'}곡)
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<details className="psm-advanced" open={advanced}>
|
||||||
|
<summary onClick={(e) => { e.preventDefault(); setAdvanced(!advanced); }}>
|
||||||
|
고급 옵션
|
||||||
|
</summary>
|
||||||
|
<label>
|
||||||
|
시각 스타일
|
||||||
|
<select value={visualStyle} onChange={e => setVisualStyle(e.target.value)}>
|
||||||
|
<option value="">기본 (구성 탭 default)</option>
|
||||||
|
<option value="essential">essential (배경 + 중앙 비주얼)</option>
|
||||||
|
<option value="single">single (커버 + 가장자리 파형)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
배경 모드
|
||||||
|
<select value={bgMode} onChange={e => setBgMode(e.target.value)}>
|
||||||
|
<option value="">기본 (구성 탭 default)</option>
|
||||||
|
<option value="static">정적 사진</option>
|
||||||
|
<option value="video_loop">영상 루프 (Pexels)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
배경 키워드 (Pexels 검색용)
|
||||||
|
<input value={bgKeyword} onChange={e => setBgKeyword(e.target.value)}
|
||||||
|
placeholder="rainy window, lofi cafe ..." />
|
||||||
|
</label>
|
||||||
|
</details>
|
||||||
|
|
||||||
{error && <div className="ms-error">{error}</div>}
|
{error && <div className="ms-error">{error}</div>}
|
||||||
<div className="modal-actions">
|
<div className="modal-actions">
|
||||||
<button onClick={onClose}>취소</button>
|
<button onClick={onClose}>취소</button>
|
||||||
<button className="button primary" onClick={submit}>시작</button>
|
<button className="button primary" onClick={submit}
|
||||||
|
disabled={inputType === 'compile' && !cid}>
|
||||||
|
시작
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -113,19 +113,52 @@ export default function SetupTab() {
|
|||||||
|
|
||||||
<section className="setup-card">
|
<section className="setup-card">
|
||||||
<h3>영상 비주얼 기본값</h3>
|
<h3>영상 비주얼 기본값</h3>
|
||||||
|
|
||||||
<label>해상도
|
<label>해상도
|
||||||
<select value={setup.visual_defaults.resolution}
|
<select value={setup.visual_defaults.resolution || '1920x1080'}
|
||||||
onChange={e => setSetup(s => ({...s, visual_defaults: {...s.visual_defaults, resolution: e.target.value}}))}>
|
onChange={e => setSetup(s => ({...s, visual_defaults: {...s.visual_defaults, resolution: e.target.value}}))}>
|
||||||
<option value="1920x1080">1920×1080 (가로)</option>
|
<option value="1920x1080">1920×1080 (가로)</option>
|
||||||
<option value="1080x1920">1080×1920 (세로/Shorts)</option>
|
<option value="1080x1920">1080×1920 (세로/Shorts)</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>스타일
|
|
||||||
<select value={setup.visual_defaults.style}
|
<label>기본 시각 스타일
|
||||||
onChange={e => setSetup(s => ({...s, visual_defaults: {...s.visual_defaults, style: e.target.value}}))}>
|
<select value={setup.visual_defaults.default_visual_style || 'essential'}
|
||||||
<option value="visualizer">Visualizer (파형)</option>
|
onChange={e => setSetup(s => ({...s, visual_defaults: {...s.visual_defaults, default_visual_style: e.target.value}}))}>
|
||||||
|
<option value="essential">essential (배경 + 중앙 비주얼)</option>
|
||||||
|
<option value="single">single (커버 + 가장자리 파형)</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label>기본 배경 모드
|
||||||
|
<select value={setup.visual_defaults.default_background_mode || 'static'}
|
||||||
|
onChange={e => setSetup(s => ({...s, visual_defaults: {...s.visual_defaults, default_background_mode: e.target.value}}))}>
|
||||||
|
<option value="static">정적 사진</option>
|
||||||
|
<option value="video_loop">영상 루프 (Pexels)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>기본 배경 키워드 (비우면 장르 기반 자동)
|
||||||
|
<input value={setup.visual_defaults.default_background_keyword || ''}
|
||||||
|
onChange={e => setSetup(s => ({...s, visual_defaults: {...s.visual_defaults, default_background_keyword: e.target.value}}))}
|
||||||
|
placeholder="lofi cafe, rainy window, mountain ..." />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>배경 이미지 소스 (정적 모드)
|
||||||
|
<select value={setup.visual_defaults.background_image_source || 'ai'}
|
||||||
|
onChange={e => setSetup(s => ({...s, visual_defaults: {...s.visual_defaults, background_image_source: e.target.value}}))}>
|
||||||
|
<option value="ai">AI 생성 (DALL·E)</option>
|
||||||
|
<option value="pexels">Pexels 스톡 사진</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="setup-checkbox">
|
||||||
|
<input type="checkbox"
|
||||||
|
checked={setup.visual_defaults.subtitle_track_titles ?? true}
|
||||||
|
onChange={e => setSetup(s => ({...s, visual_defaults: {...s.visual_defaults, subtitle_track_titles: e.target.checked}}))}/>
|
||||||
|
Mix에서 곡명 자막 표시 (트랙 시작 시 5초)
|
||||||
|
</label>
|
||||||
|
|
||||||
<button onClick={() => save({ visual_defaults: setup.visual_defaults })}>저장</button>
|
<button onClick={() => save({ visual_defaults: setup.visual_defaults })}>저장</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,12 @@ export default function YoutubeTab({ library, initialTrackId, onClearInitialTrac
|
|||||||
onClearInitialTrack={onClearInitialTrack}
|
onClearInitialTrack={onClearInitialTrack}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{subtab === 'compile' && <CompileTab library={library} />}
|
{subtab === 'compile' && (
|
||||||
|
<CompileTab
|
||||||
|
library={library}
|
||||||
|
onSwitchToPipeline={() => setSubtab('pipeline')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{subtab === 'trends' && <TrendsTab />}
|
{subtab === 'trends' && <TrendsTab />}
|
||||||
{subtab === 'revenue' && <RevenueTab />}
|
{subtab === 'revenue' && <RevenueTab />}
|
||||||
{subtab === 'setup' && <SetupTab />}
|
{subtab === 'setup' && <SetupTab />}
|
||||||
|
|||||||
Reference in New Issue
Block a user