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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

29
package-lock.json generated
View File

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

View File

@ -0,0 +1,46 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { P2vLicense } from '../utils/p2vApi';
/**
* 릿 .
*
*
* ** ** .
* ( ).
*/
const CLASS: Record<P2vLicense, string> = {
'public-domain': 'ok',
'internal-only': 'warn',
// 내부 전용과 색을 나눈다 — 해제 조건도, 책임 주체도 다르다
'user-uploaded': 'unknown',
};
export const LicenseBadge: React.FC<{ license: P2vLicense }> = ({ license }) => {
const { t } = useTranslation();
const variant = CLASS[license] ?? CLASS['internal-only'];
return (
<span className={`p2v-license p2v-license--${variant}`}>
{t(`poster.styling.license.${license}`)}
</span>
);
};
/** 내부 전용 템플릿을 고른 순간의 경고 */
export const InternalOnlyWarning: React.FC<{ note?: string }> = ({ note }) => {
const { t } = useTranslation();
return (
<div className="p2v-notice p2v-notice--warn">
<b>{t('poster.styling.internalOnlyTitle')}</b>
{' — '}
{note || t('poster.styling.internalOnlyBody')}
</div>
);
};
/** 사용자가 올린 레퍼런스 고지 — 남의 저작물일 수 있다 */
export const UserReferenceNotice: React.FC = () => {
const { t } = useTranslation();
return <div className="p2v-notice p2v-notice--unknown">{t('poster.styling.userRefNotice')}</div>;
};

View File

@ -0,0 +1,49 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { getP2vKey, setP2vKey } from '../utils/p2vApi';
interface P2vKeyGateProps {
/** 키를 저장한 뒤 호출 — 호출부가 실패했던 요청을 다시 태운다 */
onSaved: () => void;
}
/**
* P2V .
*
* P2V 401 `window.prompt` ( ),
* castad .
* ( + pill ) .
*/
const P2vKeyGate: React.FC<P2vKeyGateProps> = ({ onSaved }) => {
const { t } = useTranslation();
const [value, setValue] = useState(getP2vKey());
const save = () => {
if (!value.trim()) return;
setP2vKey(value);
onSaved();
};
return (
<div className="p2v-keygate">
<p className="p2v-keygate__title">{t('poster.key.title')}</p>
<p className="p2v-keygate__desc">{t('poster.key.desc')}</p>
<div className="p2v-keygate__row">
<input
type="password"
className="p2v-input"
placeholder={t('poster.key.placeholder')}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && save()}
autoComplete="off"
/>
<button type="button" className="p2v-btn-mint" disabled={!value.trim()} onClick={save}>
{t('poster.key.save')}
</button>
</div>
</div>
);
};
export default P2vKeyGate;

View File

