playreel/frontend/components/gate-card.tsx
2026-09-08 13:48:20 +09:00

269 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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} />;
}
}