Merge branch 'feature-ssulbox-ado2-ds' into feature-ssulbox
This commit is contained in:
commit
8ba5546f85
157
DEPLOY_REQUEST.md
Normal file
157
DEPLOY_REQUEST.md
Normal file
@ -0,0 +1,157 @@
|
||||
# 배포 요청서 — castad 프론트엔드 `feature-ssulbox-ado2-ds`
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **요청일** | 2026-08-20 |
|
||||
| **대상 저장소** | `gitea.o2o.kr/castad/o2o-castad-frontend` |
|
||||
| **브랜치** | `feature-ssulbox-ado2-ds` (브랜치 최신 커밋을 그대로 배포) |
|
||||
| **코드 커밋** | `0ca2140` — 이 문서는 그 위에 문서만 얹은 커밋입니다 |
|
||||
| **대상 환경** | dev-ssul.castad.net |
|
||||
| **연관 저장소** | `gitea.o2o.kr/castad/o2o-ado2-poster-to-video` (신규 연동 대상) |
|
||||
|
||||
---
|
||||
|
||||
## 0. 한 장 요약
|
||||
|
||||
이번 브랜치는 **두 덩어리**입니다. 서로 독립적이라 **나눠서 배포할 수 있습니다.**
|
||||
|
||||
| 덩어리 | 내용 | 추가 인프라 | 배포 난이도 |
|
||||
|---|---|---|---|
|
||||
| **A. DS 통일 + 네비게이션** | 썰박스 디자인 시스템 통일, 진입 화면 탭 개편, 레이아웃 버그 수정 | **없음** | 기존과 동일 |
|
||||
| **B. 포스터 파이프라인** | 무빙 포스터(F1), 포스터 스타일링(F2) 탭 | **P2V 서버 신규 배포 필요** | 선행조건 5개 |
|
||||
|
||||
**권장: A를 먼저 배포하고, B는 P2V 서버가 준비된 뒤 여는 2단계 진행.**
|
||||
A만 배포해도 화면은 깨지지 않습니다. 포스터 탭 2개만 "접근 키 입력" 화면이나 연결 실패로
|
||||
떨어지고, ADO2·썰박스 탭은 평소대로 동작합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. A안 — DS·네비게이션만 배포 (지금 바로 가능)
|
||||
|
||||
### 필요 작업
|
||||
```bash
|
||||
git fetch origin
|
||||
git checkout feature-ssulbox-ado2-ds # 0ca2140
|
||||
npm ci
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 환경변수
|
||||
| 변수 | 값 | 비고 |
|
||||
|---|---|---|
|
||||
| `VITE_API_URL` | 기존 값 유지 | castad 백엔드 |
|
||||
| `VITE_P2V_URL` | **설정하지 않음** | 미설정 시 포스터 탭만 비활성 상태로 남음 |
|
||||
|
||||
### 검증
|
||||
1. 진입 화면에 탭 4개(ADO2 / 썰박스 / 무빙 포스터 / 포스터 스타일링)가 아이콘 타일로 표시
|
||||
2. 썰박스 탭 — 시나리오 카드에 이모지·시나리오별 색이 없고 선택 시 민트 테두리
|
||||
3. **모바일(375px)에서 상단 ADO2 로고가 잘리지 않을 것** ← 이번에 고친 버그
|
||||
4. ADO2 탭 기존 플로우(URL 입력 → 브랜드 분석) 정상
|
||||
|
||||
---
|
||||
|
||||
## 2. B안 — 포스터 파이프라인까지 배포
|
||||
|
||||
### 2-1. 선행조건 (P2V 서버 측)
|
||||
|
||||
P2V 저장소의 `product/DEPLOY.md`가 원본 문서입니다. 아래는 **castad 연동에 필요한 것만** 추린 것입니다.
|
||||
|
||||
| # | 항목 | 상태 | 담당 |
|
||||
|---|---|---|---|
|
||||
| 1 | P2V 서버(`:8010`) 배포 | **미배포** | Dev |
|
||||
| 2 | `SUNO_CALLBACK_URL` — Suno가 결과를 POST할 **공개 주소** | 도메인 확정 필요 | Dev |
|
||||
| 3 | Higgsfield CLI 토큰 — 머신에서 **사람이 1회 로그인** | 자동화 불가 | 담당자 |
|
||||
| 4 | **P2V를 HTTPS로 서빙** | 미확인 | Dev |
|
||||
| 5 | F2 템플릿 이미지 파일 별도 전달 | 저장소에 없음 | 요청자 |
|
||||
|
||||
**4번이 이번에 새로 확인된 항목입니다.** dev-ssul.castad.net은 HTTPS인데 P2V가 HTTP면
|
||||
브라우저가 mixed content로 **전부 차단**합니다. P2V 앞에 리버스 프록시로 TLS를 붙여야 합니다.
|
||||
|
||||
**5번 주의:** P2V 저장소는 `templates/*.jpg`를 gitignore합니다(영화 포스터 = 저작권).
|
||||
`templates.json`(메타데이터)만 커밋돼 있어 **클론 직후 F2는 썸네일이 전부 깨집니다.**
|
||||
compose가 `./server/templates`를 호스트 바인드 마운트하므로, 이미지 21개를 서버에 직접
|
||||
넣어야 합니다. F2를 이관 범위에서 빼는 것도 선택지입니다(F1은 영향 없음).
|
||||
|
||||
### 2-2. P2V 서버 환경변수
|
||||
|
||||
`product/.env.example`을 `.env`로 복사해 채웁니다.
|
||||
|
||||
| 변수 | 필수 | 설명 |
|
||||
|---|---|---|
|
||||
| `CHATGPT_API_KEY` | ✅ | 영역검출·나레이션·모션선정·TTS·F2 |
|
||||
| `SUNO_API_KEY` | ✅ | BGM 생성 |
|
||||
| `SUNO_CALLBACK_URL` | ✅ | Suno 결과 수신용 **공개** URL. 없으면 BGM 단계에서 죽음 |
|
||||
| `P2V_ACCESS_KEY` | ✅ | 팀 공용 키 1개. **비우면 무인증** — 배포에서는 반드시 설정 |
|
||||
| `P2V_ALLOWED_ORIGINS` | ✅ | **`https://dev-ssul.castad.net` 추가 필수.** 없으면 CORS 차단 |
|
||||
| `P2V_QUEUE_PATH` | compose가 지정 | 큐를 볼륨 위로. 이미지 레이어에 두면 재배포 때 소실 |
|
||||
|
||||
Higgsfield는 환경변수가 아니라 **CLI 토큰 인증**입니다:
|
||||
```bash
|
||||
higgsfield auth login && higgsfield workspace set
|
||||
# 생성된 ~/.config/higgsfield/{credentials,config}.json 을 p2v-higgsfield 볼륨에 주입
|
||||
# CLI가 토큰을 자동 갱신하며 파일을 다시 쓰므로 :ro 마운트 금지
|
||||
```
|
||||
|
||||
### 2-3. castad 프론트엔드 빌드
|
||||
|
||||
```bash
|
||||
VITE_P2V_URL="https://<P2V 공개 도메인>" npm run build
|
||||
```
|
||||
|
||||
Vite는 **빌드 시점에** 값을 산출물에 박습니다(런타임 주입 아님). 검증했습니다 —
|
||||
값을 넣으면 번들에 그대로 들어가고 기본값 `localhost:8010`은 남지 않습니다.
|
||||
|
||||
### 2-4. 알아둘 제약
|
||||
|
||||
- **api를 스케일아웃하면 안 됩니다.** 인메모리 큐 + 파일 상태(`data/jobs.json`) 구조라
|
||||
인스턴스가 늘면 큐가 깨집니다. compose에도 명시돼 있습니다.
|
||||
- **처리 시간 1건 4~6분** (Suno ~2분 + veo ~3분).
|
||||
- **렌더 비용** veo 22크레딧/편(≈$1.12) + OpenAI·Suno 소액.
|
||||
- **인증이 단일 팀 키**라 사용자별 크레딧 차감이 없습니다. 현재는 내부 시연 범위이고,
|
||||
대외 공개 시 castad 백엔드에 `/p2v/*` 프록시를 세워 카카오 인증·크레딧을 얹는 것이
|
||||
다음 수순입니다. 프론트에서 갈아끼울 지점은 `src/utils/p2vApi.ts` 한 파일입니다.
|
||||
|
||||
### 2-5. 검증 절차
|
||||
|
||||
```bash
|
||||
# 1) 서버 헬스체크 (인증 없이 열려 있는 유일한 경로)
|
||||
curl -i https://<P2V 도메인>/api/health # → 200
|
||||
|
||||
# 2) 인증 게이트 동작
|
||||
curl -i https://<P2V 도메인>/api/f2/templates # → 401
|
||||
curl -i -H "X-P2V-Key: <키>" https://<P2V 도메인>/api/f2/templates # → 200 + JSON
|
||||
```
|
||||
|
||||
브라우저에서:
|
||||
|
||||
| # | 확인 항목 | 기대 결과 |
|
||||
|---|---|---|
|
||||
| 1 | 무빙 포스터 탭 첫 진입 | 접근 키 입력창 → 키 입력 후 업로드 화면 |
|
||||
| 2 | 포스터 업로드 | 5단계 스텝퍼가 "분석"으로 진행, 진행바·스테이지 라벨 표시 |
|
||||
| 3 | 1~2분 후 | **검수 화면** — 나레이션 3문장, 모션 칩, 메타태그, 우측 분석 이미지 |
|
||||
| 4 | 승인 | "생성" 단계로 넘어가고 4~6분 뒤 영상 재생 + MP4 다운로드 |
|
||||
| 5 | 포스터 스타일링 탭 | 템플릿 썸네일 표시 (**깨지면 5번 선행조건 미이행**) |
|
||||
| 6 | 모바일(375px) 검수 화면 | 포스터가 위, 승인 버튼이 하단 탭바 위에 고정 |
|
||||
|
||||
**F1은 검수 게이트에서 멈춥니다.** 승인 전까지 TTS·BGM·렌더가 시작되지 않으므로,
|
||||
3번까지만 확인하면 유료 API를 거의 쓰지 않고 연동을 검증할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 롤백
|
||||
|
||||
프론트엔드는 이전 커밋으로 되돌려 재빌드하면 끝입니다. P2V는 castad와 별도 서비스라
|
||||
내려도 castad 본체에 영향이 없습니다 — 포스터 탭만 연결 실패 상태가 됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 이번 브랜치에서 고친 기존 버그
|
||||
|
||||
배포 후 확인해 두면 좋은 항목입니다. 모두 포스터 기능과 무관하게 A안에 포함됩니다.
|
||||
|
||||
1. **진입 화면 세로 잘림** — `justify-content: center` + `overflow: hidden` 조합이라
|
||||
콘텐츠가 뷰포트보다 길면 위로 넘친 부분에 스크롤로도 닿을 수 없었습니다.
|
||||
자식의 `margin: auto 0` 중앙정렬로 교체했습니다.
|
||||
2. **한글 단어 중간 줄바꿈** — `word-break: keep-all` 적용.
|
||||
3. **입력·안내·제출 박스 높이 불일치**(46/38/48) — 48px로 통일.
|
||||
4. **카피 어휘** — 가게→업체, 크롤링→수집.
|
||||
22
README.md
22
README.md
@ -51,6 +51,28 @@ npm run build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## 환경 변수
|
||||
|
||||
| 변수 | 기본값 | 설명 |
|
||||
|---|---|---|
|
||||
| `VITE_API_URL` | `http://40.82.133.44` | castad 백엔드 (ADO2·썰박스) |
|
||||
| `VITE_P2V_URL` | `http://localhost:8010` | Poster to Video 서버 (포스터 영상·스타일링) |
|
||||
|
||||
### 포스터 파이프라인 (Poster to Video)
|
||||
|
||||
'포스터 영상'(F1)·'포스터 스타일'(F2) 탭은 castad 백엔드가 아니라 **별도 P2V 서버**를 직접
|
||||
호출한다(`src/utils/p2vApi.ts`). 붙이려면 세 가지가 필요하다:
|
||||
|
||||
1. P2V 서버 기동 — 저장소는 `o2o-ado2-poster-to-video`, 기본 포트 8010
|
||||
2. 서버의 `P2V_ALLOWED_ORIGINS` 에 이 프론트 오리진 추가 (예: `http://localhost:3000`)
|
||||
3. 첫 진입 시 화면에 뜨는 입력창에 서버의 `P2V_ACCESS_KEY` 입력 (브라우저에 저장된다)
|
||||
|
||||
인증이 단일 팀 키라 **내부 시연 범위**다. 대외 공개 시에는 castad 백엔드에 `/p2v/*` 프록시를
|
||||
세워 카카오 인증·크레딧 차감을 얹고, 프론트는 `p2vApi.ts` 한 파일만 그쪽으로 돌리면 된다.
|
||||
|
||||
포스터 스타일링(F2)은 영화 포스터 레퍼런스가 저작권 자산이라 **결과물의 외부 공개·상업 사용을
|
||||
금한다**(퍼블릭 도메인 명화 제외). 화면 상단과 템플릿 배지로 등급을 표시한다.
|
||||
|
||||
## Docker 실행
|
||||
|
||||
```bash
|
||||
|
||||
25
index.html
25
index.html
@ -36,14 +36,14 @@
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
bg: '#011c1e',
|
||||
card: '#083336',
|
||||
cardHover: '#0b3f42',
|
||||
accent: '#94FBE0',
|
||||
purple: '#a855f7',
|
||||
purpleHover: '#9333ea',
|
||||
text: '#e2e8f0',
|
||||
muted: '#94a3b8'
|
||||
bg: '#001a1c',
|
||||
card: '#003538',
|
||||
cardHover: '#004548',
|
||||
accent: '#a6ffea',
|
||||
purple: '#a682ff',
|
||||
purpleHover: '#9570f0',
|
||||
text: '#d1d5db',
|
||||
muted: '#9ca3af'
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -52,7 +52,8 @@
|
||||
</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&family=Playfair+Display:ital,wght@0,700;1,700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,700;1,700&display=swap" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--app-height: 100%;
|
||||
@ -62,11 +63,13 @@
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #121a1d;
|
||||
background-color: #001a1c;
|
||||
}
|
||||
body {
|
||||
font-family: 'Noto Sans KR', sans-serif;
|
||||
font-family: 'Pretendard', 'Apple SD Gothic Neo', -apple-system, BlinkMacSystemFont,
|
||||
'Helvetica Neue', 'Segoe UI', system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
letter-spacing: -0.006em;
|
||||
}
|
||||
.font-serif-logo {
|
||||
font-family: 'Playfair Display', serif;
|
||||
|
||||
46
src/components/LicenseBadge.tsx
Normal file
46
src/components/LicenseBadge.tsx
Normal 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>;
|
||||
};
|
||||
49
src/components/P2vKeyGate.tsx
Normal file
49
src/components/P2vKeyGate.tsx
Normal 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;
|
||||
@ -1,23 +1,48 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
BookIcon,
|
||||
PaletteIcon,
|
||||
PosterVideoIcon,
|
||||
StorefrontIcon,
|
||||
} from './pipelineIcons';
|
||||
|
||||
/** 생성할 콘텐츠 종류. 진입 화면에서 고르면 이후 플로우 전체가 갈린다. */
|
||||
export type Pipeline = 'ado2' | 'ssul';
|
||||
/**
|
||||
* 생성할 콘텐츠 종류. 진입 화면에서 고르면 이후 플로우 전체가 갈린다.
|
||||
*
|
||||
* poster·styling 은 castad 백엔드가 아니라 **P2V 서버(:8010)** 를 쓴다(utils/p2vApi.ts).
|
||||
*/
|
||||
export type Pipeline = 'ado2' | 'ssul' | 'poster' | 'styling';
|
||||
|
||||
/** 유효한 파이프라인 값인가 — localStorage 복원 때 쓴다 */
|
||||
export const PIPELINES: readonly Pipeline[] = ['ado2', 'ssul', 'poster', 'styling'];
|
||||
|
||||
export const isPipeline = (value: unknown): value is Pipeline =>
|
||||
typeof value === 'string' && (PIPELINES as readonly string[]).includes(value);
|
||||
|
||||
interface PipelineTabsProps {
|
||||
value: Pipeline;
|
||||
onChange: (value: Pipeline) => void;
|
||||
}
|
||||
|
||||
const TABS: { id: Pipeline; labelKey: string; emoji?: string }[] = [
|
||||
{ id: 'ado2', labelKey: 'pipelineTabs.ado2' },
|
||||
{ id: 'ssul', labelKey: 'pipelineTabs.ssul', emoji: '📋' },
|
||||
const TABS: { id: Pipeline; labelKey: string; Icon: React.FC<{ size?: number }> }[] = [
|
||||
{ id: 'ado2', labelKey: 'pipelineTabs.ado2', Icon: StorefrontIcon },
|
||||
{ id: 'ssul', labelKey: 'pipelineTabs.ssul', Icon: BookIcon },
|
||||
{ id: 'poster', labelKey: 'pipelineTabs.poster', Icon: PosterVideoIcon },
|
||||
{ id: 'styling', labelKey: 'pipelineTabs.styling', Icon: PaletteIcon },
|
||||
];
|
||||
|
||||
/**
|
||||
* ADO2 / 썰박스 전환 탭 (밑줄 탭).
|
||||
* 파이프라인 전환 탭 (아이콘 타일 + 기능명).
|
||||
*
|
||||
* 밑줄 탭에서 타일로 바꾼 이유: 파이프라인이 2개일 때는 글자만으로 충분했지만
|
||||
* 4개가 되자 "포스터 영상"과 "포스터 스타일"처럼 **글자가 겹치는 항목**이 생겨
|
||||
* 훑어볼 때 구분이 안 됐다. 형태(아이콘)가 붙으면 읽지 않고도 갈라진다.
|
||||
*
|
||||
* 색은 파이프라인별로 나누지 않는다 — 참조한 레퍼런스는 항목마다 파스텔색을 쓰지만,
|
||||
* ADO2 는 민트 액센트 + 퍼플 CTA 한 곳이 규칙이라 4색을 쓰면 팔레트가 깨진다.
|
||||
* 구분은 형태로, 상태(선택)는 민트로 나눈다.
|
||||
*
|
||||
* 활성 탭 아래에만 밑줄이 얹히고, 색이 민트(ADO2) ↔ 앰버(썰박스)로 바뀐다.
|
||||
* 스타일은 generation-flow.css 의 `.pipeline-tabs*` 참조.
|
||||
*
|
||||
* 접근성: castad 의 기존 탭(`.myinfo-tab` 등)에는 role/aria 가 없지만,
|
||||
@ -33,21 +58,19 @@ const PipelineTabs: React.FC<PipelineTabsProps> = ({ value, onChange }) => {
|
||||
role="tablist"
|
||||
aria-label={t('pipelineTabs.ariaLabel')}
|
||||
>
|
||||
{TABS.map((tab) => (
|
||||
{TABS.map(({ id, labelKey, Icon }) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
key={id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={value === tab.id}
|
||||
aria-selected={value === id}
|
||||
className="pipeline-tab"
|
||||
onClick={() => onChange(tab.id)}
|
||||
onClick={() => onChange(id)}
|
||||
>
|
||||
{tab.emoji && (
|
||||
<span className="pipeline-tab__emoji" aria-hidden="true">
|
||||
{tab.emoji}
|
||||
</span>
|
||||
)}
|
||||
{t(tab.labelKey)}
|
||||
<span className="pipeline-tab__tile">
|
||||
<Icon size={24} />
|
||||
</span>
|
||||
<span className="pipeline-tab__label">{t(labelKey)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
106
src/components/PosterDropzone.tsx
Normal file
106
src/components/PosterDropzone.tsx
Normal 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;
|
||||
69
src/components/pipelineIcons.tsx
Normal file
69
src/components/pipelineIcons.tsx
Normal 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
97
src/hooks/useP2vJob.ts
Normal 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 };
|
||||
}
|
||||
@ -603,7 +603,15 @@
|
||||
"pipelineTabs": {
|
||||
"ariaLabel": "Content type",
|
||||
"ado2": "ADO2",
|
||||
"ssul": "Ssulbox"
|
||||
"ssul": "Ssulbox",
|
||||
"poster": "Moving Poster",
|
||||
"styling": "Poster Styling",
|
||||
"desc": {
|
||||
"ado2": "One business URL becomes a brand analysis, lyrics, music and a short-form video.",
|
||||
"ssul": "Weaves a business into a historical story people watch to the end.",
|
||||
"poster": "One event poster becomes a short-form video with narration, music and camera moves.",
|
||||
"styling": "Reinterprets a poster in the visual language of paintings and film posters."
|
||||
}
|
||||
},
|
||||
"ssulbox": {
|
||||
"steps": {
|
||||
@ -622,5 +630,79 @@
|
||||
"submit": "Create Ssulbox",
|
||||
"submitting": "Requesting…"
|
||||
}
|
||||
},
|
||||
"poster": {
|
||||
"steps": {
|
||||
"upload": "Upload",
|
||||
"analyze": "Analyze",
|
||||
"review": "Review",
|
||||
"render": "Generate",
|
||||
"done": "Done"
|
||||
},
|
||||
"key": {
|
||||
"title": "P2V access key required",
|
||||
"desc": "The poster pipeline runs on a separate server. The key you enter is stored in this browser.",
|
||||
"placeholder": "P2V_ACCESS_KEY",
|
||||
"save": "Save"
|
||||
},
|
||||
"dropzone": {
|
||||
"cta": "Drop a poster here\nor click to choose",
|
||||
"formats": "JPG · PNG · WEBP · GIF\nNo resolution limit",
|
||||
"badType": "Only JPG / PNG / WEBP / GIF files are accepted.",
|
||||
"lowRes": "Long edge is {{edge}}px. You can proceed; deep zoom segments may look soft.",
|
||||
"previewAlt": "Poster preview"
|
||||
},
|
||||
"create": {
|
||||
"step": "STEP 1 · Upload poster",
|
||||
"namePlaceholder": "Event name (leave blank to auto-detect)",
|
||||
"submit": "Create short-form",
|
||||
"submitting": "Uploading…"
|
||||
},
|
||||
"making": {
|
||||
"titleAnalyze": "Analyzing the poster",
|
||||
"titleRender": "Generating the video",
|
||||
"descAnalyze": "Detecting regions and drafting narration and motion. The review screen opens when this finishes.",
|
||||
"descRender": "Voice, music, animation and compositing follow. The music step can take a few minutes.",
|
||||
"phaseAnalyze": "Analyzing",
|
||||
"phaseRender": "Generating",
|
||||
"failTitle": "Generation failed",
|
||||
"retry": "Retry",
|
||||
"discard": "Start over",
|
||||
"discardConfirm": "This deletes the job and every generated video, audio and analysis file. This cannot be undone."
|
||||
},
|
||||
"review": {
|
||||
"narration": "Narration review",
|
||||
"motion": "Elements to animate",
|
||||
"metadata": "Metadata",
|
||||
"field": {
|
||||
"event_name": "Event",
|
||||
"date_text": "Date",
|
||||
"place": "Place"
|
||||
},
|
||||
"analysis": "Poster analysis",
|
||||
"approve": "Approve and continue",
|
||||
"approving": "Approving…"
|
||||
},
|
||||
"result": {
|
||||
"title": "Your moving poster is ready",
|
||||
"fileName": "File name",
|
||||
"downloadThumb": "Still JPG",
|
||||
"downloadVideo": "Download MP4",
|
||||
"new": "Start a new poster"
|
||||
},
|
||||
"styling": {
|
||||
"internalScope": "Internal demo only. Because of reference licensing, results must not be published externally or used commercially (public-domain artworks excepted).",
|
||||
"templates": "Style templates",
|
||||
"myPoster": "My poster",
|
||||
"result": "Result",
|
||||
"converting": "Converting (about 30s)",
|
||||
"formats": "Output format",
|
||||
"submit": "Convert style",
|
||||
"license": {
|
||||
"public-domain": "Publishable",
|
||||
"internal-only": "Internal only",
|
||||
"user-uploaded": "Rights unverified"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -603,7 +603,15 @@
|
||||
"pipelineTabs": {
|
||||
"ariaLabel": "생성할 콘텐츠 종류",
|
||||
"ado2": "ADO2",
|
||||
"ssul": "썰박스"
|
||||
"ssul": "썰박스",
|
||||
"poster": "무빙 포스터",
|
||||
"styling": "포스터 스타일링",
|
||||
"desc": {
|
||||
"ado2": "업체 URL 하나로 브랜드를 분석해 가사·음악·숏폼 영상까지 자동 제작합니다.",
|
||||
"ssul": "유명 역사 스토리에 업체 셀링포인트를 녹여 흥미로운 숏폼 콘텐츠를 만듭니다.",
|
||||
"poster": "포스터가 나레이션, 음악, 애니메이션을 추가한 숏폼 콘텐츠로 나옵니다.",
|
||||
"styling": "포스터를 유명 영화의 한 장면 · 고전화 · 영화 포스터 스타일로 재해석합니다."
|
||||
}
|
||||
},
|
||||
"ssulbox": {
|
||||
"steps": {
|
||||
@ -613,13 +621,13 @@
|
||||
},
|
||||
"create": {
|
||||
"step1": "STEP 1 · 시나리오 선택",
|
||||
"step2": "STEP 2 · 업장 검색",
|
||||
"step2": "STEP 2 · 업체 검색",
|
||||
"searchPlaceholder": "업체명 또는 URL을 입력하세요.",
|
||||
"searchButton": "검색",
|
||||
"searching": "검색 중",
|
||||
"placeLoading": "가게를 찾는 중… (몇 초 걸려요)",
|
||||
"hintUrlOk": "네이버 링크로 이 가게를 직접 크롤링합니다.",
|
||||
"hintDefault": "업체의 사진과 정보를 크롤링해 썰박스에 사용합니다.",
|
||||
"placeLoading": "업체를 찾는 중… (몇 초 걸려요)",
|
||||
"hintUrlOk": "네이버 링크로 해당 업체를 직접 수집합니다.",
|
||||
"hintDefault": "업체를 선택하면 해당 업체의 사진과 정보를 수집해 썰박스에 씁니다.",
|
||||
"hintScenario": "{{name}} 시나리오로 약 5~6분 소요됩니다.",
|
||||
"noResult": "검색 결과가 없어요. 이름을 바꾸거나 네이버 지도 링크를 붙여넣어 주세요.",
|
||||
"cost": "생성 비용",
|
||||
@ -671,5 +679,128 @@
|
||||
"desc": "오디세우스 10년 귀향 대모험"
|
||||
}
|
||||
}
|
||||
},
|
||||
"poster": {
|
||||
"steps": {
|
||||
"upload": "업로드",
|
||||
"analyze": "분석",
|
||||
"review": "검수",
|
||||
"render": "생성",
|
||||
"done": "완성"
|
||||
},
|
||||
"key": {
|
||||
"title": "P2V 접근 키가 필요합니다",
|
||||
"desc": "포스터 파이프라인은 별도 서버에서 돕니다. 서버에 설정된 접근 키를 입력하면 이 브라우저에 저장됩니다.",
|
||||
"placeholder": "P2V_ACCESS_KEY",
|
||||
"save": "저장"
|
||||
},
|
||||
"dropzone": {
|
||||
"cta": "포스터를 끌어다 놓거나\n클릭해서 선택",
|
||||
"formats": "JPG · PNG · WEBP · GIF\n해상도 제한 없음",
|
||||
"badType": "JPG / PNG / WEBP / GIF 파일만 올릴 수 있습니다.",
|
||||
"lowRes": "긴 변 {{edge}}px입니다. 그대로 진행할 수 있고, 깊은 줌 구간만 다소 부드럽게 보일 수 있습니다.",
|
||||
"previewAlt": "포스터 미리보기"
|
||||
},
|
||||
"create": {
|
||||
"step": "STEP 1 · 포스터 업로드",
|
||||
"namePlaceholder": "행사 이름을 입력하세요 (비우면 자동 추출)",
|
||||
"submit": "숏폼 만들기",
|
||||
"submitting": "업로드 중…"
|
||||
},
|
||||
"making": {
|
||||
"titleAnalyze": "포스터를 분석하고 있어요",
|
||||
"titleRender": "영상을 만들고 있어요",
|
||||
"descAnalyze": "영역을 검출하고 나레이션·모션을 초안으로 작성합니다. 끝나면 검수 화면이 열립니다.",
|
||||
"descRender": "음성·음악·애니메이션·합성이 이어집니다. 음악 단계는 몇 분 걸릴 수 있습니다.",
|
||||
"phaseAnalyze": "분석 중",
|
||||
"phaseRender": "생성 중",
|
||||
"failTitle": "생성에 실패했어요",
|
||||
"retry": "다시 시도",
|
||||
"discard": "처음부터",
|
||||
"discardConfirm": "이 작업과 만들어진 영상·음성·분석 파일을 모두 지웁니다. 되돌릴 수 없습니다."
|
||||
},
|
||||
"stages": {
|
||||
"detect": "영역 검출",
|
||||
"narration_text": "나레이션",
|
||||
"motion": "모션 선정",
|
||||
"tts": "음성",
|
||||
"bgm": "음악",
|
||||
"i2v": "애니메이션",
|
||||
"render": "합성",
|
||||
"transfer": "변환"
|
||||
},
|
||||
"review": {
|
||||
"narration": "나레이션 검수",
|
||||
"narrationHelp": "자동 작성된 문장을 확인하고 필요하면 고쳐주세요. 이대로 음성이 됩니다.",
|
||||
"motion": "움직일 요소",
|
||||
"motionHelp": "포스터에서 찾은 요소입니다. 빼거나 더할 수 있습니다.",
|
||||
"motionEmpty": "움직일 요소를 찾지 못했습니다. 직접 고르거나, 비워두면 애니메이션 없이 진행됩니다.",
|
||||
"metadata": "메타태그",
|
||||
"metadataHelp": "아카이브에 영구 저장됩니다. 연도나 지명이 잘못 읽혔는지 포스터와 대조해 주세요.",
|
||||
"field": {
|
||||
"event_name": "행사명",
|
||||
"date_text": "일시",
|
||||
"place": "장소"
|
||||
},
|
||||
"analysis": "포스터 분석 결과",
|
||||
"analysisAlt": "영역 분석 확인 이미지",
|
||||
"analysisMissing": "분석 이미지가 없습니다",
|
||||
"approve": "승인하고 계속",
|
||||
"approving": "승인 중…"
|
||||
},
|
||||
"motion": {
|
||||
"firework": "불꽃놀이",
|
||||
"water": "물살",
|
||||
"wave": "파도",
|
||||
"cloud": "구름",
|
||||
"smoke": "연기",
|
||||
"moon": "달",
|
||||
"sun": "해",
|
||||
"star": "별",
|
||||
"light": "조명",
|
||||
"flag": "깃발·천",
|
||||
"foliage": "나뭇잎·풀",
|
||||
"wheel": "바퀴·관람차",
|
||||
"vessel": "배",
|
||||
"crowd": "사람 무리"
|
||||
},
|
||||
"result": {
|
||||
"title": "무빙 포스터가 완성됐어요",
|
||||
"fileName": "파일명",
|
||||
"videoMissing": "영상 파일을 찾지 못했습니다.",
|
||||
"downloadThumb": "정지 컷 JPG",
|
||||
"downloadVideo": "MP4 다운로드",
|
||||
"new": "새 포스터로 만들기"
|
||||
},
|
||||
"styling": {
|
||||
"internalScope": "내부 시연 전용입니다. 레퍼런스 저작권 때문에 결과물의 외부 공개·상업 사용은 금합니다(퍼블릭 도메인 명화 제외).",
|
||||
"templates": "스타일 템플릿",
|
||||
"pickTemplate": "템플릿을 선택하세요",
|
||||
"count": "{{count}}종",
|
||||
"myRefs": "내 레퍼런스",
|
||||
"myRefsHint": "직접 올린 이미지의 화법으로 변환합니다",
|
||||
"addRef": "레퍼런스 추가",
|
||||
"minEdge": "긴 변 {{px}}px 이상 권장",
|
||||
"analyzing": "화풍 분석 중…",
|
||||
"deleteRef": "레퍼런스 삭제",
|
||||
"confirmDelete": "이 레퍼런스를 삭제할까요? 되돌릴 수 없습니다.",
|
||||
"attribution": "레퍼런스 출처 · {{source}}",
|
||||
"myPoster": "내 포스터",
|
||||
"result": "결과",
|
||||
"resultAlt": "변환 결과",
|
||||
"resultHint": "변환 결과가 여기에 표시됩니다",
|
||||
"converting": "변환 중 (30초 안팎)",
|
||||
"downloadPng": "PNG 다운로드",
|
||||
"formats": "출력 포맷",
|
||||
"submit": "스타일 변환",
|
||||
"license": {
|
||||
"public-domain": "공개 가능",
|
||||
"internal-only": "내부 전용",
|
||||
"user-uploaded": "권리 미확인"
|
||||
},
|
||||
"internalOnlyTitle": "내부 전용 템플릿",
|
||||
"internalOnlyBody": "이 레퍼런스로 만든 결과물은 외부 공개·상업 사용을 금합니다.",
|
||||
"userRefNotice": "직접 올린 레퍼런스는 권리를 가진 이미지만 사용하세요. 타인의 저작물을 올려 생긴 문제의 책임은 업로드한 사람에게 있습니다. 결과물은 권리 미확인으로 분류됩니다."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,10 +30,15 @@ const revokeAllPreviews = (items: ImageItem[]) => {
|
||||
}
|
||||
});
|
||||
};
|
||||
import { Pipeline } from '../../components/PipelineTabs';
|
||||
import { Pipeline, isPipeline } from '../../components/PipelineTabs';
|
||||
import { Scen } from '../Ssulbox/ssulData';
|
||||
import SsulMakingContent, { SsulJob } from '../Ssulbox/SsulMakingContent';
|
||||
import SsulResultContent from '../Ssulbox/SsulResultContent';
|
||||
import PosterMakingContent from '../Poster/PosterMakingContent';
|
||||
import PosterReviewContent from '../Poster/PosterReviewContent';
|
||||
import PosterResultContent from '../Poster/PosterResultContent';
|
||||
import { useP2vJob } from '../../hooks/useP2vJob';
|
||||
import { deletePosterJob, isPastReview, retryPosterJob } from '../../utils/p2vApi';
|
||||
import {
|
||||
K,
|
||||
clearProjectStorage,
|
||||
@ -72,6 +77,25 @@ const SSUL_STEP_INDEX: Record<SsulStep, number> = {
|
||||
result: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* 포스터 영상(F1) 진행 단계. 잡 status 에서 파생되므로 state 로 들지 않는다 —
|
||||
* 서버 상태와 화면 상태를 따로 들면 반드시 어긋난다(썰박스에서 겪은 것).
|
||||
*/
|
||||
type PosterStep = 'entry' | 'making' | 'review' | 'result';
|
||||
|
||||
/**
|
||||
* 스텝퍼 라벨. 5단계인 이유는 검수 게이트 **앞뒤가 서로 다른 작업**이기 때문이다 —
|
||||
* 분석(검출·나레이션·모션)과 생성(음성·음악·애니메이션·렌더)을 한 칸에 묶으면
|
||||
* 승인 후에도 스텝퍼가 안 움직여 멈춘 것처럼 보인다.
|
||||
*/
|
||||
const POSTER_STEP_LABELS = [
|
||||
'poster.steps.upload',
|
||||
'poster.steps.analyze',
|
||||
'poster.steps.review',
|
||||
'poster.steps.render',
|
||||
'poster.steps.done',
|
||||
] as const;
|
||||
|
||||
interface BusinessInfo {
|
||||
customer_name: string;
|
||||
region: string;
|
||||
@ -164,7 +188,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
const getInitialPipeline = (): Pipeline => {
|
||||
// 1) 랜딩 CTA 프리셋 (1회성). 카카오 로그인 리다이렉트 왕복을 견뎌야 해서 sessionStorage
|
||||
const pending = sessionStorage.getItem(PENDING_PIPELINE_KEY);
|
||||
if (pending === 'ssul' || pending === 'ado2') {
|
||||
if (isPipeline(pending)) {
|
||||
sessionStorage.removeItem(PENDING_PIPELINE_KEY);
|
||||
return pending;
|
||||
}
|
||||
@ -172,7 +196,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
if (initialAnalysisData || savedAnalysisData) return 'ado2';
|
||||
// 3) 저장된 선택
|
||||
const saved = localStorage.getItem(K.PIPELINE);
|
||||
return saved === 'ssul' ? 'ssul' : 'ado2';
|
||||
return isPipeline(saved) ? saved : 'ado2';
|
||||
};
|
||||
|
||||
const [pipeline, setPipeline] = useState<Pipeline>(getInitialPipeline);
|
||||
@ -182,13 +206,48 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
/** 언마운트·로그아웃·재시도 시 진행 중인 폴링을 멈추는 플래그 */
|
||||
const ssulCancelledRef = useRef(false);
|
||||
|
||||
/**
|
||||
* 포스터 영상(F1) 잡. 썰박스와 달리 상태 축을 따로 들지 않고 서버 status 에서 파생한다 —
|
||||
* P2V 는 검수 게이트에서 멈춰 사용자를 기다리므로 중간 상태를 계속 그려야 하고,
|
||||
* 그때 화면 상태를 따로 들면 두 축이 어긋난다.
|
||||
*/
|
||||
const [posterJobId, setPosterJobId] = useState<string | null>(
|
||||
() => localStorage.getItem(K.POSTER_JOB_ID),
|
||||
);
|
||||
const [posterBusy, setPosterBusy] = useState(false);
|
||||
const { job: posterJob, notFound: posterNotFound, refresh: refreshPosterJob } =
|
||||
useP2vJob('f1', pipeline === 'poster' ? posterJobId : null);
|
||||
|
||||
const changePipeline = (next: Pipeline) => {
|
||||
setPipeline(next);
|
||||
localStorage.setItem(K.PIPELINE, next);
|
||||
};
|
||||
|
||||
/** 진입 화면(탭이 보이는 단계) 여부. 두 파이프라인이 공유한다 */
|
||||
const isEntry = pipeline === 'ado2' ? wizardStep === -2 : ssulStep === 'entry';
|
||||
/** 포스터 진행 단계 — 잡 status 에서 파생 */
|
||||
const posterStep: PosterStep = !posterJobId
|
||||
? 'entry'
|
||||
: posterJob?.status === 'awaiting_review'
|
||||
? 'review'
|
||||
: posterJob?.status === 'done'
|
||||
? 'result'
|
||||
: 'making'; // queued · running · failed · 조회 전(null)
|
||||
|
||||
/** 스텝퍼 인덱스. 검수 전 running(1) 과 승인 후 running(3) 을 구분한다 */
|
||||
const posterStepIndex = posterStep === 'entry'
|
||||
? 0
|
||||
: posterStep === 'review'
|
||||
? 2
|
||||
: posterStep === 'result'
|
||||
? 4
|
||||
: isPastReview(posterJob) ? 3 : 1;
|
||||
|
||||
/** 진입 화면(탭이 보이는 단계) 여부. 파이프라인마다 진입 조건이 다르다 */
|
||||
const isEntry =
|
||||
pipeline === 'ado2' ? wizardStep === -2
|
||||
: pipeline === 'ssul' ? ssulStep === 'entry'
|
||||
: pipeline === 'poster' ? posterStep === 'entry'
|
||||
// 스타일링은 전 과정이 진입 화면 안에서 끝난다 (위저드 없음)
|
||||
: true;
|
||||
const [songTaskId, setSongTaskId] = useState<string | null>(savedSongTaskId);
|
||||
const [imageTaskId, setImageTaskId] = useState<string | null>(savedImageTaskId);
|
||||
const [videoGenerationStatus, setVideoGenerationStatus] = useState<'idle' | 'generating' | 'complete' | 'error'>('idle');
|
||||
@ -551,6 +610,64 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
setSsulStep('entry');
|
||||
};
|
||||
|
||||
// ── 포스터 영상 (P2V F1) ─────────────────────────────────────────────
|
||||
|
||||
const handlePosterJobStarted = (jobId: string) => {
|
||||
localStorage.setItem(K.POSTER_JOB_ID, jobId);
|
||||
setPosterJobId(jobId);
|
||||
};
|
||||
|
||||
/** 잡을 버리고 업로드 화면으로. 서버 잡은 남겨둔다(아카이브·디버깅에 쓰인다) */
|
||||
const handlePosterNew = () => {
|
||||
localStorage.removeItem(K.POSTER_JOB_ID);
|
||||
setPosterJobId(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* 실패한 스테이지부터 다시. 업로드부터 다시 하지 않는다 —
|
||||
* i2v 실패(모델 큐·크레딧)는 같은 입력으로 재시도하면 자주 통과한다.
|
||||
*/
|
||||
const handlePosterRetry = async () => {
|
||||
if (!posterJobId || posterBusy) return;
|
||||
setPosterBusy(true);
|
||||
try {
|
||||
await retryPosterJob(posterJobId);
|
||||
refreshPosterJob();
|
||||
} catch {
|
||||
// 재시도 요청 자체가 실패해도 잡 상태는 그대로다 — 폴링 결과가 계속 화면을 그린다
|
||||
} finally {
|
||||
setPosterBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 실패한 잡과 산출물을 지우고 처음부터. 되돌릴 수 없다 */
|
||||
const handlePosterDiscard = async () => {
|
||||
if (!posterJobId || posterBusy) return;
|
||||
if (!window.confirm(t('poster.making.discardConfirm'))) return;
|
||||
setPosterBusy(true);
|
||||
try {
|
||||
await deletePosterJob(posterJobId);
|
||||
} catch {
|
||||
// 서버에서 이미 사라졌어도 화면은 처음으로 돌려보내는 게 맞다
|
||||
} finally {
|
||||
setPosterBusy(false);
|
||||
handlePosterNew();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 사라진 잡 정리.
|
||||
*
|
||||
* P2V 에는 "내 활성 잡" 엔드포인트가 없어(단일 팀 키라 사용자 개념이 없다)
|
||||
* localStorage 의 id 가 복구의 유일한 근거다. 그런데 서버를 재시작하거나 다른
|
||||
* 사람이 그 잡을 지우면 id 만 남아 **빈 진행 화면에서 영원히 멈춘다** — 실제로 겪었다.
|
||||
* 404 를 만나면 업로드 화면으로 되돌린다.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (posterNotFound) handlePosterNew();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [posterNotFound]);
|
||||
|
||||
/**
|
||||
* 새로고침·새 탭 진입 시 진행 중인 잡을 복구한다.
|
||||
*
|
||||
@ -599,6 +716,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
// 지우므로 state 만 남으면 저장소와 어긋난다.
|
||||
setPipeline('ado2');
|
||||
setSsulStep('entry');
|
||||
// clearProjectStorage() 가 POSTER_JOB_ID 를 지우므로 state 도 함께 비운다
|
||||
setPosterJobId(null);
|
||||
setSongTaskId(null);
|
||||
setImageTaskId(null);
|
||||
setAnalysisData(null);
|
||||
@ -625,6 +744,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
onManualInput={handleManualInput}
|
||||
error={analysisError}
|
||||
onSsulJobStarted={handleSsulJobStarted}
|
||||
onPosterJobStarted={handlePosterJobStarted}
|
||||
onGoToPayment={handleGoToPayment}
|
||||
/>
|
||||
);
|
||||
@ -646,6 +766,29 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// 포스터 영상도 자체 축으로 진행한다 (단계는 잡 status 파생)
|
||||
if (pipeline === 'poster') {
|
||||
switch (posterStep) {
|
||||
case 'review':
|
||||
return posterJob ? (
|
||||
<PosterReviewContent job={posterJob} onApproved={refreshPosterJob} />
|
||||
) : null;
|
||||
case 'result':
|
||||
return posterJob ? (
|
||||
<PosterResultContent job={posterJob} onNew={handlePosterNew} />
|
||||
) : null;
|
||||
default:
|
||||
return (
|
||||
<PosterMakingContent
|
||||
job={posterJob}
|
||||
onRetry={handlePosterRetry}
|
||||
onDiscard={handlePosterDiscard}
|
||||
busy={posterBusy}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
switch (wizardStep) {
|
||||
case -1:
|
||||
// 로딩 단계
|
||||
@ -758,6 +901,18 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 포스터 영상은 검수 게이트를 포함한 5단계
|
||||
if (pipeline === 'poster') {
|
||||
return (
|
||||
<div className="wizard-page-container">
|
||||
<WizardStepper
|
||||
steps={POSTER_STEP_LABELS.map((key) => t(key))}
|
||||
currentStep={posterStepIndex}
|
||||
/>
|
||||
{renderWizardContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 브랜드 분석(0)은 전체 화면 스크롤이지만 스텝퍼는 표시
|
||||
if (wizardStep === 0) {
|
||||
return (
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AutocompleteRequest } from '../../utils/api';
|
||||
import SearchInputForm, { SearchType } from '../../components/SearchInputForm';
|
||||
import PipelineTabs, { Pipeline } from '../../components/PipelineTabs';
|
||||
import SsulCreateForm from '../Ssulbox/SsulCreateForm';
|
||||
import { Scen } from '../Ssulbox/ssulData';
|
||||
import PosterCreateForm from '../Poster/PosterCreateForm';
|
||||
import StylingContent from '../Poster/StylingContent';
|
||||
|
||||
interface UrlInputContentProps {
|
||||
/** 현재 선택된 파이프라인. 소유자는 GenerationFlow (분기·리셋 때문) */
|
||||
@ -15,6 +18,8 @@ interface UrlInputContentProps {
|
||||
error: string | null;
|
||||
/** 썰박스 생성 요청 접수 시. 폴링 소유는 GenerationFlow */
|
||||
onSsulJobStarted: (taskId: number, scenario: Scen) => void;
|
||||
/** 포스터 영상(F1) 생성 요청 접수 시. 폴링 소유는 GenerationFlow */
|
||||
onPosterJobStarted: (jobId: string) => void;
|
||||
/** 크레딧 부족 시 충전 화면으로 */
|
||||
onGoToPayment: () => void;
|
||||
}
|
||||
@ -32,27 +37,51 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({
|
||||
onManualInput,
|
||||
error,
|
||||
onSsulJobStarted,
|
||||
onPosterJobStarted,
|
||||
onGoToPayment,
|
||||
}) => {
|
||||
return (
|
||||
<div className="url-input-container">
|
||||
<div className="url-input-content">
|
||||
<div className="url-input-logo">
|
||||
<img src="/assets/images/ADO2_with Slogan_white.svg" alt="ADO2" />
|
||||
</div>
|
||||
const { t } = useTranslation();
|
||||
|
||||
<PipelineTabs value={pipeline} onChange={onPipelineChange} />
|
||||
// 스타일링은 한 화면짜리 도구라 진입 폼보다 넓은 판이 필요하다 (generation-flow.css 참조)
|
||||
const wide = pipeline === 'styling';
|
||||
|
||||
{pipeline === 'ado2' ? (
|
||||
const renderForm = () => {
|
||||
switch (pipeline) {
|
||||
case 'ado2':
|
||||
return (
|
||||
<SearchInputForm
|
||||
onAnalyze={onAnalyze}
|
||||
onAutocomplete={onAutocomplete}
|
||||
onManualInput={onManualInput}
|
||||
error={error}
|
||||
/>
|
||||
) : (
|
||||
<SsulCreateForm onSubmitted={onSsulJobStarted} onNeedCredit={onGoToPayment} />
|
||||
)}
|
||||
);
|
||||
case 'ssul':
|
||||
return <SsulCreateForm onSubmitted={onSsulJobStarted} onNeedCredit={onGoToPayment} />;
|
||||
case 'poster':
|
||||
return <PosterCreateForm onSubmitted={onPosterJobStarted} />;
|
||||
case 'styling':
|
||||
// 스타일링은 위저드가 아니라 도구다 — 진입 화면 안에서 처음부터 끝까지 끝난다
|
||||
return <StylingContent />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="url-input-container">
|
||||
<div className={`url-input-content${wide ? ' url-input-content--wide' : ''}`}>
|
||||
<div className="url-input-logo">
|
||||
<img src="/assets/images/ADO2_with Slogan_white.svg" alt="ADO2" />
|
||||
</div>
|
||||
|
||||
<PipelineTabs value={pipeline} onChange={onPipelineChange} />
|
||||
|
||||
{/* 고른 기능이 무엇을 해주는지 한 줄. 탭 라벨(2~6자)만으로는 네 기능의 차이가
|
||||
전달되지 않아 처음 온 사람이 아무거나 눌러본다. STEP 1 위에 둔다. */}
|
||||
<p className="pipeline-desc">{t(`pipelineTabs.desc.${pipeline}`)}</p>
|
||||
|
||||
{renderForm()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
79
src/pages/Poster/PosterCreateForm.tsx
Normal file
79
src/pages/Poster/PosterCreateForm.tsx
Normal 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;
|
||||
105
src/pages/Poster/PosterMakingContent.tsx
Normal file
105
src/pages/Poster/PosterMakingContent.tsx
Normal 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;
|
||||
95
src/pages/Poster/PosterResultContent.tsx
Normal file
95
src/pages/Poster/PosterResultContent.tsx
Normal 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;
|
||||
172
src/pages/Poster/PosterReviewContent.tsx
Normal file
172
src/pages/Poster/PosterReviewContent.tsx
Normal 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;
|
||||
330
src/pages/Poster/StylingContent.tsx
Normal file
330
src/pages/Poster/StylingContent.tsx
Normal 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;
|
||||
28
src/pages/Poster/posterData.ts
Normal file
28
src/pages/Poster/posterData.ts
Normal 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];
|
||||
@ -7,7 +7,7 @@ import {
|
||||
createSsulJob,
|
||||
searchAccommodation,
|
||||
} from '../../utils/api';
|
||||
import { SCEN, SCEN_KEYS, Scen, scenDesc, scenName } from './ssulData';
|
||||
import { SCEN_KEYS, Scen, scenDesc, scenName } from './ssulData';
|
||||
|
||||
interface SsulCreateFormProps {
|
||||
/** 생성 요청이 접수되면 호출. 폴링 소유는 GenerationFlow */
|
||||
@ -154,7 +154,6 @@ const SsulCreateForm: React.FC<SsulCreateFormProps> = ({ onSubmitted, onNeedCred
|
||||
|
||||
<div className="ssul-create__scenarios">
|
||||
{SCEN_KEYS.map((key) => {
|
||||
const pal = SCEN[key];
|
||||
const active = scenario === key;
|
||||
return (
|
||||
<button
|
||||
@ -162,19 +161,8 @@ const SsulCreateForm: React.FC<SsulCreateFormProps> = ({ onSubmitted, onNeedCred
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
className={`ssul-scn${active ? ' active' : ''}`}
|
||||
style={
|
||||
{
|
||||
// 표지 그라디언트 2단 + 대표색. CSS 가 옅게 깔아 쓴다
|
||||
'--scn': pal.hex,
|
||||
'--scn-a': pal.g[0],
|
||||
'--scn-b': pal.g[1],
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onClick={() => setScenario(key)}
|
||||
>
|
||||
<span className="ssul-scn__emoji" aria-hidden="true">
|
||||
{pal.emoji}
|
||||
</span>
|
||||
<span className="ssul-scn__name">{t(scenName(key))}</span>
|
||||
<span className="ssul-scn__desc">{t(scenDesc(key))}</span>
|
||||
</button>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SCEN, Scen, scenName } from './ssulData';
|
||||
import { Scen, scenName } from './ssulData';
|
||||
|
||||
/** 생성 잡의 현재 상태. GenerationFlow 가 폴링해 내려준다 */
|
||||
export interface SsulJob {
|
||||
@ -45,7 +45,6 @@ const SsulMakingContent: React.FC<SsulMakingContentProps> = ({ job, onRetry }) =
|
||||
|
||||
if (!job) return null;
|
||||
|
||||
const palette = SCEN[job.scenario];
|
||||
const failed = job.status === 'error';
|
||||
// step 은 "완료한 단계 수"라 진행률은 step/4. queued(0) 면 0%
|
||||
const percent = Math.round((Math.min(job.step, TOTAL_STEPS) / TOTAL_STEPS) * 100);
|
||||
@ -54,9 +53,7 @@ const SsulMakingContent: React.FC<SsulMakingContentProps> = ({ job, onRetry }) =
|
||||
|
||||
return (
|
||||
<div className="ssul-scope ssul-making">
|
||||
<span className="ssul-making__scenario" style={{ color: palette.hex }}>
|
||||
<span aria-hidden="true">{palette.emoji}</span> {t(scenName(job.scenario))}
|
||||
</span>
|
||||
<span className="ssul-making__scenario">{t(scenName(job.scenario))}</span>
|
||||
|
||||
{failed ? (
|
||||
<>
|
||||
@ -75,13 +72,8 @@ const SsulMakingContent: React.FC<SsulMakingContentProps> = ({ job, onRetry }) =
|
||||
{/*
|
||||
단계 사이 구간에서는 진행바가 멈춰 있어 고장처럼 보인다.
|
||||
스피너는 "살아 있다"는 신호 전용이라 진행률을 표현하지 않는다.
|
||||
색은 시나리오 대표색을 따른다(민트 고정이면 삼국지·오디세이와 부딪힌다).
|
||||
*/}
|
||||
<div
|
||||
className="ssul-making__spinner"
|
||||
style={{ ['--scn' as string]: palette.hex }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="ssul-making__spinner" aria-hidden="true" />
|
||||
|
||||
<div
|
||||
className="ssul-making__bar"
|
||||
@ -90,10 +82,7 @@ const SsulMakingContent: React.FC<SsulMakingContentProps> = ({ job, onRetry }) =
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<div
|
||||
className="ssul-making__fill"
|
||||
style={{ width: `${percent}%`, background: palette.hex }}
|
||||
/>
|
||||
<div className="ssul-making__fill" style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
|
||||
<div className="ssul-making__meta">
|
||||
|
||||
@ -1,53 +1,20 @@
|
||||
/**
|
||||
* 썰박스 시나리오 팔레트와 표시 유틸.
|
||||
* 썰박스 시나리오 코드와 표시 유틸.
|
||||
*
|
||||
* 원본(o2o-ssulbox/frontend/src/data.ts)에서 이식하되 **문구(name/desc)는 제외**했다.
|
||||
* castad 는 ko/en 다국어라 한글이 코드에 박히면 i18n 밖으로 새기 때문이다.
|
||||
* 표시 문구는 `t('ssulbox.scenario.<key>.name')` / `.desc` 로 가져온다.
|
||||
*
|
||||
* 목업(SAMPLE)도 제외했다 — 백엔드 `/ssul/feed` 가 진실 원천이다.
|
||||
* 시나리오별 이모지·대표색·그라디언트는 ADO2 디자인 시스템 통일(2026-08-20)로
|
||||
* 제거했다 — 카드·스피너·진행바 모두 시스템 색(민트/퍼플)을 쓴다.
|
||||
*/
|
||||
|
||||
/** 시나리오 코드. 백엔드 `ssul_content.scenario` 와 값이 일치해야 한다 */
|
||||
export type Scen = 'joseon' | 'samgukji' | 'greek' | 'odyssey';
|
||||
|
||||
export interface ScenPalette {
|
||||
/** 카드 표지 이모지 */
|
||||
emoji: string;
|
||||
/** 대표색 (뱃지·강조선) */
|
||||
hex: string;
|
||||
/** 표지 그라디언트 시작/끝 */
|
||||
g: [string, string];
|
||||
}
|
||||
|
||||
export const SCEN: Record<Scen, ScenPalette> = {
|
||||
joseon: { emoji: '👑', hex: '#3b4cc0', g: ['#4054d6', '#8a3ffb'] },
|
||||
samgukji: { emoji: '⚔️', hex: '#d63a2f', g: ['#e0402f', '#f0872b'] },
|
||||
greek: { emoji: '🏛️', hex: '#0a8f6b', g: ['#0a9e78', '#3fb6d6'] },
|
||||
odyssey: { emoji: '⛵', hex: '#1565c0', g: ['#1565c0', '#42a5f5'] },
|
||||
};
|
||||
|
||||
/** 시나리오 순서. 선택 그리드(2x2)와 필터 칩이 이 순서를 따른다 */
|
||||
export const SCEN_KEYS = Object.keys(SCEN) as Scen[];
|
||||
|
||||
/** 카드 표지 그라디언트 */
|
||||
export const scGrad = (s: Scen): string =>
|
||||
`linear-gradient(150deg,${SCEN[s].g[0]} 0%,${SCEN[s].g[1]} 100%)`;
|
||||
export const SCEN_KEYS: Scen[] = ['joseon', 'samgukji', 'greek', 'odyssey'];
|
||||
|
||||
/** i18n 키 헬퍼 — t(scenName('joseon')) 형태로 쓴다 */
|
||||
export const scenName = (s: Scen): string => `ssulbox.scenario.${s}.name`;
|
||||
export const scenDesc = (s: Scen): string => `ssulbox.scenario.${s}.desc`;
|
||||
|
||||
/**
|
||||
* 조회수·좋아요 수 축약 표기 (1.2만 / 3.4천).
|
||||
*
|
||||
* 한글 단위가 박혀 있어 en 로케일에서는 어색하다. 통합 목록에서 castad 콘텐츠와
|
||||
* 나란히 놓이므로, 표기 방식은 Phase 8 에서 castad 쪽과 함께 정하는 게 맞다.
|
||||
* 그때까지는 원본 동작을 유지한다.
|
||||
*/
|
||||
export const fmt = (n: number): string =>
|
||||
n >= 10000
|
||||
? `${(n / 10000).toFixed(1).replace(/\.0$/, '')}만`
|
||||
: n >= 1000
|
||||
? `${(n / 1000).toFixed(1).replace(/\.0$/, '')}천`
|
||||
: `${n}`;
|
||||
|
||||
@ -2387,12 +2387,19 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* `justify-content: center` 를 쓰지 않는다.
|
||||
중앙정렬 상태에서 내용이 컨테이너보다 길어지면 위아래로 똑같이 넘치는데,
|
||||
**위로 넘친 부분은 스크롤로도 닿을 수 없다**(flexbox 중앙정렬의 알려진 함정).
|
||||
실제로 무빙 포스터 탭에서 로고 밴드가 47px 잘려 보였다.
|
||||
대신 자식의 `margin: auto 0` 으로 중앙에 놓는다 — auto 마진은 여백이 있을 때만
|
||||
중앙정렬하고, 넘칠 때는 0 이 되어 위에서부터 정상 스크롤된다. */
|
||||
justify-content: flex-start;
|
||||
background-color: var(--color-bg-darker);
|
||||
color: var(--color-text-white);
|
||||
padding: 80px 1rem 100px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@ -2402,11 +2409,24 @@
|
||||
align-items: center;
|
||||
z-index: 20;
|
||||
position: relative;
|
||||
margin-top: 50px;
|
||||
/* 세로 중앙정렬은 auto 마진으로 한다 (부모의 justify-content 주석 참조).
|
||||
넘칠 때 잘리지 않는 유일한 방법이다 */
|
||||
margin: auto 0;
|
||||
width: 100%;
|
||||
max-width: 375px;
|
||||
}
|
||||
|
||||
/* 진입 화면에 들어가는 폼은 대부분 한 줄 입력이라 375px 로 충분하지만,
|
||||
포스터 스타일링(F2)은 템플릿 그리드·입출력 2단이 한 화면에 있는 **도구**라
|
||||
그 폭에서는 못 쓴다.
|
||||
(세로 넘침 처리는 이제 컨테이너 기본 규칙이 담당한다 — 예외를 두지 않는다) */
|
||||
.url-input-content--wide {
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
/* 375px 화면에서는 타일 4개 + 라벨이 폭을 넘긴다. 타일과 글자를 함께 줄여
|
||||
한 줄을 지킨다 — 라벨이 줄바꿈되면 타일 정렬이 어긋난다 */
|
||||
|
||||
/* =====================================================
|
||||
PipelineTabs — ADO2 / 썰박스 전환 (밑줄 탭)
|
||||
진입 화면에서 로고와 하위 폼 사이에 형제로 얹힌다.
|
||||
@ -2414,76 +2434,137 @@
|
||||
컨테이너 하단의 옅은 전체 폭 구분선 위로, 활성 탭 아래에만
|
||||
진한 밑줄이 얹힌다. 밑줄 색은 탭에 따라 민트↔앰버로 바뀐다.
|
||||
===================================================== */
|
||||
/* 간격 체계 (8px 그리드 · Material 3 / 수직 리듬 가이드 기준)
|
||||
8~12px 묶음 안 (라벨 ↔ 그 라벨이 설명하는 것, 입력 ↔ 그 입력의 안내)
|
||||
20px 묶음 사이
|
||||
32px 섹션 사이 (주제가 바뀜)
|
||||
이전에는 전부 10~24px 이라 "붙은 것"과 "다른 것"이 구분되지 않아 답답해 보였다.
|
||||
근접성(Gestalt)은 거리 차이로만 전달되므로 값이 고르면 정보 구조가 사라진다. */
|
||||
.pipeline-tabs {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
/* 터치 타겟은 8dp 이상 떨어뜨린다(Material 3). 4px 이면 옆 타일 오탭이 난다 */
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
/* 전체 폭 구분선. 활성 밑줄이 이 위에 겹쳐 얹힌다 */
|
||||
border-bottom: 1px solid var(--color-border-white-10);
|
||||
}
|
||||
|
||||
.pipeline-tab {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
/* 13px 상하 패딩 + 폰트로 터치 타겟 44px 이상 확보.
|
||||
밑줄 탭은 필과 달리 배경이 없어 타겟이 작아 보이기 쉽다 */
|
||||
padding: 13px 6px;
|
||||
/* 타일(52px) + 라벨로 터치 타겟이 이미 충분하다. 좌우 패딩은 라벨이
|
||||
이웃 타일에 붙지 않을 만큼만 */
|
||||
padding: 4px 2px;
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.006em;
|
||||
color: var(--color-text-gray-400);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-normal);
|
||||
}
|
||||
|
||||
/* 활성 표시 밑줄. 구분선(1px)을 덮도록 bottom: -1px */
|
||||
.pipeline-tab::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: -1px;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
transition: background var(--transition-normal);
|
||||
/* 아이콘 타일 — 라운드 스퀘어. 아이콘은 currentColor 라 타일이 색을 정한다 */
|
||||
.pipeline-tab__tile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: var(--radius-xl);
|
||||
border: 1px solid var(--color-border-white-10);
|
||||
background: var(--color-bg-card-inner);
|
||||
transition: background-color var(--transition-normal),
|
||||
border-color var(--transition-normal),
|
||||
color var(--transition-normal),
|
||||
box-shadow var(--transition-normal);
|
||||
}
|
||||
|
||||
.pipeline-tab__label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.006em;
|
||||
white-space: nowrap;
|
||||
transition: color var(--transition-normal);
|
||||
}
|
||||
|
||||
.pipeline-tab:hover {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
color: var(--color-text-white);
|
||||
}
|
||||
|
||||
/* 활성 탭은 배경이 없으므로 밝은 글자 + 굵기로 대비를 만든다 */
|
||||
.pipeline-tab:hover .pipeline-tab__tile {
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
/* 선택 상태는 사이드바 활성 항목(.sidebar-item.active)과 같은 문법 —
|
||||
민트로 채우고 아이콘을 어두운 딥틸로 뒤집는다. 화면에서 가장 강한 표시이므로
|
||||
라벨까지 흰색으로 올려 타일과 한 덩어리로 읽히게 한다 */
|
||||
.pipeline-tab[aria-selected="true"] {
|
||||
color: var(--color-bg-dark);
|
||||
}
|
||||
|
||||
.pipeline-tab[aria-selected="true"] .pipeline-tab__tile {
|
||||
background: var(--color-mint);
|
||||
border-color: var(--color-mint);
|
||||
box-shadow: var(--shadow-mint-glow);
|
||||
}
|
||||
|
||||
.pipeline-tab[aria-selected="true"] .pipeline-tab__label {
|
||||
color: var(--color-text-white);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pipeline-tabs[data-active="ado2"] .pipeline-tab[aria-selected="true"]::after {
|
||||
background: var(--color-mint);
|
||||
}
|
||||
|
||||
.pipeline-tabs[data-active="ssul"] .pipeline-tab[aria-selected="true"]::after {
|
||||
background: var(--color-ssul-accent);
|
||||
}
|
||||
|
||||
.pipeline-tab__emoji {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* 밑줄은 위치가 아니라 색만 바뀌므로 모션이 미미하지만,
|
||||
castad 전체에서 이 컴포넌트만 전환 효과를 쓰므로 함께 끈다 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pipeline-tab,
|
||||
.pipeline-tab::after {
|
||||
.pipeline-tab__tile,
|
||||
.pipeline-tab__label {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 고른 기능의 한 줄 설명. 탭과는 붙이고(20px), 아래 폼과는 섹션 간격(32px)을 둔다 —
|
||||
설명은 탭에 딸린 것이지 폼의 일부가 아니다 */
|
||||
.pipeline-desc {
|
||||
width: 100%;
|
||||
margin: 0 0 32px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
letter-spacing: -0.006em;
|
||||
text-align: center;
|
||||
color: var(--color-text-gray-300);
|
||||
/* 한글은 기본값(word-break: normal)이면 단어 중간에서 잘린다 — "애니메이/션".
|
||||
keep-all 로 어절 단위 줄바꿈을 강제한다(한국어 조판 표준) */
|
||||
word-break: keep-all;
|
||||
/* 두 줄이 될 때 길이를 고르게 나눠 한쪽만 길게 늘어지지 않게 한다 */
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* 좁은 화면 조정.
|
||||
**이 블록은 반드시 위 기본 규칙들보다 뒤에 있어야 한다** — 미디어 쿼리는 명시도를
|
||||
올리지 않아서, 앞에 두면 뒤에 오는 같은 명시도의 기본 규칙에 그대로 덮인다
|
||||
(실제로 그렇게 두었다가 모바일에서 타일이 48px 로 줄지 않았다). */
|
||||
@media (max-width: 420px) {
|
||||
/* 타일은 48dp 최소 터치 타겟이라 더 줄이지 않는다. 라벨만 줄여 한 줄을 지키고,
|
||||
타일 사이 8px 은 유지한다 — 간격을 없애면 옆 타일 오탭이 난다 */
|
||||
.pipeline-tab__tile {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.pipeline-tab__label {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pipeline-desc {
|
||||
margin-bottom: 28px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 썰박스 탭의 임시 자리 (Phase 7 에서 SsulCreateForm 으로 교체) */
|
||||
.pipeline-placeholder {
|
||||
width: 100%;
|
||||
@ -2501,33 +2582,21 @@
|
||||
}
|
||||
|
||||
.url-input-logo {
|
||||
margin-bottom: 1rem;
|
||||
/* 로고 → 탭은 "묶음 사이" 간격 (generation-flow.css 상단 간격 체계 참조) */
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* 로고는 **아래 폼 열보다 넓어질 수 없다.**
|
||||
이전에는 화면이 커질수록 350 → 400 → 500 → 554px 로 키웠는데, 정작 폼 열은
|
||||
375px 로 고정이라 데스크톱에서 로고만 좌우로 90px 씩 삐져나왔다.
|
||||
min() 으로 열 폭과 상한을 동시에 걸면 어느 폭에서도 넘지 않는다 —
|
||||
브레이크포인트를 늘려도 다시 어긋날 일이 없다. */
|
||||
.url-input-logo img {
|
||||
width: 350px;
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
width: min(100%, 280px);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.url-input-logo img {
|
||||
width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.url-input-logo img {
|
||||
width: 500px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.url-input-logo img {
|
||||
width: 554px;
|
||||
}
|
||||
}
|
||||
|
||||
.url-input-title {
|
||||
font-family: 'Pretendard', sans-serif;
|
||||
font-size: 40px;
|
||||
|
||||
@ -16,3 +16,6 @@
|
||||
castad 안에서 새로 만든 화면의 스타일은 ssulbox-castad.css 에 둔다. */
|
||||
@import './ssulbox.css';
|
||||
@import './ssulbox-castad.css';
|
||||
|
||||
/* Poster to Video (F1 영상 · F2 스타일링) — 모든 규칙이 `.p2v-scope` 안에 갇혀 있다 */
|
||||
@import './poster-p2v.css';
|
||||
|
||||
938
src/styles/poster-p2v.css
Normal file
938
src/styles/poster-p2v.css
Normal 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; }
|
||||
}
|
||||
@ -25,13 +25,15 @@
|
||||
color: var(--color-text-white);
|
||||
}
|
||||
|
||||
/* 간격은 generation-flow.css 상단의 체계를 따른다 (묶음 안 8~12 / 묶음 사이 20 / 섹션 32) */
|
||||
.ssul-create__step {
|
||||
align-self: flex-start;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-ssul-accent);
|
||||
margin-bottom: 10px;
|
||||
color: var(--color-mint);
|
||||
/* 라벨과 그 라벨이 설명하는 것은 붙여야 한 덩어리로 읽힌다 */
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* 2단계 라벨. 강조를 1단계에만 두어 시선 순서를 만든다 */
|
||||
@ -44,9 +46,10 @@
|
||||
.ssul-create__scenarios {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
margin-bottom: 22px;
|
||||
/* STEP 1 → STEP 2 는 주제가 바뀌는 자리라 섹션 간격 */
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.ssul-scn {
|
||||
@ -54,8 +57,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
padding: 14px 12px;
|
||||
gap: 6px;
|
||||
padding: 16px 14px;
|
||||
/* 테두리 두께는 모든 상태에서 1px 로 고정한다. 선택 시 굵기를 바꾸면
|
||||
border-box 안쪽 폭이 줄어 글자가 흔들린다 — 굵기는 box-shadow 링으로 더한다 */
|
||||
border: 1px solid var(--color-border-white-10);
|
||||
@ -69,56 +72,25 @@
|
||||
transition: border-color var(--transition-normal), transform var(--transition-normal);
|
||||
}
|
||||
|
||||
/* 시나리오 표지 그라디언트를 옅게 깐다.
|
||||
--scn / --scn-a / --scn-b 는 컴포넌트가 인라인으로 넘긴다. */
|
||||
.ssul-scn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(150deg, var(--scn-a), var(--scn-b));
|
||||
/* 비선택은 눌러 둔다 — 채도까지 낮춰야 선택 카드와 확실히 갈린다 */
|
||||
opacity: 0.22;
|
||||
filter: saturate(0.75);
|
||||
pointer-events: none;
|
||||
transition: opacity var(--transition-normal), filter var(--transition-normal);
|
||||
}
|
||||
|
||||
/* 선택한 카드는 채도·명도를 함께 올린다.
|
||||
불투명도만 올리면 어두운 배경과 섞여 오히려 탁해지므로 filter 로 직접 올린다. */
|
||||
.ssul-scn.active::before {
|
||||
opacity: 0.72;
|
||||
filter: saturate(1.55) brightness(1.18);
|
||||
}
|
||||
|
||||
.ssul-scn:hover {
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 선택 표시는 밝은 중립색 테두리로 둔다.
|
||||
앰버(썰박스 강조색)를 쓰면 시나리오마다 다른 표지색과 부딪힌다 —
|
||||
특히 삼국지(적)·오디세이(청) 위에서 탁해진다. */
|
||||
/* 선택 표시 — castad .btn-select.selected 와 동일한 문법(민트 테두리 + 민트 틴트) */
|
||||
.ssul-scn.active {
|
||||
border-color: var(--color-mint);
|
||||
background: var(--color-mint-10);
|
||||
/* 링을 **테두리와 같은 색**으로 둔다. 색이 다르면 안쪽 테두리와 바깥 링이
|
||||
갈라져 두 줄로 보인다. 같은 색이면 1px 테두리 + 1px 링이 이어져
|
||||
2px 한 줄로 읽히고, 테두리 두께를 바꾸지 않아 글자도 흔들리지 않는다.
|
||||
마지막 그림자만 시나리오 대표색 글로우. */
|
||||
box-shadow:
|
||||
0 0 0 1px var(--color-mint),
|
||||
0 0 20px -2px var(--scn);
|
||||
2px 한 줄로 읽히고, 테두리 두께를 바꾸지 않아 글자도 흔들리지 않는다. */
|
||||
box-shadow: 0 0 0 1px var(--color-mint);
|
||||
}
|
||||
|
||||
/* 표지가 밝아진 만큼 설명 글자도 올려 대비를 지킨다 */
|
||||
.ssul-scn.active .ssul-scn__desc {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.ssul-scn__emoji {
|
||||
position: relative;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.ssul-scn__name {
|
||||
position: relative;
|
||||
font-size: 14px;
|
||||
@ -139,13 +111,17 @@
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
/* 입력과 바로 아래 안내는 한 덩어리 — 붙여 둔다 */
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* 이 폼의 가로 박스(입력·안내·선택결과·제출)는 높이를 48px 로 맞춘다.
|
||||
제각각이면(입력 46 / 안내 38 / 버튼 48) 세로로 쌓였을 때 리듬이 깨져
|
||||
정렬이 틀어진 것처럼 보인다. 48px 은 CTA·터치 타겟 기준값이다. */
|
||||
.ssul-create__input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 46px;
|
||||
min-height: 48px;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
@ -233,16 +209,20 @@
|
||||
color: var(--color-text-gray-400);
|
||||
}
|
||||
|
||||
/* 안내(.ssul-hint)와 같은 자리에 번갈아 나오므로 아래 간격도 같아야 한다 */
|
||||
.ssul-picked {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
/* 안내(.ssul-hint)와 같은 자리에 번갈아 나오므로 하한을 맞춘다.
|
||||
업체명 + 주소 2줄이라 실제로는 이보다 커지는데, 그건 내용이 늘어난 것이라 괜찮다 */
|
||||
min-height: 48px;
|
||||
padding: 11px 14px;
|
||||
margin-bottom: 14px;
|
||||
border: 1.5px solid var(--color-ssul-accent);
|
||||
margin-bottom: 28px;
|
||||
border: 1.5px solid var(--color-mint);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 184, 107, 0.08);
|
||||
background: var(--color-mint-10);
|
||||
}
|
||||
|
||||
.ssul-picked__body {
|
||||
@ -264,9 +244,14 @@
|
||||
}
|
||||
|
||||
.ssul-hint {
|
||||
/* 높이를 입력·제출과 같은 48px 로. 글이 길어 두 줄이 되면 그때만 늘어난다 */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 14px;
|
||||
/* 안내 다음은 비용·제출 묶음이라 섹션 간격을 준다 */
|
||||
margin-bottom: 28px;
|
||||
border-radius: 12px;
|
||||
background: var(--color-bg-card-inner);
|
||||
font-size: 12px;
|
||||
@ -292,7 +277,7 @@
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-ssul-accent);
|
||||
background: var(--color-mint);
|
||||
}
|
||||
|
||||
.ssul-hint.warn {
|
||||
@ -300,12 +285,14 @@
|
||||
}
|
||||
|
||||
/* ── 비용 고지 + 제출 ────────────────────────────── */
|
||||
/* 비용 고지와 제출 버튼은 한 덩어리 — 누르기 직전에 읽어야 하므로 붙여 둔다 */
|
||||
.ssul-create__cost {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 10px 4px;
|
||||
padding: 0 4px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-gray-400);
|
||||
}
|
||||
@ -315,7 +302,7 @@
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-weight: 700;
|
||||
color: var(--color-ssul-accent);
|
||||
color: var(--color-mint);
|
||||
}
|
||||
|
||||
.ssul-create__coin {
|
||||
@ -323,6 +310,7 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* castad .btn-cta 와 같은 문법 — 퍼플은 페이지에서 이 CTA 한 곳만 쓴다 */
|
||||
.ssul-create__submit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -331,18 +319,18 @@
|
||||
height: 48px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, #ff8a5c, var(--color-ssul-accent));
|
||||
color: #1a1200;
|
||||
background: var(--color-purple);
|
||||
color: var(--color-text-white);
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: filter var(--transition-normal), box-shadow var(--transition-normal);
|
||||
box-shadow: var(--shadow-purple);
|
||||
transition: background-color var(--transition-normal);
|
||||
}
|
||||
|
||||
.ssul-create__submit:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
box-shadow: 0 4px 28px rgba(255, 180, 90, 0.4);
|
||||
background: var(--color-purple-hover);
|
||||
}
|
||||
|
||||
.ssul-create__submit:disabled {
|
||||
@ -376,6 +364,7 @@
|
||||
.ssul-making__scenario {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--color-mint);
|
||||
}
|
||||
|
||||
.ssul-making__title {
|
||||
@ -390,8 +379,7 @@
|
||||
}
|
||||
|
||||
/* 진행 스피너 — 원본 LoadingSection 의 회전 요소만 가져왔다.
|
||||
SVG(loading-spinner.svg)를 쓰지 않은 이유: 색이 민트로 하드코딩돼 있어
|
||||
시나리오 대표색(삼국지=적, 오디세이=청)과 부딪힌다. CSS 로 그리면 --scn 을 받는다.
|
||||
색은 ADO2 생성 스피너 표준(퍼플)을 따른다.
|
||||
`spin` 키프레임은 castad 전역에 이미 있어 재사용한다(새로 만들면 3번째 중복). */
|
||||
.ssul-making__spinner {
|
||||
position: relative;
|
||||
@ -417,9 +405,9 @@
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 7px solid transparent;
|
||||
border-top-color: var(--scn);
|
||||
border-right-color: var(--scn);
|
||||
border-bottom-color: var(--scn);
|
||||
border-top-color: var(--color-purple);
|
||||
border-right-color: var(--color-purple);
|
||||
border-bottom-color: var(--color-purple);
|
||||
animation: spin 1.1s linear infinite;
|
||||
}
|
||||
|
||||
@ -435,6 +423,7 @@
|
||||
.ssul-making__fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--color-mint);
|
||||
transition: width 400ms ease;
|
||||
}
|
||||
|
||||
|
||||
@ -24,9 +24,6 @@
|
||||
--color-purple-glow: rgba(166, 130, 255, 0.2);
|
||||
--color-purple-80: rgba(166, 130, 255, 0.8);
|
||||
|
||||
/* 썰박스 파이프라인 강조색 (진입 화면 탭 활성 표시 전용) */
|
||||
--color-ssul-accent: #ffb86b;
|
||||
|
||||
/* Background Colors - Teal-600 based */
|
||||
--color-bg-dark: #002224;
|
||||
--color-bg-darker: #001a1c;
|
||||
|
||||
263
src/utils/p2vApi.ts
Normal file
263
src/utils/p2vApi.ts
Normal 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';
|
||||
@ -32,10 +32,24 @@ export const K = {
|
||||
VIDEO_RATIO: 'castad_video_ratio',
|
||||
|
||||
// 썰박스 통합
|
||||
/** 현재 선택된 파이프라인 ('ado2' | 'ssul') */
|
||||
/** 현재 선택된 파이프라인 (PipelineTabs 의 Pipeline 값) */
|
||||
PIPELINE: 'castad_pipeline',
|
||||
/** 진행 중인 썰박스 생성 잡 ID. 권위는 서버(`/ssul/tasks/active`)이고 이건 복구 힌트다 */
|
||||
SSUL_TASK_ID: 'castad_ssul_task_id',
|
||||
|
||||
// Poster to Video 통합
|
||||
/**
|
||||
* 진행 중인 포스터 영상(F1) 잡 ID.
|
||||
*
|
||||
* 썰박스의 `/ssul/tasks/active` 같은 "내 활성 잡" 엔드포인트가 P2V 에는 없다
|
||||
* (단일 팀 키라 사용자 개념이 없다). 그래서 이 값이 새로고침 복구의 유일한 근거다 —
|
||||
* 지우면 진행 중인 잡을 화면에서 잃는다. 서버에서 잡이 사라졌으면 조회가 404 로
|
||||
* 떨어지고 GenerationFlow 가 이 키를 정리한다.
|
||||
*
|
||||
* 접근 키(castad_p2v_key)는 여기 두지 않는다 — 서버 자격증명이라 프로젝트 초기화·
|
||||
* 로그아웃으로 지워지면 시연 중 매번 다시 물어야 한다 (utils/p2vApi.ts 참조).
|
||||
*/
|
||||
POSTER_JOB_ID: 'castad_poster_job_id',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@ -52,6 +66,7 @@ export const PROJECT_KEYS: readonly string[] = [
|
||||
K.VIDEO_RATIO,
|
||||
K.PIPELINE,
|
||||
K.SSUL_TASK_ID,
|
||||
K.POSTER_JOB_ID,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
Loading…
Reference in New Issue
Block a user