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
```
## 환경 변수
| 변수 | 기본값 | 설명 |
|---|---|---|
| `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 실행
```bash

View File

@ -36,14 +36,14 @@
extend: {
colors: {
brand: {
bg: '#011c1e',
card: '#083336',
cardHover: '#0b3f42',
accent: '#94FBE0',
purple: '#a855f7',
purpleHover: '#9333ea',
text: '#e2e8f0',
muted: '#94a3b8'
bg: '#001a1c',
card: '#003538',
cardHover: '#004548',
accent: '#a6ffea',
purple: '#a682ff',
purpleHover: '#9570f0',
text: '#d1d5db',
muted: '#9ca3af'
}
}
}
@ -53,7 +53,8 @@
<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.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>
:root {
--app-height: 100%;
@ -63,11 +64,13 @@
padding: 0;
width: 100%;
height: 100%;
background-color: #121a1d;
background-color: #001a1c;
}
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;
letter-spacing: -0.006em;
}
.font-serif-logo {
font-family: 'Playfair Display', serif;

29
package-lock.json generated
View File

@ -16,6 +16,8 @@
},
"devDependencies": {
"@types/node": "^22.14.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
@ -1292,6 +1294,26 @@
"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": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
@ -1400,6 +1422,13 @@
"dev": true,
"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": {
"version": "3.2.4",
"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 { 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 {
value: Pipeline;
onChange: (value: Pipeline) => void;
}
const TABS: { id: Pipeline; labelKey: string; emoji?: string }[] = [
{ id: 'ado2', labelKey: 'pipelineTabs.ado2' },
{ id: 'ssul', labelKey: 'pipelineTabs.ssul', emoji: '📋' },
const TABS: { id: Pipeline; labelKey: string; Icon: React.FC<{ size?: number }> }[] = [
{ id: 'ado2', labelKey: 'pipelineTabs.ado2', Icon: StorefrontIcon },
{ 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*` .
*
* 접근성: castad (`.myinfo-tab` ) role/aria ,
@ -33,21 +58,19 @@ const PipelineTabs: React.FC<PipelineTabsProps> = ({ value, onChange }) => {
role="tablist"
aria-label={t('pipelineTabs.ariaLabel')}
>
{TABS.map((tab) => (
{TABS.map(({ id, labelKey, Icon }) => (
<button
key={tab.id}
key={id}
type="button"
role="tab"
aria-selected={value === tab.id}
aria-selected={value === id}
className="pipeline-tab"
onClick={() => onChange(tab.id)}
onClick={() => onChange(id)}
>
{tab.emoji && (
<span className="pipeline-tab__emoji" aria-hidden="true">
{tab.emoji}
</span>
)}
{t(tab.labelKey)}
<span className="pipeline-tab__tile">
<Icon size={24} />
</span>
<span className="pipeline-tab__label">{t(labelKey)}</span>
</button>
))}
</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": {
"ariaLabel": "Content type",
"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": {
"steps": {
@ -623,5 +631,79 @@
"submit": "Create Ssulbox",
"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": {
"ariaLabel": "생성할 콘텐츠 종류",
"ado2": "ADO2",
"ssul": "썰박스"
"ssul": "썰박스",
"poster": "무빙 포스터",
"styling": "포스터 스타일링",
"desc": {
"ado2": "업체 URL 하나로 브랜드를 분석해 가사·음악·숏폼 영상까지 자동 제작합니다.",
"ssul": "유명 역사 스토리에 업체 셀링포인트를 녹여 흥미로운 숏폼 콘텐츠를 만듭니다.",
"poster": "포스터가 나레이션, 음악, 애니메이션을 추가한 숏폼 콘텐츠로 나옵니다.",
"styling": "포스터를 유명 영화의 한 장면 · 고전화 · 영화 포스터 스타일로 재해석합니다."
}
},
"ssulbox": {
"steps": {
@ -614,13 +622,13 @@
},
"create": {
"step1": "STEP 1 · 시나리오 선택",
"step2": "STEP 2 · 업 검색",
"step2": "STEP 2 · 업 검색",
"searchPlaceholder": "업체명 또는 URL을 입력하세요.",
"searchButton": "검색",
"searching": "검색 중",
"placeLoading": "가게를 찾는 중… (몇 초 걸려요)",
"hintUrlOk": "네이버 링크로 이 가게를 직접 크롤링합니다.",
"hintDefault": "가게를 선택하면 그 가게의 사진과 정보를 크롤링해 썰박스에 씁니다.",
"placeLoading": "업체를 찾는 중… (몇 초 걸려요)",
"hintUrlOk": "네이버 링크로 해당 업체를 직접 수집합니다.",
"hintDefault": "업체를 선택하면 해당 업체의 사진과 정보를 수집해 썰박스에 씁니다.",
"hintScenario": "{{name}} 시나리오로 약 5~6분 소요됩니다.",
"noResult": "검색 결과가 없어요. 이름을 바꾸거나 네이버 지도 링크를 붙여넣어 주세요.",
"cost": "생성 비용",
@ -672,5 +680,128 @@
"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 SsulMakingContent, { SsulJob } from '../Ssulbox/SsulMakingContent';
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 {
K,
clearProjectStorage,
@ -72,6 +77,25 @@ const SSUL_STEP_INDEX: Record<SsulStep, number> = {
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 {
customer_name: string;
region: string;
@ -164,7 +188,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
const getInitialPipeline = (): Pipeline => {
// 1) 랜딩 CTA 프리셋 (1회성). 카카오 로그인 리다이렉트 왕복을 견뎌야 해서 sessionStorage
const pending = sessionStorage.getItem(PENDING_PIPELINE_KEY);
if (pending === 'ssul' || pending === 'ado2') {
if (isPipeline(pending)) {
sessionStorage.removeItem(PENDING_PIPELINE_KEY);
return pending;
}
@ -172,7 +196,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
if (initialAnalysisData || savedAnalysisData) return 'ado2';
// 3) 저장된 선택
const saved = localStorage.getItem(K.PIPELINE);
return saved === 'ssul' ? 'ssul' : 'ado2';
return isPipeline(saved) ? saved : 'ado2';
};
const [pipeline, setPipeline] = useState<Pipeline>(getInitialPipeline);
@ -182,13 +206,48 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
/** 언마운트·로그아웃·재시도 시 진행 중인 폴링을 멈추는 플래그 */
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) => {
setPipeline(next);
localStorage.setItem(K.PIPELINE, next);
};
/** 진입 화면(탭이 보이는 단계) 여부. 두 파이프라인이 공유한다 */
const isEntry = pipeline === 'ado2' ? wizardStep === -2 : ssulStep === 'entry';
/** 포스터 진행 단계 — 잡 status 에서 파생 */
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 [imageTaskId, setImageTaskId] = useState<string | null>(savedImageTaskId);
const [videoGenerationStatus, setVideoGenerationStatus] = useState<'idle' | 'generating' | 'complete' | 'error'>('idle');
@ -551,6 +610,64 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
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 만 남으면 저장소와 어긋난다.
setPipeline('ado2');
setSsulStep('entry');
// clearProjectStorage() 가 POSTER_JOB_ID 를 지우므로 state 도 함께 비운다
setPosterJobId(null);
setSongTaskId(null);
setImageTaskId(null);
setAnalysisData(null);
@ -625,6 +744,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
onManualInput={handleManualInput}
error={analysisError}
onSsulJobStarted={handleSsulJobStarted}
onPosterJobStarted={handlePosterJobStarted}
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) {
case -1:
// 로딩 단계
@ -758,6 +901,18 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
</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)은 전체 화면 스크롤이지만 스텝퍼는 표시
if (wizardStep === 0) {
return (

View File

@ -1,9 +1,12 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { AutocompleteRequest } from '../../utils/api';
import SearchInputForm, { SearchType } from '../../components/SearchInputForm';
import PipelineTabs, { Pipeline } from '../../components/PipelineTabs';
import SsulCreateForm from '../Ssulbox/SsulCreateForm';
import { Scen } from '../Ssulbox/ssulData';
import PosterCreateForm from '../Poster/PosterCreateForm';
import StylingContent from '../Poster/StylingContent';
interface UrlInputContentProps {
/** 현재 선택된 파이프라인. 소유자는 GenerationFlow (분기·리셋 때문) */
@ -15,6 +18,8 @@ interface UrlInputContentProps {
error: string | null;
/** 썰박스 생성 요청 접수 시. 폴링 소유는 GenerationFlow */
onSsulJobStarted: (taskId: number, scenario: Scen) => void;
/** 포스터 영상(F1) 생성 요청 접수 시. 폴링 소유는 GenerationFlow */
onPosterJobStarted: (jobId: string) => void;
/** 크레딧 부족 시 충전 화면으로 */
onGoToPayment: () => void;
}
@ -32,27 +37,51 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({
onManualInput,
error,
onSsulJobStarted,
onPosterJobStarted,
onGoToPayment,
}) => {
return (
<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>
const { t } = useTranslation();
<PipelineTabs value={pipeline} onChange={onPipelineChange} />
// 스타일링은 한 화면짜리 도구라 진입 폼보다 넓은 판이 필요하다 (generation-flow.css 참조)
const wide = pipeline === 'styling';
{pipeline === 'ado2' ? (
const renderForm = () => {
switch (pipeline) {
case 'ado2':
return (
<SearchInputForm
onAnalyze={onAnalyze}
onAutocomplete={onAutocomplete}
onManualInput={onManualInput}
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>
);

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,
searchAccommodation,
} from '../../utils/api';
import { SCEN, SCEN_KEYS, Scen, scenDesc, scenName } from './ssulData';
import { SCEN_KEYS, Scen, scenDesc, scenName } from './ssulData';
interface SsulCreateFormProps {
/** 생성 요청이 접수되면 호출. 폴링 소유는 GenerationFlow */
@ -154,7 +154,6 @@ const SsulCreateForm: React.FC<SsulCreateFormProps> = ({ onSubmitted, onNeedCred
<div className="ssul-create__scenarios">
{SCEN_KEYS.map((key) => {
const pal = SCEN[key];
const active = scenario === key;
return (
<button
@ -162,19 +161,8 @@ const SsulCreateForm: React.FC<SsulCreateFormProps> = ({ onSubmitted, onNeedCred
type="button"
aria-pressed={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)}
>
<span className="ssul-scn__emoji" aria-hidden="true">
{pal.emoji}
</span>
<span className="ssul-scn__name">{t(scenName(key))}</span>
<span className="ssul-scn__desc">{t(scenDesc(key))}</span>
</button>

View File

@ -1,6 +1,6 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { SCEN, Scen, scenName } from './ssulData';
import { Scen, scenName } from './ssulData';
/** 생성 잡의 현재 상태. GenerationFlow 가 폴링해 내려준다 */
export interface SsulJob {
@ -45,7 +45,6 @@ const SsulMakingContent: React.FC<SsulMakingContentProps> = ({ job, onRetry }) =
if (!job) return null;
const palette = SCEN[job.scenario];
const failed = job.status === 'error';
// step 은 "완료한 단계 수"라 진행률은 step/4. queued(0) 면 0%
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 (
<div className="ssul-scope ssul-making">
<span className="ssul-making__scenario" style={{ color: palette.hex }}>
<span aria-hidden="true">{palette.emoji}</span> {t(scenName(job.scenario))}
</span>
<span className="ssul-making__scenario">{t(scenName(job.scenario))}</span>
{failed ? (
<>
@ -75,13 +72,8 @@ const SsulMakingContent: React.FC<SsulMakingContentProps> = ({ job, onRetry }) =
{/*
.
"살아 있다" .
( · ).
*/}
<div
className="ssul-making__spinner"
style={{ ['--scn' as string]: palette.hex }}
aria-hidden="true"
/>
<div className="ssul-making__spinner" aria-hidden="true" />
<div
className="ssul-making__bar"
@ -90,10 +82,7 @@ const SsulMakingContent: React.FC<SsulMakingContentProps> = ({ job, onRetry }) =
aria-valuemin={0}
aria-valuemax={100}
>
<div
className="ssul-making__fill"
style={{ width: `${percent}%`, background: palette.hex }}
/>
<div className="ssul-making__fill" style={{ width: `${percent}%` }} />
</div>
<div className="ssul-making__meta">

View File

@ -1,53 +1,20 @@
/**
* .
* .
*
* (o2o-ssulbox/frontend/src/data.ts) **(name/desc) **.
* castad ko/en i18n .
* `t('ssulbox.scenario.<key>.name')` / `.desc` .
*
* (SAMPLE) `/ssul/feed` .
* ·· ADO2 (2026-08-20)
* ·· (/) .
*/
/** 시나리오 코드. 백엔드 `ssul_content.scenario` 와 값이 일치해야 한다 */
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)와 필터 칩이 이 순서를 따른다 */
export const SCEN_KEYS = Object.keys(SCEN) as Scen[];
/** 카드 표지 그라디언트 */
export const scGrad = (s: Scen): string =>
`linear-gradient(150deg,${SCEN[s].g[0]} 0%,${SCEN[s].g[1]} 100%)`;
export const SCEN_KEYS: Scen[] = ['joseon', 'samgukji', 'greek', 'odyssey'];
/** i18n 키 헬퍼 — t(scenName('joseon')) 형태로 쓴다 */
export const scenName = (s: Scen): string => `ssulbox.scenario.${s}.name`;
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;
flex-direction: column;
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);
color: var(--color-text-white);
padding: 80px 1rem 100px;
position: relative;
overflow: hidden;
overflow-y: auto;
overflow-x: hidden;
box-sizing: border-box;
}
@ -2402,11 +2409,24 @@
align-items: center;
z-index: 20;
position: relative;
margin-top: 50px;
/* 세로 중앙정렬은 auto 마진으로 한다 (부모의 justify-content 주석 참조).
넘칠 잘리지 않는 유일한 방법이다 */
margin: auto 0;
width: 100%;
max-width: 375px;
}
/* 진입 화면에 들어가는 폼은 대부분 입력이라 375px 충분하지만,
포스터 스타일링(F2) 템플릿 그리드·입출력 2단이 화면에 있는 **도구**
폭에서는 쓴다.
(세로 넘침 처리는 이제 컨테이너 기본 규칙이 담당한다 예외를 두지 않는다) */
.url-input-content--wide {
max-width: 900px;
}
/* 375px 화면에서는 타일 4개 + 라벨이 폭을 넘긴다. 타일과 글자를 함께 줄여
줄을 지킨다 라벨이 줄바꿈되면 타일 정렬이 어긋난다 */
/* =====================================================
PipelineTabs ADO2 / 썰박스 전환 (밑줄 )
진입 화면에서 로고와 하위 사이에 형제로 얹힌다.
@ -2414,76 +2434,137 @@
컨테이너 하단의 옅은 전체 구분선 위로, 활성 아래에만
진한 밑줄이 얹힌다. 밑줄 색은 탭에 따라 민트앰버로 바뀐다.
===================================================== */
/* 간격 체계 (8px 그리드 · Material 3 / 수직 리듬 가이드 기준)
8~12px 묶음 (라벨 라벨이 설명하는 , 입력 입력의 안내)
20px 묶음 사이
32px 섹션 사이 (주제가 바뀜)
이전에는 전부 10~24px 이라 "붙은 것" "다른 것" 구분되지 않아 답답해 보였다.
근접성(Gestalt) 거리 차이로만 전달되므로 값이 고르면 정보 구조가 사라진다. */
.pipeline-tabs {
position: relative;
display: flex;
justify-content: center;
/* 터치 타겟은 8dp 이상 떨어뜨린다(Material 3). 4px 이면 옆 타일 오탭이 난다 */
gap: 8px;
width: 100%;
margin-bottom: 20px;
/* 전체 폭 구분선. 활성 밑줄이 이 위에 겹쳐 얹힌다 */
border-bottom: 1px solid var(--color-border-white-10);
}
.pipeline-tab {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
flex: 1;
/* 13px 상하 패딩 + 폰트로 터치 타겟 44px 이상 확보.
밑줄 탭은 필과 달리 배경이 없어 타겟이 작아 보이기 쉽다 */
padding: 13px 6px;
/* 타일(52px) + 라벨로 터치 타겟이 이미 충분하다. 좌우 패딩은 라벨이
이웃 타일에 붙지 않을 만큼만 */
padding: 4px 2px;
border: none;
background: none;
font-family: inherit;
font-size: 14px;
font-weight: 600;
letter-spacing: -0.006em;
color: var(--color-text-gray-400);
cursor: pointer;
transition: color var(--transition-normal);
}
/* 활성 표시 밑줄. 구분선(1px)을 덮도록 bottom: -1px */
.pipeline-tab::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: -1px;
height: 2px;
border-radius: 2px;
background: transparent;
transition: background var(--transition-normal);
/* 아이콘 타일 — 라운드 스퀘어. 아이콘은 currentColor 라 타일이 색을 정한다 */
.pipeline-tab__tile {
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
border-radius: var(--radius-xl);
border: 1px solid var(--color-border-white-10);
background: var(--color-bg-card-inner);
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 {
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"] {
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);
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) {
.pipeline-tab,
.pipeline-tab::after {
.pipeline-tab__tile,
.pipeline-tab__label {
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 으로 교체) */
.pipeline-placeholder {
width: 100%;

View File

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

View File

@ -24,9 +24,6 @@
--color-purple-glow: rgba(166, 130, 255, 0.2);
--color-purple-80: rgba(166, 130, 255, 0.8);
/* 썰박스 파이프라인 강조색 (진입 화면 탭 활성 표시 전용) */
--color-ssul-accent: #ffb86b;
/* Background Colors - Teal-600 based */
--color-bg-dark: #002224;
--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',
// 썰박스 통합
/** 현재 선택된 파이프라인 ('ado2' | 'ssul') */
/** 현재 선택된 파이프라인 (PipelineTabs 의 Pipeline 값) */
PIPELINE: 'castad_pipeline',
/** 진행 중인 썰박스 생성 잡 ID. 권위는 서버(`/ssul/tasks/active`)이고 이건 복구 힌트다 */
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;
/**
@ -52,6 +66,7 @@ export const PROJECT_KEYS: readonly string[] = [
K.VIDEO_RATIO,
K.PIPELINE,
K.SSUL_TASK_ID,
K.POSTER_JOB_ID,
];
/**