Merge branch 'feature-ssulbox-ado2-ds' into feature-ssulbox

This commit is contained in:
김성경 2026-08-24 14:12:19 +09:00
commit 8ba5546f85
29 changed files with 3236 additions and 240 deletions

157
DEPLOY_REQUEST.md Normal file
View File

@ -0,0 +1,157 @@
# 배포 요청서 — castad 프론트엔드 `feature-ssulbox-ado2-ds`
| | |
|---|---|
| **요청일** | 2026-08-20 |
| **대상 저장소** | `gitea.o2o.kr/castad/o2o-castad-frontend` |
| **브랜치** | `feature-ssulbox-ado2-ds` (브랜치 최신 커밋을 그대로 배포) |
| **코드 커밋** | `0ca2140` — 이 문서는 그 위에 문서만 얹은 커밋입니다 |
| **대상 환경** | dev-ssul.castad.net |
| **연관 저장소** | `gitea.o2o.kr/castad/o2o-ado2-poster-to-video` (신규 연동 대상) |
---
## 0. 한 장 요약
이번 브랜치는 **두 덩어리**입니다. 서로 독립적이라 **나눠서 배포할 수 있습니다.**
| 덩어리 | 내용 | 추가 인프라 | 배포 난이도 |
|---|---|---|---|
| **A. DS 통일 + 네비게이션** | 썰박스 디자인 시스템 통일, 진입 화면 탭 개편, 레이아웃 버그 수정 | **없음** | 기존과 동일 |
| **B. 포스터 파이프라인** | 무빙 포스터(F1), 포스터 스타일링(F2) 탭 | **P2V 서버 신규 배포 필요** | 선행조건 5개 |
**권장: A를 먼저 배포하고, B는 P2V 서버가 준비된 뒤 여는 2단계 진행.**
A만 배포해도 화면은 깨지지 않습니다. 포스터 탭 2개만 "접근 키 입력" 화면이나 연결 실패로
떨어지고, ADO2·썰박스 탭은 평소대로 동작합니다.
---
## 1. A안 — DS·네비게이션만 배포 (지금 바로 가능)
### 필요 작업
```bash
git fetch origin
git checkout feature-ssulbox-ado2-ds # 0ca2140
npm ci
npm run build
```
### 환경변수
| 변수 | 값 | 비고 |
|---|---|---|
| `VITE_API_URL` | 기존 값 유지 | castad 백엔드 |
| `VITE_P2V_URL` | **설정하지 않음** | 미설정 시 포스터 탭만 비활성 상태로 남음 |
### 검증
1. 진입 화면에 탭 4개(ADO2 / 썰박스 / 무빙 포스터 / 포스터 스타일링)가 아이콘 타일로 표시
2. 썰박스 탭 — 시나리오 카드에 이모지·시나리오별 색이 없고 선택 시 민트 테두리
3. **모바일(375px)에서 상단 ADO2 로고가 잘리지 않을 것** ← 이번에 고친 버그
4. ADO2 탭 기존 플로우(URL 입력 → 브랜드 분석) 정상
---
## 2. B안 — 포스터 파이프라인까지 배포
### 2-1. 선행조건 (P2V 서버 측)
P2V 저장소의 `product/DEPLOY.md`가 원본 문서입니다. 아래는 **castad 연동에 필요한 것만** 추린 것입니다.
| # | 항목 | 상태 | 담당 |
|---|---|---|---|
| 1 | P2V 서버(`:8010`) 배포 | **미배포** | Dev |
| 2 | `SUNO_CALLBACK_URL` — Suno가 결과를 POST할 **공개 주소** | 도메인 확정 필요 | Dev |
| 3 | Higgsfield CLI 토큰 — 머신에서 **사람이 1회 로그인** | 자동화 불가 | 담당자 |
| 4 | **P2V를 HTTPS로 서빙** | 미확인 | Dev |
| 5 | F2 템플릿 이미지 파일 별도 전달 | 저장소에 없음 | 요청자 |
**4번이 이번에 새로 확인된 항목입니다.** dev-ssul.castad.net은 HTTPS인데 P2V가 HTTP면
브라우저가 mixed content로 **전부 차단**합니다. P2V 앞에 리버스 프록시로 TLS를 붙여야 합니다.
**5번 주의:** P2V 저장소는 `templates/*.jpg`를 gitignore합니다(영화 포스터 = 저작권).
`templates.json`(메타데이터)만 커밋돼 있어 **클론 직후 F2는 썸네일이 전부 깨집니다.**
compose가 `./server/templates`를 호스트 바인드 마운트하므로, 이미지 21개를 서버에 직접
넣어야 합니다. F2를 이관 범위에서 빼는 것도 선택지입니다(F1은 영향 없음).
### 2-2. P2V 서버 환경변수
`product/.env.example`을 `.env`로 복사해 채웁니다.
| 변수 | 필수 | 설명 |
|---|---|---|
| `CHATGPT_API_KEY` | ✅ | 영역검출·나레이션·모션선정·TTS·F2 |
| `SUNO_API_KEY` | ✅ | BGM 생성 |
| `SUNO_CALLBACK_URL` | ✅ | Suno 결과 수신용 **공개** URL. 없으면 BGM 단계에서 죽음 |
| `P2V_ACCESS_KEY` | ✅ | 팀 공용 키 1개. **비우면 무인증** — 배포에서는 반드시 설정 |
| `P2V_ALLOWED_ORIGINS` | ✅ | **`https://dev-ssul.castad.net` 추가 필수.** 없으면 CORS 차단 |
| `P2V_QUEUE_PATH` | compose가 지정 | 큐를 볼륨 위로. 이미지 레이어에 두면 재배포 때 소실 |
Higgsfield는 환경변수가 아니라 **CLI 토큰 인증**입니다:
```bash
higgsfield auth login && higgsfield workspace set
# 생성된 ~/.config/higgsfield/{credentials,config}.json 을 p2v-higgsfield 볼륨에 주입
# CLI가 토큰을 자동 갱신하며 파일을 다시 쓰므로 :ro 마운트 금지
```
### 2-3. castad 프론트엔드 빌드
```bash
VITE_P2V_URL="https://<P2V 공개 도메인>" npm run build
```
Vite는 **빌드 시점에** 값을 산출물에 박습니다(런타임 주입 아님). 검증했습니다 —
값을 넣으면 번들에 그대로 들어가고 기본값 `localhost:8010`은 남지 않습니다.
### 2-4. 알아둘 제약
- **api를 스케일아웃하면 안 됩니다.** 인메모리 큐 + 파일 상태(`data/jobs.json`) 구조라
인스턴스가 늘면 큐가 깨집니다. compose에도 명시돼 있습니다.
- **처리 시간 1건 4~6분** (Suno ~2분 + veo ~3분).
- **렌더 비용** veo 22크레딧/편(≈$1.12) + OpenAI·Suno 소액.
- **인증이 단일 팀 키**라 사용자별 크레딧 차감이 없습니다. 현재는 내부 시연 범위이고,
대외 공개 시 castad 백엔드에 `/p2v/*` 프록시를 세워 카카오 인증·크레딧을 얹는 것이
다음 수순입니다. 프론트에서 갈아끼울 지점은 `src/utils/p2vApi.ts` 한 파일입니다.
### 2-5. 검증 절차
```bash
# 1) 서버 헬스체크 (인증 없이 열려 있는 유일한 경로)
curl -i https://<P2V 도메인>/api/health # → 200
# 2) 인증 게이트 동작
curl -i https://<P2V 도메인>/api/f2/templates # → 401
curl -i -H "X-P2V-Key: <키>" https://<P2V 도메인>/api/f2/templates # → 200 + JSON
```
브라우저에서:
| # | 확인 항목 | 기대 결과 |
|---|---|---|
| 1 | 무빙 포스터 탭 첫 진입 | 접근 키 입력창 → 키 입력 후 업로드 화면 |
| 2 | 포스터 업로드 | 5단계 스텝퍼가 "분석"으로 진행, 진행바·스테이지 라벨 표시 |
| 3 | 1~2분 후 | **검수 화면** — 나레이션 3문장, 모션 칩, 메타태그, 우측 분석 이미지 |
| 4 | 승인 | "생성" 단계로 넘어가고 4~6분 뒤 영상 재생 + MP4 다운로드 |
| 5 | 포스터 스타일링 탭 | 템플릿 썸네일 표시 (**깨지면 5번 선행조건 미이행**) |
| 6 | 모바일(375px) 검수 화면 | 포스터가 위, 승인 버튼이 하단 탭바 위에 고정 |
**F1은 검수 게이트에서 멈춥니다.** 승인 전까지 TTS·BGM·렌더가 시작되지 않으므로,
3번까지만 확인하면 유료 API를 거의 쓰지 않고 연동을 검증할 수 있습니다.
---
## 3. 롤백
프론트엔드는 이전 커밋으로 되돌려 재빌드하면 끝입니다. P2V는 castad와 별도 서비스라
내려도 castad 본체에 영향이 없습니다 — 포스터 탭만 연결 실패 상태가 됩니다.
---
## 4. 이번 브랜치에서 고친 기존 버그
배포 후 확인해 두면 좋은 항목입니다. 모두 포스터 기능과 무관하게 A안에 포함됩니다.
1. **진입 화면 세로 잘림** — `justify-content: center` + `overflow: hidden` 조합이라
콘텐츠가 뷰포트보다 길면 위로 넘친 부분에 스크롤로도 닿을 수 없었습니다.
자식의 `margin: auto 0` 중앙정렬로 교체했습니다.
2. **한글 단어 중간 줄바꿈** — `word-break: keep-all` 적용.
3. **입력·안내·제출 박스 높이 불일치**(46/38/48) — 48px로 통일.
4. **카피 어휘** — 가게→업체, 크롤링→수집.

View File

@ -51,6 +51,28 @@ npm run build
npm run preview npm run preview
``` ```
## 환경 변수
| 변수 | 기본값 | 설명 |
|---|---|---|
| `VITE_API_URL` | `http://40.82.133.44` | castad 백엔드 (ADO2·썰박스) |
| `VITE_P2V_URL` | `http://localhost:8010` | Poster to Video 서버 (포스터 영상·스타일링) |
### 포스터 파이프라인 (Poster to Video)
'포스터 영상'(F1)·'포스터 스타일'(F2) 탭은 castad 백엔드가 아니라 **별도 P2V 서버**를 직접
호출한다(`src/utils/p2vApi.ts`). 붙이려면 세 가지가 필요하다:
1. P2V 서버 기동 — 저장소는 `o2o-ado2-poster-to-video`, 기본 포트 8010
2. 서버의 `P2V_ALLOWED_ORIGINS` 에 이 프론트 오리진 추가 (예: `http://localhost:3000`)
3. 첫 진입 시 화면에 뜨는 입력창에 서버의 `P2V_ACCESS_KEY` 입력 (브라우저에 저장된다)
인증이 단일 팀 키라 **내부 시연 범위**다. 대외 공개 시에는 castad 백엔드에 `/p2v/*` 프록시를
세워 카카오 인증·크레딧 차감을 얹고, 프론트는 `p2vApi.ts` 한 파일만 그쪽으로 돌리면 된다.
포스터 스타일링(F2)은 영화 포스터 레퍼런스가 저작권 자산이라 **결과물의 외부 공개·상업 사용을
금한다**(퍼블릭 도메인 명화 제외). 화면 상단과 템플릿 배지로 등급을 표시한다.
## Docker 실행 ## Docker 실행
```bash ```bash

View File

