feat: 썰박스 DS 통일과 Poster to Video 통합, 진입 화면 네비게이션 개편

썰박스를 ADO2 디자인 시스템으로 맞추고, 포스터 파이프라인 두 종을 파이프라인
탭에 추가한다. 탭이 4개가 되면서 진입 화면 네비게이션도 함께 손봤다.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Haewon Kam 2026-08-20 12:55:06 +09:00
parent 4dff60c1ba
commit 0ca2140b98
29 changed files with 3099 additions and 219 deletions

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'
} }
} }
} }
@ -53,7 +53,8 @@
<script src="https://t1.kakaocdn.net/kakao_js_sdk/2.7.4/kakao.min.js" crossorigin="anonymous"></script> <script src="https://t1.kakaocdn.net/kakao_js_sdk/2.7.4/kakao.min.js" crossorigin="anonymous"></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%;
@ -63,11 +64,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;

29
package-lock.json generated
View File

@ -16,6 +16,8 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2", "typescript": "~5.8.2",
"vite": "^6.2.0" "vite": "^6.2.0"
@ -1292,6 +1294,26 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/react": {
"version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
}
},
"node_modules/@types/use-sync-external-store": { "node_modules/@types/use-sync-external-store": {
"version": "0.0.6", "version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
@ -1400,6 +1422,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": { "node_modules/d3-array": {
"version": "3.2.4", "version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",

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

@ -604,7 +604,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": {
@ -623,5 +631,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

@ -604,7 +604,15 @@
"pipelineTabs": { "pipelineTabs": {
"ariaLabel": "생성할 콘텐츠 종류", "ariaLabel": "생성할 콘텐츠 종류",
"ado2": "ADO2", "ado2": "ADO2",
"ssul": "썰박스" "ssul": "썰박스",
"poster": "무빙 포스터",
"styling": "포스터 스타일링",
"desc": {
"ado2": "업체 URL 하나로 브랜드를 분석해 가사·음악·숏폼 영상까지 자동 제작합니다.",
"ssul": "유명 역사 스토리에 업체 셀링포인트를 녹여 흥미로운 숏폼 콘텐츠를 만듭니다.",
"poster": "포스터가 나레이션, 음악, 애니메이션을 추가한 숏폼 콘텐츠로 나옵니다.",
"styling": "포스터를 유명 영화의 한 장면 · 고전화 · 영화 포스터 스타일로 재해석합니다."
}
}, },
"ssulbox": { "ssulbox": {
"steps": { "steps": {
@ -614,13 +622,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": "생성 비용",
@ -672,5 +680,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%;

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,
]; ];
/** /**