"use client"; /** * Playreel 잡 계약 — product/PLAYREEL_JOURNEY.md §3 과 1:1. * 서버(/api/playreel)가 아직 없으므로 `?mock=` 로 각 게이트 화면을 띄울 수 있다. */ import { useEffect, useRef, useState } from "react"; import { apiFetch, type Job, type StageState } from "./api"; export type GateKey = | "fetch_confirm" | "analysis_confirm" | "narration_confirm" | "clip_confirm" | "final_confirm"; export const GATE_ORDER: GateKey[] = [ "fetch_confirm", "analysis_confirm", "narration_confirm", "clip_confirm", "final_confirm", ]; /** 스테퍼 순서 — 롱컷 파이프라인 (product_integration.md) */ export const PLAYREEL_STAGES = [ "fetch", "split", "upscale", "analyze", "motion", "narration", "tts", "bgm", "i2v", "hybrid", "compose", "review", ] as const; export const PLAYREEL_STAGE_LABELS: Record = { fetch: "수집", split: "섹션", upscale: "화질", analyze: "요소", motion: "연출", narration: "나레이션", tts: "음성", bgm: "음악", i2v: "생성", hybrid: "합성", compose: "조립", review: "검수", }; // ── 게이트별 검수 페이로드 ────────────────────────────────────────── export interface DetailSection { id: string; tag: string; // split_detail.py --tag 12태그 어휘 label: string; // 한글 라벨 thumb_url: string; height: number; required?: boolean; // 캐스팅 스케줄 = 항상 포함 selected: boolean; } export interface FetchReview { poster_url: string; poster_width: number; // 750이면 저해상 경고 meta: { title: string; date_text: string; place: string; cast: string[]; genre: string }; sections: DetailSection[]; } export interface AnalysisReview { grid_url: string; // 5% 격자 오버레이 movable: { key: string; label: string; on: boolean }[]; // motion_plan MOTION_PHRASE 어휘 fixed: { key: string; label: string; on: boolean }[]; // 제목·로고·인물 … model: "kling3_0" | "veo3_1"; ip_risk: boolean; // 디즈니 등 — veo 거부 이력 has_qr: boolean; } export interface NarrationReview { lines: { slot: string; text: string }[]; // 8문장, slot = 훅1·훅2·수상·넘버·캐스팅스케줄·일시·CTA… voice: { id: string; name: string; sample_url: string }; est_seconds: number; // 28~31 정상 } export interface ClipReview { clip_url: string; frames_url: string; // 5시점 프레임 시트 title_mae: number; // ≤4 통과 gate_passed: boolean; gate_reason: string | null; // VLM 2단 사유 retry_credits: number; // 재생성 시 재과금 } export interface FinalReview { video_url: string; proof_url: string; duration: number; checks: { key: string; label: string; ok: boolean }[]; version: number; } export type GateReview = | { gate: "fetch_confirm"; data: FetchReview } | { gate: "analysis_confirm"; data: AnalysisReview } | { gate: "narration_confirm"; data: NarrationReview } | { gate: "clip_confirm"; data: ClipReview } | { gate: "final_confirm"; data: FinalReview }; export interface PlayreelJob extends Omit { kind: "playreel"; source: { url: string; goods_id: string; slug: string }; gate: GateKey | null; review: GateReview | null; credits_used: number; version: number; } // ── URL 인식 ──────────────────────────────────────────────────────── /** NOL티켓/인터파크 상품 URL에서 goodsId를 뽑는다. 못 뽑으면 null. */ export function parseGoodsId(url: string): string | null { const s = url.trim(); if (!s) return null; const m = // 인터파크 주소는 야놀자로 리다이렉트된다. 상품 번호는 양쪽이 같다 s.match(/nol\.yanolja\.com\/ticket\/products\/(\d{5,})/i) ?? s.match(/tickets\.interpark\.com\/goods\/(\d{5,})/i) ?? s.match(/nol\.interpark\.com\/[^?]*?(\d{8,})/i) ?? s.match(/[?&]goodsCode=(\d{5,})/i) ?? s.match(/^(\d{8,})$/); return m ? m[1] : null; } // ── 게이트 메타 (카드 헤더 문구) ─────────────────────────────────── export const GATE_META: Record = { fetch_confirm: { step: 1, name: "수집 확인", title: "이 공연이 맞는지, 어떤 부분을 넣을지 확인해 주세요", why: "상세페이지에서 가져온 정보로 영상의 뼈대를 만듭니다. 캐스팅 스케줄은 항상 들어갑니다.", next: "포스터 화질 보정 · 요소 분석", eta: "약 2분", canBack: false, }, analysis_confirm: { step: 2, name: "연출 확인", title: "포스터에서 무엇을 움직일지 정해주세요", why: "승인하면 영상 생성이 시작되고 되돌릴 수 없습니다. 제목·로고·인물은 원본 그대로 고정됩니다.", next: "Kling 3.0 영상 생성", eta: "약 5~8분", canBack: true, }, narration_confirm: { step: 3, name: "나레이션 확인", title: "나레이션 문장과 목소리를 확인해 주세요", why: "이 문장이 그대로 읽힙니다. 캐스팅 스케줄 안내와 일시·CTA 문장은 꼭 필요합니다.", next: "음성 합성 · 배경음악 생성", eta: "약 3분", canBack: true, }, clip_confirm: { step: 4, name: "클립 검수", title: "생성된 장면을 확인해 주세요", why: "제목이 깨지지 않았는지 자동 검사한 결과입니다. 다시 만들면 처음부터 다시 만듭니다.", next: "원본 글자 합성 · 상세페이지 스크롤 조립", eta: "약 5분", canBack: false, }, final_confirm: { step: 5, name: "최종 검수", title: "완성된 예고편을 확인해 주세요", why: "승인하면 이 버전이 고정되어 아카이브에 저장됩니다. 이후 수정은 새 버전으로 만들어집니다.", next: "아카이브 저장 · 다운로드", eta: "즉시", canBack: false, }, }; // ── 목 데이터 — 서버 없이 게이트 화면을 보기 위한 것 ───────────────── function stages(doneUpTo: number, running?: string): Record { const out: Record = {}; PLAYREEL_STAGES.forEach((k, i) => { out[k] = { status: i < doneUpTo ? "done" : k === running ? "running" : "idle", started: null, ended: null, }; }); return out; } const PH = (w: number, h: number, text: string) => `data:image/svg+xml;utf8,${encodeURIComponent( `${text}`, )}`; // 실물이 오는 모양. 심규선 콘서트 상세페이지 실측 19조각이다 — 라벨이 겹치고 대부분 공지다. // 이 자리에 6개짜리 깔끔한 목을 두었다가 게이트 ①이 같은 글자 열아홉 개만 뱉는 걸 놓쳤다. const MOCK_SECTIONS: DetailSection[] = ([ ["keyvisual", "키비주얼", 1108], ["synopsis", "작품 소개", 313], ["fragment", "조각", 244], ["info", "관람 정보", 270], ["notice", "유의사항", 608], ["info", "관람 정보", 346], ["fragment", "조각", 270], ["notice", "유의사항", 887], ["schedule", "캐스팅 스케줄", 582], ["notice", "유의사항", 1149], ["notice", "유의사항", 968], ["info", "관람 정보", 383], ["discount", "할인 안내", 473], ["fragment", "조각", 285], ["notice", "유의사항", 875], ["notice", "유의사항", 1063], ["fragment", "조각", 152], ["notice", "유의사항", 1068], ["info", "관람 정보", 371], ] as const).map(([tag, label, height], i) => ({ id: `detail_01_s${String(i + 1).padStart(2, "0")}`, tag, label, height, thumb_url: PH(180, 240, String(i + 1)), required: tag === "schedule" || undefined, selected: tag !== "fragment", })); export function mockJob(gate: GateKey | "running" | "done" | "failed"): PlayreelJob { const base: PlayreelJob = { id: "mock", kind: "playreel", name: "뮤지컬 〈겨울왕국〉", status: "awaiting_review", stage: null, stages: stages(0), narration: null, metadata: null, error: null, artifacts: {}, created_at: 0, source: { url: "https://tickets.interpark.com/goods/25012345", goods_id: "25012345", slug: "frozen" }, gate: null, review: null, credits_used: 0, version: 0, queue_size: 0, }; switch (gate) { case "running": return { ...base, status: "running", stage: "i2v", stages: stages(8, "i2v"), credits_used: 16, gate: null }; case "failed": return { ...base, status: "failed", stages: { ...stages(8), i2v: { status: "failed", started: null, ended: null } }, error: { stage: "i2v", detail: "제목 훼손 게이트 실패 — t=7.5s 제목 'N' 가려짐 (VLM 2단)" }, credits_used: 16 }; case "done": return { ...base, status: "done", stages: stages(12), credits_used: 16, version: 1, artifacts: { video: "", thumbnail: "" } }; case "fetch_confirm": return { ...base, gate, stages: stages(2), review: { gate, data: { poster_url: PH(300, 420, "포스터 750px"), poster_width: 750, meta: { title: "뮤지컬 〈겨울왕국〉", date_text: "2026.11.25 ~ 2027.03.01", place: "샤롯데씨어터", cast: ["박혜나", "정선아", "이지혜"], genre: "뮤지컬" }, sections: MOCK_SECTIONS, } } }; case "analysis_confirm": return { ...base, gate, stages: stages(4), credits_used: 2, review: { gate, data: { grid_url: PH(300, 420, "5% 격자"), movable: [ { key: "light", label: "빛줄기·조명", on: true }, { key: "smoke", label: "안개·연기", on: true }, { key: "star", label: "별·눈 결정", on: true }, { key: "cloud", label: "구름", on: false }, { key: "flag", label: "천·망토", on: false }, { key: "crowd", label: "인물", on: false }, ], fixed: [ { key: "title", label: "제목", on: true }, { key: "logo", label: "로고·후원바", on: true }, { key: "figure", label: "인물 실루엣", on: true }, { key: "date", label: "일시·장소", on: true }, ], model: "kling3_0", ip_risk: true, has_qr: false, } } }; case "narration_confirm": return { ...base, gate, stages: stages(6), credits_used: 16, review: { gate, data: { lines: [ { slot: "훅 1", text: "얼어붙은 왕국이 무대 위에서 깨어납니다." }, { slot: "훅 2", text: "전 세계를 사로잡은 그 이야기, 이제 눈앞에서." }, { slot: "수상·세계관", text: "토니상 노미네이트, 브로드웨이 오리지널 프로덕션." }, { slot: "넘버·캐스트", text: "'Let It Go'를 박혜나, 정선아, 이지혜가 부릅니다." }, { slot: "캐스팅 스케줄", text: "회차별 캐스팅은 상세페이지 캐스팅 스케줄에서 확인하세요." }, { slot: "일시", text: "11월 25일부터 샤롯데씨어터에서." }, { slot: "할인", text: "얼리버드 예매 시 최대 30% 할인." }, { slot: "CTA", text: "지금 예매 페이지에서 예매하세요." }, ], voice: { id: "tc_61e748d0", name: "Yena (여성 · 또렷한 안내톤)", sample_url: "" }, est_seconds: 30.4, } } }; case "clip_confirm": return { ...base, gate, stages: stages(9), credits_used: 16, review: { gate, data: { clip_url: "", frames_url: PH(720, 200, "5시점 프레임 시트"), title_mae: 3.04, gate_passed: true, gate_reason: null, retry_credits: 14, } } }; case "final_confirm": return { ...base, gate, stages: stages(11), credits_used: 16, review: { gate, data: { video_url: "", proof_url: PH(720, 240, "proof 시트"), duration: 30.6, version: 1, checks: [ { key: "first", label: "첫 프레임 = 포스터 원본", ok: true }, { key: "title", label: "제목 온전 (MAE 3.0)", ok: true }, { key: "scroll", label: "상세페이지 풀프레임 스크롤", ok: true }, { key: "schedule", label: "캐스팅 스케줄 포함", ok: true }, { key: "band", label: "예매 안내 밴드 + QR", ok: true }, { key: "duck", label: "BGM 덕킹", ok: true }, ], } } }; } } // ── 폴링 훅 (api.ts useJob 과 같은 규약, mock 지원) ─────────────────── const ACTIVE = new Set(["queued", "running"]); export function usePlayreelJob(id: string | null, mock: string | null, intervalMs = 2500) { const [job, setJob] = useState(null); const [error, setError] = useState(null); const timer = useRef | null>(null); const refresh = async () => { if (!id) return; if (mock) { await Promise.resolve(); setJob(mockJob(mock as GateKey)); return; } try { const j = await apiFetch(`/api/playreel/jobs/${id}`); setJob(j); setError(null); if (!ACTIVE.has(j.status) && timer.current) { clearInterval(timer.current); timer.current = null; } } catch (e) { setError((e as Error).message); } }; useEffect(() => { if (!id) return; // eslint-disable-next-line react-hooks/set-state-in-effect -- 폴링 시작: api.ts useJob 과 같은 규약 void refresh(); if (!mock) timer.current = setInterval(refresh, intervalMs); return () => { if (timer.current) clearInterval(timer.current); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [id, mock]); return { job, error, refresh: () => { refresh(); if (!mock && !timer.current) timer.current = setInterval(refresh, intervalMs); } }; }