"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}
{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 ( ); } 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) => ( ))}

{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 ?

제목 훼손 검사

{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.checks.map((c) => (

{c.label}

))}

proof 시트

{/* eslint-disable-next-line @next/next/no-img-element */} proof

승인하면 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 ; } }