import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import PosterDropzone from '../../components/PosterDropzone'; import P2vKeyGate from '../../components/P2vKeyGate'; import { InternalOnlyWarning, LicenseBadge, UserReferenceNotice, } from '../../components/LicenseBadge'; import { useP2vJob } from '../../hooks/useP2vJob'; import { F2Category, F2Format, F2Template, F2UploadHint, P2vAuthError, createF2Template, createStylingJob, deleteF2Template, getF2UploadHint, isP2vActive, listF2Categories, listF2Formats, listF2Templates, p2vFile, } from '../../utils/p2vApi'; /** 사용자가 올린 레퍼런스 카테고리. 서버 카테고리 목록에서 따로 떼어 아래에 둔다 */ const USER_CATEGORY = 'user'; /** * 포스터 스타일링(F2). * * **내부 시연 전용이다.** 영화 포스터 레퍼런스는 저작권 자산이라 결과물을 외부에 * 공개하면 안 된다(퍼블릭 도메인 명화만 예외). 그래서 화면 상단에 범위를 못박고, * 템플릿마다 배포 등급 배지를 붙인다. * * 다른 파이프라인과 달리 위저드가 아니라 **한 화면짜리 도구**다 — 업로드·템플릿· * 포맷·결과가 한눈에 있어야 여러 스타일을 빠르게 갈아 끼우며 비교할 수 있고, * 30초짜리 작업에 단계를 나누면 왕복만 늘어난다. 그래서 폴링도 GenerationFlow 가 * 아니라 이 컴포넌트가 직접 소유한다(F1·썰박스와 다른 유일한 지점). */ const StylingContent: React.FC = () => { const { t } = useTranslation(); const [templates, setTemplates] = useState([]); const [categories, setCategories] = useState([]); const [formats, setFormats] = useState([]); const [hint, setHint] = useState(null); const [file, setFile] = useState(null); const [selected, setSelected] = useState(null); const [format, setFormat] = useState('poster'); const [jobId, setJobId] = useState(null); const [tplOpen, setTplOpen] = useState(true); const [busy, setBusy] = useState(false); const [refBusy, setRefBusy] = useState(false); const [error, setError] = useState(null); const [authNeeded, setAuthNeeded] = useState(false); const refInput = useRef(null); const { job, authNeeded: jobAuthNeeded } = useP2vJob('f2', jobId); const selectedTpl = templates.find((tpl) => tpl.id === selected) ?? null; const running = isP2vActive(job); const loadAll = useCallback(async () => { try { const [tpls, cats, fmts, uploadHint] = await Promise.all([ listF2Templates(), listF2Categories(), listF2Formats(), getF2UploadHint(), ]); setTemplates(tpls); setCategories(cats); setFormats(fmts); setHint(uploadHint); setAuthNeeded(false); setError(null); } catch (e) { if (e instanceof P2vAuthError) setAuthNeeded(true); else setError((e as Error).message); } }, []); useEffect(() => { loadAll(); }, [loadAll]); const uploadReference = async (f: File) => { setRefBusy(true); setError(null); try { const tpl = await createF2Template(f, f.name.replace(/\.[^.]+$/, '').slice(0, 40)); setTemplates(await listF2Templates()); setSelected(tpl.id); // 방금 올린 것을 바로 고른 상태로 } catch (e) { if (e instanceof P2vAuthError) setAuthNeeded(true); else setError((e as Error).message); } finally { setRefBusy(false); if (refInput.current) refInput.current.value = ''; } }; const removeReference = async (id: string) => { if (!window.confirm(t('poster.styling.confirmDelete'))) return; try { await deleteF2Template(id); if (selected === id) setSelected(null); setTemplates(await listF2Templates()); } catch (e) { setError((e as Error).message); } }; const submit = async () => { if (!file || !selected || busy) return; setBusy(true); setError(null); try { const { id } = await createStylingJob(file, selected, format); setJobId(id); } catch (e) { if (e instanceof P2vAuthError) setAuthNeeded(true); else setError((e as Error).message); } finally { setBusy(false); } }; if (authNeeded || jobAuthNeeded) { return (
{ setAuthNeeded(false); loadAll(); }} />
); } const renderCard = (tpl: F2Template) => (
{tpl.removable && ( )}
); return (
{t('poster.styling.internalScope')}
{/* ── 스타일 템플릿 (접이식) ─────────────────────── */} {tplOpen && categories.filter((c) => c.id !== USER_CATEGORY).map((cat) => { const items = templates.filter((tpl) => tpl.category === cat.id); if (items.length === 0) return null; return (

{cat.label} {t('poster.styling.count', { count: items.length })}

{items.map(renderCard)}
); })} {/* 내 레퍼런스 — 목록이 비어도 업로드 타일은 보여야 하므로 categories 가 아니라 upload-hint 의 enabled 로 렌더한다 */} {tplOpen && hint?.enabled && (

{t('poster.styling.myRefs')} {t('poster.styling.myRefsHint')}

{templates.filter((tpl) => tpl.category === USER_CATEGORY).map(renderCard)} { const f = e.target.files?.[0]; if (f) uploadReference(f); }} />
)} {/* 경고는 실제로 고른 순간에만 */} {selectedTpl?.license === 'internal-only' && ( )} {selectedTpl?.attribution && (

{t('poster.styling.attribution', { source: selectedTpl.attribution })}

)} {/* ── 입력 / 결과 ─────────────────────────────── */}

{t('poster.styling.myPoster')}

{t('poster.styling.result')}

{job?.status === 'done' && job.artifacts.image ? (
{t('poster.styling.resultAlt')}
) : (
{running &&
)} {job?.status === 'done' && job.artifacts.image && ( )}
{/* ── 출력 포맷 ───────────────────────────────── */}

{t('poster.styling.formats')}

{formats.map((f) => ( ))}
{error &&
{error}
} {job?.status === 'failed' && job.error && (
{job.error.detail}
)}
); }; export default StylingContent;