o2o-castad-frontend/src/pages/Poster/StylingContent.tsx
Haewon Kam 0ca2140b98 feat: 썰박스 DS 통일과 Poster to Video 통합, 진입 화면 네비게이션 개편
썰박스를 ADO2 디자인 시스템으로 맞추고, 포스터 파이프라인 두 종을 파이프라인
탭에 추가한다. 탭이 4개가 되면서 진입 화면 네비게이션도 함께 손봤다.

## 썰박스 DS 통일
시나리오별 팔레트(조선=파랑·삼국지=빨강·그리스=녹·오디세이=청)와 이모지, 앰버
강조색(--color-ssul-accent)을 걷어냈다. 선택 표시는 castad 표준 문법(민트 테두리
+ 민트 틴트), 스피너는 퍼플, CTA는 퍼플 단색으로 통일했다. 색으로 구분하던 것을
없앤 대신 상태(선택)만 색으로 남긴다.

## Poster to Video 통합 (내부 시연 범위)
- 무빙 포스터(F1): 업로드 → 분석 → **검수 게이트** → 생성 → 완성 5단계 위저드.
  검수에서 나레이션·모션·메타태그를 포스터와 대조해 고치고 승인해야 렌더가 이어진다.
- 포스터 스타일링(F2): 위저드가 아닌 한 화면 도구. 여러 스타일을 갈아 끼우며
  비교하는 작업이라 단계를 나누면 왕복만 늘어난다.