@ -36,14 +36,14 @@
extend: { extend: {
colors: { colors: {
brand: { brand: {
bg: '#011c1e', bg: '#001a1c',
card: '#083336', card: '#003538',
cardHover: '#0b3f42', cardHover: '#004548',
accent: '#94FBE0', accent: '#a6ffea',
purple: '#a855f7', purple: '#a682ff',
purpleHover: '#9333ea', purpleHover: '#9570f0',
text: '#e2e8f0', text: '#d1d5db',
muted: '#94a3b8' muted: '#9ca3af'
} }
} }
} }
@ -52,7 +52,8 @@
</script> </script>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&family=Playfair+Display:ital,wght@0,700;1,700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,700;1,700&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" rel="stylesheet">
<style> <style>
:root { :root {
--app-height: 100%; --app-height: 100%;
@ -62,11 +63,13 @@
padding: 0; padding: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
background-color: #121a1d; background-color: #001a1c;
} }
body { body {
font-family: 'Noto Sans KR', sans-serif; font-family: 'Pretendard', 'Apple SD Gothic Neo', -apple-system, BlinkMacSystemFont,
'Helvetica Neue', 'Segoe UI', system-ui, sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
letter-spacing: -0.006em;
} }
.font-serif-logo { .font-serif-logo {
font-family: 'Playfair Display', serif; font-family: 'Playfair Display', serif;

View File

@ -0,0 +1,46 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { P2vLicense } from '../utils/p2vApi';
/**
* 스타일 템플릿의 배포 등급 표시.
*
* 왜 카드마다 붙이나 — 퍼블릭 도메인 명화와 영화 포스터가 한 그리드에 섞여 있어
* 페이지 배너로는 **어느 결과물이 공개 가능한지** 구분되지 않는다. 경고 문구는
* 실제로 고른 순간에만 띄운다(항상 떠 있으면 아무도 안 읽는다).
*/
const CLASS: Record<P2vLicense, string> = {
'public-domain': 'ok',
'internal-only': 'warn',
// 내부 전용과 색을 나눈다 — 해제 조건도, 책임 주체도 다르다
'user-uploaded': 'unknown',
};
export const LicenseBadge: React.FC<{ license: P2vLicense }> = ({ license }) => {
const { t } = useTranslation();
const variant = CLASS[license] ?? CLASS['internal-only'];
return (
<span className={`p2v-license p2v-license--${variant}`}>
{t(`poster.styling.license.${license}`)}
</span>
);
};
/** 내부 전용 템플릿을 고른 순간의 경고 */
export const InternalOnlyWarning: React.FC<{ note?: string }> = ({ note }) => {
const { t } = useTranslation();
return (
<div className="p2v-notice p2v-notice--warn">
<b>{t('poster.styling.internalOnlyTitle')}</b>
{' — '}
{note || t('poster.styling.internalOnlyBody')}
</div>
);
};
/** 사용자가 올린 레퍼런스 고지 — 남의 저작물일 수 있다 */
export const UserReferenceNotice: React.FC = () => {
const { t } = useTranslation();
return <div className="p2v-notice p2v-notice--unknown">{t('poster.styling.userRefNotice')}</div>;
};

View File

@ -0,0 +1,49 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { getP2vKey, setP2vKey } from '../utils/p2vApi';
interface P2vKeyGateProps {
/** 키를 저장한 뒤 호출 — 호출부가 실패했던 요청을 다시 태운다 */
onSaved: () => void;
}
/**
* P2V 접근 키 입력.
*
* P2V 웹은 401 에서 `window.prompt` 를 띄우지만(내부 도구라 그걸로 충분했다),
* castad 안에서는 제품 화면 한가운데라 시스템 다이얼로그가 튀어나오면 어색하다.
* 디자인 시스템 문법(민트 테두리 알림 + pill 입력)으로 인라인 처리한다.
*/
const P2vKeyGate: React.FC<P2vKeyGateProps> = ({ onSaved }) => {
const { t } = useTranslation();
const [value, setValue] = useState(getP2vKey());
const save = () => {
if (!value.trim()) return;
setP2vKey(value);
onSaved();
};
return (
<div className="p2v-keygate">
<p className="p2v-keygate__title">{t('poster.key.title')}</p>
<p className="p2v-keygate__desc">{t('poster.key.desc')}</p>
<div className="p2v-keygate__row">
<input
type="password"
className="p2v-input"
placeholder={t('poster.key.placeholder')}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && save()}
autoComplete="off"
/>
<button type="button" className="p2v-btn-mint" disabled={!value.trim()} onClick={save}>
{t('poster.key.save')}
</button>
</div>
</div>
);
};
export default P2vKeyGate;

View File

@ -1,23 +1,48 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import {
BookIcon,
PaletteIcon,
PosterVideoIcon,
StorefrontIcon,
} from './pipelineIcons';
/** 생성할 콘텐츠 종류. 진입 화면에서 고르면 이후 플로우 전체가 갈린다. */ /**
export type Pipeline = 'ado2' | 'ssul'; * 생성할 콘텐츠 종류. 진입 화면에서 고르면 이후 플로우 전체가 갈린다.
*
* poster·styling 은 castad 백엔드가 아니라 **P2V 서버(:8010)** 를 쓴다(utils/p2vApi.ts).
*/
export type Pipeline = 'ado2' | 'ssul' | 'poster' | 'styling';
/** 유효한 파이프라인 값인가 — localStorage 복원 때 쓴다 */
export const PIPELINES: readonly Pipeline[] = ['ado2', 'ssul', 'poster', 'styling'];
export const isPipeline = (value: unknown): value is Pipeline =>
typeof value === 'string' && (PIPELINES as readonly string[]).includes(value);
interface PipelineTabsProps { interface PipelineTabsProps {
value: Pipeline; value: Pipeline;
onChange: (value: Pipeline) => void; onChange: (value: Pipeline) => void;
} }
const TABS: { id: Pipeline; labelKey: string; emoji?: string }[] = [ const TABS: { id: Pipeline; labelKey: string; Icon: React.FC<{ size?: number }> }[] = [
{ id: 'ado2', labelKey: 'pipelineTabs.ado2' }, { id: 'ado2', labelKey: 'pipelineTabs.ado2', Icon: StorefrontIcon },
{ id: 'ssul', labelKey: 'pipelineTabs.ssul', emoji: '📋' }, { id: 'ssul', labelKey: 'pipelineTabs.ssul', Icon: BookIcon },
{ id: 'poster', labelKey: 'pipelineTabs.poster', Icon: PosterVideoIcon },
{ id: 'styling', labelKey: 'pipelineTabs.styling', Icon: PaletteIcon },
]; ];
/** /**
* ADO2 / 썰박스 전환 탭 (밑줄 탭). * 파이프라인 전환 탭 (아이콘 타일 + 기능명).
*
* 밑줄 탭에서 타일로 바꾼 이유: 파이프라인이 2개일 때는 글자만으로 충분했지만
* 4개가 되자 "포스터 영상"과 "포스터 스타일"처럼 **글자가 겹치는 항목**이 생겨
* 훑어볼 때 구분이 안 됐다. 형태(아이콘)가 붙으면 읽지 않고도 갈라진다.
*
* 색은 파이프라인별로 나누지 않는다 — 참조한 레퍼런스는 항목마다 파스텔색을 쓰지만,
* ADO2 는 민트 액센트 + 퍼플 CTA 한 곳이 규칙이라 4색을 쓰면 팔레트가 깨진다.
* 구분은 형태로, 상태(선택)는 민트로 나눈다.
* *
* 활성 탭 아래에만 밑줄이 얹히고, 색이 민트(ADO2) ↔ 앰버(썰박스)로 바뀐다.
* 스타일은 generation-flow.css 의 `.pipeline-tabs*` 참조. * 스타일은 generation-flow.css 의 `.pipeline-tabs*` 참조.
* *
* 접근성: castad 의 기존 탭(`.myinfo-tab` 등)에는 role/aria 가 없지만, * 접근성: castad 의 기존 탭(`.myinfo-tab` 등)에는 role/aria 가 없지만,
@ -33,21 +58,19 @@ const PipelineTabs: React.FC<PipelineTabsProps> = ({ value, onChange }) => {
role="tablist" role="tablist"
aria-label={t('pipelineTabs.ariaLabel')} aria-label={t('pipelineTabs.ariaLabel')}
> >
{TABS.map((tab) => ( {TABS.map(({ id, labelKey, Icon }) => (
<button <button
key={tab.id} key={id}
type="button" type="button"
role="tab" role="tab"
aria-selected={value === tab.id} aria-selected={value === id}
className="pipeline-tab" className="pipeline-tab"
onClick={() => onChange(tab.id)} onClick={() => onChange(id)}
> >
{tab.emoji && ( <span className="pipeline-tab__tile">
<span className="pipeline-tab__emoji" aria-hidden="true"> <Icon size={24} />
{tab.emoji} </span>
</span> <span className="pipeline-tab__label">{t(labelKey)}</span>
)}
{t(tab.labelKey)}
</button> </button>
))} ))}
</div> </div>

View File

@ -0,0 +1,106 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
interface PosterDropzoneProps {
file: File | null;
onFile: (file: File | null) => void;
}
/**
* 해상도 하한. **차단하지 않고 알리기만 한다** —
* 지자체·소상공인 포스터는 고해상도 원본 수급이 어려운 게 현실이라
* 막아버리면 실제 소재의 상당수가 들어오지 못한다.
*/
const SOFT_LONG_EDGE = 1000;
const ACCEPTED = /^image\/(jpeg|png|webp|gif)$/;
/**
* 포스터 업로드 드롭존. F1(영상)·F2(스타일링) 양쪽이 쓴다.
*
* 입력창을 4:5 세로로 두는 것은 포스터가 세로물이기 때문이다 — 가로 박스에 세로
* 이미지를 넣으면 미리보기가 작게 박혀 무엇을 올렸는지 확인이 안 된다.
*/
const PosterDropzone: React.FC<PosterDropzoneProps> = ({ file, onFile }) => {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const [preview, setPreview] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [dragging, setDragging] = useState(false);
// objectURL 은 명시적으로 놓아줘야 한다. 시연 중 포스터를 여러 장 갈아 끼우면
// 해제 안 된 URL 이 그대로 쌓인다(castad 의 revokeAllPreviews 와 같은 이유).
useEffect(() => {
return () => {
if (preview) URL.revokeObjectURL(preview);
};
}, [preview]);
const accept = useCallback((f: File) => {
if (!ACCEPTED.test(f.type)) {
setError(t('poster.dropzone.badType'));
return;
}
setError(null);
const url = URL.createObjectURL(f);
const img = new Image();
img.onload = () => {
const edge = Math.max(img.width, img.height);
setNotice(edge < SOFT_LONG_EDGE ? t('poster.dropzone.lowRes', { edge }) : null);
setPreview((prev) => {
if (prev) URL.revokeObjectURL(prev);
return url;
});
onFile(f);
};
img.onerror = () => {
URL.revokeObjectURL(url);
setError(t('poster.dropzone.badType'));
};
img.src = url;
}, [onFile, t]);
return (
<div className="p2v-dropzone-wrap">
<div
className={`p2v-dropzone${preview ? ' has-file' : ''}${dragging ? ' dragging' : ''}`}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
const f = e.dataTransfer.files?.[0];
if (f) accept(f);
}}
>
{preview ? (
<img src={preview} alt={t('poster.dropzone.previewAlt')} />
) : (
<div className="p2v-dropzone__empty">
<p className="p2v-dropzone__cta">{t('poster.dropzone.cta')}</p>
<p className="p2v-dropzone__hint">{t('poster.dropzone.formats')}</p>
</div>
)}
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
hidden
onChange={(e) => { const f = e.target.files?.[0]; if (f) accept(f); }}
/>
</div>
{error && <p className="p2v-note warn">{error}</p>}
{notice && <p className="p2v-note">{notice}</p>}
{file && (
<p className="p2v-note">
{file.name} · {(file.size / 1024 / 1024).toFixed(1)}MB
</p>
)}
</div>
);
};
export default PosterDropzone;

View File

@ -0,0 +1,69 @@
import React from 'react';
/**
* 파이프라인 타일 아이콘.
*
* ADO2 Design System 의 아이콘 규칙을 그대로 따른다(`assets/icons/*.svg`):
* viewBox 24×24 · fill none · stroke currentColor · stroke-width 2 · round cap/join
*
* 색은 넣지 않는다 — currentColor 로 두고 타일이 상태(민트/회색)를 정한다.
* 파이프라인마다 다른 색을 주면 ADO2 팔레트(민트 액센트 + 퍼플 CTA 한 곳)가 깨진다.
* 구분은 **형태**로만 한다.
*/
interface IconProps {
size?: number;
}
const base = (size: number) => ({
width: size,
height: size,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 2,
strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const,
});
/** ADO2 — 업체(가게)를 분석해 콘텐츠를 만든다 → 상점 차양 */
export const StorefrontIcon: React.FC<IconProps> = ({ size = 24 }) => (
<svg {...base(size)} aria-hidden="true">
<path d="M4 10v9a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-9" />
<path d="M3 10l1.4-4.3A1 1 0 0 1 5.35 5h13.3a1 1 0 0 1 .95.7L21 10a2.5 2.5 0 0 1-4.5 1.5A2.5 2.5 0 0 1 12 11.4a2.5 2.5 0 0 1-4.5.1A2.5 2.5 0 0 1 3 10z" />
<path d="M10 20v-5h4v5" />
</svg>
);
/** 썰박스 — 역사 이야기 → 펼친 책 */
export const BookIcon: React.FC<IconProps> = ({ size = 24 }) => (
<svg {...base(size)} aria-hidden="true">
<path d="M12 7.5v12" />
<path d="M3 5h5a4 4 0 0 1 4 2.5V19.5A3 3 0 0 0 9.5 18H3V5z" />
<path d="M21 5h-5a4 4 0 0 0-4 2.5V19.5A3 3 0 0 1 14.5 18H21V5z" />
</svg>
);
/**
* 무빙 포스터 — 포스터가 영상이 된다 → 세로 포스터 + 재생.
*
* 프레임은 2:3 세로다(12×18). 가로 프레임으로 그리면 일반 '동영상' 아이콘과
* 구별되지 않는다 — 소재가 **포스터**라는 점이 이 기능의 정체성이라 비율로 드러낸다.
*/
export const PosterVideoIcon: React.FC<IconProps> = ({ size = 24 }) => (
<svg {...base(size)} aria-hidden="true">
<rect x="6" y="3" width="12" height="18" rx="2" />
{/* 재생 삼각형만 채운다 — 아웃라인으로 그리면 이 크기에서 뭉개진다 */}
<path d="M10.5 9v6l4.2-3z" fill="currentColor" />
</svg>
);
/** 포스터 스타일 — 화풍 재해석 → 팔레트 */
export const PaletteIcon: React.FC<IconProps> = ({ size = 24 }) => (
<svg {...base(size)} aria-hidden="true">
<path d="M12 3a9 9 0 1 0 0 18 1.8 1.8 0 0 0 1.35-3 1.8 1.8 0 0 1 1.35-3H17a4 4 0 0 0 4-4c0-4.42-4.03-8-9-8z" />
<circle cx="7.6" cy="12.4" r="1.1" fill="currentColor" stroke="none" />
<circle cx="9.8" cy="8.2" r="1.1" fill="currentColor" stroke="none" />
<circle cx="14.6" cy="7.6" r="1.1" fill="currentColor" stroke="none" />
</svg>
);

97
src/hooks/useP2vJob.ts Normal file
View File

@ -0,0 +1,97 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
P2vAuthError,
P2vJob,
P2vNotFoundError,
getPosterJob,
getStylingJob,
isP2vActive,
} from '../utils/p2vApi';
interface UseP2vJobResult {
job: P2vJob | null;
error: string | null;
/** 401 — 화면이 접근 키 입력을 띄워야 한다 */
authNeeded: boolean;
/** 404 — 서버에 없는 잡이다. 호출부가 화면을 처음으로 되돌려야 한다 */
notFound: boolean;
/** 즉시 1회 조회하고, 멈춰 있던 폴링을 다시 켠다 (승인·재시도 직후에 쓴다) */
refresh: () => void;
}
const POLL_INTERVAL = 2500;
/**
* P2V 잡 폴링 훅.
*
* castad 에는 웹소켓이 없고 썰박스도 폴링(`waitForSsulComplete`)이라 같은 방식을 쓴다.
* 다만 썰박스는 "끝날 때까지 기다리는" 재귀 함수인 반면 이쪽은 **중간 상태를 계속
* 그려야** 한다 — 검수 게이트에서 멈춰 사용자 입력을 기다리기 때문이다.
*
* 폴링은 queued/running 에서만 돈다. awaiting_review·done·failed 는 사용자가 뭔가
* 하기 전까지 절대 안 바뀌므로 계속 두드리면 서버만 때린다. 승인·재시도 후에는
* 호출부가 refresh() 로 다시 켠다.
*/
export function useP2vJob(kind: 'f1' | 'f2', id: string | null): UseP2vJobResult {
const [job, setJob] = useState<P2vJob | null>(null);
const [error, setError] = useState<string | null>(null);
const [authNeeded, setAuthNeeded] = useState(false);
const [notFound, setNotFound] = useState(false);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const stop = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, []);
const fetchOnce = useCallback(async (jobId: string) => {
try {
const next = kind === 'f1' ? await getPosterJob(jobId) : await getStylingJob(jobId);
setJob(next);
setError(null);
setAuthNeeded(false);
setNotFound(false);
if (!isP2vActive(next)) stop();
} catch (e) {
if (e instanceof P2vAuthError) {
setAuthNeeded(true);
stop(); // 키가 없으면 계속 두드려봐야 401만 쌓인다
return;
}
if (e instanceof P2vNotFoundError) {
setNotFound(true);
stop(); // 없는 잡은 다시 생기지 않는다
return;
}
setError((e as Error).message);
}
}, [kind, stop]);
useEffect(() => {
if (!id) {
setJob(null);
setError(null);
setNotFound(false);
stop();
return;
}
// 잡이 바뀌면 이전 잡의 상태가 한 틱 남아 보이므로 먼저 비운다
setJob(null);
setNotFound(false);
fetchOnce(id);
timerRef.current = setInterval(() => fetchOnce(id), POLL_INTERVAL);
return stop;
}, [id, fetchOnce, stop]);
const refresh = useCallback(() => {
if (!id) return;
fetchOnce(id);
if (!timerRef.current) {
timerRef.current = setInterval(() => fetchOnce(id), POLL_INTERVAL);
}
}, [id, fetchOnce]);
return { job, error, authNeeded, notFound, refresh };
}

View File

@ -603,7 +603,15 @@
"pipelineTabs": { "pipelineTabs": {
"ariaLabel": "Content type", "ariaLabel": "Content type",
"ado2": "ADO2", "ado2": "ADO2",
"ssul": "Ssulbox" "ssul": "Ssulbox",
"poster": "Moving Poster",
"styling": "Poster Styling",
"desc": {
"ado2": "One business URL becomes a brand analysis, lyrics, music and a short-form video.",
"ssul": "Weaves a business into a historical story people watch to the end.",
"poster": "One event poster becomes a short-form video with narration, music and camera moves.",
"styling": "Reinterprets a poster in the visual language of paintings and film posters."
}
}, },
"ssulbox": { "ssulbox": {
"steps": { "steps": {
@ -622,5 +630,79 @@
"submit": "Create Ssulbox", "submit": "Create Ssulbox",
"submitting": "Requesting…" "submitting": "Requesting…"
} }
},
"poster": {
"steps": {
"upload": "Upload",
"analyze": "Analyze",
"review": "Review",
"render": "Generate",
"done": "Done"
},
"key": {
"title": "P2V access key required",
"desc": "The poster pipeline runs on a separate server. The key you enter is stored in this browser.",
"placeholder": "P2V_ACCESS_KEY",
"save": "Save"
},
"dropzone": {
"cta": "Drop a poster here\nor click to choose",
"formats": "JPG · PNG · WEBP · GIF\nNo resolution limit",
"badType": "Only JPG / PNG / WEBP / GIF files are accepted.",
"lowRes": "Long edge is {{edge}}px. You can proceed; deep zoom segments may look soft.",
"previewAlt": "Poster preview"
},
"create": {
"step": "STEP 1 · Upload poster",
"namePlaceholder": "Event name (leave blank to auto-detect)",
"submit": "Create short-form",
"submitting": "Uploading…"
},
"making": {
"titleAnalyze": "Analyzing the poster",
"titleRender": "Generating the video",
"descAnalyze": "Detecting regions and drafting narration and motion. The review screen opens when this finishes.",
"descRender": "Voice, music, animation and compositing follow. The music step can take a few minutes.",
"phaseAnalyze": "Analyzing",
"phaseRender": "Generating",
"failTitle": "Generation failed",
"retry": "Retry",
"discard": "Start over",
"discardConfirm": "This deletes the job and every generated video, audio and analysis file. This cannot be undone."
},
"review": {
"narration": "Narration review",
"motion": "Elements to animate",
"metadata": "Metadata",
"field": {
"event_name": "Event",
"date_text": "Date",
"place": "Place"
},
"analysis": "Poster analysis",
"approve": "Approve and continue",
"approving": "Approving…"
},
"result": {
"title": "Your moving poster is ready",
"fileName": "File name",
"downloadThumb": "Still JPG",
"downloadVideo": "Download MP4",
"new": "Start a new poster"
},
"styling": {
"internalScope": "Internal demo only. Because of reference licensing, results must not be published externally or used commercially (public-domain artworks excepted).",
"templates": "Style templates",
"myPoster": "My poster",
"result": "Result",
"converting": "Converting (about 30s)",
"formats": "Output format",
"submit": "Convert style",
"license": {
"public-domain": "Publishable",
"internal-only": "Internal only",
"user-uploaded": "Rights unverified"
}
}
} }
} }