@ -1,23 +1,48 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import {
BookIcon,
PaletteIcon,
PosterVideoIcon,
StorefrontIcon,
} from './pipelineIcons';
/** 생성할 콘텐츠 종류. 진입 화면에서 고르면 이후 플로우 전체가 갈린다. */ /**
export type Pipeline = 'ado2' | 'ssul'; * . .
*
* poster·styling castad **P2V (:8010)** (utils/p2vApi.ts).
*/
export type Pipeline = 'ado2' | 'ssul' | 'poster' | 'styling';
/** 유효한 파이프라인 값인가 — localStorage 복원 때 쓴다 */
export const PIPELINES: readonly Pipeline[] = ['ado2', 'ssul', 'poster', 'styling'];
export const isPipeline = (value: unknown): value is Pipeline =>
typeof value === 'string' && (PIPELINES as readonly string[]).includes(value);
interface PipelineTabsProps { interface PipelineTabsProps {
value: Pipeline; value: Pipeline;
onChange: (value: Pipeline) => void; onChange: (value: Pipeline) => void;
} }
const TABS: { id: Pipeline; labelKey: string; emoji?: string }[] = [ const TABS: { id: Pipeline; labelKey: string; Icon: React.FC<{ size?: number }> }[] = [
{ id: 'ado2', labelKey: 'pipelineTabs.ado2' }, { id: 'ado2', labelKey: 'pipelineTabs.ado2', Icon: StorefrontIcon },
{ id: 'ssul', labelKey: 'pipelineTabs.ssul', emoji: '📋' }, { id: 'ssul', labelKey: 'pipelineTabs.ssul', Icon: BookIcon },
{ id: 'poster', labelKey: 'pipelineTabs.poster', Icon: PosterVideoIcon },
{ id: 'styling', labelKey: 'pipelineTabs.styling', Icon: PaletteIcon },
]; ];
/** /**
* ADO2 / ( ). * ( + ).
*
* 이유: 파이프라인이 2
* 4 "포스터 영상" "포스터 스타일" ** **
* . () .
*
* ,
* ADO2 + CTA 4 .
* , () .
* *
* , (ADO2) () .
* generation-flow.css `.pipeline-tabs*` . * generation-flow.css `.pipeline-tabs*` .
* *
* 접근성: castad (`.myinfo-tab` ) role/aria , * 접근성: castad (`.myinfo-tab` ) role/aria ,
@ -33,21 +58,19 @@ const PipelineTabs: React.FC<PipelineTabsProps> = ({ value, onChange }) => {
role="tablist" role="tablist"
aria-label={t('pipelineTabs.ariaLabel')} aria-label={t('pipelineTabs.ariaLabel')}
> >
{TABS.map((tab) => ( {TABS.map(({ id, labelKey, Icon }) => (
<button <button
key={tab.id} key={id}
type="button" type="button"
role="tab" role="tab"
aria-selected={value === tab.id} aria-selected={value === id}
className="pipeline-tab" className="pipeline-tab"
onClick={() => onChange(tab.id)} onClick={() => onChange(id)}
> >
{tab.emoji && ( <span className="pipeline-tab__tile">
<span className="pipeline-tab__emoji" aria-hidden="true"> <Icon size={24} />
{tab.emoji} </span>
</span> <span className="pipeline-tab__label">{t(labelKey)}</span>
)}
{t(tab.labelKey)}
</button> </button>
))} ))}
</div> </div>

View File

@ -0,0 +1,106 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
interface PosterDropzoneProps {
file: File | null;
onFile: (file: File | null) => void;
}
/**
* . ** **
* ·
* .
*/
const SOFT_LONG_EDGE = 1000;
const ACCEPTED = /^image\/(jpeg|png|webp|gif)$/;
/**
* . F1()·F2() .
*
* 4:5
* .
*/
const PosterDropzone: React.FC<PosterDropzoneProps> = ({ file, onFile }) => {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const [preview, setPreview] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [dragging, setDragging] = useState(false);
// objectURL 은 명시적으로 놓아줘야 한다. 시연 중 포스터를 여러 장 갈아 끼우면
// 해제 안 된 URL 이 그대로 쌓인다(castad 의 revokeAllPreviews 와 같은 이유).
useEffect(() => {
return () => {
if (preview) URL.revokeObjectURL(preview);
};
}, [preview]);
const accept = useCallback((f: File) => {
if (!ACCEPTED.test(f.type)) {
setError(t('poster.dropzone.badType'));
return;
}
setError(null);
const url = URL.createObjectURL(f);
const img = new Image();
img.onload = () => {
const edge = Math.max(img.width, img.height);
setNotice(edge < SOFT_LONG_EDGE ? t('poster.dropzone.lowRes', { edge }) : null);
setPreview((prev) => {
if (prev) URL.revokeObjectURL(prev);
return url;
});
onFile(f);
};
img.onerror = () => {
URL.revokeObjectURL(url);
setError(t('poster.dropzone.badType'));
};
img.src = url;
}, [onFile, t]);
return (
<div className="p2v-dropzone-wrap">
<div
className={`p2v-dropzone${preview ? ' has-file' : ''}${dragging ? ' dragging' : ''}`}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
const f = e.dataTransfer.files?.[0];
if (f) accept(f);
}}
>
{preview ? (
<img src={preview} alt={t('poster.dropzone.previewAlt')} />
) : (
<div className="p2v-dropzone__empty">
<p className="p2v-dropzone__cta">{t('poster.dropzone.cta')}</p>
<p className="p2v-dropzone__hint">{t('poster.dropzone.formats')}</p>
</div>
)}
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
hidden
onChange={(e) => { const f = e.target.files?.[0]; if (f) accept(f); }}
/>
</div>
{error && <p className="p2v-note warn">{error}</p>}
{notice && <p className="p2v-note">{notice}</p>}
{file && (
<p className="p2v-note">
{file.name} · {(file.size / 1024 / 1024).toFixed(1)}MB
</p>
)}
</div>
);
};
export default PosterDropzone;

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,79 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import PosterDropzone from '../../components/PosterDropzone';
import P2vKeyGate from '../../components/P2vKeyGate';
import { P2vAuthError, createPosterJob } from '../../utils/p2vApi';
interface PosterCreateFormProps {
/** 생성 요청이 접수되면 호출. 폴링 소유는 GenerationFlow (썰박스와 같은 규칙) */
onSubmitted: (jobId: string) => void;
}
/**
* (F1) . '포스터 영상' .
*
* .
* ( ).
*/
const PosterCreateForm: React.FC<PosterCreateFormProps> = ({ onSubmitted }) => {
const { t } = useTranslation();
const [file, setFile] = useState<File | null>(null);
const [name, setName] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [authNeeded, setAuthNeeded] = useState(false);
const submit = async () => {
if (!file || submitting) return;
setSubmitting(true);
setError(null);
try {
const { id } = await createPosterJob(file, name.trim());
onSubmitted(id);
} catch (e) {
if (e instanceof P2vAuthError) {
setAuthNeeded(true);
} else {
setError((e as Error).message);
}
setSubmitting(false);
}
};
if (authNeeded) {
return (
<div className="p2v-scope p2v-create">
<P2vKeyGate onSaved={() => { setAuthNeeded(false); submit(); }} />
</div>
);
}
return (
<div className="p2v-scope p2v-create">
<span className="p2v-step">{t('poster.create.step')}</span>
<PosterDropzone file={file} onFile={setFile} />
<input
className="p2v-input p2v-input--center"
placeholder={t('poster.create.namePlaceholder')}
value={name}
onChange={(e) => setName(e.target.value)}
/>
{error && <div className="p2v-note warn">{error}</div>}
<button
type="button"
className="p2v-btn-cta"
disabled={!file || submitting}
onClick={submit}
>
{submitting ? t('poster.create.submitting') : t('poster.create.submit')}
</button>
</div>
);
};
export default PosterCreateForm;

View File

