import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { P2vJob, approvePosterJob, p2vFile, updatePosterMetadata, updatePosterNarration, } from '../../utils/p2vApi'; import { META_FIELDS, MOTION_KEYS, MetaField, motionLabel } from './posterData'; interface PosterReviewContentProps { job: P2vJob; /** 승인 요청이 끝나면 호출 — GenerationFlow 가 폴링을 다시 켠다 */ onApproved: () => void; } type MetaDraft = Record; const emptyMeta = (): MetaDraft => ({ event_name: '', date_text: '', place: '' }); /** * 검수 게이트. * * 이 화면이 파이프라인에서 가장 중요한 지점이다 — 여기서 승인해야 TTS·음악· * 애니메이션·렌더가 이어지고, 그 뒤로는 되돌리려면 전부 다시 만들어야 한다. * 그래서 셋을 한 화면에 모아 **포스터 원본과 대조하며** 고치게 한다: * * 1) 나레이션 3문장 — 그대로 음성이 된다 * 2) 움직일 요소 — 애니메이션 대상. 비우면 정지 영상이 된다 * 3) 메타태그 — 아카이브에 영구 저장된다. 연도·지명 오독이 여기서 걸린다 * * 왼쪽에 편집, 오른쪽에 영역 분석 이미지를 두는 배치는 P2V 웹과 같다. */ const PosterReviewContent: React.FC = ({ job, onApproved }) => { const { t } = useTranslation(); const [lines, setLines] = useState([]); const [motions, setMotions] = useState(null); const [meta, setMeta] = useState(emptyMeta); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); // 서버 값으로 초안을 채우되, 사용자가 고치기 시작한 뒤에는 폴링 응답이 덮지 않아야 한다. // 잡 id 가 바뀔 때만 다시 채운다(같은 잡의 재조회는 무시). useEffect(() => { setLines(job.narration ?? []); // 0종([])도 유효한 값이라 null(미로딩)로만 판별한다 setMotions(job.motion_elements ?? []); setMeta({ event_name: job.metadata?.event_name ?? '', date_text: job.metadata?.date_text ?? '', place: job.metadata?.place ?? '', }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [job.id]); const toggleMotion = (key: string) => { setMotions((prev) => { const cur = prev ?? []; return cur.includes(key) ? cur.filter((m) => m !== key) : [...cur, key]; }); }; const approve = async () => { if (busy) return; setBusy(true); setError(null); try { // 바뀐 것만 보낸다. 서버는 PUT 마다 잡 파일을 다시 쓰므로 무의미한 쓰기를 줄인다. if (JSON.stringify(lines) !== JSON.stringify(job.narration ?? [])) { await updatePosterNarration(job.id, lines); } const md = job.metadata; const metaChanged = !md || META_FIELDS.some((f) => (md[f] ?? '') !== meta[f]); if (metaChanged) { await updatePosterMetadata(job.id, meta); } // 순서 차이는 의미가 없다(서버가 집합으로 쓴다) — 정렬해 비교해야 헛 전송이 없다 const motionsChanged = motions !== null && JSON.stringify([...motions].sort()) !== JSON.stringify([...(job.motion_elements ?? [])].sort()); await approvePosterJob(job.id, motionsChanged ? motions : null); onApproved(); } catch (e) { setError((e as Error).message); setBusy(false); } }; const noMotion = motions !== null && motions.length === 0; return (
{/* ── 나레이션 ─────────────────────────────── */}

{t('poster.review.narration')}

{t('poster.review.narrationHelp')}

{lines.map((line, i) => ( setLines(lines.map((v, j) => (j === i ? e.target.value : v)))} /> ))} {/* ── 움직일 요소 ──────────────────────────── */}

{t('poster.review.motion')}

{t(noMotion ? 'poster.review.motionEmpty' : 'poster.review.motionHelp')}

{MOTION_KEYS.map((key) => { const on = motions?.includes(key) ?? false; return ( ); })}
{/* ── 메타태그 ─────────────────────────────── */}

{t('poster.review.metadata')}

{t('poster.review.metadataHelp')}

{META_FIELDS.map((field) => (
{t(`poster.review.field.${field}`)} setMeta({ ...meta, [field]: e.target.value })} />
))} {error &&
{error}
}
{/* ── 대조용 분석 이미지 ────────────────────── */}

{t('poster.review.analysis')}

{job.artifacts.check_jpg ? ( {t('poster.review.analysisAlt')} ) : (
{t('poster.review.analysisMissing')}
)}
); }; export default PosterReviewContent;