View File

@ -603,7 +603,15 @@
"pipelineTabs": { "pipelineTabs": {
"ariaLabel": "생성할 콘텐츠 종류", "ariaLabel": "생성할 콘텐츠 종류",
"ado2": "ADO2", "ado2": "ADO2",
"ssul": "썰박스" "ssul": "썰박스",
"poster": "무빙 포스터",
"styling": "포스터 스타일링",
"desc": {
"ado2": "업체 URL 하나로 브랜드를 분석해 가사·음악·숏폼 영상까지 자동 제작합니다.",
"ssul": "유명 역사 스토리에 업체 셀링포인트를 녹여 흥미로운 숏폼 콘텐츠를 만듭니다.",
"poster": "포스터가 나레이션, 음악, 애니메이션을 추가한 숏폼 콘텐츠로 나옵니다.",
"styling": "포스터를 유명 영화의 한 장면 · 고전화 · 영화 포스터 스타일로 재해석합니다."
}
}, },
"ssulbox": { "ssulbox": {
"steps": { "steps": {
@ -613,13 +621,13 @@
}, },
"create": { "create": {
"step1": "STEP 1 · 시나리오 선택", "step1": "STEP 1 · 시나리오 선택",
"step2": "STEP 2 · 업장 검색", "step2": "STEP 2 · 업체 검색",
"searchPlaceholder": "업체명 또는 URL을 입력하세요.", "searchPlaceholder": "업체명 또는 URL을 입력하세요.",
"searchButton": "검색", "searchButton": "검색",
"searching": "검색 중", "searching": "검색 중",
"placeLoading": "가게를 찾는 중… (몇 초 걸려요)", "placeLoading": "업체를 찾는 중… (몇 초 걸려요)",
"hintUrlOk": "네이버 링크로 이 가게를 직접 크롤링합니다.", "hintUrlOk": "네이버 링크로 해당 업체를 직접 수집합니다.",
"hintDefault": "업체의 사진과 정보를 크롤링해 썰박스에 사용합니다.", "hintDefault": "업체를 선택하면 해당 업체의 사진과 정보를 수집해 썰박스에 씁니다.",
"hintScenario": "{{name}} 시나리오로 약 5~6분 소요됩니다.", "hintScenario": "{{name}} 시나리오로 약 5~6분 소요됩니다.",
"noResult": "검색 결과가 없어요. 이름을 바꾸거나 네이버 지도 링크를 붙여넣어 주세요.", "noResult": "검색 결과가 없어요. 이름을 바꾸거나 네이버 지도 링크를 붙여넣어 주세요.",
"cost": "생성 비용", "cost": "생성 비용",
@ -671,5 +679,128 @@
"desc": "오디세우스 10년 귀향 대모험" "desc": "오디세우스 10년 귀향 대모험"
} }
} }
},
"poster": {
"steps": {
"upload": "업로드",
"analyze": "분석",
"review": "검수",
"render": "생성",
"done": "완성"
},
"key": {
"title": "P2V 접근 키가 필요합니다",
"desc": "포스터 파이프라인은 별도 서버에서 돕니다. 서버에 설정된 접근 키를 입력하면 이 브라우저에 저장됩니다.",
"placeholder": "P2V_ACCESS_KEY",
"save": "저장"
},
"dropzone": {
"cta": "포스터를 끌어다 놓거나\n클릭해서 선택",
"formats": "JPG · PNG · WEBP · GIF\n해상도 제한 없음",
"badType": "JPG / PNG / WEBP / GIF 파일만 올릴 수 있습니다.",
"lowRes": "긴 변 {{edge}}px입니다. 그대로 진행할 수 있고, 깊은 줌 구간만 다소 부드럽게 보일 수 있습니다.",
"previewAlt": "포스터 미리보기"
},
"create": {
"step": "STEP 1 · 포스터 업로드",
"namePlaceholder": "행사 이름을 입력하세요 (비우면 자동 추출)",
"submit": "숏폼 만들기",
"submitting": "업로드 중…"
},
"making": {
"titleAnalyze": "포스터를 분석하고 있어요",
"titleRender": "영상을 만들고 있어요",
"descAnalyze": "영역을 검출하고 나레이션·모션을 초안으로 작성합니다. 끝나면 검수 화면이 열립니다.",
"descRender": "음성·음악·애니메이션·합성이 이어집니다. 음악 단계는 몇 분 걸릴 수 있습니다.",
"phaseAnalyze": "분석 중",
"phaseRender": "생성 중",
"failTitle": "생성에 실패했어요",
"retry": "다시 시도",
"discard": "처음부터",
"discardConfirm": "이 작업과 만들어진 영상·음성·분석 파일을 모두 지웁니다. 되돌릴 수 없습니다."
},
"stages": {
"detect": "영역 검출",
"narration_text": "나레이션",
"motion": "모션 선정",
"tts": "음성",
"bgm": "음악",
"i2v": "애니메이션",
"render": "합성",
"transfer": "변환"
},
"review": {
"narration": "나레이션 검수",
"narrationHelp": "자동 작성된 문장을 확인하고 필요하면 고쳐주세요. 이대로 음성이 됩니다.",
"motion": "움직일 요소",
"motionHelp": "포스터에서 찾은 요소입니다. 빼거나 더할 수 있습니다.",
"motionEmpty": "움직일 요소를 찾지 못했습니다. 직접 고르거나, 비워두면 애니메이션 없이 진행됩니다.",
"metadata": "메타태그",
"metadataHelp": "아카이브에 영구 저장됩니다. 연도나 지명이 잘못 읽혔는지 포스터와 대조해 주세요.",
"field": {
"event_name": "행사명",
"date_text": "일시",
"place": "장소"
},
"analysis": "포스터 분석 결과",
"analysisAlt": "영역 분석 확인 이미지",
"analysisMissing": "분석 이미지가 없습니다",
"approve": "승인하고 계속",
"approving": "승인 중…"
},
"motion": {
"firework": "불꽃놀이",
"water": "물살",
"wave": "파도",
"cloud": "구름",
"smoke": "연기",
"moon": "달",
"sun": "해",
"star": "별",
"light": "조명",
"flag": "깃발·천",
"foliage": "나뭇잎·풀",
"wheel": "바퀴·관람차",
"vessel": "배",
"crowd": "사람 무리"
},
"result": {
"title": "무빙 포스터가 완성됐어요",
"fileName": "파일명",
"videoMissing": "영상 파일을 찾지 못했습니다.",
"downloadThumb": "정지 컷 JPG",
"downloadVideo": "MP4 다운로드",
"new": "새 포스터로 만들기"
},
"styling": {
"internalScope": "내부 시연 전용입니다. 레퍼런스 저작권 때문에 결과물의 외부 공개·상업 사용은 금합니다(퍼블릭 도메인 명화 제외).",
"templates": "스타일 템플릿",
"pickTemplate": "템플릿을 선택하세요",
"count": "{{count}}종",
"myRefs": "내 레퍼런스",
"myRefsHint": "직접 올린 이미지의 화법으로 변환합니다",
"addRef": "레퍼런스 추가",
"minEdge": "긴 변 {{px}}px 이상 권장",
"analyzing": "화풍 분석 중…",
"deleteRef": "레퍼런스 삭제",
"confirmDelete": "이 레퍼런스를 삭제할까요? 되돌릴 수 없습니다.",
"attribution": "레퍼런스 출처 · {{source}}",
"myPoster": "내 포스터",
"result": "결과",
"resultAlt": "변환 결과",
"resultHint": "변환 결과가 여기에 표시됩니다",
"converting": "변환 중 (30초 안팎)",
"downloadPng": "PNG 다운로드",
"formats": "출력 포맷",
"submit": "스타일 변환",
"license": {
"public-domain": "공개 가능",
"internal-only": "내부 전용",
"user-uploaded": "권리 미확인"
},
"internalOnlyTitle": "내부 전용 템플릿",
"internalOnlyBody": "이 레퍼런스로 만든 결과물은 외부 공개·상업 사용을 금합니다.",
"userRefNotice": "직접 올린 레퍼런스는 권리를 가진 이미지만 사용하세요. 타인의 저작물을 올려 생긴 문제의 책임은 업로드한 사람에게 있습니다. 결과물은 권리 미확인으로 분류됩니다."
}
} }
} }

View File