@ -0,0 +1,105 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { P2vJob, isPastReview, p2vProgress } from '../../utils/p2vApi';
interface PosterMakingContentProps {
job: P2vJob | null;
/** 실패 시 같은 잡을 다시 태운다 (업로드부터 다시 하지 않는다) */
onRetry: () => void;
/** 실패한 잡을 버리고 처음부터 */
onDiscard: () => void;
busy: boolean;
}
/**
* . () () .
*
* 이유: 화면 .
* ·· .
*
*
* "살아 있다" ( SsulMakingContent ).
*/
const PosterMakingContent: React.FC<PosterMakingContentProps> = ({
job, onRetry, onDiscard, busy,
}) => {
const { t } = useTranslation();
if (!job) return null;
const failed = job.status === 'failed';
const percent = p2vProgress(job);
const past = isPastReview(job);
// 현재 스테이지 라벨. 서버가 stage 를 비워 보내는 순간(스테이지 전환 사이)이 있어
// 그때는 구간 이름으로 대신한다 — 라벨이 깜빡이며 사라지면 멈춘 것처럼 보인다.
const stageLabel = job.stage
? t(`poster.stages.${job.stage}`, { defaultValue: job.stage })
: t(past ? 'poster.making.phaseRender' : 'poster.making.phaseAnalyze');
return (
<div className="p2v-scope p2v-making">
<span className="p2v-making__name">{job.name}</span>
{failed ? (
<>
<h2 className="p2v-making__title warn">{t('poster.making.failTitle')}</h2>
<p className="p2v-making__desc">
{t(`poster.stages.${job.error?.stage}`, { defaultValue: job.error?.stage ?? '' })}
</p>
{job.error?.detail && (
<pre className="p2v-errorbox">{job.error.detail}</pre>
)}
<div className="p2v-making__actions">
<button type="button" className="p2v-btn-outline" disabled={busy} onClick={onDiscard}>
{t('poster.making.discard')}
</button>
<button type="button" className="p2v-btn-mint" disabled={busy} onClick={onRetry}>
{t('poster.making.retry')}
</button>
</div>
</>
) : (
<>
<h2 className="p2v-making__title">
{t(past ? 'poster.making.titleRender' : 'poster.making.titleAnalyze')}
</h2>
<div className="p2v-spinner" aria-hidden="true" />
<div
className="p2v-bar"
role="progressbar"
aria-valuenow={percent}
aria-valuemin={0}
aria-valuemax={100}
>
<div className="p2v-bar__fill" style={{ width: `${percent}%` }} />
</div>
<div className="p2v-making__meta">
<span>{stageLabel}</span>
<span className="p2v-making__pct">{percent}%</span>
</div>
<p className="p2v-making__desc">
{t(past ? 'poster.making.descRender' : 'poster.making.descAnalyze')}
</p>
{/* . ,
*/}
{job.narration && job.narration.length > 0 && (
<div className="p2v-panel p2v-making__narration">
<p className="p2v-label">{t('poster.review.narration')}</p>
{job.narration.map((line, i) => (
<p key={i} className="p2v-narration-line">{line}</p>
))}
</div>
)}
</>
)}
</div>
);
};
export default PosterMakingContent;

View File

@ -0,0 +1,95 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { P2vJob, p2vFile } from '../../utils/p2vApi';
interface PosterResultContentProps {
job: P2vJob;
/** 새 포스터로 처음부터 */
onNew: () => void;
}
/**
* .
*
* `<a download>` p2vFile()
* (P2V ?key= ).
*/
const PosterResultContent: React.FC<PosterResultContentProps> = ({ job, onNew }) => {
const { t } = useTranslation();
const video = p2vFile(job.artifacts.video);
const thumbnail = job.artifacts.thumbnail ? p2vFile(job.artifacts.thumbnail) : null;
return (
<div className="p2v-scope p2v-result">
<h2 className="p2v-result__title">{t('poster.result.title')}</h2>
<div className="p2v-result__grid">
<div className="p2v-panel p2v-result__player">
{video ? (
<video src={video} controls playsInline />
) : (
<p className="p2v-note">{t('poster.result.videoMissing')}</p>
)}
</div>
<div className="p2v-result__side">
<p className="p2v-label">{t('poster.result.fileName')}</p>
<p className="p2v-result__filename">{job.name}.mp4</p>
{job.metadata && (
<div className="p2v-result__block">
{([
['event_name', job.metadata.event_name],
['date_text', job.metadata.date_text],
['place', job.metadata.place],
] as const).map(([field, value]) => (
value ? (
<div key={field} className="p2v-result__row">
<span>{t(`poster.review.field.${field}`)}</span>
<b>{value}</b>
</div>
) : null
))}
{job.metadata.keywords?.length > 0 && (
<div className="p2v-tags">
{job.metadata.keywords.map((kw) => (
<span key={kw} className="p2v-tag">#{kw}</span>
))}
</div>
)}
</div>
)}
{job.narration && job.narration.length > 0 && (
<div className="p2v-result__block">
<p className="p2v-label">{t('poster.review.narration')}</p>
{job.narration.map((line, i) => (
<p key={i} className="p2v-narration-line">{line}</p>
))}
</div>
)}
<div className="p2v-result__actions">
{thumbnail && (
<a href={thumbnail} download className="p2v-btn-tonal">
{t('poster.result.downloadThumb')}
</a>
)}
{video && (
<a href={video} download className="p2v-btn-cta p2v-btn-cta--compact">
{t('poster.result.downloadVideo')}
</a>
)}
</div>
</div>
</div>
<button type="button" className="p2v-btn-outline p2v-result__new" onClick={onNew}>
{t('poster.result.new')}
</button>
</div>
);
};
export default PosterResultContent;

View File