P2V 서버(:8010)를 castad 백엔드가 아니라 **직접 호출**한다. 검증된 scripts를
subprocess로 감싼 단일 워커라 castad 백엔드에 흡수하려면 실행 환경(Higgsfield
CLI·ffmpeg·모델 키)을 통째로 이식해야 하기 때문이다. 호출은 utils/p2vApi.ts 한
파일에 격리했다 — 대외 공개 시 castad 백엔드에 /p2v/* 프록시를 세우고 카카오 인증·
크레딧 차감을 얹을 때 갈아끼울 지점이 여기 하나다.

인증은 단일 팀 키(P2V_ACCESS_KEY)다. 다른 오리진이라 쿠키가 실리지 않아 정적
산출물(영상·이미지·다운로드)은 p2vFile()이 ?key= 로 태운다.

F2는 영화 포스터 레퍼런스가 저작권 자산이라 **결과물의 외부 공개·상업 사용을
금한다**(퍼블릭 도메인 명화 제외). 화면 상단 고지 + 템플릿별 배포 등급 배지 +
내부 전용을 고른 순간에만 뜨는 경고로 표시한다.

## 진입 화면 네비게이션
- 밑줄 탭 → 아이콘 타일 + 기능명. 탭이 4개가 되자 "무빙 포스터"와 "포스터 스타일링"이
  글자로는 갈라지지 않아, 형태로 구분되게 했다. 아이콘은 ADO2 Design System 규칙
  (24×24 · fill none · stroke currentColor 2 · round)을 따르고 색은 넣지 않는다.
- 고른 기능의 한 줄 설명을 탭 아래에 붙였다.
- 간격 체계를 8px 그리드로 다시 잡았다(묶음 안 8~12 / 묶음 사이 20 / 섹션 28~32).
  이전에는 전부 10~24px이라 근접성으로 구조가 읽히지 않아 답답했다. 타일 간격은
  터치 타겟 최소 8dp를 지킨다.

## 버그 수정
- 진입 화면 세로 넘침 잘림: justify-content:center + overflow:hidden 조합이라
  콘텐츠가 길어지면 위로 넘친 부분에 스크롤로도 닿을 수 없었다(무빙 포스터에서
  로고 밴드 47px 잘림). 자식의 margin:auto 0 중앙정렬로 바꿔 해소.
- 사라진 P2V 잡: 404를 문구가 아니라 상태 코드로 판정한다(P2vNotFoundError).
  서버에서 지워진 잡 ID가 남으면 빈 진행 화면에서 멈췄다.
- 검수 화면 모바일: 좌우 여백 0(부모 위저드 컨테이너에 패딩이 없다) → 16px 부여.
  대조할 포스터를 편집 위로 올리고, 승인 CTA를 하단 탭바 위에 sticky로 고정.
- 한글 단어 중간 줄바꿈("애니메이/션") → word-break: keep-all.
- 입력·안내·제출 박스 높이를 48px로 통일.

## 카피
가게→업체, 크롤링→수집으로 어휘 통일.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 12:55:06 +09:00

331 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<F2Template[]>([]);
const [categories, setCategories] = useState<F2Category[]>([]);
const [formats, setFormats] = useState<F2Format[]>([]);
const [hint, setHint] = useState<F2UploadHint | null>(null);
const [file, setFile] = useState<File | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const [format, setFormat] = useState('poster');
const [jobId, setJobId] = useState<string | null>(null);
const [tplOpen, setTplOpen] = useState(true);
const [busy, setBusy] = useState(false);
const [refBusy, setRefBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [authNeeded, setAuthNeeded] = useState(false);
const refInput = useRef<HTMLInputElement>(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 (
<div className="p2v-scope p2v-styling">
<P2vKeyGate onSaved={() => { setAuthNeeded(false); loadAll(); }} />
</div>
);
}
const renderCard = (tpl: F2Template) => (
<div key={tpl.id} className="p2v-tplcard">
<button
type="button"
aria-pressed={selected === tpl.id}
className={`p2v-tpl${selected === tpl.id ? ' active' : ''}`}
onClick={() => setSelected(tpl.id)}
>
{/* 명화는 가로 그림도 있다 — cover 로 자르면 무엇인지 알 수 없어 contain 을 쓴다 */}
<img src={p2vFile(tpl.thumb_url)} alt={tpl.name_ko} />
<span className="p2v-tpl__name">{tpl.name_ko}</span>
{/* 제목이 1줄인 카드와 2줄인 카드가 섞인다. 아래로 밀어붙여야 한 행의 배지가 같은 선에 선다 */}
<span className="p2v-tpl__badge">
<LicenseBadge license={tpl.license} />
</span>
</button>
{tpl.removable && (
<button
type="button"
className="p2v-tpl__remove"
title={t('poster.styling.deleteRef')}
onClick={() => removeReference(tpl.id)}
>
×
</button>
)}
</div>
);
return (
<div className="p2v-scope p2v-styling">
<div className="p2v-notice p2v-notice--warn p2v-styling__scope">
{t('poster.styling.internalScope')}
</div>
{/* ── 스타일 템플릿 (접이식) ─────────────────────── */}
<button
type="button"
className="p2v-collapse"
aria-expanded={tplOpen}
onClick={() => setTplOpen(!tplOpen)}
>
<span className="p2v-label">{t('poster.styling.templates')}</span>
{!tplOpen && selectedTpl && (
<span className="p2v-collapse__picked">
<img src={p2vFile(selectedTpl.thumb_url)} alt="" />
<b>{selectedTpl.name_ko}</b>
<LicenseBadge license={selectedTpl.license} />
</span>
)}
{!tplOpen && !selectedTpl && (
<span className="p2v-collapse__empty">{t('poster.styling.pickTemplate')}</span>
)}
<svg
className={`p2v-collapse__caret${tplOpen ? ' open' : ''}`}
width="18" height="18" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
{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 (
<div key={cat.id} className="p2v-tplgroup">
<p className="p2v-tplgroup__title">
{cat.label}
<span>{t('poster.styling.count', { count: items.length })}</span>
</p>
<div className="p2v-tplgrid">{items.map(renderCard)}</div>
</div>
);
})}
{/* 내 레퍼런스 — 목록이 비어도 업로드 타일은 보여야 하므로 categories 가 아니라
upload-hint 의 enabled 로 렌더한다 */}
{tplOpen && hint?.enabled && (
<div className="p2v-tplgroup">
<p className="p2v-tplgroup__title">
{t('poster.styling.myRefs')}
<span>{t('poster.styling.myRefsHint')}</span>
</p>
<div className="p2v-tplgrid">
{templates.filter((tpl) => tpl.category === USER_CATEGORY).map(renderCard)}
<button
type="button"
className="p2v-tpl p2v-tpl--add"
disabled={refBusy}
onClick={() => refInput.current?.click()}
>
{refBusy ? (
<>
<span className="p2v-spinner p2v-spinner--sm" aria-hidden="true" />
<span className="p2v-tpl__name">{t('poster.styling.analyzing')}</span>
</>
) : (
<>
<span className="p2v-tpl__plus" aria-hidden="true">+</span>
<span className="p2v-tpl__name">{t('poster.styling.addRef')}</span>
<span className="p2v-tpl__sub">
{t('poster.styling.minEdge', { px: hint.min_long_edge })}
</span>
</>
)}
</button>
<input
ref={refInput}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
hidden
onChange={(e) => { const f = e.target.files?.[0]; if (f) uploadReference(f); }}
/>
</div>
<UserReferenceNotice />
</div>
)}
{/* 경고는 실제로 고른 순간에만 */}
{selectedTpl?.license === 'internal-only' && (
<InternalOnlyWarning note={selectedTpl.license_note} />
)}
{selectedTpl?.attribution && (
<p className="p2v-note">{t('poster.styling.attribution', { source: selectedTpl.attribution })}</p>
)}
{/* ── 입력 / 결과 ─────────────────────────────── */}
<div className="p2v-styling__io">
<div>
<p className="p2v-label">{t('poster.styling.myPoster')}</p>
<PosterDropzone file={file} onFile={setFile} />
</div>
<div>
<p className="p2v-label">{t('poster.styling.result')}</p>
{job?.status === 'done' && job.artifacts.image ? (
<div className="p2v-panel p2v-styling__out">
<img src={p2vFile(job.artifacts.image)} alt={t('poster.styling.resultAlt')} />
</div>
) : (
<div className="p2v-panel p2v-styling__out p2v-styling__out--empty">
{running && <span className="p2v-spinner p2v-spinner--md" aria-hidden="true" />}
<span>{t(running ? 'poster.styling.converting' : 'poster.styling.resultHint')}</span>
</div>
)}
{job?.status === 'done' && job.artifacts.image && (
<div className="p2v-styling__download">
<a href={p2vFile(job.artifacts.image)} download className="p2v-btn-tonal">
{t('poster.styling.downloadPng')}
</a>
</div>
)}
</div>
</div>
{/* ── 출력 포맷 ───────────────────────────────── */}
<p className="p2v-label">{t('poster.styling.formats')}</p>
<div className="p2v-formats">
{formats.map((f) => (
<button
key={f.id}
type="button"
aria-pressed={format === f.id}
className={`p2v-select${format === f.id ? ' active' : ''}`}
onClick={() => setFormat(f.id)}
>
{f.label}
</button>
))}
</div>
<button
type="button"
className="p2v-btn-cta"
disabled={!file || !selected || busy || running}
onClick={submit}
>
{running
? t('poster.styling.converting')
: busy ? t('poster.create.submitting') : t('poster.styling.submit')}
</button>
{error && <div className="p2v-note warn">{error}</div>}
{job?.status === 'failed' && job.error && (
<pre className="p2v-errorbox">{job.error.detail}</pre>
)}
</div>
);
};
export default StylingContent;