썰박스를 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>
331 lines
12 KiB
TypeScript
331 lines
12 KiB
TypeScript
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;
|