@ -0,0 +1,172 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
P2vJob,
approvePosterJob,
p2vFile,
updatePosterMetadata,
updatePosterNarration,
} from '../../utils/p2vApi';
import { META_FIELDS, MOTION_KEYS, MetaField, motionLabel } from './posterData';
interface PosterReviewContentProps {
job: P2vJob;
/** 승인 요청이 끝나면 호출 — GenerationFlow 가 폴링을 다시 켠다 */
onApproved: () => void;
}
type MetaDraft = Record<MetaField, string>;
const emptyMeta = (): MetaDraft => ({ event_name: '', date_text: '', place: '' });
/**
* .
*
* TTS··
* · , .
* ** ** :
*
* 1) 3
* 2) .
* 3) . ·
*
* , P2V .
*/
const PosterReviewContent: React.FC<PosterReviewContentProps> = ({ job, onApproved }) => {
const { t } = useTranslation();
const [lines, setLines] = useState<string[]>([]);
const [motions, setMotions] = useState<string[] | null>(null);
const [meta, setMeta] = useState<MetaDraft>(emptyMeta);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// 서버 값으로 초안을 채우되, 사용자가 고치기 시작한 뒤에는 폴링 응답이 덮지 않아야 한다.
// 잡 id 가 바뀔 때만 다시 채운다(같은 잡의 재조회는 무시).
useEffect(() => {
setLines(job.narration ?? []);
// 0종([])도 유효한 값이라 null(미로딩)로만 판별한다
setMotions(job.motion_elements ?? []);
setMeta({
event_name: job.metadata?.event_name ?? '',
date_text: job.metadata?.date_text ?? '',
place: job.metadata?.place ?? '',
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [job.id]);
const toggleMotion = (key: string) => {
setMotions((prev) => {
const cur = prev ?? [];
return cur.includes(key) ? cur.filter((m) => m !== key) : [...cur, key];
});
};
const approve = async () => {
if (busy) return;
setBusy(true);
setError(null);
try {
// 바뀐 것만 보낸다. 서버는 PUT 마다 잡 파일을 다시 쓰므로 무의미한 쓰기를 줄인다.
if (JSON.stringify(lines) !== JSON.stringify(job.narration ?? [])) {
await updatePosterNarration(job.id, lines);
}
const md = job.metadata;
const metaChanged = !md || META_FIELDS.some((f) => (md[f] ?? '') !== meta[f]);
if (metaChanged) {
await updatePosterMetadata(job.id, meta);
}
// 순서 차이는 의미가 없다(서버가 집합으로 쓴다) — 정렬해 비교해야 헛 전송이 없다
const motionsChanged = motions !== null
&& JSON.stringify([...motions].sort())
!== JSON.stringify([...(job.motion_elements ?? [])].sort());
await approvePosterJob(job.id, motionsChanged ? motions : null);
onApproved();
} catch (e) {
setError((e as Error).message);
setBusy(false);
}
};
const noMotion = motions !== null && motions.length === 0;
return (
<div className="p2v-scope p2v-review">
<div className="p2v-review__grid">
<div className="p2v-review__edit">
{/* ── 나레이션 ─────────────────────────────── */}
<p className="p2v-label">{t('poster.review.narration')}</p>
<p className="p2v-help">{t('poster.review.narrationHelp')}</p>
{lines.map((line, i) => (
<input
key={i}
className="p2v-input"
value={line}
onChange={(e) => setLines(lines.map((v, j) => (j === i ? e.target.value : v)))}
/>
))}
{/* ── 움직일 요소 ──────────────────────────── */}
<p className="p2v-label">{t('poster.review.motion')}</p>
<p className="p2v-help">
{t(noMotion ? 'poster.review.motionEmpty' : 'poster.review.motionHelp')}
</p>
<div className="p2v-chips">
{MOTION_KEYS.map((key) => {
const on = motions?.includes(key) ?? false;
return (
<button
key={key}
type="button"
aria-pressed={on}
className={`p2v-chip${on ? ' active' : ''}`}
onClick={() => toggleMotion(key)}
>
{t(motionLabel(key))}
</button>
);
})}
</div>
{/* ── 메타태그 ─────────────────────────────── */}
<p className="p2v-label">{t('poster.review.metadata')}</p>
<p className="p2v-help">{t('poster.review.metadataHelp')}</p>
{META_FIELDS.map((field) => (
<div key={field} className="p2v-field">
<span className="p2v-field__label">{t(`poster.review.field.${field}`)}</span>
<input
className="p2v-input"
value={meta[field]}
onChange={(e) => setMeta({ ...meta, [field]: e.target.value })}
/>
</div>
))}
{error && <div className="p2v-note warn">{error}</div>}
<button type="button" className="p2v-btn-cta" disabled={busy} onClick={approve}>
{busy ? t('poster.review.approving') : t('poster.review.approve')}
</button>
</div>
{/* ── 대조용 분석 이미지 ────────────────────── */}
<div className="p2v-review__preview">
<p className="p2v-label">{t('poster.review.analysis')}</p>
{job.artifacts.check_jpg ? (
<img
className="p2v-review__image"
src={p2vFile(job.artifacts.check_jpg)}
alt={t('poster.review.analysisAlt')}
/>
) : (
<div className="p2v-panel p2v-review__image-empty">
{t('poster.review.analysisMissing')}
</div>
)}
</div>
</div>
</div>
);
};
export default PosterReviewContent;

View File

@ -0,0 +1,330 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import PosterDropzone from '../../components/PosterDropzone';
import P2vKeyGate from '../../components/P2vKeyGate';
import {
InternalOnlyWarning,
LicenseBadge,
UserReferenceNotice,
} from '../../components/LicenseBadge';
import { useP2vJob } from '../../hooks/useP2vJob';
import {
F2Category,
F2Format,
F2Template,
F2UploadHint,
P2vAuthError,
createF2Template,
createStylingJob,
deleteF2Template,
getF2UploadHint,
isP2vActive,
listF2Categories,
listF2Formats,
listF2Templates,
p2vFile,
} from '../../utils/p2vApi';
/** 사용자가 올린 레퍼런스 카테고리. 서버 카테고리 목록에서 따로 떼어 아래에 둔다 */
const USER_CATEGORY = 'user';
/**
* (F2).
*
* ** .**
* ( ). ,
* 릿 .
*
* ** ** ·릿·
* · ,
* 30 . GenerationFlow
* (F1· ).
*/
const StylingContent: React.FC = () => {
const { t } = useTranslation();
const [templates, setTemplates] = useState<F2Template[]>([]);
const [categories, setCategories] = useState<F2Category[]>([]);
const [formats, setFormats] = useState<F2Format[]>([]);
const [hint, setHint] = useState<F2UploadHint | null>(null);
const [file, setFile] = useState<File | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const [format, setFormat] = useState('poster');
const [jobId, setJobId] = useState<string | null>(null);
const [tplOpen, setTplOpen] = useState(true);
const [busy, setBusy] = useState(false);
const [refBusy, setRefBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [authNeeded, setAuthNeeded] = useState(false);
const refInput = useRef<HTMLInputElement>(null);
const { job, authNeeded: jobAuthNeeded } = useP2vJob('f2', jobId);
const selectedTpl = templates.find((tpl) => tpl.id === selected) ?? null;
const running = isP2vActive(job);
const loadAll = useCallback(async () => {
try {
const [tpls, cats, fmts, uploadHint] = await Promise.all([
listF2Templates(),
listF2Categories(),
listF2Formats(),
getF2UploadHint(),
]);
setTemplates(tpls);
setCategories(cats);
setFormats(fmts);
setHint(uploadHint);
setAuthNeeded(false);
setError(null);
} catch (e) {
if (e instanceof P2vAuthError) setAuthNeeded(true);
else setError((e as Error).message);
}
}, []);
useEffect(() => { loadAll(); }, [loadAll]);
const uploadReference = async (f: File) => {
setRefBusy(true);
setError(null);
try {
const tpl = await createF2Template(f, f.name.replace(/\.[^.]+$/, '').slice(0, 40));
setTemplates(await listF2Templates());
setSelected(tpl.id); // 방금 올린 것을 바로 고른 상태로
} catch (e) {
if (e instanceof P2vAuthError) setAuthNeeded(true);
else setError((e as Error).message);
} finally {
setRefBusy(false);
if (refInput.current) refInput.current.value = '';
}
};
const removeReference = async (id: string) => {
if (!window.confirm(t('poster.styling.confirmDelete'))) return;
try {
await deleteF2Template(id);
if (selected === id) setSelected(null);
setTemplates(await listF2Templates());
} catch (e) {
setError((e as Error).message);
}
};
const submit = async () => {
if (!file || !selected || busy) return;
setBusy(true);
setError(null);
try {
const { id } = await createStylingJob(file, selected, format);
setJobId(id);
} catch (e) {
if (e instanceof P2vAuthError) setAuthNeeded(true);
else setError((e as Error).message);
} finally {
setBusy(false);
}
};
if (authNeeded || jobAuthNeeded) {
return (
<div className="p2v-scope p2v-styling">
<P2vKeyGate onSaved={() => { setAuthNeeded(false); loadAll(); }} />
</div>
);
}
const renderCard = (tpl: F2Template) => (
<div key={tpl.id} className="p2v-tplcard">
<button
type="button"
aria-pressed={selected === tpl.id}
className={`p2v-tpl${selected === tpl.id ? ' active' : ''}`}
onClick={() => setSelected(tpl.id)}
>
{/* 명화는 가로 그림도 있다 — cover 로 자르면 무엇인지 알 수 없어 contain 을 쓴다 */}
<img src={p2vFile(tpl.thumb_url)} alt={tpl.name_ko} />
<span className="p2v-tpl__name">{tpl.name_ko}</span>
{/* 제목이 1줄인 카드와 2줄인 카드가 섞인다. 아래로 밀어붙여야 한 행의 배지가 같은 선에 선다 */}
<span className="p2v-tpl__badge">
<LicenseBadge license={tpl.license} />
</span>
</button>
{tpl.removable && (
<button
type="button"
className="p2v-tpl__remove"
title={t('poster.styling.deleteRef')}
onClick={() => removeReference(tpl.id)}
>
×
</button>
)}
</div>
);
return (
<div className="p2v-scope p2v-styling">
<div className="p2v-notice p2v-notice--warn p2v-styling__scope">
{t('poster.styling.internalScope')}
</div>
{/* ── 스타일 템플릿 (접이식) ─────────────────────── */}
<button
type="button"
className="p2v-collapse"
aria-expanded={tplOpen}
onClick={() => setTplOpen(!tplOpen)}
>
<span className="p2v-label">{t('poster.styling.templates')}</span>
{!tplOpen && selectedTpl && (
<span className="p2v-collapse__picked">
<img src={p2vFile(selectedTpl.thumb_url)} alt="" />
<b>{selectedTpl.name_ko}</b>
<LicenseBadge license={selectedTpl.license} />
</span>
)}
{!tplOpen && !selectedTpl && (
<span className="p2v-collapse__empty">{t('poster.styling.pickTemplate')}</span>
)}
<svg
className={`p2v-collapse__caret${tplOpen ? ' open' : ''}`}
width="18" height="18" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
{tplOpen && categories.filter((c) => c.id !== USER_CATEGORY).map((cat) => {
const items = templates.filter((tpl) => tpl.category === cat.id);
if (items.length === 0) return null;
return (
<div key={cat.id} className="p2v-tplgroup">
<p className="p2v-tplgroup__title">
{cat.label}
<span>{t('poster.styling.count', { count: items.length })}</span>
</p>
<div className="p2v-tplgrid">{items.map(renderCard)}</div>
</div>
);
})}
{/* categories
upload-hint enabled */}
{tplOpen && hint?.enabled && (
<div className="p2v-tplgroup">
<p className="p2v-tplgroup__title">
{t('poster.styling.myRefs')}
<span>{t('poster.styling.myRefsHint')}</span>
</p>
<div className="p2v-tplgrid">
{templates.filter((tpl) => tpl.category === USER_CATEGORY).map(renderCard)}
<button
type="button"
className="p2v-tpl p2v-tpl--add"
disabled={refBusy}
onClick={() => refInput.current?.click()}
>
{refBusy ? (
<>
<span className="p2v-spinner p2v-spinner--sm" aria-hidden="true" />
<span className="p2v-tpl__name">{t('poster.styling.analyzing')}</span>
</>
) : (
<>
<span className="p2v-tpl__plus" aria-hidden="true">+</span>
<span className="p2v-tpl__name">{t('poster.styling.addRef')}</span>
<span className="p2v-tpl__sub">
{t('poster.styling.minEdge', { px: hint.min_long_edge })}
</span>
</>
)}
</button>
<input
ref={refInput}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
hidden
onChange={(e) => { const f = e.target.files?.[0]; if (f) uploadReference(f); }}
/>
</div>
<UserReferenceNotice />
</div>
)}
{/* 경고는 실제로 고른 순간에만 */}
{selectedTpl?.license === 'internal-only' && (
<InternalOnlyWarning note={selectedTpl.license_note} />
)}
{selectedTpl?.attribution && (
<p className="p2v-note">{t('poster.styling.attribution', { source: selectedTpl.attribution })}</p>
)}
{/* ── 입력 / 결과 ─────────────────────────────── */}
<div className="p2v-styling__io">
<div>
<p className="p2v-label">{t('poster.styling.myPoster')}</p>
<PosterDropzone file={file} onFile={setFile} />
</div>
<div>
<p className="p2v-label">{t('poster.styling.result')}</p>
{job?.status === 'done' && job.artifacts.image ? (
<div className="p2v-panel p2v-styling__out">
<img src={p2vFile(job.artifacts.image)} alt={t('poster.styling.resultAlt')} />
</div>
) : (
<div className="p2v-panel p2v-styling__out p2v-styling__out--empty">
{running && <span className="p2v-spinner p2v-spinner--md" aria-hidden="true" />}
<span>{t(running ? 'poster.styling.converting' : 'poster.styling.resultHint')}</span>
</div>
)}
{job?.status === 'done' && job.artifacts.image && (
<div className="p2v-styling__download">
<a href={p2vFile(job.artifacts.image)} download className="p2v-btn-tonal">
{t('poster.styling.downloadPng')}
</a>
</div>
)}
</div>
</div>
{/* ── 출력 포맷 ───────────────────────────────── */}
<p className="p2v-label">{t('poster.styling.formats')}</p>
<div className="p2v-formats">
{formats.map((f) => (
<button
key={f.id}
type="button"
aria-pressed={format === f.id}
className={`p2v-select${format === f.id ? ' active' : ''}`}
onClick={() => setFormat(f.id)}
>
{f.label}
</button>
))}
</div>
<button
type="button"
className="p2v-btn-cta"
disabled={!file || !selected || busy || running}
onClick={submit}
>
{running
? t('poster.styling.converting')
: busy ? t('poster.create.submitting') : t('poster.styling.submit')}
</button>
{error && <div className="p2v-note warn">{error}</div>}
{job?.status === 'failed' && job.error && (
<pre className="p2v-errorbox">{job.error.detail}</pre>
)}
</div>
);
};
export default StylingContent;

View File

@ -0,0 +1,28 @@
/**
* .
*
* ssulData.ts ** i18n .**
* castad ko/en .
*/
/**
* .
*
* P2V `scripts/motion_plan.py` MOTION_PHRASE **1:1 **
* 422 . .
*/
export const MOTION_KEYS = [
'firework', 'water', 'wave', 'cloud', 'smoke',
'moon', 'sun', 'star', 'light', 'flag',
'foliage', 'wheel', 'vessel', 'crowd',
] as const;
export type MotionKey = (typeof MOTION_KEYS)[number];
/** i18n 키 헬퍼 — t(motionLabel('firework')) 형태로 쓴다 */
export const motionLabel = (key: string): string => `poster.motion.${key}`;
/** 검수 화면에서 고칠 수 있는 메타 필드. 나머지(category·keywords)는 서버 판단을 따른다 */
export const META_FIELDS = ['event_name', 'date_text', 'place'] as const;
export type MetaField = (typeof META_FIELDS)[number];

View File

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

View File

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

View File

@ -1,53 +1,20 @@
/** /**
* . * .
* *
* (o2o-ssulbox/frontend/src/data.ts) **(name/desc) **. * (o2o-ssulbox/frontend/src/data.ts) **(name/desc) **.
* castad ko/en i18n . * castad ko/en i18n .
* `t('ssulbox.scenario.<key>.name')` / `.desc` . * `t('ssulbox.scenario.<key>.name')` / `.desc` .
* *
* (SAMPLE) `/ssul/feed` . * ·· ADO2 (2026-08-20)
* ·· (/) .
*/ */
/** 시나리오 코드. 백엔드 `ssul_content.scenario` 와 값이 일치해야 한다 */ /** 시나리오 코드. 백엔드 `ssul_content.scenario` 와 값이 일치해야 한다 */
export type Scen = 'joseon' | 'samgukji' | 'greek' | 'odyssey'; export type Scen = 'joseon' | 'samgukji' | 'greek' | 'odyssey';
export interface ScenPalette {
/** 카드 표지 이모지 */
emoji: string;
/** 대표색 (뱃지·강조선) */
hex: string;
/** 표지 그라디언트 시작/끝 */
g: [string, string];
}
export const SCEN: Record<Scen, ScenPalette> = {
joseon: { emoji: '👑', hex: '#3b4cc0', g: ['#4054d6', '#8a3ffb'] },
samgukji: { emoji: '⚔️', hex: '#d63a2f', g: ['#e0402f', '#f0872b'] },
greek: { emoji: '🏛️', hex: '#0a8f6b', g: ['#0a9e78', '#3fb6d6'] },
odyssey: { emoji: '⛵', hex: '#1565c0', g: ['#1565c0', '#42a5f5'] },
};
/** 시나리오 순서. 선택 그리드(2x2)와 필터 칩이 이 순서를 따른다 */ /** 시나리오 순서. 선택 그리드(2x2)와 필터 칩이 이 순서를 따른다 */
export const SCEN_KEYS = Object.keys(SCEN) as Scen[]; export const SCEN_KEYS: Scen[] = ['joseon', 'samgukji', 'greek', 'odyssey'];
/** 카드 표지 그라디언트 */
export const scGrad = (s: Scen): string =>
`linear-gradient(150deg,${SCEN[s].g[0]} 0%,${SCEN[s].g[1]} 100%)`;
/** i18n 키 헬퍼 — t(scenName('joseon')) 형태로 쓴다 */ /** i18n 키 헬퍼 — t(scenName('joseon')) 형태로 쓴다 */
export const scenName = (s: Scen): string => `ssulbox.scenario.${s}.name`; export const scenName = (s: Scen): string => `ssulbox.scenario.${s}.name`;
export const scenDesc = (s: Scen): string => `ssulbox.scenario.${s}.desc`; export const scenDesc = (s: Scen): string => `ssulbox.scenario.${s}.desc`;
/**
* · (1.2 / 3.4).
*
* en . castad
* , Phase 8 castad .
* .
*/
export const fmt = (n: number): string =>
n >= 10000
? `${(n / 10000).toFixed(1).replace(/\.0$/, '')}`
: n >= 1000
? `${(n / 1000).toFixed(1).replace(/\.0$/, '')}`
: `${n}`;

View File

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

View File

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

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

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

View File

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

View File

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

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

@ -0,0 +1,263 @@
/**
* Poster to Video (P2V) .
*
* castad ** (:8010)** .
* (ADO2·) `utils/api.ts` castad .
*
* P2V `scripts/*.py` subprocess
* castad (Higgsfield CLI·ffmpeg· )
* . ** ** ,
* castad `/p2v/*` · .
* .
*
* (`P2V_ACCESS_KEY`). 3 :
* - `X-P2V-Key` fetch
* - `p2v_key` ** .** castad P2V
* document.cookie P2V .
* - `?key=` <img>/<video>/<a download> (p2vFile)
*/
/** P2V 서버 오리진. 배포 시 `.env` 의 VITE_P2V_URL 로 덮는다 */
export const P2V_URL = (import.meta.env.VITE_P2V_URL || 'http://localhost:8010').replace(/\/$/, '');
/**
* .
*
* storageKeys.K ·
* , .
*/
const KEY_STORAGE = 'castad_p2v_key';
export const getP2vKey = (): string => localStorage.getItem(KEY_STORAGE) ?? '';
export const setP2vKey = (key: string): void => localStorage.setItem(KEY_STORAGE, key.trim());
export const clearP2vKey = (): void => localStorage.removeItem(KEY_STORAGE);
/** 401 — 화면이 키 입력을 띄워야 하는 상황. 일반 실패와 구분해야 해서 타입을 나눈다 */
export class P2vAuthError extends Error {
constructor() {
super('P2V_AUTH_REQUIRED');
this.name = 'P2vAuthError';
}
}
/**
* 404 . .
*
* 이유: 서버 detail ("잡 없음")
* . .
*/
export class P2vNotFoundError extends Error {
constructor(detail: string) {
super(detail);
this.name = 'P2vNotFoundError';
}
}
export async function p2vFetch<T>(path: string, init?: RequestInit): Promise<T> {
const key = getP2vKey();
const res = await fetch(`${P2V_URL}${path}`, {
...init,
headers: {
...(init?.headers ?? {}),
...(key ? { 'X-P2V-Key': key } : {}),
},
});
if (res.status === 401) throw new P2vAuthError();
if (!res.ok) {
// FastAPI 는 {detail: ...} 로 준다. 본문이 없는 에러(502 등)도 있으므로 감싼다
let detail = res.statusText;
try {
const body = await res.json();
detail = typeof body.detail === 'string' ? body.detail : JSON.stringify(body);
} catch {
/* 본문 없음 — statusText 로 간다 */
}
if (res.status === 404) throw new P2vNotFoundError(detail);
throw new Error(detail);
}
return res.json() as Promise<T>;
}
/**
* (·) URL.
*
* artifacts `/files/render/x.mp4` ** **
* castad . ,
* (<video src> ) .
*/
export const p2vFile = (path: string | null | undefined): string => {
if (!path) return '';
const key = getP2vKey();
return `${P2V_URL}${path}${key ? `?key=${encodeURIComponent(key)}` : ''}`;
};
// ── 타입 (서버 jobs.py / routers 와 1:1) ──────────────────────────────
export type P2vStageStatus = 'idle' | 'running' | 'done' | 'failed';
export interface P2vStageState {
status: P2vStageStatus;
started: number | null;
ended: number | null;
}
export interface PosterMeta {
event_name: string;
date_text: string;
place: string;
category: string;
keywords: string[];
region_guess?: string;
}
export interface P2vJob {
id: string;
kind: 'f1' | 'f2';
name: string;
status: 'queued' | 'running' | 'awaiting_review' | 'failed' | 'done';
stage: string | null;
stages: Record<string, P2vStageState>;
narration: string[] | null;
motion_elements?: string[] | null;
metadata: PosterMeta | null;
error: { stage: string; detail: string } | null;
artifacts: Record<string, string>;
created_at: number;
queue_size?: number;
template_id?: string | null;
}
/** 배포 등급. public-domain 만 외부 공개 가능 — 나머지는 내부 시연 한정 */
export type P2vLicense = 'public-domain' | 'internal-only' | 'user-uploaded';
export interface F2Template {
id: string;
name_ko: string;
thumb_url: string;
category: string;
license: P2vLicense;
attribution: string;
license_note: string;
/** 사용자가 올린 레퍼런스인가 (삭제 가능) */
removable: boolean;
small_ref: boolean;
}
export interface F2Category { id: string; label: string }
export interface F2Format { id: string; label: string }
export interface F2UploadHint { enabled: boolean; min_long_edge: number }
// ── F1 (포스터 → 영상) ────────────────────────────────────────────────
/** 포스터 업로드 → 잡 생성. 반환된 id 로 폴링한다 */
export async function createPosterJob(poster: File, name: string): Promise<{ id: string }> {
const fd = new FormData();
fd.append('poster', poster);
fd.append('name', name);
return p2vFetch<{ id: string }>('/api/f1/jobs', { method: 'POST', body: fd });
}
export const getPosterJob = (id: string): Promise<P2vJob> =>
p2vFetch<P2vJob>(`/api/f1/jobs/${id}`);
export const updatePosterNarration = (id: string, narration: string[]): Promise<unknown> =>
p2vFetch(`/api/f1/jobs/${id}/narration`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ narration }),
});
export const updatePosterMetadata = (
id: string,
meta: { event_name: string; date_text: string; place: string },
): Promise<unknown> =>
p2vFetch(`/api/f1/jobs/${id}/metadata`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(meta),
});
/** 검수 승인 → TTS·BGM·애니메이션·렌더가 이어진다. motions 를 넘기면 모션 선정을 덮어쓴다 */
export const approvePosterJob = (id: string, motions?: string[] | null): Promise<unknown> =>
p2vFetch(`/api/f1/jobs/${id}/approve`, {
method: 'POST',
...(motions
? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ motions }) }
: {}),
});
export const retryPosterJob = (id: string, motions?: string[] | null): Promise<unknown> =>
p2vFetch(`/api/f1/jobs/${id}/retry`, {
method: 'POST',
...(motions
? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ motions }) }
: {}),
});
export const deletePosterJob = (id: string): Promise<unknown> =>
p2vFetch(`/api/f1/jobs/${id}`, { method: 'DELETE' });
// ── F2 (포스터 스타일링) ──────────────────────────────────────────────
export const listF2Templates = (): Promise<F2Template[]> => p2vFetch<F2Template[]>('/api/f2/templates');
export const listF2Categories = (): Promise<F2Category[]> => p2vFetch<F2Category[]>('/api/f2/categories');
export const listF2Formats = (): Promise<F2Format[]> => p2vFetch<F2Format[]>('/api/f2/formats');
export const getF2UploadHint = (): Promise<F2UploadHint> => p2vFetch<F2UploadHint>('/api/f2/upload-hint');
/** 레퍼런스 업로드 — 저장 + 화풍 분석까지 서버가 동기로 끝낸다(10초 안팎) */
export async function createF2Template(reference: File, name: string): Promise<F2Template> {
const fd = new FormData();
fd.append('reference', reference);
fd.append('name', name);
return p2vFetch<F2Template>('/api/f2/templates', { method: 'POST', body: fd });
}
export const deleteF2Template = (id: string): Promise<unknown> =>
p2vFetch(`/api/f2/templates/${id}`, { method: 'DELETE' });
export async function createStylingJob(
poster: File,
templateId: string,
format: string,
): Promise<{ id: string }> {
const fd = new FormData();
fd.append('poster', poster);
fd.append('template_id', templateId);
fd.append('format', format);
return p2vFetch<{ id: string }>('/api/f2/jobs', { method: 'POST', body: fd });
}
export const getStylingJob = (id: string): Promise<P2vJob> =>
p2vFetch<P2vJob>(`/api/f2/jobs/${id}`);
// ── 진행 계산 ────────────────────────────────────────────────────────
/** 아직 도는 중인가. 이 두 상태에서만 폴링을 계속한다 */
export const isP2vActive = (job: P2vJob | null): boolean =>
job !== null && (job.status === 'queued' || job.status === 'running');
/** 스테이지 진행률(%) — 선형 진행바용. 도는 중인 스테이지는 절반으로 친다 */
export function p2vProgress(job: P2vJob | null): number {
if (!job) return 0;
const states = Object.values(job.stages);
if (states.length === 0) return 0;
const done = states.filter((s) => s.status === 'done').length;
const running = states.some((s) => s.status === 'running') ? 0.5 : 0;
return Math.round(((done + running) / states.length) * 100);
}
/**
* .
*
* "분석 → 검수 → 생성" status
* running running . tts
* ( F1_STAGES
* tts ).
*/
const POST_REVIEW_STAGE = 'tts';
export const isPastReview = (job: P2vJob | null): boolean =>
!!job && job.stages[POST_REVIEW_STAGE]?.status !== undefined
&& job.stages[POST_REVIEW_STAGE].status !== 'idle';