@ -30,10 +30,15 @@ const revokeAllPreviews = (items: ImageItem[]) => {
} }
}); });
}; };
import { Pipeline } from '../../components/PipelineTabs'; import { Pipeline, isPipeline } from '../../components/PipelineTabs';
import { Scen } from '../Ssulbox/ssulData'; import { Scen } from '../Ssulbox/ssulData';
import SsulMakingContent, { SsulJob } from '../Ssulbox/SsulMakingContent'; import SsulMakingContent, { SsulJob } from '../Ssulbox/SsulMakingContent';
import SsulResultContent from '../Ssulbox/SsulResultContent'; import SsulResultContent from '../Ssulbox/SsulResultContent';
import PosterMakingContent from '../Poster/PosterMakingContent';
import PosterReviewContent from '../Poster/PosterReviewContent';
import PosterResultContent from '../Poster/PosterResultContent';
import { useP2vJob } from '../../hooks/useP2vJob';
import { deletePosterJob, isPastReview, retryPosterJob } from '../../utils/p2vApi';
import { import {
K, K,
clearProjectStorage, clearProjectStorage,
@ -72,6 +77,25 @@ const SSUL_STEP_INDEX: Record<SsulStep, number> = {
result: 2, result: 2,
}; };
/**
* 포스터 영상(F1) 진행 단계. 잡 status 에서 파생되므로 state 로 들지 않는다 —
* 서버 상태와 화면 상태를 따로 들면 반드시 어긋난다(썰박스에서 겪은 것).
*/
type PosterStep = 'entry' | 'making' | 'review' | 'result';
/**
* 스텝퍼 라벨. 5단계인 이유는 검수 게이트 **앞뒤가 서로 다른 작업**이기 때문이다 —
* 분석(검출·나레이션·모션)과 생성(음성·음악·애니메이션·렌더)을 한 칸에 묶으면
* 승인 후에도 스텝퍼가 안 움직여 멈춘 것처럼 보인다.
*/
const POSTER_STEP_LABELS = [
'poster.steps.upload',
'poster.steps.analyze',
'poster.steps.review',
'poster.steps.render',
'poster.steps.done',
] as const;
interface BusinessInfo { interface BusinessInfo {
customer_name: string; customer_name: string;
region: string; region: string;
@ -164,7 +188,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
const getInitialPipeline = (): Pipeline => { const getInitialPipeline = (): Pipeline => {
// 1) 랜딩 CTA 프리셋 (1회성). 카카오 로그인 리다이렉트 왕복을 견뎌야 해서 sessionStorage // 1) 랜딩 CTA 프리셋 (1회성). 카카오 로그인 리다이렉트 왕복을 견뎌야 해서 sessionStorage
const pending = sessionStorage.getItem(PENDING_PIPELINE_KEY); const pending = sessionStorage.getItem(PENDING_PIPELINE_KEY);
if (pending === 'ssul' || pending === 'ado2') { if (isPipeline(pending)) {
sessionStorage.removeItem(PENDING_PIPELINE_KEY); sessionStorage.removeItem(PENDING_PIPELINE_KEY);
return pending; return pending;
} }
@ -172,7 +196,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
if (initialAnalysisData || savedAnalysisData) return 'ado2'; if (initialAnalysisData || savedAnalysisData) return 'ado2';
// 3) 저장된 선택 // 3) 저장된 선택
const saved = localStorage.getItem(K.PIPELINE); const saved = localStorage.getItem(K.PIPELINE);
return saved === 'ssul' ? 'ssul' : 'ado2'; return isPipeline(saved) ? saved : 'ado2';
}; };
const [pipeline, setPipeline] = useState<Pipeline>(getInitialPipeline); const [pipeline, setPipeline] = useState<Pipeline>(getInitialPipeline);
@ -182,13 +206,48 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
/** 언마운트·로그아웃·재시도 시 진행 중인 폴링을 멈추는 플래그 */ /** 언마운트·로그아웃·재시도 시 진행 중인 폴링을 멈추는 플래그 */
const ssulCancelledRef = useRef(false); const ssulCancelledRef = useRef(false);
/**
* 포스터 영상(F1) 잡. 썰박스와 달리 상태 축을 따로 들지 않고 서버 status 에서 파생한다 —
* P2V 는 검수 게이트에서 멈춰 사용자를 기다리므로 중간 상태를 계속 그려야 하고,
* 그때 화면 상태를 따로 들면 두 축이 어긋난다.
*/
const [posterJobId, setPosterJobId] = useState<string | null>(
() => localStorage.getItem(K.POSTER_JOB_ID),
);
const [posterBusy, setPosterBusy] = useState(false);
const { job: posterJob, notFound: posterNotFound, refresh: refreshPosterJob } =
useP2vJob('f1', pipeline === 'poster' ? posterJobId : null);
const changePipeline = (next: Pipeline) => { const changePipeline = (next: Pipeline) => {
setPipeline(next); setPipeline(next);
localStorage.setItem(K.PIPELINE, next); localStorage.setItem(K.PIPELINE, next);
}; };
/** 진입 화면(탭이 보이는 단계) 여부. 두 파이프라인이 공유한다 */ /** 포스터 진행 단계 — 잡 status 에서 파생 */
const isEntry = pipeline === 'ado2' ? wizardStep === -2 : ssulStep === 'entry'; const posterStep: PosterStep = !posterJobId
? 'entry'
: posterJob?.status === 'awaiting_review'
? 'review'
: posterJob?.status === 'done'
? 'result'
: 'making'; // queued · running · failed · 조회 전(null)
/** 스텝퍼 인덱스. 검수 전 running(1) 과 승인 후 running(3) 을 구분한다 */
const posterStepIndex = posterStep === 'entry'
? 0
: posterStep === 'review'
? 2
: posterStep === 'result'
? 4
: isPastReview(posterJob) ? 3 : 1;
/** 진입 화면(탭이 보이는 단계) 여부. 파이프라인마다 진입 조건이 다르다 */
const isEntry =
pipeline === 'ado2' ? wizardStep === -2
: pipeline === 'ssul' ? ssulStep === 'entry'
: pipeline === 'poster' ? posterStep === 'entry'
// 스타일링은 전 과정이 진입 화면 안에서 끝난다 (위저드 없음)
: true;
const [songTaskId, setSongTaskId] = useState<string | null>(savedSongTaskId); const [songTaskId, setSongTaskId] = useState<string | null>(savedSongTaskId);
const [imageTaskId, setImageTaskId] = useState<string | null>(savedImageTaskId); const [imageTaskId, setImageTaskId] = useState<string | null>(savedImageTaskId);
const [videoGenerationStatus, setVideoGenerationStatus] = useState<'idle' | 'generating' | 'complete' | 'error'>('idle'); const [videoGenerationStatus, setVideoGenerationStatus] = useState<'idle' | 'generating' | 'complete' | 'error'>('idle');
@ -551,6 +610,64 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
setSsulStep('entry'); setSsulStep('entry');
}; };
// ── 포스터 영상 (P2V F1) ─────────────────────────────────────────────
const handlePosterJobStarted = (jobId: string) => {
localStorage.setItem(K.POSTER_JOB_ID, jobId);
setPosterJobId(jobId);
};
/** 잡을 버리고 업로드 화면으로. 서버 잡은 남겨둔다(아카이브·디버깅에 쓰인다) */
const handlePosterNew = () => {
localStorage.removeItem(K.POSTER_JOB_ID);
setPosterJobId(null);
};
/**
* 실패한 스테이지부터 다시. 업로드부터 다시 하지 않는다 —
* i2v 실패(모델 큐·크레딧)는 같은 입력으로 재시도하면 자주 통과한다.
*/
const handlePosterRetry = async () => {
if (!posterJobId || posterBusy) return;
setPosterBusy(true);
try {
await retryPosterJob(posterJobId);
refreshPosterJob();
} catch {
// 재시도 요청 자체가 실패해도 잡 상태는 그대로다 — 폴링 결과가 계속 화면을 그린다
} finally {
setPosterBusy(false);
}
};
/** 실패한 잡과 산출물을 지우고 처음부터. 되돌릴 수 없다 */
const handlePosterDiscard = async () => {
if (!posterJobId || posterBusy) return;
if (!window.confirm(t('poster.making.discardConfirm'))) return;
setPosterBusy(true);
try {
await deletePosterJob(posterJobId);
} catch {
// 서버에서 이미 사라졌어도 화면은 처음으로 돌려보내는 게 맞다
} finally {
setPosterBusy(false);
handlePosterNew();
}
};
/**
* 사라진 잡 정리.
*
* P2V 에는 "내 활성 잡" 엔드포인트가 없어(단일 팀 키라 사용자 개념이 없다)
* localStorage 의 id 가 복구의 유일한 근거다. 그런데 서버를 재시작하거나 다른
* 사람이 그 잡을 지우면 id 만 남아 **빈 진행 화면에서 영원히 멈춘다** — 실제로 겪었다.
* 404 를 만나면 업로드 화면으로 되돌린다.
*/
useEffect(() => {
if (posterNotFound) handlePosterNew();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [posterNotFound]);
/** /**
* 새로고침·새 탭 진입 시 진행 중인 잡을 복구한다. * 새로고침·새 탭 진입 시 진행 중인 잡을 복구한다.
* *
@ -599,6 +716,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
// 지우므로 state 만 남으면 저장소와 어긋난다. // 지우므로 state 만 남으면 저장소와 어긋난다.
setPipeline('ado2'); setPipeline('ado2');
setSsulStep('entry'); setSsulStep('entry');
// clearProjectStorage() 가 POSTER_JOB_ID 를 지우므로 state 도 함께 비운다
setPosterJobId(null);
setSongTaskId(null); setSongTaskId(null);
setImageTaskId(null); setImageTaskId(null);
setAnalysisData(null); setAnalysisData(null);
@ -625,6 +744,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
onManualInput={handleManualInput} onManualInput={handleManualInput}
error={analysisError} error={analysisError}
onSsulJobStarted={handleSsulJobStarted} onSsulJobStarted={handleSsulJobStarted}
onPosterJobStarted={handlePosterJobStarted}
onGoToPayment={handleGoToPayment} onGoToPayment={handleGoToPayment}
/> />
); );
@ -646,6 +766,29 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
} }
} }
// 포스터 영상도 자체 축으로 진행한다 (단계는 잡 status 파생)
if (pipeline === 'poster') {
switch (posterStep) {
case 'review':
return posterJob ? (
<PosterReviewContent job={posterJob} onApproved={refreshPosterJob} />
) : null;
case 'result':
return posterJob ? (
<PosterResultContent job={posterJob} onNew={handlePosterNew} />
) : null;
default:
return (
<PosterMakingContent
job={posterJob}
onRetry={handlePosterRetry}
onDiscard={handlePosterDiscard}
busy={posterBusy}
/>
);
}
}
switch (wizardStep) { switch (wizardStep) {
case -1: case -1:
// 로딩 단계 // 로딩 단계
@ -758,6 +901,18 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
</div> </div>
); );
} }
// 포스터 영상은 검수 게이트를 포함한 5단계
if (pipeline === 'poster') {
return (
<div className="wizard-page-container">
<WizardStepper
steps={POSTER_STEP_LABELS.map((key) => t(key))}
currentStep={posterStepIndex}
/>
{renderWizardContent()}
</div>
);
}
// 브랜드 분석(0)은 전체 화면 스크롤이지만 스텝퍼는 표시 // 브랜드 분석(0)은 전체 화면 스크롤이지만 스텝퍼는 표시
if (wizardStep === 0) { if (wizardStep === 0) {
return ( return (

View File

@ -1,9 +1,12 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
import { AutocompleteRequest } from '../../utils/api'; import { AutocompleteRequest } from '../../utils/api';
import SearchInputForm, { SearchType } from '../../components/SearchInputForm'; import SearchInputForm, { SearchType } from '../../components/SearchInputForm';
import PipelineTabs, { Pipeline } from '../../components/PipelineTabs'; import PipelineTabs, { Pipeline } from '../../components/PipelineTabs';
import SsulCreateForm from '../Ssulbox/SsulCreateForm'; import SsulCreateForm from '../Ssulbox/SsulCreateForm';
import { Scen } from '../Ssulbox/ssulData'; import { Scen } from '../Ssulbox/ssulData';
import PosterCreateForm from '../Poster/PosterCreateForm';
import StylingContent from '../Poster/StylingContent';
interface UrlInputContentProps { interface UrlInputContentProps {
/** 현재 선택된 파이프라인. 소유자는 GenerationFlow (분기·리셋 때문) */ /** 현재 선택된 파이프라인. 소유자는 GenerationFlow (분기·리셋 때문) */
@ -15,6 +18,8 @@ interface UrlInputContentProps {
error: string | null; error: string | null;
/** 썰박스 생성 요청 접수 시. 폴링 소유는 GenerationFlow */ /** 썰박스 생성 요청 접수 시. 폴링 소유는 GenerationFlow */
onSsulJobStarted: (taskId: number, scenario: Scen) => void; onSsulJobStarted: (taskId: number, scenario: Scen) => void;
/** 포스터 영상(F1) 생성 요청 접수 시. 폴링 소유는 GenerationFlow */
onPosterJobStarted: (jobId: string) => void;
/** 크레딧 부족 시 충전 화면으로 */ /** 크레딧 부족 시 충전 화면으로 */
onGoToPayment: () => void; onGoToPayment: () => void;
} }
@ -32,27 +37,51 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({
onManualInput, onManualInput,
error, error,
onSsulJobStarted, onSsulJobStarted,
onPosterJobStarted,
onGoToPayment, onGoToPayment,
}) => { }) => {
return ( const { t } = useTranslation();
<div className="url-input-container">
<div className="url-input-content">
<div className="url-input-logo">
<img src="/assets/images/ADO2_with Slogan_white.svg" alt="ADO2" />
</div>
<PipelineTabs value={pipeline} onChange={onPipelineChange} /> // 스타일링은 한 화면짜리 도구라 진입 폼보다 넓은 판이 필요하다 (generation-flow.css 참조)
const wide = pipeline === 'styling';
{pipeline === 'ado2' ? ( const renderForm = () => {
switch (pipeline) {
case 'ado2':
return (
<SearchInputForm <SearchInputForm
onAnalyze={onAnalyze} onAnalyze={onAnalyze}
onAutocomplete={onAutocomplete} onAutocomplete={onAutocomplete}
onManualInput={onManualInput} onManualInput={onManualInput}
error={error} error={error}
/> />
) : ( );
<SsulCreateForm onSubmitted={onSsulJobStarted} onNeedCredit={onGoToPayment} /> case 'ssul':
)} return <SsulCreateForm onSubmitted={onSsulJobStarted} onNeedCredit={onGoToPayment} />;
case 'poster':
return <PosterCreateForm onSubmitted={onPosterJobStarted} />;
case 'styling':
// 스타일링은 위저드가 아니라 도구다 — 진입 화면 안에서 처음부터 끝까지 끝난다
return <StylingContent />;
default:
return null;
}
};
return (
<div className="url-input-container">
<div className={`url-input-content${wide ? ' url-input-content--wide' : ''}`}>
<div className="url-input-logo">
<img src="/assets/images/ADO2_with Slogan_white.svg" alt="ADO2" />
</div>
<PipelineTabs value={pipeline} onChange={onPipelineChange} />
{/* 고른 기능이 무엇을 해주는지 한 줄. 탭 라벨(2~6자)만으로는 네 기능의 차이가
전달되지 않아 처음 온 사람이 아무거나 눌러본다. STEP 1 위에 둔다. */}
<p className="pipeline-desc">{t(`pipelineTabs.desc.${pipeline}`)}</p>
{renderForm()}
</div> </div>
</div> </div>
); );

View File

@ -0,0 +1,79 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import PosterDropzone from '../../components/PosterDropzone';
import P2vKeyGate from '../../components/P2vKeyGate';
import { P2vAuthError, createPosterJob } from '../../utils/p2vApi';
interface PosterCreateFormProps {
/** 생성 요청이 접수되면 호출. 폴링 소유는 GenerationFlow (썰박스와 같은 규칙) */
onSubmitted: (jobId: string) => void;
}
/**
* 포스터 → 영상(F1) 생성 폼. 진입 화면의 '포스터 영상' 탭에 들어간다.
*
* 행사명은 비워도 된다 — 서버가 포스터에서 뽑는다. 다만 뽑은 값이 틀릴 수 있어
* 검수 단계에서 다시 고칠 기회를 준다(메타태그는 아카이브에 영구 저장된다).
*/
const PosterCreateForm: React.FC<PosterCreateFormProps> = ({ onSubmitted }) => {
const { t } = useTranslation();
const [file, setFile] = useState<File | null>(null);
const [name, setName] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [authNeeded, setAuthNeeded] = useState(false);
const submit = async () => {
if (!file || submitting) return;
setSubmitting(true);
setError(null);
try {
const { id } = await createPosterJob(file, name.trim());
onSubmitted(id);
} catch (e) {
if (e instanceof P2vAuthError) {
setAuthNeeded(true);
} else {
setError((e as Error).message);
}
setSubmitting(false);
}
};
if (authNeeded) {
return (
<div className="p2v-scope p2v-create">
<P2vKeyGate onSaved={() => { setAuthNeeded(false); submit(); }} />
</div>
);
}
return (
<div className="p2v-scope p2v-create">
<span className="p2v-step">{t('poster.create.step')}</span>
<PosterDropzone file={file} onFile={setFile} />
<input
className="p2v-input p2v-input--center"
placeholder={t('poster.create.namePlaceholder')}
value={name}
onChange={(e) => setName(e.target.value)}
/>
{error && <div className="p2v-note warn">{error}</div>}
<button
type="button"
className="p2v-btn-cta"
disabled={!file || submitting}
onClick={submit}
>
{submitting ? t('poster.create.submitting') : t('poster.create.submit')}
</button>
</div>
);
};
export default PosterCreateForm;

View File

@ -0,0 +1,105 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { P2vJob, isPastReview, p2vProgress } from '../../utils/p2vApi';
interface PosterMakingContentProps {
job: P2vJob | null;
/** 실패 시 같은 잡을 다시 태운다 (업로드부터 다시 하지 않는다) */
onRetry: () => void;
/** 실패한 잡을 버리고 처음부터 */
onDiscard: () => void;
busy: boolean;
}
/**
* 포스터 영상 생성 진행 화면. 검수 전(분석)과 검수 후(생성) 양쪽에 쓰인다.
*
* 두 구간을 한 컴포넌트로 둔 이유: 화면 구성이 같고 문구만 다르다. 나누면
* 스피너·진행바·에러 처리가 두 벌이 되어 한쪽만 고치는 사고가 난다.
*
* 진행바는 스테이지 완료 수 기반이라 구간 사이에서 멈춰 보인다 —
* 스피너가 "살아 있다"를 따로 알린다(썰박스 SsulMakingContent 와 같은 판단).
*/
const PosterMakingContent: React.FC<PosterMakingContentProps> = ({
job, onRetry, onDiscard, busy,
}) => {
const { t } = useTranslation();
if (!job) return null;
const failed = job.status === 'failed';
const percent = p2vProgress(job);
const past = isPastReview(job);
// 현재 스테이지 라벨. 서버가 stage 를 비워 보내는 순간(스테이지 전환 사이)이 있어
// 그때는 구간 이름으로 대신한다 — 라벨이 깜빡이며 사라지면 멈춘 것처럼 보인다.
const stageLabel = job.stage
? t(`poster.stages.${job.stage}`, { defaultValue: job.stage })
: t(past ? 'poster.making.phaseRender' : 'poster.making.phaseAnalyze');
return (
<div className="p2v-scope p2v-making">
<span className="p2v-making__name">{job.name}</span>
{failed ? (
<>
<h2 className="p2v-making__title warn">{t('poster.making.failTitle')}</h2>
<p className="p2v-making__desc">
{t(`poster.stages.${job.error?.stage}`, { defaultValue: job.error?.stage ?? '' })}
</p>
{job.error?.detail && (
<pre className="p2v-errorbox">{job.error.detail}</pre>
)}
<div className="p2v-making__actions">
<button type="button" className="p2v-btn-outline" disabled={busy} onClick={onDiscard}>
{t('poster.making.discard')}
</button>
<button type="button" className="p2v-btn-mint" disabled={busy} onClick={onRetry}>
{t('poster.making.retry')}
</button>
</div>
</>
) : (
<>
<h2 className="p2v-making__title">
{t(past ? 'poster.making.titleRender' : 'poster.making.titleAnalyze')}
</h2>
<div className="p2v-spinner" aria-hidden="true" />
<div
className="p2v-bar"
role="progressbar"
aria-valuenow={percent}
aria-valuemin={0}
aria-valuemax={100}
>
<div className="p2v-bar__fill" style={{ width: `${percent}%` }} />
</div>
<div className="p2v-making__meta">
<span>{stageLabel}</span>
<span className="p2v-making__pct">{percent}%</span>
</div>
<p className="p2v-making__desc">
{t(past ? 'poster.making.descRender' : 'poster.making.descAnalyze')}
</p>
{/* 나레이션은 검수 전에 이미 나온다. 먼저 보여주면 대기 시간이 덜 비어 보이고,
검수 화면에서 처음 읽는 것보다 훑을 시간이 생긴다 */}
{job.narration && job.narration.length > 0 && (
<div className="p2v-panel p2v-making__narration">
<p className="p2v-label">{t('poster.review.narration')}</p>
{job.narration.map((line, i) => (
<p key={i} className="p2v-narration-line">{line}</p>
))}
</div>
)}
</>
)}
</div>
);
};
export default PosterMakingContent;

View File

@ -0,0 +1,95 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { P2vJob, p2vFile } from '../../utils/p2vApi';
interface PosterResultContentProps {
job: P2vJob;
/** 새 포스터로 처음부터 */
onNew: () => void;
}
/**
* 포스터 영상 완성 화면.
*
* 다운로드는 `<a download>` 다 — 헤더를 못 붙이므로 p2vFile() 이 접근 키를
* 쿼리로 태워준다(P2V 서버가 ?key= 경로를 지원하는 이유가 이것이다).
*/
const PosterResultContent: React.FC<PosterResultContentProps> = ({ job, onNew }) => {
const { t } = useTranslation();
const video = p2vFile(job.artifacts.video);
const thumbnail = job.artifacts.thumbnail ? p2vFile(job.artifacts.thumbnail) : null;
return (
<div className="p2v-scope p2v-result">
<h2 className="p2v-result__title">{t('poster.result.title')}</h2>
<div className="p2v-result__grid">
<div className="p2v-panel p2v-result__player">
{video ? (
<video src={video} controls playsInline />
) : (
<p className="p2v-note">{t('poster.result.videoMissing')}</p>
)}
</div>
<div className="p2v-result__side">
<p className="p2v-label">{t('poster.result.fileName')}</p>
<p className="p2v-result__filename">{job.name}.mp4</p>
{job.metadata && (
<div className="p2v-result__block">
{([
['event_name', job.metadata.event_name],
['date_text', job.metadata.date_text],
['place', job.metadata.place],
] as const).map(([field, value]) => (
value ? (
<div key={field} className="p2v-result__row">
<span>{t(`poster.review.field.${field}`)}</span>
<b>{value}</b>
</div>
) : null
))}
{job.metadata.keywords?.length > 0 && (
<div className="p2v-tags">
{job.metadata.keywords.map((kw) => (
<span key={kw} className="p2v-tag">#{kw}</span>
))}
</div>
)}
</div>
)}
{job.narration && job.narration.length > 0 && (
<div className="p2v-result__block">
<p className="p2v-label">{t('poster.review.narration')}</p>
{job.narration.map((line, i) => (
<p key={i} className="p2v-narration-line">{line}</p>
))}
</div>
)}
<div className="p2v-result__actions">
{thumbnail && (
<a href={thumbnail} download className="p2v-btn-tonal">
{t('poster.result.downloadThumb')}
</a>
)}
{video && (
<a href={video} download className="p2v-btn-cta p2v-btn-cta--compact">
{t('poster.result.downloadVideo')}
</a>
)}
</div>
</div>
</div>
<button type="button" className="p2v-btn-outline p2v-result__new" onClick={onNew}>
{t('poster.result.new')}
</button>
</div>
);
};
export default PosterResultContent;

View File

@ -0,0 +1,172 @@
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<MetaField, string>;
const emptyMeta = (): MetaDraft => ({ event_name: '', date_text: '', place: '' });
/**
* 검수 게이트.
*
* 이 화면이 파이프라인에서 가장 중요한 지점이다 — 여기서 승인해야 TTS·음악·
* 애니메이션·렌더가 이어지고, 그 뒤로는 되돌리려면 전부 다시 만들어야 한다.
* 그래서 셋을 한 화면에 모아 **포스터 원본과 대조하며** 고치게 한다:
*
* 1) 나레이션 3문장 — 그대로 음성이 된다
* 2) 움직일 요소 — 애니메이션 대상. 비우면 정지 영상이 된다
* 3) 메타태그 — 아카이브에 영구 저장된다. 연도·지명 오독이 여기서 걸린다
*
* 왼쪽에 편집, 오른쪽에 영역 분석 이미지를 두는 배치는 P2V 웹과 같다.
*/
const PosterReviewContent: React.FC<PosterReviewContentProps> = ({ job, onApproved }) => {
const { t } = useTranslation();
const [lines, setLines] = useState<string[]>([]);
const [motions, setMotions] = useState<string[] | null>(null);
const [meta, setMeta] = useState<MetaDraft>(emptyMeta);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="p2v-scope p2v-review">
<div className="p2v-review__grid">
<div className="p2v-review__edit">
{/* ── 나레이션 ─────────────────────────────── */}
<p className="p2v-label">{t('poster.review.narration')}</p>
<p className="p2v-help">{t('poster.review.narrationHelp')}</p>
{lines.map((line, i) => (
<input
key={i}
className="p2v-input"
value={line}
onChange={(e) => setLines(lines.map((v, j) => (j === i ? e.target.value : v)))}
/>
))}
{/* ── 움직일 요소 ──────────────────────────── */}
<p className="p2v-label">{t('poster.review.motion')}</p>
<p className="p2v-help">
{t(noMotion ? 'poster.review.motionEmpty' : 'poster.review.motionHelp')}
</p>
<div className="p2v-chips">
{MOTION_KEYS.map((key) => {
const on = motions?.includes(key) ?? false;
return (
<button
key={key}
type="button"
aria-pressed={on}
className={`p2v-chip${on ? ' active' : ''}`}
onClick={() => toggleMotion(key)}
>
{t(motionLabel(key))}
</button>
);
})}
</div>
{/* ── 메타태그 ─────────────────────────────── */}
<p className="p2v-label">{t('poster.review.metadata')}</p>
<p className="p2v-help">{t('poster.review.metadataHelp')}</p>
{META_FIELDS.map((field) => (
<div key={field} className="p2v-field">
<span className="p2v-field__label">{t(`poster.review.field.${field}`)}</span>
<input
className="p2v-input"
value={meta[field]}
onChange={(e) => setMeta({ ...meta, [field]: e.target.value })}
/>
</div>
))}
{error && <div className="p2v-note warn">{error}</div>}
<button type="button" className="p2v-btn-cta" disabled={busy} onClick={approve}>
{busy ? t('poster.review.approving') : t('poster.review.approve')}
</button>
</div>
{/* ── 대조용 분석 이미지 ────────────────────── */}
<div className="p2v-review__preview">
<p className="p2v-label">{t('poster.review.analysis')}</p>
{job.artifacts.check_jpg ? (
<img
className="p2v-review__image"
src={p2vFile(job.artifacts.check_jpg)}
alt={t('poster.review.analysisAlt')}
/>
) : (
<div className="p2v-panel p2v-review__image-empty">
{t('poster.review.analysisMissing')}
</div>
)}
</div>
</div>
</div>
);
};
export default PosterReviewContent;

