269 lines
16 KiB
TypeScript
269 lines
16 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* 컨펌 게이트 카드 — PLAYREEL_JOURNEY.md §2 의 공통 구조.
|
||
* [eyebrow] n/5 · 이름 [title] [why] [body] [actions] [cost]
|
||
* 게이트별 본문은 아래 5개 컴포넌트. 편집값은 `edits` 로 부모에게 올려 approve 시 함께 보낸다.
|
||
*/
|
||
|
||
import { useState } from "react";
|
||
import {
|
||
GATE_META, type AnalysisReview, type ClipReview, type FetchReview, type FinalReview,
|
||
type GateKey, type GateReview, type NarrationReview,
|
||
} from "@/lib/playreel";
|
||
|
||
// ── 공통 셸 ──────────────────────────────────────────────────────────
|
||
export function GateShell({ gate, children, onApprove, onBack, busy, error, approveLabel }: {
|
||
gate: GateKey; children: React.ReactNode;
|
||
onApprove: () => void; onBack?: () => void; busy: boolean; error: string | null; approveLabel?: string;
|
||
}) {
|
||
const g = GATE_META[gate];
|
||
return (
|
||
<div>
|
||
<p className="eyebrow" style={{ margin: 0, color: "var(--color-mint)", fontSize: "var(--text-sm)" }}>
|
||
확인 {g.step} / 5 · {g.name}
|
||
</p>
|
||
<h2 style={{ margin: "0.4rem 0 0.5rem", fontSize: "var(--text-2xl)", fontWeight: 700, letterSpacing: "-0.02em" }}>{g.title}</h2>
|
||
<p style={{ margin: "0 0 1.5rem", fontSize: "var(--text-base)", color: "var(--color-text-gray-400)", lineHeight: 1.6 }}>{g.why}</p>
|
||
|
||
{children}
|
||
|
||
<div className="gate-actions">
|
||
<button className="btn-cta btn-lg" disabled={busy} onClick={onApprove}>
|
||
{busy ? "처리 중…" : (approveLabel ?? "승인하고 계속")}
|
||
</button>
|
||
{g.canBack && onBack && (
|
||
<button className="btn-outline btn-lg" disabled={busy} onClick={onBack}>‹ 이전 단계로</button>
|
||
)}
|
||
</div>
|
||
<p style={{ margin: "0.75rem 0 0", fontSize: "var(--text-sm)", color: "var(--color-text-gray-500)" }}>
|
||
다음 단계 · {g.next} · {g.eta}
|
||
{g.credits > 0 && <> · <strong style={{ color: "#ffd27a" }}>{g.credits}크레딧</strong></>}
|
||
</p>
|
||
{error && <p style={{ color: "#ff7a7a", fontSize: "var(--text-sm)", marginTop: "0.75rem" }}>{error}</p>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 작은 공용 조각 (아이콘은 SVG만 — 이모지 금지) ─────────────────────
|
||
const Ico = ({ d, size = 12 }: { d: string; size?: number }) => (
|
||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" aria-hidden style={{ flexShrink: 0 }}>
|
||
<path d={d} />
|
||
</svg>
|
||
);
|
||
const CHECK = "M20 6 9 17l-5-5";
|
||
const CROSS = "M18 6 6 18M6 6l12 12";
|
||
const PLUS = "M12 5v14M5 12h14";
|
||
const Lock = ({ size = 12 }: { size?: number }) => (
|
||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden style={{ flexShrink: 0 }}>
|
||
<rect x="4" y="11" width="16" height="10" rx="2" /><path d="M8 11V7a4 4 0 0 1 8 0v4" />
|
||
</svg>
|
||
);
|
||
|
||
function Chip({ on, onClick, children, locked }: { on: boolean; onClick?: () => void; children: React.ReactNode; locked?: boolean }) {
|
||
return (
|
||
<button type="button" onClick={locked ? undefined : onClick}
|
||
style={{
|
||
display: "inline-flex", alignItems: "center", gap: 6,
|
||
padding: "0.35rem 0.8rem", borderRadius: 999, cursor: locked ? "default" : "pointer", fontSize: "var(--text-sm)",
|
||
border: on ? "1px solid var(--color-mint)" : "1px solid var(--color-border-white-10)",
|
||
background: on ? "var(--color-mint-20)" : "transparent",
|
||
color: on ? "var(--color-mint)" : "var(--color-text-gray-400)", opacity: locked ? 0.85 : 1,
|
||
}}>
|
||
{locked ? <Lock /> : on ? <Ico d={CHECK} /> : <Ico d={PLUS} />}{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function Warn({ children }: { children: React.ReactNode }) {
|
||
return (
|
||
<p style={{ margin: "0.75rem 0 0", padding: "0.6rem 0.9rem", borderRadius: "var(--radius-md)", background: "rgba(255,210,122,0.1)", border: "1px solid rgba(255,210,122,0.35)", color: "#ffd27a", fontSize: "var(--text-sm)", lineHeight: 1.5 }}>
|
||
{children}
|
||
</p>
|
||
);
|
||
}
|
||
|
||
const two = { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: "var(--spacing-page-md)", alignItems: "start" } as const;
|
||
|
||
// ── ① 수집 확인 ───────────────────────────────────────────────────────
|
||
export function FetchGate({ data, onChange }: { data: FetchReview; onChange: (edits: { sections: string[]; meta: FetchReview["meta"] }) => void }) {
|
||
const [sections, setSections] = useState(data.sections);
|
||
const [meta, setMeta] = useState(data.meta);
|
||
const emit = (s = sections, m = meta) => onChange({ sections: s.filter((x) => x.selected).map((x) => x.id), meta: m });
|
||
|
||
return (
|
||
<div style={two}>
|
||
<div>
|
||
<p className="field-label" style={{ margin: "0 0 0.5rem" }}>공연 정보</p>
|
||
{([["title", "공연명"], ["date_text", "일시"], ["place", "장소"]] as const).map(([k, label]) => (
|
||
<div key={k} style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.5rem" }}>
|
||
<span style={{ width: 52, flexShrink: 0, fontSize: "var(--text-sm)", color: "var(--color-text-gray-400)" }}>{label}</span>
|
||
<input className="input" style={{ height: 44 }} value={meta[k]}
|
||
onChange={(e) => { const m = { ...meta, [k]: e.target.value }; setMeta(m); emit(sections, m); }} />
|
||
</div>
|
||
))}
|
||
<p style={{ margin: "0.5rem 0 0", fontSize: "var(--text-sm)", color: "var(--color-text-gray-400)" }}>
|
||
캐스트 · {meta.cast.join(", ")}
|
||
</p>
|
||
|
||
<p className="field-label" style={{ margin: "1.5rem 0 0.5rem" }}>영상에 넣을 상세페이지 부분</p>
|
||
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
|
||
{sections.map((s) => (
|
||
<Chip key={s.id} on={s.selected} locked={s.required}
|
||
onClick={() => { const n = sections.map((x) => x.id === s.id ? { ...x, selected: !x.selected } : x); setSections(n); emit(n); }}>
|
||
{s.label}
|
||
</Chip>
|
||
))}
|
||
</div>
|
||
<p style={{ margin: "0.6rem 0 0", fontSize: "var(--text-xs)", color: "var(--color-text-gray-500)", display: "flex", alignItems: "center", gap: 6 }}>
|
||
<Lock size={11} /> 캐스팅 스케줄은 항상 포함됩니다.
|
||
</p>
|
||
{data.poster_width <= 800 && (
|
||
<Warn>포스터가 {data.poster_width}px로 작습니다. 다음 단계에서 화질을 2배 보정합니다(2크레딧). 원본 파일이 있으면 무빙포스터 경로에서 직접 올리는 편이 더 선명합니다.</Warn>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<p className="field-label" style={{ margin: "0 0 0.5rem" }}>포스터</p>
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
<img src={data.poster_url} alt="포스터" className="card-inner" style={{ width: "100%", maxWidth: 300, padding: 0, display: "block" }} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── ② 연출 확인 ───────────────────────────────────────────────────────
|
||
export function AnalysisGate({ data, onChange }: { data: AnalysisReview; onChange: (edits: { movable: string[]; fixed: string[]; model: string }) => void }) {
|
||
const [movable, setMovable] = useState(data.movable);
|
||
const [fixed, setFixed] = useState(data.fixed);
|
||
const emit = (m = movable, f = fixed) => onChange({ movable: m.filter((x) => x.on).map((x) => x.key), fixed: f.filter((x) => x.on).map((x) => x.key), model: data.model });
|
||
|
||
return (
|
||
<div style={two}>
|
||
<div>
|
||
<p className="field-label" style={{ margin: "0 0 0.5rem" }}>움직일 요소</p>
|
||
<p style={{ margin: "0 0 0.6rem", fontSize: "var(--text-sm)", color: "var(--color-text-gray-400)" }}>카메라는 움직이지 않습니다. 빛·안개·천 같은 요소만 살아납니다.</p>
|
||
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
|
||
{movable.map((m) => (
|
||
<Chip key={m.key} on={m.on} onClick={() => { const n = movable.map((x) => x.key === m.key ? { ...x, on: !x.on } : x); setMovable(n); emit(n); }}>{m.label}</Chip>
|
||
))}
|
||
</div>
|
||
|
||
<p className="field-label" style={{ margin: "1.5rem 0 0.5rem" }}>원본 그대로 고정</p>
|
||
<p style={{ margin: "0 0 0.6rem", fontSize: "var(--text-sm)", color: "var(--color-text-gray-400)" }}>생성된 화면 위에 원본 픽셀을 다시 덮습니다. 제목은 한 픽셀도 바뀌지 않습니다.</p>
|
||
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
|
||
{fixed.map((f) => (
|
||
<Chip key={f.key} on={f.on} locked={f.key === "title"} onClick={() => { const n = fixed.map((x) => x.key === f.key ? { ...x, on: !x.on } : x); setFixed(n); emit(movable, n); }}>{f.label}</Chip>
|
||
))}
|
||
</div>
|
||
|
||
<p className="field-label" style={{ margin: "1.5rem 0 0.5rem" }}>생성 모델</p>
|
||
<p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--color-text-gray-300)" }}>Kling 3.0 pro · 8초 · 14크레딧</p>
|
||
{data.ip_risk && <Warn>알려진 IP(디즈니 등) 작품입니다. 다른 모델은 저작권 필터로 거부된 이력이 있어 Kling으로만 진행합니다. 실패하면 아트워크 푸시인으로 대체됩니다.</Warn>}
|
||
{!data.has_qr && <p style={{ margin: "0.6rem 0 0", fontSize: "var(--text-xs)", color: "var(--color-text-gray-500)" }}>포스터에 QR이 없어 엔딩 밴드 QR은 상품 페이지 링크로 자동 생성합니다.</p>}
|
||
</div>
|
||
<div>
|
||
<p className="field-label" style={{ margin: "0 0 0.5rem" }}>요소 분석 (5% 격자)</p>
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
<img src={data.grid_url} alt="격자 오버레이" className="card-inner" style={{ width: "100%", maxWidth: 300, padding: 0, display: "block" }} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── ③ 나레이션·보이스 확인 ────────────────────────────────────────────
|
||
export function NarrationGate({ data, onChange }: { data: NarrationReview; onChange: (edits: { lines: string[]; voice_id: string }) => void }) {
|
||
const [lines, setLines] = useState(data.lines);
|
||
const lenOk = data.est_seconds >= 28 && data.est_seconds <= 31;
|
||
return (
|
||
<div style={two}>
|
||
<div>
|
||
<p className="field-label" style={{ margin: "0 0 0.5rem" }}>나레이션 8문장</p>
|
||
{lines.map((l, i) => (
|
||
<div key={i} className="narr-row">
|
||
<span className="narr-slot" style={{ color: l.slot === "캐스팅 스케줄" ? "var(--color-mint)" : "var(--color-text-gray-400)" }}>{l.slot}</span>
|
||
<input className="input" style={{ height: 42, fontSize: "var(--text-sm)" }} value={l.text}
|
||
onChange={(e) => { const n = lines.map((x, j) => j === i ? { ...x, text: e.target.value } : x); setLines(n); onChange({ lines: n.map((x) => x.text), voice_id: data.voice.id }); }} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div>
|
||
<p className="field-label" style={{ margin: "0 0 0.5rem" }}>목소리</p>
|
||
<div className="card-inner">
|
||
<p style={{ margin: 0, fontWeight: 700 }}>{data.voice.name}</p>
|
||
{data.voice.sample_url
|
||
? <audio controls src={data.voice.sample_url} style={{ width: "100%", marginTop: "0.6rem" }} />
|
||
: <p style={{ margin: "0.5rem 0 0", fontSize: "var(--text-sm)", color: "var(--color-text-gray-500)" }}>샘플 준비 중</p>}
|
||
</div>
|
||
<p className="field-label" style={{ margin: "1.25rem 0 0.5rem" }}>예상 길이</p>
|
||
<p style={{ margin: 0, fontSize: "var(--text-2xl)", fontWeight: 700, color: lenOk ? "var(--color-mint)" : "#ffd27a" }}>
|
||
{data.est_seconds.toFixed(1)}초
|
||
</p>
|
||
<p style={{ margin: "0.25rem 0 0", fontSize: "var(--text-sm)", color: "var(--color-text-gray-500)" }}>목표 28~31초{!lenOk && " — 문장을 줄이거나 늘려주세요"}</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── ④ 클립 검수 ───────────────────────────────────────────────────────
|
||
export function ClipGate({ data }: { data: ClipReview }) {
|
||
return (
|
||
<div style={two}>
|
||
<div className="card-inner" style={{ display: "flex", alignItems: "center", justifyContent: "center", minHeight: 320 }}>
|
||
{data.clip_url
|
||
? <video src={data.clip_url} controls style={{ width: "100%", maxHeight: 520, borderRadius: "var(--radius-md)" }} />
|
||
: <p style={{ color: "var(--color-text-gray-500)", fontSize: "var(--text-sm)" }}>생성 클립 8초</p>}
|
||
</div>
|
||
<div>
|
||
<p className="field-label" style={{ margin: "0 0 0.5rem" }}>제목 훼손 검사</p>
|
||
<div className="card-inner" style={{ borderColor: data.gate_passed ? "var(--color-mint-30)" : "rgba(255,122,122,0.4)" }}>
|
||
<p style={{ margin: 0, fontSize: "var(--text-xl)", fontWeight: 700, color: data.gate_passed ? "var(--color-mint)" : "#ff8c8c" }}>
|
||
{data.gate_passed ? "통과" : "실패"} · 제목대 편차 {data.title_mae.toFixed(2)}
|
||
</p>
|
||
<p style={{ margin: "0.35rem 0 0", fontSize: "var(--text-sm)", color: "var(--color-text-gray-400)" }}>기준 4.0 이하. 원본 제목과 픽셀 단위로 비교한 값입니다.</p>
|
||
{data.gate_reason && <p style={{ margin: "0.5rem 0 0", fontSize: "var(--text-sm)", color: "#ffb37a" }}>{data.gate_reason}</p>}
|
||
</div>
|
||
<p className="field-label" style={{ margin: "1.25rem 0 0.5rem" }}>5시점 프레임</p>
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
<img src={data.frames_url} alt="프레임 시트" className="card-inner" style={{ width: "100%", padding: 0, display: "block" }} />
|
||
<Warn>다시 만들기는 영상 생성을 처음부터 반복하며 {data.retry_credits}크레딧이 다시 듭니다. 이 클립 위에 원본 글자를 덮는 합성은 무료입니다.</Warn>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── ⑤ 최종 검수 ───────────────────────────────────────────────────────
|
||
export function FinalGate({ data }: { data: FinalReview }) {
|
||
return (
|
||
<div style={two}>
|
||
<div className="card-inner" style={{ display: "flex", alignItems: "center", justifyContent: "center", minHeight: 420 }}>
|
||
{data.video_url
|
||
? <video src={data.video_url} controls style={{ width: "100%", maxHeight: 560, borderRadius: "var(--radius-md)" }} />
|
||
: <p style={{ color: "var(--color-text-gray-500)", fontSize: "var(--text-sm)" }}>완성본 {data.duration.toFixed(1)}초</p>}
|
||
</div>
|
||
<div>
|
||
<p className="field-label" style={{ margin: "0 0 0.5rem" }}>자동 확인 항목</p>
|
||
{data.checks.map((c) => (
|
||
<p key={c.key} style={{ margin: "0.3rem 0", fontSize: "var(--text-base)", color: c.ok ? "var(--color-text-gray-300)" : "#ff8c8c", display: "flex", alignItems: "center", gap: 8 }}>
|
||
<span style={{ color: c.ok ? "var(--color-mint)" : "#ff8c8c", display: "inline-flex" }}><Ico d={c.ok ? CHECK : CROSS} size={14} /></span>{c.label}
|
||
</p>
|
||
))}
|
||
<p className="field-label" style={{ margin: "1.25rem 0 0.5rem" }}>proof 시트</p>
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
<img src={data.proof_url} alt="proof" className="card-inner" style={{ width: "100%", padding: 0, display: "block" }} />
|
||
<p style={{ margin: "0.75rem 0 0", fontSize: "var(--text-sm)", color: "var(--color-text-gray-500)" }}>승인하면 v{data.version}으로 고정됩니다.</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** review 페이로드 → 게이트 본문. edits 콜백은 승인 시 부모가 모아 보낸다. */
|
||
export function GateBody({ review, onEdits }: { review: GateReview; onEdits: (e: unknown) => void }) {
|
||
switch (review.gate) {
|
||
case "fetch_confirm": return <FetchGate data={review.data} onChange={onEdits} />;
|
||
case "analysis_confirm": return <AnalysisGate data={review.data} onChange={onEdits} />;
|
||
case "narration_confirm": return <NarrationGate data={review.data} onChange={onEdits} />;
|
||
case "clip_confirm": return <ClipGate data={review.data} />;
|
||
case "final_confirm": return <FinalGate data={review.data} />;
|
||
}
|
||
}
|