썰박스를 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>
264 lines
10 KiB
TypeScript
264 lines
10 KiB
TypeScript
/**
|
|
* Poster to Video (P2V) 클라이언트.
|
|
*
|
|
* castad 백엔드가 아니라 **별도 서버(:8010)** 를 직접 호출한다. 다른 파이프라인들
|
|
* (ADO2·썰박스)이 `utils/api.ts` 로 castad 백엔드에 붙는 것과 대비된다.
|
|
*
|
|
* 왜 프록시를 두지 않았나 — P2V 서버는 검증된 `scripts/*.py` 를 subprocess 로 감싼
|
|
* 단일 워커라 castad 백엔드에 흡수하려면 실행 환경(Higgsfield CLI·ffmpeg·모델 키)을
|
|
* 통째로 이식해야 한다. **내부 시연 범위**에서는 직접 호출이 맞고, 대외 공개 단계에서
|
|
* castad 백엔드에 `/p2v/*` 프록시를 세워 카카오 인증·크레딧 차감을 얹는 게 다음 수순이다.
|
|
* 그때 갈아끼울 지점은 이 파일 하나다.
|
|
*
|
|
* 인증은 단일 팀 키(`P2V_ACCESS_KEY`). 서버는 3경로로 받는다:
|
|
* - `X-P2V-Key` 헤더 → fetch 호출
|
|
* - `p2v_key` 쿠키 → **여기서는 못 쓴다.** castad 와 P2V 는 다른 오리진이라
|
|
* document.cookie 로 심어도 P2V 요청에 실리지 않는다.
|
|
* - `?key=` 쿼리 → 헤더를 못 붙이는 <img>/<video>/<a download> 용 (p2vFile)
|
|
*/
|
|
|
|
/** P2V 서버 오리진. 배포 시 `.env` 의 VITE_P2V_URL 로 덮는다 */
|
|
export const P2V_URL = (import.meta.env.VITE_P2V_URL || 'http://localhost:8010').replace(/\/$/, '');
|
|
|
|
/**
|
|
* 접근 키 저장소.
|
|
*
|
|
* storageKeys.K 에 넣지 않은 것은 의도적이다 — 그쪽 키들은 로그아웃·새 프로젝트에
|
|
* 일괄 삭제되는데, 이건 서버 자격증명이라 지워지면 시연 중에 매번 다시 물어야 한다.
|
|
*/
|
|
const KEY_STORAGE = 'castad_p2v_key';
|
|
|
|
export const getP2vKey = (): string => localStorage.getItem(KEY_STORAGE) ?? '';
|
|
export const setP2vKey = (key: string): void => localStorage.setItem(KEY_STORAGE, key.trim());
|
|
export const clearP2vKey = (): void => localStorage.removeItem(KEY_STORAGE);
|
|
|
|
/** 401 — 화면이 키 입력을 띄워야 하는 상황. 일반 실패와 구분해야 해서 타입을 나눈다 */
|
|
export class P2vAuthError extends Error {
|
|
constructor() {
|
|
super('P2V_AUTH_REQUIRED');
|
|
this.name = 'P2vAuthError';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 404 — 서버에 그 잡이 없다. 화면은 처음으로 되돌려야 한다.
|
|
*
|
|
* 상태 코드로 판정하는 이유: 서버 detail 문구("잡 없음")를 문자열로 맞추려다
|
|
* 실제로 한 번 놓쳤다. 문구는 서버 사정으로 바뀌고 번역도 되지만 코드는 안 바뀐다.
|
|
*/
|
|
export class P2vNotFoundError extends Error {
|
|
constructor(detail: string) {
|
|
super(detail);
|
|
this.name = 'P2vNotFoundError';
|
|
}
|
|
}
|
|
|
|
export async function p2vFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
|
const key = getP2vKey();
|
|
const res = await fetch(`${P2V_URL}${path}`, {
|
|
...init,
|
|
headers: {
|
|
...(init?.headers ?? {}),
|
|
...(key ? { 'X-P2V-Key': key } : {}),
|
|
},
|
|
});
|
|
|
|
if (res.status === 401) throw new P2vAuthError();
|
|
|
|
if (!res.ok) {
|
|
// FastAPI 는 {detail: ...} 로 준다. 본문이 없는 에러(502 등)도 있으므로 감싼다
|
|
let detail = res.statusText;
|
|
try {
|
|
const body = await res.json();
|
|
detail = typeof body.detail === 'string' ? body.detail : JSON.stringify(body);
|
|
} catch {
|
|
/* 본문 없음 — statusText 로 간다 */
|
|
}
|
|
if (res.status === 404) throw new P2vNotFoundError(detail);
|
|
throw new Error(detail);
|
|
}
|
|
return res.json() as Promise<T>;
|
|
}
|
|
|
|
/**
|
|
* 정적 산출물(영상·이미지) URL.
|
|
*
|
|
* 서버가 주는 artifacts 값은 `/files/render/x.mp4` 같은 **루트 상대 경로**라
|
|
* 다른 오리진인 castad 에서는 그대로 쓸 수 없다. 오리진을 붙이고, 헤더를 실을 수 없는
|
|
* 태그(<video src> 등)를 위해 키를 쿼리로 태운다.
|
|
*/
|
|
export const p2vFile = (path: string | null | undefined): string => {
|
|
if (!path) return '';
|
|
const key = getP2vKey();
|
|
return `${P2V_URL}${path}${key ? `?key=${encodeURIComponent(key)}` : ''}`;
|
|
};
|
|
|
|
// ── 타입 (서버 jobs.py / routers 와 1:1) ──────────────────────────────
|
|
|
|
export type P2vStageStatus = 'idle' | 'running' | 'done' | 'failed';
|
|
|
|
export interface P2vStageState {
|
|
status: P2vStageStatus;
|
|
started: number | null;
|
|
ended: number | null;
|
|
}
|
|
|
|
export interface PosterMeta {
|
|
event_name: string;
|
|
date_text: string;
|
|
place: string;
|
|
category: string;
|
|
keywords: string[];
|
|
region_guess?: string;
|
|
}
|
|
|
|
export interface P2vJob {
|
|
id: string;
|
|
kind: 'f1' | 'f2';
|
|
name: string;
|
|
status: 'queued' | 'running' | 'awaiting_review' | 'failed' | 'done';
|
|
stage: string | null;
|
|
stages: Record<string, P2vStageState>;
|
|
narration: string[] | null;
|
|
motion_elements?: string[] | null;
|
|
metadata: PosterMeta | null;
|
|
error: { stage: string; detail: string } | null;
|
|
artifacts: Record<string, string>;
|
|
created_at: number;
|
|
queue_size?: number;
|
|
template_id?: string | null;
|
|
}
|
|
|
|
/** 배포 등급. public-domain 만 외부 공개 가능 — 나머지는 내부 시연 한정 */
|
|
export type P2vLicense = 'public-domain' | 'internal-only' | 'user-uploaded';
|
|
|
|
export interface F2Template {
|
|
id: string;
|
|
name_ko: string;
|
|
thumb_url: string;
|
|
category: string;
|
|
license: P2vLicense;
|
|
attribution: string;
|
|
license_note: string;
|
|
/** 사용자가 올린 레퍼런스인가 (삭제 가능) */
|
|
removable: boolean;
|
|
small_ref: boolean;
|
|
}
|
|
|
|
export interface F2Category { id: string; label: string }
|
|
export interface F2Format { id: string; label: string }
|
|
export interface F2UploadHint { enabled: boolean; min_long_edge: number }
|
|
|
|
// ── F1 (포스터 → 영상) ────────────────────────────────────────────────
|
|
|
|
/** 포스터 업로드 → 잡 생성. 반환된 id 로 폴링한다 */
|
|
export async function createPosterJob(poster: File, name: string): Promise<{ id: string }> {
|
|
const fd = new FormData();
|
|
fd.append('poster', poster);
|
|
fd.append('name', name);
|
|
return p2vFetch<{ id: string }>('/api/f1/jobs', { method: 'POST', body: fd });
|
|
}
|
|
|
|
export const getPosterJob = (id: string): Promise<P2vJob> =>
|
|
p2vFetch<P2vJob>(`/api/f1/jobs/${id}`);
|
|
|
|
export const updatePosterNarration = (id: string, narration: string[]): Promise<unknown> =>
|
|
p2vFetch(`/api/f1/jobs/${id}/narration`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ narration }),
|
|
});
|
|
|
|
export const updatePosterMetadata = (
|
|
id: string,
|
|
meta: { event_name: string; date_text: string; place: string },
|
|
): Promise<unknown> =>
|
|
p2vFetch(`/api/f1/jobs/${id}/metadata`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(meta),
|
|
});
|
|
|
|
/** 검수 승인 → TTS·BGM·애니메이션·렌더가 이어진다. motions 를 넘기면 모션 선정을 덮어쓴다 */
|
|
export const approvePosterJob = (id: string, motions?: string[] | null): Promise<unknown> =>
|
|
p2vFetch(`/api/f1/jobs/${id}/approve`, {
|
|
method: 'POST',
|
|
...(motions
|
|
? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ motions }) }
|
|
: {}),
|
|
});
|
|
|
|
export const retryPosterJob = (id: string, motions?: string[] | null): Promise<unknown> =>
|
|
p2vFetch(`/api/f1/jobs/${id}/retry`, {
|
|
method: 'POST',
|
|
...(motions
|
|
? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ motions }) }
|
|
: {}),
|
|
});
|
|
|
|
export const deletePosterJob = (id: string): Promise<unknown> =>
|
|
p2vFetch(`/api/f1/jobs/${id}`, { method: 'DELETE' });
|
|
|
|
// ── F2 (포스터 스타일링) ──────────────────────────────────────────────
|
|
|
|
export const listF2Templates = (): Promise<F2Template[]> => p2vFetch<F2Template[]>('/api/f2/templates');
|
|
export const listF2Categories = (): Promise<F2Category[]> => p2vFetch<F2Category[]>('/api/f2/categories');
|
|
export const listF2Formats = (): Promise<F2Format[]> => p2vFetch<F2Format[]>('/api/f2/formats');
|
|
export const getF2UploadHint = (): Promise<F2UploadHint> => p2vFetch<F2UploadHint>('/api/f2/upload-hint');
|
|
|
|
/** 레퍼런스 업로드 — 저장 + 화풍 분석까지 서버가 동기로 끝낸다(10초 안팎) */
|
|
export async function createF2Template(reference: File, name: string): Promise<F2Template> {
|
|
const fd = new FormData();
|
|
fd.append('reference', reference);
|
|
fd.append('name', name);
|
|
return p2vFetch<F2Template>('/api/f2/templates', { method: 'POST', body: fd });
|
|
}
|
|
|
|
export const deleteF2Template = (id: string): Promise<unknown> =>
|
|
p2vFetch(`/api/f2/templates/${id}`, { method: 'DELETE' });
|
|
|
|
export async function createStylingJob(
|
|
poster: File,
|
|
templateId: string,
|
|
format: string,
|
|
): Promise<{ id: string }> {
|
|
const fd = new FormData();
|
|
fd.append('poster', poster);
|
|
fd.append('template_id', templateId);
|
|
fd.append('format', format);
|
|
return p2vFetch<{ id: string }>('/api/f2/jobs', { method: 'POST', body: fd });
|
|
}
|
|
|
|
export const getStylingJob = (id: string): Promise<P2vJob> =>
|
|
p2vFetch<P2vJob>(`/api/f2/jobs/${id}`);
|
|
|
|
// ── 진행 계산 ────────────────────────────────────────────────────────
|
|
|
|
/** 아직 도는 중인가. 이 두 상태에서만 폴링을 계속한다 */
|
|
export const isP2vActive = (job: P2vJob | null): boolean =>
|
|
job !== null && (job.status === 'queued' || job.status === 'running');
|
|
|
|
/** 스테이지 진행률(%) — 선형 진행바용. 도는 중인 스테이지는 절반으로 친다 */
|
|
export function p2vProgress(job: P2vJob | null): number {
|
|
if (!job) return 0;
|
|
const states = Object.values(job.stages);
|
|
if (states.length === 0) return 0;
|
|
const done = states.filter((s) => s.status === 'done').length;
|
|
const running = states.some((s) => s.status === 'running') ? 0.5 : 0;
|
|
return Math.round(((done + running) / states.length) * 100);
|
|
}
|
|
|
|
/**
|
|
* 검수 게이트를 이미 지났는가.
|
|
*
|
|
* 스텝퍼가 "분석 → 검수 → 생성" 중 어디인지 알아야 하는데 status 만으로는
|
|
* 검수 전 running 과 승인 후 running 이 구분되지 않는다. tts 는 승인 이후에만
|
|
* 시작하므로 이게 게이트 통과의 신호다(스테이지 이름을 박는 대신 F1_STAGES 순서에
|
|
* 의존하지 않도록 tts 하나만 본다 — 게이트가 옮겨가면 이 상수만 고친다).
|
|
*/
|
|
const POST_REVIEW_STAGE = 'tts';
|
|
|
|
export const isPastReview = (job: P2vJob | null): boolean =>
|
|
!!job && job.stages[POST_REVIEW_STAGE]?.status !== undefined
|
|
&& job.stages[POST_REVIEW_STAGE].status !== 'idle';
|