View File

@ -0,0 +1,330 @@
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;

View File

@ -0,0 +1,28 @@
/**
* 포스터 파이프라인의 코드 목록.
*
* 썰박스 ssulData.ts 와 같은 규칙 — **코드만 두고 표시 문구는 i18n 에 둔다.**
* castad 는 ko/en 다국어라 한글이 코드에 박히면 번역 밖으로 샌다.
*/
/**
* 움직일 수 있는 요소.
*
* P2V `scripts/motion_plan.py` 의 MOTION_PHRASE 화이트리스트와 **1:1 이어야 한다** —
* 여기 없는 키를 보내면 서버가 422 로 거른다. 순서는 그쪽 정의 순서를 따른다.
*/
export const MOTION_KEYS = [
'firework', 'water', 'wave', 'cloud', 'smoke',
'moon', 'sun', 'star', 'light', 'flag',
'foliage', 'wheel', 'vessel', 'crowd',
] as const;
export type MotionKey = (typeof MOTION_KEYS)[number];
/** i18n 키 헬퍼 — t(motionLabel('firework')) 형태로 쓴다 */
export const motionLabel = (key: string): string => `poster.motion.${key}`;
/** 검수 화면에서 고칠 수 있는 메타 필드. 나머지(category·keywords)는 서버 판단을 따른다 */
export const META_FIELDS = ['event_name', 'date_text', 'place'] as const;
export type MetaField = (typeof META_FIELDS)[number];

View File

