"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 DetailSection, 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 (
확인 {g.step} / 5 · {g.name}
{g.title}
{g.why}
{children}
{busy ? "처리 중…" : (approveLabel ?? "승인하고 계속")}
{g.canBack && onBack && (
‹ 이전 단계로
)}
다음 단계 · {g.next} · {g.eta}
{error &&
{error}
}
);
}
// ── 작은 공용 조각 (아이콘은 SVG만 — 이모지 금지) ─────────────────────
const Ico = ({ d, size = 12 }: { d: string; size?: number }) => (
);
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 }) => (
);
function Chip({ on, onClick, children, locked }: { on: boolean; onClick?: () => void; children: React.ReactNode; locked?: boolean }) {
return (
{locked ? : on ? : }{children}
);
}
function Warn({ children }: { children: React.ReactNode }) {
return (
{children}
);
}
const two = { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: "var(--spacing-page-md)", alignItems: "start" } as const;
// 상세페이지 조각은 라벨이 겹친다 — "유의사항"만 일곱 개인 페이지가 흔하다.
// 칩으로 그리면 같은 글자가 줄줄이 서서 무엇을 고르는지 알 수 없어 썸네일을 그린다.
function SectionPicker({ sections, onToggle }: {
sections: DetailSection[]; onToggle: (id: string) => void;
}) {
const chosen = sections.filter((s) => s.selected).length;
return (
<>
상세페이지에서 훑기 시작할 지점
고른 지점부터 상세페이지를 이어서 훑습니다. 조각을 잘라 붙이는 것이 아닙니다.
{sections.map((s, i) => (
onToggle(s.id)}
style={{
position: "relative", padding: 0, borderRadius: "var(--radius-md)", overflow: "hidden",
cursor: s.required ? "default" : "pointer", textAlign: "left", background: "transparent",
border: s.selected ? "2px solid var(--color-mint)" : "2px solid var(--color-border-white-10)",
opacity: s.selected ? 1 : 0.5,
}}>
{/* eslint-disable-next-line @next/next/no-img-element */}
{i + 1}
{s.required ? : s.selected ? : }
{s.label}
))}
{sections.length}개 중 {chosen}개 선택 · 캐스팅 스케줄은 항상 포함됩니다.
>
);
}
// ── ① 수집 확인 ───────────────────────────────────────────────────────
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 (
공연 정보
{([["title", "공연명"], ["date_text", "일시"], ["place", "장소"]] as const).map(([k, label]) => (
{label}
{ const m = { ...meta, [k]: e.target.value }; setMeta(m); emit(sections, m); }} />
))}
캐스트 · {meta.cast.join(", ")}
{
const n = sections.map((x) => x.id === id ? { ...x, selected: !x.selected } : x);
setSections(n); emit(n);
}} />
{data.poster_width <= 800 && (
포스터가 {data.poster_width}px로 작습니다. 다음 단계에서 화질을 2배 보정합니다. 원본 파일이 있으면 무빙포스터 경로에서 직접 올리는 편이 더 선명합니다.
)}
포스터
{/* eslint-disable-next-line @next/next/no-img-element */}
);
}
// ── ② 연출 확인 ───────────────────────────────────────────────────────
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 (
움직일 요소
카메라는 움직이지 않습니다. 빛·안개·천 같은 요소만 살아납니다.
{movable.map((m) => (
{ const n = movable.map((x) => x.key === m.key ? { ...x, on: !x.on } : x); setMovable(n); emit(n); }}>{m.label}
))}
원본 그대로 고정
생성된 화면 위에 원본 픽셀을 다시 덮습니다. 제목은 한 픽셀도 바뀌지 않습니다.
{fixed.map((f) => (
{ const n = fixed.map((x) => x.key === f.key ? { ...x, on: !x.on } : x); setFixed(n); emit(movable, n); }}>{f.label}
))}
생성 모델
Kling 3.0 pro · 8초
{data.ip_risk &&
알려진 IP(디즈니 등) 작품입니다. 다른 모델은 저작권 필터로 거부된 이력이 있어 Kling으로만 진행합니다. 실패하면 아트워크 푸시인으로 대체됩니다. }
{!data.has_qr &&
포스터에 QR이 없어 엔딩 밴드 QR은 상품 페이지 링크로 자동 생성합니다.
}
요소 분석 (5% 격자)
{/* eslint-disable-next-line @next/next/no-img-element */}
);
}
// ── ③ 나레이션·보이스 확인 ────────────────────────────────────────────
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 (
나레이션 8문장
{lines.map((l, i) => (
{l.slot}
{ 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 }); }} />
))}
목소리
{data.voice.name}
{data.voice.sample_url
?
:
샘플 준비 중
}
예상 길이
{data.est_seconds.toFixed(1)}초
목표 28~31초{!lenOk && " — 문장을 줄이거나 늘려주세요"}
);
}
// ── ④ 클립 검수 ───────────────────────────────────────────────────────
export function ClipGate({ data }: { data: ClipReview }) {
return (
{data.clip_url
?
:
생성 클립 8초
}
제목 훼손 검사
{data.gate_passed ? "통과" : "실패"} · 제목대 편차 {data.title_mae.toFixed(2)}
기준 4.0 이하. 원본 제목과 픽셀 단위로 비교한 값입니다.
{data.gate_reason &&
{data.gate_reason}
}
5시점 프레임
{/* eslint-disable-next-line @next/next/no-img-element */}
다시 만들기는 영상 생성을 처음부터 반복합니다. 이 클립 위에 원본 글자를 덮는 합성은 다시 만들지 않습니다.
);
}
// ── ⑤ 최종 검수 ───────────────────────────────────────────────────────
export function FinalGate({ data }: { data: FinalReview }) {
return (
{data.video_url
?
:
완성본 {data.duration.toFixed(1)}초
}
자동 확인 항목
{data.checks.map((c) => (
{c.label}
))}
proof 시트
{/* eslint-disable-next-line @next/next/no-img-element */}
승인하면 v{data.version}으로 고정됩니다.
);
}
/** review 페이로드 → 게이트 본문. edits 콜백은 승인 시 부모가 모아 보낸다. */
export function GateBody({ review, onEdits }: { review: GateReview; onEdits: (e: unknown) => void }) {
switch (review.gate) {
case "fetch_confirm": return ;
case "analysis_confirm": return ;
case "narration_confirm": return ;
case "clip_confirm": return ;
case "final_confirm": return ;
}
}