274 lines
14 KiB
TypeScript
274 lines
14 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* Playreel 잡 계약 — product/PLAYREEL_JOURNEY.md §3 과 1:1.
|
|
* 서버(/api/playreel)가 아직 없으므로 `?mock=<gate>` 로 각 게이트 화면을 띄울 수 있다.
|
|
*/
|
|
|
|
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<string, string> = {
|
|
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<Job, "kind"> {
|
|
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<GateKey, {
|
|
step: number; name: string; title: string; why: string; next: string; credits: number; eta: string; canBack: boolean;
|
|
}> = {
|
|
fetch_confirm: {
|
|
step: 1, name: "수집 확인", title: "이 공연이 맞는지, 어떤 부분을 넣을지 확인해 주세요",
|
|
why: "상세페이지에서 가져온 정보로 영상의 뼈대를 만듭니다. 캐스팅 스케줄은 항상 들어갑니다.",
|
|
next: "포스터 화질 보정 · 요소 분석", credits: 2, eta: "약 2분", canBack: false,
|
|
},
|
|
analysis_confirm: {
|
|
step: 2, name: "연출 확인", title: "포스터에서 무엇을 움직일지 정해주세요",
|
|
why: "승인하면 영상 생성이 시작되고 되돌릴 수 없습니다. 제목·로고·인물은 원본 그대로 고정됩니다.",
|
|
next: "Kling 3.0 영상 생성", credits: 14, eta: "약 5~8분", canBack: true,
|
|
},
|
|
narration_confirm: {
|
|
step: 3, name: "나레이션 확인", title: "나레이션 문장과 목소리를 확인해 주세요",
|
|
why: "이 문장이 그대로 읽힙니다. 캐스팅 스케줄 안내와 일시·CTA 문장은 꼭 필요합니다.",
|
|
next: "음성 합성 · 배경음악 생성", credits: 0, eta: "약 3분", canBack: true,
|
|
},
|
|
clip_confirm: {
|
|
step: 4, name: "클립 검수", title: "생성된 장면을 확인해 주세요",
|
|
why: "제목이 깨지지 않았는지 자동 검사한 결과입니다. 다시 만들면 크레딧이 다시 듭니다.",
|
|
next: "원본 글자 합성 · 상세페이지 스크롤 조립", credits: 0, eta: "약 5분", canBack: false,
|
|
},
|
|
final_confirm: {
|
|
step: 5, name: "최종 검수", title: "완성된 예고편을 확인해 주세요",
|
|
why: "승인하면 이 버전이 고정되어 아카이브에 저장됩니다. 이후 수정은 새 버전으로 만들어집니다.",
|
|
next: "아카이브 저장 · 다운로드", credits: 0, eta: "즉시", canBack: false,
|
|
},
|
|
};
|
|
|
|
// ── 목 데이터 — 서버 없이 게이트 화면을 보기 위한 것 ─────────────────
|
|
function stages(doneUpTo: number, running?: string): Record<string, StageState> {
|
|
const out: Record<string, StageState> = {};
|
|
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(
|
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}"><rect width="100%" height="100%" fill="#003538"/><text x="50%" y="50%" fill="#a6ffea" font-size="20" font-family="sans-serif" text-anchor="middle" dominant-baseline="middle">${text}</text></svg>`,
|
|
)}`;
|
|
|
|
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: [
|
|
{ id: "s1", tag: "story", label: "작품 소개", thumb_url: PH(160, 90, "소개"), height: 1800, selected: true },
|
|
{ id: "s2", tag: "awards", label: "수상·세계관", thumb_url: PH(160, 90, "수상"), height: 900, selected: true },
|
|
{ id: "s3", tag: "cast", label: "캐스트", thumb_url: PH(160, 90, "캐스트"), height: 1400, selected: true },
|
|
{ id: "s4", tag: "schedule", label: "캐스팅 스케줄", thumb_url: PH(160, 90, "스케줄"), height: 2200, required: true, selected: true },
|
|
{ id: "s5", tag: "discount", label: "할인 안내", thumb_url: PH(160, 90, "할인"), height: 700, selected: true },
|
|
{ id: "s6", tag: "notice", label: "유의사항", thumb_url: PH(160, 90, "유의"), height: 1200, selected: false },
|
|
],
|
|
} } };
|
|
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<PlayreelJob | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const timer = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
const refresh = async () => {
|
|
if (!id) return;
|
|
if (mock) { await Promise.resolve(); setJob(mockJob(mock as GateKey)); return; }
|
|
try {
|
|
const j = await apiFetch<PlayreelJob>(`/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);
|
|
} };
|
|
}
|