@ -7,7 +7,7 @@ import {
createSsulJob, createSsulJob,
searchAccommodation, searchAccommodation,
} from '../../utils/api'; } from '../../utils/api';
import { SCEN, SCEN_KEYS, Scen, scenDesc, scenName } from './ssulData'; import { SCEN_KEYS, Scen, scenDesc, scenName } from './ssulData';
interface SsulCreateFormProps { interface SsulCreateFormProps {
/** 생성 요청이 접수되면 호출. 폴링 소유는 GenerationFlow */ /** 생성 요청이 접수되면 호출. 폴링 소유는 GenerationFlow */
@ -154,7 +154,6 @@ const SsulCreateForm: React.FC<SsulCreateFormProps> = ({ onSubmitted, onNeedCred
<div className="ssul-create__scenarios"> <div className="ssul-create__scenarios">
{SCEN_KEYS.map((key) => { {SCEN_KEYS.map((key) => {
const pal = SCEN[key];
const active = scenario === key; const active = scenario === key;
return ( return (
<button <button
@ -162,19 +161,8 @@ const SsulCreateForm: React.FC<SsulCreateFormProps> = ({ onSubmitted, onNeedCred
type="button" type="button"
aria-pressed={active} aria-pressed={active}
className={`ssul-scn${active ? ' active' : ''}`} className={`ssul-scn${active ? ' active' : ''}`}
style={
{
// 표지 그라디언트 2단 + 대표색. CSS 가 옅게 깔아 쓴다
'--scn': pal.hex,
'--scn-a': pal.g[0],
'--scn-b': pal.g[1],
} as React.CSSProperties
}
onClick={() => setScenario(key)} onClick={() => setScenario(key)}
> >
<span className="ssul-scn__emoji" aria-hidden="true">
{pal.emoji}
</span>
<span className="ssul-scn__name">{t(scenName(key))}</span> <span className="ssul-scn__name">{t(scenName(key))}</span>
<span className="ssul-scn__desc">{t(scenDesc(key))}</span> <span className="ssul-scn__desc">{t(scenDesc(key))}</span>
</button> </button>

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { SCEN, Scen, scenName } from './ssulData'; import { Scen, scenName } from './ssulData';
/** 생성 잡의 현재 상태. GenerationFlow 가 폴링해 내려준다 */ /** 생성 잡의 현재 상태. GenerationFlow 가 폴링해 내려준다 */
export interface SsulJob { export interface SsulJob {
@ -45,7 +45,6 @@ const SsulMakingContent: React.FC<SsulMakingContentProps> = ({ job, onRetry }) =
if (!job) return null; if (!job) return null;
const palette = SCEN[job.scenario];
const failed = job.status === 'error'; const failed = job.status === 'error';
// step 은 "완료한 단계 수"라 진행률은 step/4. queued(0) 면 0% // step 은 "완료한 단계 수"라 진행률은 step/4. queued(0) 면 0%
const percent = Math.round((Math.min(job.step, TOTAL_STEPS) / TOTAL_STEPS) * 100); const percent = Math.round((Math.min(job.step, TOTAL_STEPS) / TOTAL_STEPS) * 100);
@ -54,9 +53,7 @@ const SsulMakingContent: React.FC<SsulMakingContentProps> = ({ job, onRetry }) =
return ( return (
<div className="ssul-scope ssul-making"> <div className="ssul-scope ssul-making">
<span className="ssul-making__scenario" style={{ color: palette.hex }}> <span className="ssul-making__scenario">{t(scenName(job.scenario))}</span>
<span aria-hidden="true">{palette.emoji}</span> {t(scenName(job.scenario))}
</span>
{failed ? ( {failed ? (
<> <>
@ -75,13 +72,8 @@ const SsulMakingContent: React.FC<SsulMakingContentProps> = ({ job, onRetry }) =
{/* {/*
단계 사이 구간에서는 진행바가 멈춰 있어 고장처럼 보인다. 단계 사이 구간에서는 진행바가 멈춰 있어 고장처럼 보인다.
스피너는 "살아 있다"는 신호 전용이라 진행률을 표현하지 않는다. 스피너는 "살아 있다"는 신호 전용이라 진행률을 표현하지 않는다.
색은 시나리오 대표색을 따른다(민트 고정이면 삼국지·오디세이와 부딪힌다).
*/} */}
<div <div className="ssul-making__spinner" aria-hidden="true" />
className="ssul-making__spinner"
style={{ ['--scn' as string]: palette.hex }}
aria-hidden="true"
/>
<div <div
className="ssul-making__bar" className="ssul-making__bar"
@ -90,10 +82,7 @@ const SsulMakingContent: React.FC<SsulMakingContentProps> = ({ job, onRetry }) =
aria-valuemin={0} aria-valuemin={0}
aria-valuemax={100} aria-valuemax={100}
> >
<div <div className="ssul-making__fill" style={{ width: `${percent}%` }} />
className="ssul-making__fill"
style={{ width: `${percent}%`, background: palette.hex }}
/>
</div> </div>
<div className="ssul-making__meta"> <div className="ssul-making__meta">

View File

@ -1,53 +1,20 @@
/** /**
* 썰박스 시나리오 팔레트와 표시 유틸. * 썰박스 시나리오 코드와 표시 유틸.
* *
* 원본(o2o-ssulbox/frontend/src/data.ts)에서 이식하되 **문구(name/desc)는 제외**했다. * 원본(o2o-ssulbox/frontend/src/data.ts)에서 이식하되 **문구(name/desc)는 제외**했다.
* castad 는 ko/en 다국어라 한글이 코드에 박히면 i18n 밖으로 새기 때문이다. * castad 는 ko/en 다국어라 한글이 코드에 박히면 i18n 밖으로 새기 때문이다.
* 표시 문구는 `t('ssulbox.scenario.<key>.name')` / `.desc` 로 가져온다. * 표시 문구는 `t('ssulbox.scenario.<key>.name')` / `.desc` 로 가져온다.
* *
* 목업(SAMPLE)도 제외했다 — 백엔드 `/ssul/feed` 가 진실 원천이다. * 시나리오별 이모지·대표색·그라디언트는 ADO2 디자인 시스템 통일(2026-08-20)로
* 제거했다 — 카드·스피너·진행바 모두 시스템 색(민트/퍼플)을 쓴다.
*/ */
/** 시나리오 코드. 백엔드 `ssul_content.scenario` 와 값이 일치해야 한다 */ /** 시나리오 코드. 백엔드 `ssul_content.scenario` 와 값이 일치해야 한다 */
export type Scen = 'joseon' | 'samgukji' | 'greek' | 'odyssey'; export type Scen = 'joseon' | 'samgukji' | 'greek' | 'odyssey';
export interface ScenPalette {
/** 카드 표지 이모지 */
emoji: string;
/** 대표색 (뱃지·강조선) */
hex: string;
/** 표지 그라디언트 시작/끝 */
g: [string, string];
}
export const SCEN: Record<Scen, ScenPalette> = {
joseon: { emoji: '👑', hex: '#3b4cc0', g: ['#4054d6', '#8a3ffb'] },
samgukji: { emoji: '⚔️', hex: '#d63a2f', g: ['#e0402f', '#f0872b'] },
greek: { emoji: '🏛️', hex: '#0a8f6b', g: ['#0a9e78', '#3fb6d6'] },
odyssey: { emoji: '⛵', hex: '#1565c0', g: ['#1565c0', '#42a5f5'] },
};
/** 시나리오 순서. 선택 그리드(2x2)와 필터 칩이 이 순서를 따른다 */ /** 시나리오 순서. 선택 그리드(2x2)와 필터 칩이 이 순서를 따른다 */
export const SCEN_KEYS = Object.keys(SCEN) as Scen[]; export const SCEN_KEYS: Scen[] = ['joseon', 'samgukji', 'greek', 'odyssey'];
/** 카드 표지 그라디언트 */
export const scGrad = (s: Scen): string =>
`linear-gradient(150deg,${SCEN[s].g[0]} 0%,${SCEN[s].g[1]} 100%)`;
/** i18n 키 헬퍼 — t(scenName('joseon')) 형태로 쓴다 */ /** i18n 키 헬퍼 — t(scenName('joseon')) 형태로 쓴다 */
export const scenName = (s: Scen): string => `ssulbox.scenario.${s}.name`; export const scenName = (s: Scen): string => `ssulbox.scenario.${s}.name`;
export const scenDesc = (s: Scen): string => `ssulbox.scenario.${s}.desc`; export const scenDesc = (s: Scen): string => `ssulbox.scenario.${s}.desc`;
/**
* 조회수·좋아요 수 축약 표기 (1.2만 / 3.4천).
*
* 한글 단위가 박혀 있어 en 로케일에서는 어색하다. 통합 목록에서 castad 콘텐츠와
* 나란히 놓이므로, 표기 방식은 Phase 8 에서 castad 쪽과 함께 정하는 게 맞다.
* 그때까지는 원본 동작을 유지한다.
*/
export const fmt = (n: number): string =>
n >= 10000
? `${(n / 10000).toFixed(1).replace(/\.0$/, '')}만`
: n >= 1000
? `${(n / 1000).toFixed(1).replace(/\.0$/, '')}천`
: `${n}`;

View File

@ -2387,12 +2387,19 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; /* `justify-content: center` 를 쓰지 않는다.
중앙정렬 상태에서 내용이 컨테이너보다 길어지면 위아래로 똑같이 넘치는데,
**위로 넘친 부분은 스크롤로도 닿을 수 없다**(flexbox 중앙정렬의 알려진 함정).
실제로 무빙 포스터 탭에서 로고 밴드가 47px 잘려 보였다.
대신 자식의 `margin: auto 0` 으로 중앙에 놓는다 — auto 마진은 여백이 있을 때만
중앙정렬하고, 넘칠 때는 0 이 되어 위에서부터 정상 스크롤된다. */
justify-content: flex-start;
background-color: var(--color-bg-darker); background-color: var(--color-bg-darker);
color: var(--color-text-white); color: var(--color-text-white);
padding: 80px 1rem 100px; padding: 80px 1rem 100px;
position: relative; position: relative;
overflow: hidden; overflow-y: auto;
overflow-x: hidden;
box-sizing: border-box; box-sizing: border-box;
} }
@ -2402,11 +2409,24 @@
align-items: center; align-items: center;
z-index: 20; z-index: 20;
position: relative; position: relative;
margin-top: 50px; /* 세로 중앙정렬은 auto 마진으로 한다 (부모의 justify-content 주석 참조).
넘칠 때 잘리지 않는 유일한 방법이다 */
margin: auto 0;
width: 100%; width: 100%;
max-width: 375px; max-width: 375px;
} }
/* 진입 화면에 들어가는 폼은 대부분 한 줄 입력이라 375px 로 충분하지만,
포스터 스타일링(F2)은 템플릿 그리드·입출력 2단이 한 화면에 있는 **도구**라
그 폭에서는 못 쓴다.
(세로 넘침 처리는 이제 컨테이너 기본 규칙이 담당한다 — 예외를 두지 않는다) */
.url-input-content--wide {
max-width: 900px;
}
/* 375px 화면에서는 타일 4개 + 라벨이 폭을 넘긴다. 타일과 글자를 함께 줄여
한 줄을 지킨다 — 라벨이 줄바꿈되면 타일 정렬이 어긋난다 */
/* ===================================================== /* =====================================================
PipelineTabs — ADO2 / 썰박스 전환 (밑줄 탭) PipelineTabs — ADO2 / 썰박스 전환 (밑줄 탭)
진입 화면에서 로고와 하위 폼 사이에 형제로 얹힌다. 진입 화면에서 로고와 하위 폼 사이에 형제로 얹힌다.
@ -2414,76 +2434,137 @@
컨테이너 하단의 옅은 전체 폭 구분선 위로, 활성 탭 아래에만 컨테이너 하단의 옅은 전체 폭 구분선 위로, 활성 탭 아래에만
진한 밑줄이 얹힌다. 밑줄 색은 탭에 따라 민트↔앰버로 바뀐다. 진한 밑줄이 얹힌다. 밑줄 색은 탭에 따라 민트↔앰버로 바뀐다.
===================================================== */ ===================================================== */
/* 간격 체계 (8px 그리드 · Material 3 / 수직 리듬 가이드 기준)
8~12px 묶음 안 (라벨 ↔ 그 라벨이 설명하는 것, 입력 ↔ 그 입력의 안내)
20px 묶음 사이
32px 섹션 사이 (주제가 바뀜)
이전에는 전부 10~24px 이라 "붙은 것"과 "다른 것"이 구분되지 않아 답답해 보였다.
근접성(Gestalt)은 거리 차이로만 전달되므로 값이 고르면 정보 구조가 사라진다. */
.pipeline-tabs { .pipeline-tabs {
position: relative; position: relative;
display: flex; display: flex;
justify-content: center;
/* 터치 타겟은 8dp 이상 떨어뜨린다(Material 3). 4px 이면 옆 타일 오탭이 난다 */
gap: 8px;
width: 100%; width: 100%;
margin-bottom: 20px; margin-bottom: 20px;
/* 전체 폭 구분선. 활성 밑줄이 이 위에 겹쳐 얹힌다 */
border-bottom: 1px solid var(--color-border-white-10);
} }
.pipeline-tab { .pipeline-tab {
position: relative; display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
flex: 1; flex: 1;
/* 13px 상하 패딩 + 폰트로 터치 타겟 44px 이상 확보. /* 타일(52px) + 라벨로 터치 타겟이 이미 충분하다. 좌우 패딩은 라벨이
밑줄 탭은 필과 달리 배경이 없어 타겟이 작아 보이기 쉽다 */ 이웃 타일에 붙지 않을 만큼만 */
padding: 13px 6px; padding: 4px 2px;
border: none; border: none;
background: none; background: none;
font-family: inherit; font-family: inherit;
font-size: 14px;
font-weight: 600;
letter-spacing: -0.006em;
color: var(--color-text-gray-400); color: var(--color-text-gray-400);
cursor: pointer; cursor: pointer;
transition: color var(--transition-normal); transition: color var(--transition-normal);
} }
/* 활성 표시 밑줄. 구분선(1px)을 덮도록 bottom: -1px */ /* 아이콘 타일 — 라운드 스퀘어. 아이콘은 currentColor 라 타일이 색을 정한다 */
.pipeline-tab::after { .pipeline-tab__tile {
content: ''; display: flex;
position: absolute; align-items: center;
left: 0; justify-content: center;
right: 0; width: 52px;
bottom: -1px; height: 52px;
height: 2px; border-radius: var(--radius-xl);
border-radius: 2px; border: 1px solid var(--color-border-white-10);
background: transparent; background: var(--color-bg-card-inner);
transition: background var(--transition-normal); transition: background-color var(--transition-normal),
border-color var(--transition-normal),
color var(--transition-normal),
box-shadow var(--transition-normal);
}
.pipeline-tab__label {
font-size: 13px;
font-weight: 600;
letter-spacing: -0.006em;
white-space: nowrap;
transition: color var(--transition-normal);
} }
.pipeline-tab:hover { .pipeline-tab:hover {
color: rgba(255, 255, 255, 0.85); color: var(--color-text-white);
} }
/* 활성 탭은 배경이 없으므로 밝은 글자 + 굵기로 대비를 만든다 */ .pipeline-tab:hover .pipeline-tab__tile {
border-color: rgba(255, 255, 255, 0.25);
}
/* 선택 상태는 사이드바 활성 항목(.sidebar-item.active)과 같은 문법 —
민트로 채우고 아이콘을 어두운 딥틸로 뒤집는다. 화면에서 가장 강한 표시이므로
라벨까지 흰색으로 올려 타일과 한 덩어리로 읽히게 한다 */
.pipeline-tab[aria-selected="true"] { .pipeline-tab[aria-selected="true"] {
color: var(--color-bg-dark);
}
.pipeline-tab[aria-selected="true"] .pipeline-tab__tile {
background: var(--color-mint);
border-color: var(--color-mint);
box-shadow: var(--shadow-mint-glow);
}
.pipeline-tab[aria-selected="true"] .pipeline-tab__label {
color: var(--color-text-white); color: var(--color-text-white);
font-weight: 700; font-weight: 700;
} }
.pipeline-tabs[data-active="ado2"] .pipeline-tab[aria-selected="true"]::after {
background: var(--color-mint);
}
.pipeline-tabs[data-active="ssul"] .pipeline-tab[aria-selected="true"]::after {
background: var(--color-ssul-accent);
}
.pipeline-tab__emoji {
margin-right: 4px;
}
/* 밑줄은 위치가 아니라 색만 바뀌므로 모션이 미미하지만,
castad 전체에서 이 컴포넌트만 전환 효과를 쓰므로 함께 끈다 */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.pipeline-tab, .pipeline-tab,
.pipeline-tab::after { .pipeline-tab__tile,
.pipeline-tab__label {
transition: none; transition: none;
} }
} }
/* 고른 기능의 한 줄 설명. 탭과는 붙이고(20px), 아래 폼과는 섹션 간격(32px)을 둔다 —
설명은 탭에 딸린 것이지 폼의 일부가 아니다 */
.pipeline-desc {
width: 100%;
margin: 0 0 32px;
font-size: 14px;
line-height: 1.6;
letter-spacing: -0.006em;
text-align: center;
color: var(--color-text-gray-300);
/* 한글은 기본값(word-break: normal)이면 단어 중간에서 잘린다 — "애니메이/션".
keep-all 로 어절 단위 줄바꿈을 강제한다(한국어 조판 표준) */
word-break: keep-all;
/* 두 줄이 될 때 길이를 고르게 나눠 한쪽만 길게 늘어지지 않게 한다 */
text-wrap: balance;
}
/* 좁은 화면 조정.
**이 블록은 반드시 위 기본 규칙들보다 뒤에 있어야 한다** — 미디어 쿼리는 명시도를
올리지 않아서, 앞에 두면 뒤에 오는 같은 명시도의 기본 규칙에 그대로 덮인다
(실제로 그렇게 두었다가 모바일에서 타일이 48px 로 줄지 않았다). */
@media (max-width: 420px) {
/* 타일은 48dp 최소 터치 타겟이라 더 줄이지 않는다. 라벨만 줄여 한 줄을 지키고,
타일 사이 8px 은 유지한다 — 간격을 없애면 옆 타일 오탭이 난다 */
.pipeline-tab__tile {
width: 48px;
height: 48px;
border-radius: var(--radius-md);
}
.pipeline-tab__label {
font-size: 12px;
}
.pipeline-desc {
margin-bottom: 28px;
font-size: 13px;
}
}
/* 썰박스 탭의 임시 자리 (Phase 7 에서 SsulCreateForm 으로 교체) */ /* 썰박스 탭의 임시 자리 (Phase 7 에서 SsulCreateForm 으로 교체) */
.pipeline-placeholder { .pipeline-placeholder {
width: 100%; width: 100%;
@ -2501,33 +2582,21 @@
} }
.url-input-logo { .url-input-logo {
margin-bottom: 1rem; /* 로고 → 탭은 "묶음 사이" 간격 (generation-flow.css 상단 간격 체계 참조) */
margin-bottom: 20px;
} }
/* 로고는 **아래 폼 열보다 넓어질 수 없다.**
이전에는 화면이 커질수록 350 → 400 → 500 → 554px 로 키웠는데, 정작 폼 열은
375px 로 고정이라 데스크톱에서 로고만 좌우로 90px 씩 삐져나왔다.
min() 으로 열 폭과 상한을 동시에 걸면 어느 폭에서도 넘지 않는다 —
브레이크포인트를 늘려도 다시 어긋날 일이 없다. */
.url-input-logo img { .url-input-logo img {
width: 350px; display: block;
max-width: 100%; width: min(100%, 280px);
height: auto; height: auto;
} }
@media (min-width: 640px) {
.url-input-logo img {
width: 400px;
}
}
@media (min-width: 768px) {
.url-input-logo img {
width: 500px;
}
}
@media (min-width: 1024px) {
.url-input-logo img {
width: 554px;
}
}
.url-input-title { .url-input-title {
font-family: 'Pretendard', sans-serif; font-family: 'Pretendard', sans-serif;
font-size: 40px; font-size: 40px;

View File

@ -16,3 +16,6 @@
castad 안에서 새로 만든 화면의 스타일은 ssulbox-castad.css 에 둔다. */ castad 안에서 새로 만든 화면의 스타일은 ssulbox-castad.css 에 둔다. */
@import './ssulbox.css'; @import './ssulbox.css';
@import './ssulbox-castad.css'; @import './ssulbox-castad.css';
/* Poster to Video (F1 영상 · F2 스타일링) — 모든 규칙이 `.p2v-scope` 안에 갇혀 있다 */
@import './poster-p2v.css';

938
src/styles/poster-p2v.css Normal file
View File

@ -0,0 +1,938 @@
/* =====================================================
Poster to Video (P2V) — castad 통합 화면 스타일
F1(포스터→영상) · F2(포스터 스타일링) 두 파이프라인이 함께 쓴다.
규칙 (썰박스 통합과 동일):
- 모든 선택자를 `.p2v-scope` 안에 가둔다 — castad 기존 화면에 새지 않게.
- 색·반경·간격은 tokens.css 토큰만 쓴다. 하드코딩 색은 상태색(실패 빨강) 뿐이고
그것도 castad 가 이미 쓰는 값(#ff7a7a 계열)을 따른다.
- 버튼 문법은 ADO2 프로덕트를 따른다: 퍼플=주 CTA(화면당 하나), 민트=보조·선택,
아웃라인=중립. 이 규칙을 어기면 화면이 알록달록해진다.
===================================================== */
.p2v-scope {
width: 100%;
color: var(--color-text-white);
font-family: inherit;
}
/* ── 공통 요소 ────────────────────────────────────── */
.p2v-scope .p2v-label {
display: block;
margin: 0 0 8px;
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-text-white);
}
.p2v-scope .p2v-help {
margin: 0 0 12px;
font-size: var(--text-sm);
line-height: 1.6;
color: var(--color-text-gray-400);
}
.p2v-scope .p2v-note {
margin: 8px 0 0;
font-size: var(--text-sm);
line-height: 1.6;
color: var(--color-text-gray-400);
}
.p2v-scope .p2v-note.warn {
color: #ff9a9a;
}
.p2v-scope .p2v-panel {
background: var(--color-bg-card-inner);
border: 1px solid var(--color-border-white-5);
border-radius: var(--radius-2xl);
padding: var(--spacing-page);
}
/* 서버 stderr tail — 개발자용이라 폭이 넘칠 수 있어 스스로 스크롤한다 */
.p2v-scope .p2v-errorbox {
margin: 12px 0 0;
padding: 12px 14px;
max-height: 220px;
overflow: auto;
background: var(--color-bg-card-inner);
border: 1px solid var(--color-border-white-5);
border-radius: var(--radius-md);
font-size: var(--text-sm);
line-height: 1.5;
color: var(--color-text-gray-400);
white-space: pre-wrap;
word-break: break-all;
text-align: left;
}
.p2v-scope .p2v-step {
align-self: flex-start;
margin-bottom: 10px;
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.04em;
color: var(--color-mint);
}
/* ── 입력 (프로덕트 흰 pill) ──────────────────────── */
.p2v-scope .p2v-input {
width: 100%;
height: 52px;
padding: 0 20px;
margin-bottom: 8px;
border: 1.5px solid transparent;
border-radius: var(--radius-full);
background: var(--color-text-white);
color: var(--color-bg-dark);
font-family: inherit;
font-size: var(--text-base);
font-weight: 600;
letter-spacing: -0.006em;
}
.p2v-scope .p2v-input::placeholder {
color: #3a8f86;
font-weight: 700;
}
.p2v-scope .p2v-input:focus {
outline: none;
border-color: var(--color-mint);
}
.p2v-scope .p2v-input--center,
.p2v-scope .p2v-input--center::placeholder {
text-align: center;
}
.p2v-scope .p2v-field {
display: flex;
align-items: center;
gap: 12px;
}
.p2v-scope .p2v-field__label {
flex: none;
width: 52px;
font-size: var(--text-sm);
color: var(--color-text-gray-400);
}
/* ── 버튼 ────────────────────────────────────────── */
.p2v-scope .p2v-btn-cta {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 48px;
margin-top: 20px;
border: none;
border-radius: var(--radius-full);
background: var(--color-purple);
color: var(--color-text-white);
box-shadow: var(--shadow-purple);
font-family: inherit;
font-size: var(--text-base);
font-weight: 700;
cursor: pointer;
transition: background-color var(--transition-normal);
text-decoration: none;
}
.p2v-scope .p2v-btn-cta:hover:not(:disabled) {
background: var(--color-purple-hover);
}
.p2v-scope .p2v-btn-cta:disabled {
opacity: 0.45;
cursor: default;
}
/* 다운로드처럼 행 안에 들어가는 CTA — 폭을 차지하지 않는다 */
.p2v-scope .p2v-btn-cta--compact {
width: auto;
margin-top: 0;
padding: 0 24px;
height: 44px;
}
.p2v-scope .p2v-btn-mint,
.p2v-scope .p2v-btn-tonal,
.p2v-scope .p2v-btn-outline {
display: inline-flex;
align-items: center;
justify-content: center;
height: 44px;
padding: 0 22px;
border-radius: var(--radius-full);
font-family: inherit;
font-size: var(--text-base);
font-weight: 700;
cursor: pointer;
white-space: nowrap;
text-decoration: none;
transition: background-color var(--transition-normal), border-color var(--transition-normal);
}
.p2v-scope .p2v-btn-mint {
border: none;
background: var(--color-mint);
color: var(--color-bg-dark);
}
.p2v-scope .p2v-btn-mint:hover:not(:disabled) { background: var(--color-mint-hover); }
.p2v-scope .p2v-btn-tonal {
border: 1px solid var(--color-mint-30);
background: var(--color-mint-20);
color: var(--color-mint);
}
.p2v-scope .p2v-btn-tonal:hover { background: var(--color-mint-30); }
.p2v-scope .p2v-btn-outline {
border: 1px solid var(--color-border-gray-600);
background: transparent;
color: var(--color-text-white);
}
.p2v-scope .p2v-btn-outline:hover:not(:disabled) { background: rgba(255, 255, 255, 0.05); }
.p2v-scope .p2v-btn-mint:disabled,
.p2v-scope .p2v-btn-outline:disabled {
opacity: 0.45;
cursor: default;
}
/* ── 접근 키 게이트 ───────────────────────────────── */
.p2v-scope .p2v-keygate {
width: 100%;
padding: 22px;
border: 1px solid var(--color-mint-30);
border-radius: var(--radius-2xl);
background: var(--color-mint-10);
}
.p2v-scope .p2v-keygate__title {
margin: 0 0 6px;
font-size: var(--text-lg);
font-weight: 700;
}
.p2v-scope .p2v-keygate__desc {
margin: 0 0 16px;
font-size: var(--text-sm);
line-height: 1.6;
color: var(--color-text-gray-300);
}
.p2v-scope .p2v-keygate__row {
display: flex;
gap: 10px;
align-items: flex-start;
}
.p2v-scope .p2v-keygate__row .p2v-input { margin-bottom: 0; }
.p2v-scope .p2v-keygate__row .p2v-btn-mint { height: 52px; flex: none; }
/* ── 드롭존 ──────────────────────────────────────── */
.p2v-scope .p2v-dropzone-wrap { width: 100%; }
.p2v-scope .p2v-dropzone {
/* 포스터는 세로물 — 입력창도 세로여야 무엇을 올렸는지 바로 보인다 */
aspect-ratio: 4 / 5;
max-width: 400px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
padding: var(--spacing-page);
border: 1px dashed var(--color-border-gray-700);
border-radius: var(--radius-2xl);
background: var(--color-bg-card-inner);
text-align: center;
cursor: pointer;
transition: border-color var(--transition-normal), background-color var(--transition-normal);
}
.p2v-scope .p2v-dropzone.has-file { padding: 0; border-style: solid; }
.p2v-scope .p2v-dropzone.dragging {
border-color: var(--color-mint);
background: var(--color-mint-10);
}
.p2v-scope .p2v-dropzone img {
width: 100%;
height: 100%;
object-fit: contain;
}
.p2v-scope .p2v-dropzone__cta {
margin: 0;
font-size: var(--text-base);
font-weight: 700;
line-height: 1.5;
white-space: pre-line;
}
.p2v-scope .p2v-dropzone__hint {
margin: 12px 0 0;
font-size: var(--text-sm);
line-height: 1.5;
color: var(--color-text-gray-400);
white-space: pre-line;
}
/* ── F1 생성 폼 ───────────────────────────────────── */
.p2v-scope.p2v-create {
display: flex;
flex-direction: column;
max-width: 480px;
margin: 0 auto;
}
/* 드롭존과 이름 입력은 "업로드" 한 묶음이라 붙이고(16), 제출과는 섹션 간격을 둔다.
간격 체계는 generation-flow.css 상단 참조 */
.p2v-scope.p2v-create .p2v-input {
margin-top: 16px;
margin-bottom: 0;
}
.p2v-scope.p2v-create .p2v-btn-cta { margin-top: 28px; }
/* ── F1 진행 화면 ────────────────────────────────── */
.p2v-scope.p2v-making {
display: flex;
flex-direction: column;
align-items: center;
max-width: 520px;
/* 부모(.wizard-page-container)가 height:100% flex column 이라
margin:auto 가 스텝퍼~바닥 사이 중앙에 놓는다 (썰박스와 같은 처리) */
height: auto;
margin: auto;
padding: 48px 20px;
text-align: center;
}
.p2v-making__name {
font-size: var(--text-xl);
font-weight: 700;
color: var(--color-mint);
}
.p2v-making__title {
margin: 8px 0 24px;
font-size: var(--text-2xl);
font-weight: 700;
letter-spacing: -0.015em;
}
.p2v-making__title.warn { color: #ffb4a8; }
.p2v-making__meta {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin-top: 10px;
font-size: var(--text-base);
color: var(--color-text-gray-300);
}
.p2v-making__pct {
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.p2v-making__desc {
margin: 22px 0 0;
font-size: var(--text-sm);
line-height: 1.6;
color: var(--color-text-gray-400);
}
.p2v-making__actions {
display: flex;
gap: 10px;
margin-top: 28px;
}
.p2v-scope .p2v-making__narration {
width: 100%;
margin-top: 26px;
text-align: left;
}
.p2v-scope .p2v-narration-line {
margin: 6px 0;
font-size: var(--text-base);
line-height: 1.6;
color: var(--color-text-gray-300);
}
/* 스피너 — castad 전역 `spin` 키프레임을 재사용한다(새로 만들면 중복) */
.p2v-scope .p2v-spinner {
position: relative;
flex: none;
width: 120px;
height: 120px;
margin: 4px 0 22px;
}
.p2v-scope .p2v-spinner--md { width: 48px; height: 48px; margin: 0; }
.p2v-scope .p2v-spinner--sm { width: 26px; height: 26px; margin: 0; }
.p2v-scope .p2v-spinner::before,
.p2v-scope .p2v-spinner::after {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
border: 6px solid transparent;
}
.p2v-scope .p2v-spinner::before { border-color: var(--color-border-white-10); }
/* 3/4 만 칠해 회전이 보이게 한다. 색은 ADO2 생성 스피너 표준(퍼플) */
.p2v-scope .p2v-spinner::after {
border-top-color: var(--color-purple);
border-right-color: var(--color-purple);
border-bottom-color: var(--color-purple);
animation: spin 1.1s linear infinite;
}
.p2v-scope .p2v-spinner--md::before,
.p2v-scope .p2v-spinner--md::after { border-width: 4px; }
.p2v-scope .p2v-spinner--sm::before,
.p2v-scope .p2v-spinner--sm::after { border-width: 3px; }
.p2v-scope .p2v-bar {
width: 100%;
height: 6px;
margin-top: 20px;
border-radius: var(--radius-full);
background: var(--color-bg-card-inner);
overflow: hidden;
}
.p2v-scope .p2v-bar__fill {
height: 100%;
border-radius: var(--radius-full);
background: var(--color-mint);
transition: width 400ms ease;
}
/* ── F1 검수 게이트 ───────────────────────────────── */
/* 좌우 패딩을 스스로 갖는다. 부모 `.wizard-page-container` 에는 패딩이 없어서
0 으로 두면 모바일에서 글자가 화면 가장자리에 그대로 붙는다(실제로 그랬다).
16px 은 모바일 화면 가장자리 여백 기준값(Material 3). */
.p2v-scope.p2v-review {
max-width: 1080px;
margin: 0 auto;
padding: 8px 16px 40px;
}
.p2v-review__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-page-md);
align-items: start;
}
.p2v-scope .p2v-review__edit .p2v-label { margin-top: 26px; }
.p2v-scope .p2v-review__edit > .p2v-label:first-child { margin-top: 0; }
.p2v-scope .p2v-review__image {
display: block;
width: 100%;
border: 1px solid var(--color-border-white-5);
border-radius: var(--radius-2xl);
}
.p2v-scope .p2v-review__image-empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 320px;
font-size: var(--text-sm);
color: var(--color-text-gray-500);
}
/* 모션 칩 — 선택은 민트 테두리+틴트(castad .btn-select.selected 문법) */
.p2v-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.p2v-scope .p2v-chip {
padding: 7px 14px;
border: 1px solid var(--color-border-white-10);
border-radius: var(--radius-full);
background: transparent;
color: var(--color-text-gray-400);
font-family: inherit;
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
transition: border-color var(--transition-normal), background-color var(--transition-normal),
color var(--transition-normal);
}
.p2v-scope .p2v-chip:hover { color: var(--color-text-white); }
.p2v-scope .p2v-chip.active {
border-color: var(--color-mint);
background: var(--color-mint-20);
color: var(--color-mint);
}
/* ── F1 결과 ─────────────────────────────────────── */
.p2v-scope.p2v-result {
max-width: 1000px;
margin: 0 auto;
padding: 8px 16px 40px;
}
.p2v-result__title {
margin: 0 0 22px;
font-size: var(--text-2xl);
font-weight: 700;
text-align: center;
letter-spacing: -0.015em;
}
.p2v-result__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-page-md);
align-items: stretch;
}
.p2v-scope .p2v-result__player {
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
}
.p2v-scope .p2v-result__player video {
display: block;
width: 100%;
max-height: 560px;
border-radius: var(--radius-md);
}
.p2v-result__side {
display: flex;
flex-direction: column;
padding-left: var(--spacing-page);
border-left: 2px solid var(--color-mint-20);
}
.p2v-result__filename {
margin: 4px 0 16px;
font-size: var(--text-xl);
font-weight: 700;
line-height: 1.35;
word-break: break-all;
}
.p2v-result__block {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--color-border-white-10);
}
.p2v-result__row {
display: flex;
gap: 12px;
margin-bottom: 6px;
font-size: var(--text-base);
}
.p2v-result__row span {
flex: none;
width: 52px;
color: var(--color-text-gray-400);
}
.p2v-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 12px;
}
.p2v-scope .p2v-tag {
padding: 3px 10px;
border-radius: var(--radius-full);
background: var(--color-mint-10);
color: var(--color-mint);
font-size: var(--text-sm);
font-weight: 600;
}
.p2v-result__actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: auto;
padding-top: 24px;
}
.p2v-scope .p2v-result__new {
display: flex;
margin: 28px auto 0;
}
/* ── F2 스타일링 ─────────────────────────────────── */
.p2v-scope.p2v-styling {
max-width: 900px;
margin: 0 auto;
padding: 8px 0 40px;
}
.p2v-scope .p2v-styling__scope { margin-bottom: 22px; }
.p2v-scope .p2v-notice {
padding: 12px 18px;
border-radius: var(--radius-xl);
font-size: var(--text-sm);
line-height: 1.6;
color: var(--color-text-gray-300);
}
/* 내부 전용 — 주황 계열. 민트/퍼플과 겹치지 않아야 "제한"으로 읽힌다 */
.p2v-scope .p2v-notice--warn {
border: 1px solid rgba(255, 154, 90, 0.35);
background: rgba(255, 154, 90, 0.12);
}
.p2v-scope .p2v-notice--warn b { color: #ffc9a3; }
.p2v-scope .p2v-notice--unknown {
margin-top: 12px;
border: 1px solid rgba(166, 130, 255, 0.38);
background: rgba(166, 130, 255, 0.14);
}
.p2v-scope .p2v-license {
display: inline-block;
padding: 2px 8px;
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 700;
line-height: 1.5;
white-space: nowrap;
}
.p2v-scope .p2v-license--ok {
border: 1px solid var(--color-mint-30);
background: var(--color-mint-10);
color: var(--color-mint);
}
.p2v-scope .p2v-license--warn {
border: 1px solid rgba(255, 154, 90, 0.35);
background: rgba(255, 154, 90, 0.12);
color: #ffc9a3;
}
.p2v-scope .p2v-license--unknown {
border: 1px solid rgba(166, 130, 255, 0.38);
background: rgba(166, 130, 255, 0.14);
color: #c9b6ff;
}
.p2v-scope .p2v-collapse {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 0;
border: none;
background: none;
color: var(--color-text-white);
font-family: inherit;
text-align: left;
cursor: pointer;
}
.p2v-scope .p2v-collapse .p2v-label { margin: 0; }
.p2v-collapse__picked {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: var(--text-base);
}
.p2v-collapse__picked img {
width: 22px;
height: 30px;
object-fit: cover;
border-radius: var(--radius-sm);
}
.p2v-collapse__picked b { color: var(--color-mint); font-weight: 700; }
.p2v-collapse__empty {
font-size: var(--text-base);
color: var(--color-text-gray-500);
}
.p2v-collapse__caret {
margin-left: auto;
color: var(--color-text-gray-400);
transition: transform var(--transition-normal);
}
.p2v-collapse__caret.open { transform: rotate(180deg); }
.p2v-tplgroup { margin-top: 20px; }
.p2v-tplgroup__title {
margin: 0 0 10px;
font-size: var(--text-sm);
font-weight: 700;
color: var(--color-text-gray-400);
}
.p2v-tplgroup__title span {
margin-left: 8px;
font-weight: 500;
color: var(--color-text-gray-500);
}
.p2v-tplgrid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
}
.p2v-tplcard { position: relative; }
.p2v-scope .p2v-tpl {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
height: 100%;
padding: 8px;
border: 1px solid var(--color-border-gray-700);
border-radius: var(--radius-xl);
background: var(--color-bg-card-inner);
color: var(--color-text-gray-400);
font-family: inherit;
cursor: pointer;
transition: border-color var(--transition-normal), background-color var(--transition-normal),
color var(--transition-normal);
}
.p2v-scope .p2v-tpl:hover { color: var(--color-text-white); }
.p2v-scope .p2v-tpl.active {
border: 1px solid var(--color-mint);
background: var(--color-mint-10);
color: var(--color-text-white);
/* 링을 테두리와 같은 색으로 둔다 — 다르면 두 줄로 갈라져 보인다.
테두리 두께를 바꾸지 않아 카드 안 글자도 흔들리지 않는다 */
box-shadow: 0 0 0 1px var(--color-mint);
}
/* 명화는 가로 그림도 있다 — cover 로 자르면 무엇인지 알 수 없다 */
.p2v-scope .p2v-tpl img {
display: block;
width: 100%;
aspect-ratio: 2 / 3;
object-fit: contain;
border-radius: var(--radius-md);
background: var(--color-bg-darker);
}
.p2v-tpl__name {
font-size: var(--text-sm);
font-weight: 700;
line-height: 1.3;
white-space: normal;
}
/* 제목이 1줄인 카드와 2줄인 카드가 섞인다 — 아래로 밀어야 배지가 같은 선에 선다 */
.p2v-tpl__badge {
display: flex;
margin-top: auto;
}
.p2v-tpl__sub {
font-size: var(--text-xs);
line-height: 1.4;
color: var(--color-text-gray-500);
white-space: normal;
}
.p2v-scope .p2v-tpl--add {
align-items: center;
justify-content: center;
gap: 10px;
min-height: 200px;
border-style: dashed;
text-align: center;
}
.p2v-tpl__plus { font-size: 26px; font-weight: 300; line-height: 1; }
.p2v-scope .p2v-tpl__remove {
position: absolute;
top: 10px;
right: 10px;
width: 26px;
height: 26px;
border: 1px solid var(--color-border-white-10);
border-radius: var(--radius-full);
background: rgba(0, 0, 0, 0.62);
color: var(--color-text-gray-300);
font-family: inherit;
font-size: 15px;
line-height: 1;
cursor: pointer;
}
.p2v-styling__io {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-page-md);
margin-top: 28px;
}
.p2v-scope .p2v-styling__out {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 4 / 5;
max-width: 400px;
margin: 0 auto;
padding: 0;
overflow: hidden;
}
.p2v-scope .p2v-styling__out--empty {
flex-direction: column;
gap: 16px;
padding: var(--spacing-page);
font-size: var(--text-base);
color: var(--color-text-gray-500);
text-align: center;
}
.p2v-scope .p2v-styling__out img {
width: 100%;
height: 100%;
object-fit: contain;
}
.p2v-styling__download {
display: flex;
justify-content: center;
margin-top: 12px;
}
.p2v-scope.p2v-styling .p2v-label { margin-top: 26px; }
.p2v-scope.p2v-styling .p2v-styling__io .p2v-label { margin-top: 0; }
.p2v-formats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.p2v-scope .p2v-select {
padding: 12px;
border: 1px solid var(--color-border-gray-700);
border-radius: var(--radius-xl);
background: var(--color-bg-card-inner);
color: var(--color-text-gray-400);
font-family: inherit;
font-size: var(--text-sm);
font-weight: 700;
cursor: pointer;
transition: border-color var(--transition-normal), background-color var(--transition-normal),
color var(--transition-normal);
}
.p2v-scope .p2v-select:hover { color: var(--color-text-white); }
.p2v-scope .p2v-select.active {
border-color: var(--color-mint);
background: var(--color-mint-10);
color: var(--color-text-white);
box-shadow: 0 0 0 1px var(--color-mint);
}
/* ── 모바일 ──────────────────────────────────────── */
@media (max-width: 900px) {
.p2v-tplgrid { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 768px) {
.p2v-review__grid,
.p2v-result__grid,
.p2v-styling__io {
grid-template-columns: 1fr;
}
/* 세로로 쌓이면 왼쪽 테두리는 위쪽 구분선이 되어야 한다 */
.p2v-result__side {
padding-left: 0;
padding-top: var(--spacing-page);
border-left: none;
border-top: 2px solid var(--color-mint-20);
}
.p2v-tplgrid { grid-template-columns: repeat(2, 1fr); }
.p2v-formats { grid-template-columns: repeat(2, 1fr); }
/* 검수 화면(모바일) — 2단이 1단으로 접히면서 순서 문제가 생긴다.
데스크톱은 좌(편집)·우(포스터)라 나란히 보이지만, 세로로 쌓으면 편집이 먼저 오고
**대조할 포스터가 그 아래로 밀린다.** 연도·지명이 맞는지 보려면 위아래로 오가야 한다.
포스터를 먼저 올려 읽고 나서 고치는 순서로 만든다. */
.p2v-review__preview { order: -1; margin-bottom: 20px; }
.p2v-scope .p2v-review__image { max-height: 52vh; object-fit: contain; }
/* 주 액션은 하단 탭바 바로 위에 붙여 둔다.
탭바(고정 72px)가 화면 아래를 차지해 CTA 가 문서 흐름 한가운데로 밀리는데,
그러면 편집을 마치고도 버튼을 찾아 위로 스크롤해야 한다.
castad 가 이미 쓰는 오프셋 패턴(`bottom: nav + 16px`)과 같은 발상이되,
여기서는 스크롤 컨테이너 안이라 sticky 로 충분하다.
box-shadow 의 두 번째 값은 배경색 링 — 뒤로 지나가는 콘텐츠를 가려 준다. */
.p2v-scope.p2v-review .p2v-btn-cta {
position: sticky;
bottom: 12px;
z-index: 5;
box-shadow: var(--shadow-purple), 0 0 0 10px var(--color-bg-darker);
}
}
/* 진행 애니메이션은 모션 민감 사용자에게 끈다.
스피너는 회전을 멈춰도 3/4 호가 남아 정적 표시로 읽힌다 */
@media (prefers-reduced-motion: reduce) {
.p2v-scope .p2v-spinner::after { animation: none; }
.p2v-scope .p2v-bar__fill { transition: none; }
}

View File

@ -25,13 +25,15 @@
color: var(--color-text-white); color: var(--color-text-white);
} }
/* 간격은 generation-flow.css 상단의 체계를 따른다 (묶음 안 8~12 / 묶음 사이 20 / 섹션 32) */
.ssul-create__step { .ssul-create__step {
align-self: flex-start; align-self: flex-start;
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.04em; letter-spacing: 0.04em;
color: var(--color-ssul-accent); color: var(--color-mint);
margin-bottom: 10px; /* 라벨과 그 라벨이 설명하는 것은 붙여야 한 덩어리로 읽힌다 */
margin-bottom: 12px;
} }
/* 2단계 라벨. 강조를 1단계에만 두어 시선 순서를 만든다 */ /* 2단계 라벨. 강조를 1단계에만 두어 시선 순서를 만든다 */
@ -44,9 +46,10 @@
.ssul-create__scenarios { .ssul-create__scenarios {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 10px; gap: 12px;
width: 100%; width: 100%;
margin-bottom: 22px; /* STEP 1 → STEP 2 는 주제가 바뀌는 자리라 섹션 간격 */
margin-bottom: 32px;
} }
.ssul-scn { .ssul-scn {
@ -54,8 +57,8 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: 5px; gap: 6px;
padding: 14px 12px; padding: 16px 14px;
/* 테두리 두께는 모든 상태에서 1px 로 고정한다. 선택 시 굵기를 바꾸면 /* 테두리 두께는 모든 상태에서 1px 로 고정한다. 선택 시 굵기를 바꾸면
border-box 안쪽 폭이 줄어 글자가 흔들린다 — 굵기는 box-shadow 링으로 더한다 */ border-box 안쪽 폭이 줄어 글자가 흔들린다 — 굵기는 box-shadow 링으로 더한다 */
border: 1px solid var(--color-border-white-10); border: 1px solid var(--color-border-white-10);
@ -69,56 +72,25 @@
transition: border-color var(--transition-normal), transform var(--transition-normal); transition: border-color var(--transition-normal), transform var(--transition-normal);
} }
/* 시나리오 표지 그라디언트를 옅게 깐다.
--scn / --scn-a / --scn-b 는 컴포넌트가 인라인으로 넘긴다. */
.ssul-scn::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(150deg, var(--scn-a), var(--scn-b));
/* 비선택은 눌러 둔다 — 채도까지 낮춰야 선택 카드와 확실히 갈린다 */
opacity: 0.22;
filter: saturate(0.75);
pointer-events: none;
transition: opacity var(--transition-normal), filter var(--transition-normal);
}
/* 선택한 카드는 채도·명도를 함께 올린다.
불투명도만 올리면 어두운 배경과 섞여 오히려 탁해지므로 filter 로 직접 올린다. */
.ssul-scn.active::before {
opacity: 0.72;
filter: saturate(1.55) brightness(1.18);
}
.ssul-scn:hover { .ssul-scn:hover {
border-color: rgba(255, 255, 255, 0.25); border-color: rgba(255, 255, 255, 0.25);
transform: translateY(-1px); transform: translateY(-1px);
} }
/* 선택 표시는 밝은 중립색 테두리로 둔다. /* 선택 표시 — castad .btn-select.selected 와 동일한 문법(민트 테두리 + 민트 틴트) */
앰버(썰박스 강조색)를 쓰면 시나리오마다 다른 표지색과 부딪힌다 —
특히 삼국지(적)·오디세이(청) 위에서 탁해진다. */
.ssul-scn.active { .ssul-scn.active {
border-color: var(--color-mint); border-color: var(--color-mint);
background: var(--color-mint-10);
/* 링을 **테두리와 같은 색**으로 둔다. 색이 다르면 안쪽 테두리와 바깥 링이 /* 링을 **테두리와 같은 색**으로 둔다. 색이 다르면 안쪽 테두리와 바깥 링이
갈라져 두 줄로 보인다. 같은 색이면 1px 테두리 + 1px 링이 이어져 갈라져 두 줄로 보인다. 같은 색이면 1px 테두리 + 1px 링이 이어져
2px 한 줄로 읽히고, 테두리 두께를 바꾸지 않아 글자도 흔들리지 않는다. 2px 한 줄로 읽히고, 테두리 두께를 바꾸지 않아 글자도 흔들리지 않는다. */
마지막 그림자만 시나리오 대표색 글로우. */ box-shadow: 0 0 0 1px var(--color-mint);
box-shadow:
0 0 0 1px var(--color-mint),
0 0 20px -2px var(--scn);
} }
/* 표지가 밝아진 만큼 설명 글자도 올려 대비를 지킨다 */
.ssul-scn.active .ssul-scn__desc { .ssul-scn.active .ssul-scn__desc {
color: rgba(255, 255, 255, 0.92); color: rgba(255, 255, 255, 0.92);
} }
.ssul-scn__emoji {
position: relative;
font-size: 22px;
}
.ssul-scn__name { .ssul-scn__name {
position: relative; position: relative;
font-size: 14px; font-size: 14px;
@ -139,13 +111,17 @@
display: flex; display: flex;
gap: 8px; gap: 8px;
width: 100%; width: 100%;
/* 입력과 바로 아래 안내는 한 덩어리 — 붙여 둔다 */
margin-bottom: 10px; margin-bottom: 10px;
} }
/* 이 폼의 가로 박스(입력·안내·선택결과·제출)는 높이를 48px 로 맞춘다.
제각각이면(입력 46 / 안내 38 / 버튼 48) 세로로 쌓였을 때 리듬이 깨져
정렬이 틀어진 것처럼 보인다. 48px 은 CTA·터치 타겟 기준값이다. */
.ssul-create__input { .ssul-create__input {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
min-height: 46px; min-height: 48px;
padding: 8px 16px; padding: 8px 16px;
border: none; border: none;
border-radius: 999px; border-radius: 999px;
@ -233,16 +209,20 @@
color: var(--color-text-gray-400); color: var(--color-text-gray-400);
} }
/* 안내(.ssul-hint)와 같은 자리에 번갈아 나오므로 아래 간격도 같아야 한다 */
.ssul-picked { .ssul-picked {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
width: 100%; width: 100%;
/* 안내(.ssul-hint)와 같은 자리에 번갈아 나오므로 하한을 맞춘다.
업체명 + 주소 2줄이라 실제로는 이보다 커지는데, 그건 내용이 늘어난 것이라 괜찮다 */
min-height: 48px;
padding: 11px 14px; padding: 11px 14px;
margin-bottom: 14px; margin-bottom: 28px;
border: 1.5px solid var(--color-ssul-accent); border: 1.5px solid var(--color-mint);
border-radius: 12px; border-radius: 12px;
background: rgba(255, 184, 107, 0.08); background: var(--color-mint-10);
} }
.ssul-picked__body { .ssul-picked__body {
@ -264,9 +244,14 @@
} }
.ssul-hint { .ssul-hint {
/* 높이를 입력·제출과 같은 48px 로. 글이 길어 두 줄이 되면 그때만 늘어난다 */
display: flex;
align-items: center;
width: 100%; width: 100%;
min-height: 48px;
padding: 10px 14px; padding: 10px 14px;
margin-bottom: 14px; /* 안내 다음은 비용·제출 묶음이라 섹션 간격을 준다 */
margin-bottom: 28px;
border-radius: 12px; border-radius: 12px;
background: var(--color-bg-card-inner); background: var(--color-bg-card-inner);
font-size: 12px; font-size: 12px;
@ -292,7 +277,7 @@
width: 5px; width: 5px;
height: 5px; height: 5px;
border-radius: 50%; border-radius: 50%;
background: var(--color-ssul-accent); background: var(--color-mint);
} }
.ssul-hint.warn { .ssul-hint.warn {
@ -300,12 +285,14 @@
} }
/* ── 비용 고지 + 제출 ────────────────────────────── */ /* ── 비용 고지 + 제출 ────────────────────────────── */
/* 비용 고지와 제출 버튼은 한 덩어리 — 누르기 직전에 읽어야 하므로 붙여 둔다 */
.ssul-create__cost { .ssul-create__cost {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
width: 100%; width: 100%;
padding: 10px 4px; padding: 0 4px;
margin-bottom: 12px;
font-size: 12px; font-size: 12px;
color: var(--color-text-gray-400); color: var(--color-text-gray-400);
} }
@ -315,7 +302,7 @@
align-items: center; align-items: center;
gap: 5px; gap: 5px;
font-weight: 700; font-weight: 700;
color: var(--color-ssul-accent); color: var(--color-mint);
} }
.ssul-create__coin { .ssul-create__coin {
@ -323,6 +310,7 @@
line-height: 1; line-height: 1;
} }
/* castad .btn-cta 와 같은 문법 — 퍼플은 페이지에서 이 CTA 한 곳만 쓴다 */
.ssul-create__submit { .ssul-create__submit {
display: flex; display: flex;
align-items: center; align-items: center;
@ -331,18 +319,18 @@
height: 48px; height: 48px;
border: none; border: none;
border-radius: 999px; border-radius: 999px;
background: linear-gradient(90deg, #ff8a5c, var(--color-ssul-accent)); background: var(--color-purple);
color: #1a1200; color: var(--color-text-white);
font-family: inherit; font-family: inherit;
font-size: 16px; font-size: 16px;
font-weight: 800; font-weight: 700;
cursor: pointer; cursor: pointer;
transition: filter var(--transition-normal), box-shadow var(--transition-normal); box-shadow: var(--shadow-purple);
transition: background-color var(--transition-normal);
} }
.ssul-create__submit:hover:not(:disabled) { .ssul-create__submit:hover:not(:disabled) {
filter: brightness(1.08); background: var(--color-purple-hover);
box-shadow: 0 4px 28px rgba(255, 180, 90, 0.4);
} }
.ssul-create__submit:disabled { .ssul-create__submit:disabled {
@ -376,6 +364,7 @@
.ssul-making__scenario { .ssul-making__scenario {
font-size: 20px; font-size: 20px;
font-weight: 700; font-weight: 700;
color: var(--color-mint);
} }
.ssul-making__title { .ssul-making__title {
@ -390,8 +379,7 @@
} }
/* 진행 스피너 — 원본 LoadingSection 의 회전 요소만 가져왔다. /* 진행 스피너 — 원본 LoadingSection 의 회전 요소만 가져왔다.
SVG(loading-spinner.svg)를 쓰지 않은 이유: 색이 민트로 하드코딩돼 있어 색은 ADO2 생성 스피너 표준(퍼플)을 따른다.
시나리오 대표색(삼국지=적, 오디세이=청)과 부딪힌다. CSS 로 그리면 --scn 을 받는다.
`spin` 키프레임은 castad 전역에 이미 있어 재사용한다(새로 만들면 3번째 중복). */ `spin` 키프레임은 castad 전역에 이미 있어 재사용한다(새로 만들면 3번째 중복). */
.ssul-making__spinner { .ssul-making__spinner {
position: relative; position: relative;
@ -417,9 +405,9 @@
inset: 0; inset: 0;
border-radius: 50%; border-radius: 50%;
border: 7px solid transparent; border: 7px solid transparent;
border-top-color: var(--scn); border-top-color: var(--color-purple);
border-right-color: var(--scn); border-right-color: var(--color-purple);
border-bottom-color: var(--scn); border-bottom-color: var(--color-purple);
animation: spin 1.1s linear infinite; animation: spin 1.1s linear infinite;
} }
@ -435,6 +423,7 @@
.ssul-making__fill { .ssul-making__fill {
height: 100%; height: 100%;
border-radius: 999px; border-radius: 999px;
background: var(--color-mint);
transition: width 400ms ease; transition: width 400ms ease;
} }

View File

@ -24,9 +24,6 @@
--color-purple-glow: rgba(166, 130, 255, 0.2); --color-purple-glow: rgba(166, 130, 255, 0.2);
--color-purple-80: rgba(166, 130, 255, 0.8); --color-purple-80: rgba(166, 130, 255, 0.8);
/* 썰박스 파이프라인 강조색 (진입 화면 탭 활성 표시 전용) */
--color-ssul-accent: #ffb86b;
/* Background Colors - Teal-600 based */ /* Background Colors - Teal-600 based */
--color-bg-dark: #002224; --color-bg-dark: #002224;
--color-bg-darker: #001a1c; --color-bg-darker: #001a1c;

263
src/utils/p2vApi.ts Normal file
View File

@ -0,0 +1,263 @@
/**
* 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';

View File

@ -32,10 +32,24 @@ export const K = {
VIDEO_RATIO: 'castad_video_ratio', VIDEO_RATIO: 'castad_video_ratio',
// 썰박스 통합 // 썰박스 통합
/** 현재 선택된 파이프라인 ('ado2' | 'ssul') */ /** 현재 선택된 파이프라인 (PipelineTabs 의 Pipeline 값) */
PIPELINE: 'castad_pipeline', PIPELINE: 'castad_pipeline',
/** 진행 중인 썰박스 생성 잡 ID. 권위는 서버(`/ssul/tasks/active`)이고 이건 복구 힌트다 */ /** 진행 중인 썰박스 생성 잡 ID. 권위는 서버(`/ssul/tasks/active`)이고 이건 복구 힌트다 */
SSUL_TASK_ID: 'castad_ssul_task_id', SSUL_TASK_ID: 'castad_ssul_task_id',
// Poster to Video 통합
/**
* 진행 중인 포스터 영상(F1) 잡 ID.
*
* 썰박스의 `/ssul/tasks/active` 같은 "내 활성 잡" 엔드포인트가 P2V 에는 없다
* (단일 팀 키라 사용자 개념이 없다). 그래서 이 값이 새로고침 복구의 유일한 근거다 —
* 지우면 진행 중인 잡을 화면에서 잃는다. 서버에서 잡이 사라졌으면 조회가 404 로
* 떨어지고 GenerationFlow 가 이 키를 정리한다.
*
* 접근 키(castad_p2v_key)는 여기 두지 않는다 — 서버 자격증명이라 프로젝트 초기화·
* 로그아웃으로 지워지면 시연 중 매번 다시 물어야 한다 (utils/p2vApi.ts 참조).
*/
POSTER_JOB_ID: 'castad_poster_job_id',
} as const; } as const;
/** /**
@ -52,6 +66,7 @@ export const PROJECT_KEYS: readonly string[] = [
K.VIDEO_RATIO, K.VIDEO_RATIO,
K.PIPELINE, K.PIPELINE,
K.SSUL_TASK_ID, K.SSUL_TASK_ID,
K.POSTER_JOB_ID,
]; ];
/** /**