View File

@ -32,10 +32,24 @@ export const K = {
VIDEO_RATIO: 'castad_video_ratio', VIDEO_RATIO: 'castad_video_ratio',
// 썰박스 통합 // 썰박스 통합
/** 현재 선택된 파이프라인 ('ado2' | 'ssul') */ /** 현재 선택된 파이프라인 (PipelineTabs 의 Pipeline 값) */
PIPELINE: 'castad_pipeline', PIPELINE: 'castad_pipeline',
/** 진행 중인 썰박스 생성 잡 ID. 권위는 서버(`/ssul/tasks/active`)이고 이건 복구 힌트다 */ /** 진행 중인 썰박스 생성 잡 ID. 권위는 서버(`/ssul/tasks/active`)이고 이건 복구 힌트다 */
SSUL_TASK_ID: 'castad_ssul_task_id', SSUL_TASK_ID: 'castad_ssul_task_id',
// Poster to Video 통합
/**
* (F1) ID.
*
* `/ssul/tasks/active` "내 활성 잡" P2V
* ( ).
* . 404
* GenerationFlow .
*
* (castad_p2v_key) ·
* (utils/p2vApi.ts ).
*/
POSTER_JOB_ID: 'castad_poster_job_id',
} as const; } as const;
/** /**
@ -52,6 +66,7 @@ export const PROJECT_KEYS: readonly string[] = [
K.VIDEO_RATIO, K.VIDEO_RATIO,
K.PIPELINE, K.PIPELINE,
K.SSUL_TASK_ID, K.SSUL_TASK_ID,
K.POSTER_JOB_ID,
]; ];
/** /**