Compare commits

...

2 Commits

Author SHA1 Message Date
a18f7a5c8b add fronted docker 2026-09-08 15:00:42 +09:00
5cd5ec5649 add frontend 2026-09-08 13:48:20 +09:00
50 changed files with 10190 additions and 9 deletions

View File

@ -23,7 +23,7 @@ from services.narration import generate_narration
from services.render import render
from services.tts import synthesize
from services.upscale_poster import as_png_path
from tables.task import PosterAliveTask
from tables.task import PosterAliveTask, new_task_id
from utils import blob
from utils.image import sniff_extension
@ -32,16 +32,18 @@ PIPELINE = "poster_alive"
async def create_task(session: AsyncSession, name: str, poster: bytes, *,
skip_review: bool = False) -> PosterAliveTask:
task = PosterAliveTask(name=name, skip_review=skip_review,
# poster_url이 NOT NULL이라 blob에 올린 뒤에야 행을 넣을 수 있다.
# 그 경로에 id가 필요하므로 여기서 미리 만든다
task = PosterAliveTask(id=new_task_id(), name=name, skip_review=skip_review,
stage_timings=initial_timings(PosterAliveState))
with Image.open(io.BytesIO(poster)) as image:
task.poster_width, task.poster_height = image.size
# i2v만 거절하는 하드 게이트라 여기서는 기록만 함
task.is_low_resolution = max(image.size) < MIN_LONG_EDGE_PX
session.add(task)
await session.flush() # blob 경로에 쓸 id를 확정
task.poster_url = await store_bytes(PIPELINE, task.id,
f"poster.{sniff_extension(poster)}", poster)
session.add(task)
await session.commit()
return task

View File

@ -1,6 +1,7 @@
"""현재 state부터 다음 게이트까지 단계를 이어 돌림
어느 잡을 언제 돌릴지는 worker가 정하고 여기는 실행만 함
"""
import re
import time
import traceback
@ -69,6 +70,16 @@ def reset_from(task, stage: str) -> None:
task.state = state_type(stage)
URL_IN_TEXT = re.compile(r"https?://\S+")
MAX_ERROR_DETAIL = 500
def safe_detail(failure: BaseException) -> str:
"""검수 화면까지 가는 값이라 트레이스백과 스토리지 주소는 뺀다"""
message = f"{type(failure).__name__}: {failure}"
return URL_IN_TEXT.sub("<url>", message)[:MAX_ERROR_DETAIL]
def stops_here(task, stage) -> str | None:
"""이 단계 뒤에 사람이 볼 게이트가 서는가. playreel은 게이트 이름을 돌려준다"""
if isinstance(task, PlayreelTask):
@ -115,14 +126,15 @@ async def run_until_gate(session: AsyncSession, task) -> None:
while task.state != state_type.COMPLETED:
if not await step(session, task):
return
except Exception:
except Exception as failure:
failed_stage = str(task.state)
traceback.print_exc() # 전문은 서버 로그에만 남긴다
await session.rollback()
await session.refresh(task)
mark(task, failed_stage, FAILED)
task.status = FAILED
task.error_stage = failed_stage
task.error_detail = traceback.format_exc()[-2000:]
task.error_detail = safe_detail(failure)
await session.commit()
return

View File

@ -68,7 +68,9 @@ async def upload_bytes(data: bytes, path: str, content_type: str | None = None)
headers = {"Content-Type": content_type or guess_content_type(path), **BLOB_TYPE_HEADER}
response = await get_client().put(upload_url(path), content=data, headers=headers)
if response.status_code not in (200, 201):
raise RuntimeError(f"blob 업로드 실패 {response.status_code}: {response.text[:300]}")
print(f"[blob] 업로드 실패 {response.status_code} {path}: {response.text[:300]}",
flush=True)
raise RuntimeError(f"blob 업로드 실패 {response.status_code}")
return public_url(path)
@ -78,8 +80,13 @@ async def upload_file(file_path: Path, path: str, content_type: str | None = Non
async def download_bytes(url: str) -> bytes:
"""upload_bytes가 돌려준 URL을 그대로 받아 내용을 가져온다."""
"""upload_bytes가 돌려준 URL을 그대로 받아 내용을 가져온다.
예외 메시지에 주소를 넣지 않는다 — 실패는 잡에 기록되어 화면까지 간다.
어느 파일이었는지는 로그를 본다.
"""
response = await get_client().get(with_sas(url))
if response.status_code != 200:
raise RuntimeError(f"blob 다운로드 실패 {response.status_code}: {url}")
print(f"[blob] 다운로드 실패 {response.status_code}: {url}", flush=True)
raise RuntimeError(f"blob 다운로드 실패 {response.status_code}")
return response.content

View File

@ -12,3 +12,14 @@ services:
# Higgsfield CLI 토큰을 호스트에서 참조한다. 만료 시 갱신해야 해서 읽기 전용이 아니다.
- ${HOME}/.config/higgsfield:/root/.config/higgsfield
restart: unless-stopped
frontend:
build: ./frontend
ports:
- "30100:30100"
environment:
# Next 리라이트가 /api/* 를 여기로 넘긴다
API_ORIGIN: http://backend:30101
depends_on:
- backend
restart: unless-stopped

41
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

9
frontend/AGENTS.md Normal file
View File

@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->

1
frontend/CLAUDE.md Normal file
View File

@ -0,0 +1 @@
@AGENTS.md

23
frontend/Dockerfile Normal file
View File

@ -0,0 +1,23 @@
# poster-alive — 웹 (Next.js)
FROM node:22-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-slim AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-slim AS run
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/.next ./.next
COPY --from=build /app/public ./public
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./package.json
COPY --from=build /app/next.config.ts ./next.config.ts
EXPOSE 30100
CMD ["npx", "next", "start", "-p", "30100"]

28
frontend/README.md Normal file
View File

@ -0,0 +1,28 @@
# frontend
Next.js 16 · React 19 · TypeScript. `~/workspace/o2o-ado2-poster-to-video/product/web` 를 옮겨온 것이다.
## 실행
```bash
npm ci
npm run dev # http://localhost:30100
```
백엔드는 `API_ORIGIN` 으로 지정한다. 기본값은 `http://localhost:30101` 이고,
`next.config.ts` 의 리라이트가 `/api/*` 를 그리로 넘긴다. CORS는 쓰지 않는다.
docker compose 로 띄우면 `API_ORIGIN=http://backend:30101` 이 들어간다.
## 화면
| 경로 | 하는 일 |
| --- | --- |
| `/playreel` | 공연 상품페이지 주소 → 30초 롱컷. 게이트 5회 |
| `/playreel/[id]` | 진행 상황과 게이트 검수 카드 |
| `/` `/poster` `/poster/[id]` | 포스터 업로드 → 8초 숏폼 |
| `/studio` | 포스터 스타일 변환 — **백엔드에 `/api/f2` 가 없다** |
| `/archive` `/playreel/archive` | 아카이브 — **백엔드에 `/api/archive` 가 없다** |
`?mock=<gate>` 를 붙이면 서버 없이 게이트 화면만 볼 수 있다
(`fetch_confirm` `analysis_confirm` `narration_confirm` `clip_confirm` `final_confirm`).

View File

@ -0,0 +1,96 @@
"use client";
import DisabledNotice from "@/components/disabled-notice";
import { FEATURES } from "@/lib/features";
import { use, useEffect, useState } from "react";
import Link from "next/link";
import MetadataCard from "@/components/metadata-card";
import { InternalOnlyWarning, LicenseBadge } from "@/components/license-badge";
import { apiFetch, archiveKind, type ArchiveEntry } from "@/lib/api";
function ArchiveDetailPageInner({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = use(params);
const [entry, setEntry] = useState<ArchiveEntry | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
apiFetch<ArchiveEntry>(`/api/archive/${slug}`).then(setEntry).catch((e) => setError(e.message));
}, [slug]);
if (error) return <p style={{ color: "rgb(255,122,122)" }}>{error}</p>;
if (!entry) return <p style={{ color: "var(--text-teal-1)" }}>불러오는 중…</p>;
return (
<div>
<div style={{ display: "flex", alignItems: "flex-start", marginBottom: "1rem" }}>
<Link href="/archive" className="btn-back">‹ 아카이브</Link>
</div>
<span className="tag" style={{ height: 20, padding: "0 8px", fontSize: "var(--text-xs)" }}>{archiveKind(entry)}</span>
<h1 className="page-title" style={{ textAlign: "left", margin: "0.5rem 0 1.5rem" }}>
{entry.metadata?.event_name ?? entry.name}
</h1>
<div className="archive-detail">
<div className="card" style={{ padding: "var(--s-4)" }}>
{entry.video_url ? (
<video src={entry.video_url} controls style={{ width: "100%", borderRadius: "var(--r-sm)", display: "block" }} />
) : (
// eslint-disable-next-line @next/next/no-img-element
<img src={entry.poster_url} alt={entry.name} style={{ width: "100%", borderRadius: "var(--r-sm)" }} />
)}
{entry.video_url && (
<div style={{ display: "flex", justifyContent: "center", marginTop: "var(--s-3)" }}>
<a href={entry.video_url} download className="btn-tonal-mint">MP4 다운로드</a>
</div>
)}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "var(--s-5)" }}>
{entry.metadata && <MetadataCard meta={entry.metadata} />}
{entry.narration && (
<div className="card" style={{ padding: "var(--s-5)" }}>
<p className="eyebrow" style={{ margin: "0 0 var(--s-3)" }}>
나레이션
</p>
<ol style={{ margin: 0, paddingLeft: 18, fontSize: 15, lineHeight: 2 }}>
{entry.narration.map((s, i) => <li key={i}>{s}</li>)}
</ol>
</div>
)}
{entry.f2_variants.length > 0 && (
<div>
<p className="eyebrow" style={{ margin: "0 0 var(--s-3)" }}>
스타일 변형
</p>
{entry.f2_variants.some((v) => v.license === "internal-only") && (
<InternalOnlyWarning />
)}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))", gap: "var(--s-3)", marginTop: "var(--s-3)" }}>
{entry.f2_variants.map((v) => (
<div key={v.template_id} className="card" style={{ overflow: "hidden" }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={v.image_url} alt={v.name_ko ?? v.template_id} style={{ width: "100%", display: "block" }} />
<div style={{ padding: "var(--s-2)", display: "flex", flexDirection: "column", alignItems: "center", gap: 4 }}>
<p style={{ margin: 0, fontSize: 12, fontWeight: 600, color: "var(--text-teal-1)", textAlign: "center" }}>
{v.name_ko ?? v.template_id}
</p>
<LicenseBadge license={v.license ?? "internal-only"} />
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}
export default function ArchiveDetailPage(props: Parameters<typeof ArchiveDetailPageInner>[0]) {
if (!FEATURES.archive) return <DisabledNotice title="아카이브" backHref="/" />;
return <ArchiveDetailPageInner {...props} />;
}

View File

@ -0,0 +1,60 @@
"use client";
import DisabledNotice from "@/components/disabled-notice";
import { FEATURES } from "@/lib/features";
import { useEffect, useState } from "react";
import Link from "next/link";
import { apiFetch, archiveKind, type ArchiveEntry } from "@/lib/api";
function ArchivePageInner() {
const [entries, setEntries] = useState<ArchiveEntry[] | null>(null);
useEffect(() => {
apiFetch<ArchiveEntry[]>("/api/archive").then(setEntries).catch(() => setEntries([]));
}, []);
return (
<div>
<h1 className="page-title">포스터 숏폼 아카이브</h1>
<p className="page-subtitle" style={{ marginBottom: "2rem" }}>
만들어진 모든 포스터 숏폼이 메타태그와 함께 쌓입니다. 행사·지역·키워드로 축적되는 온라인 숏폼 광고판입니다.
</p>
{entries === null && <p style={{ color: "var(--text-teal-1)" }}>불러오는 중…</p>}
{entries?.length === 0 && <p style={{ color: "var(--text-teal-3)" }}>아직 아카이브된 영상이 없습니다.</p>}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))", gap: "var(--s-5)" }}>
{entries?.map((e) => (
<Link key={e.slug} href={`/archive/${e.slug}`} style={{ textDecoration: "none", color: "inherit" }}>
<div className="card" style={{ overflow: "hidden" }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={e.poster_url} alt={e.name}
style={{ width: "100%", aspectRatio: "3/4", objectFit: "cover", display: "block" }} />
<div style={{ padding: "var(--s-4)" }}>
<span className="tag" style={{ height: 20, padding: "0 8px", fontSize: "var(--text-xs)", marginBottom: "var(--s-2)" }}>{archiveKind(e)}</span>
<p style={{ margin: 0, fontSize: 15, fontWeight: 800 }}>{e.metadata?.event_name ?? e.name}</p>
<p style={{ margin: "var(--s-1) 0 0", fontSize: 13, color: "var(--text-teal-1)" }}>
{e.metadata?.place}{e.metadata?.category ? ` · ${e.metadata.category}` : ""}
</p>
<div style={{ marginTop: "var(--s-3)", display: "flex", flexWrap: "wrap", gap: "var(--s-1)" }}>
{(e.metadata?.keywords ?? []).slice(0, 3).map((k) => (
<span key={k} className="tag" style={{ fontSize: 12 }}>{k}</span>
))}
{(e.metadata?.keywords?.length ?? 0) > 3 && (
<span className="tag" style={{ fontSize: 12 }}>+{(e.metadata!.keywords.length - 3)}</span>
)}
</div>
</div>
</div>
</Link>
))}
</div>
</div>
);
}
export default function ArchivePage() {
if (!FEATURES.archive) return <DisabledNotice title="아카이브" backHref="/" />;
return <ArchivePageInner />;
}

View File

@ -0,0 +1,34 @@
import type { Metadata } from "next";
import Link from "next/link";
import Ado2Logo from "@/components/ado2-logo";
import NavLink from "@/components/nav-link";
import { FEATURES } from "@/lib/features";
export const metadata: Metadata = {
title: "ADO2 무빙포스터",
description: "포스터 한 장으로 세로 영상을 만듭니다",
};
export default function Ado2Layout({ children }: { children: React.ReactNode }) {
return (
<>
<aside className="sidebar">
<div className="sidebar-logo">
<Link href="/" style={{ color: "var(--color-text-white)", display: "inline-flex", alignItems: "baseline", gap: 8, textDecoration: "none" }}>
<Ado2Logo height={20} />
<span className="sidebar-product">MOVING POSTER</span>
</Link>
</div>
<nav className="sidebar-menu">
<NavLink href="/" icon="video">무빙포스터</NavLink>
{FEATURES.styling && <NavLink href="/studio" icon="image">포스터 스타일링</NavLink>}
{FEATURES.archive && <NavLink href="/archive" icon="folder">아카이브</NavLink>}
</nav>
<div className="sidebar-foot">무빙포스터 · 내부 빌드</div>
</aside>
<main className="main-content">
<div style={{ maxWidth: 1080, margin: "0 auto" }}>{children}</div>
</main>
</>
);
}

View File

@ -0,0 +1,107 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { apiFetch, type Job } from "@/lib/api";
import { GATE_META, type PlayreelJob } from "@/lib/playreel";
/* 무빙포스터 진입 — 두 갈래 (MOVING_POSTER_ENTRY_PLAN.md §1·§5) */
const ENTRIES = [
{
href: "/poster", icon: "IMG", title: "이미지로 시작하기",
desc: "포스터 한 장이 첫 프레임 그대로 살아나는 8~15초 무빙포스터",
flow: ["포스터 원본이 첫 프레임", "불꽃·물결·조명 등 요소만 움직임", "제목·일시 나레이션 + 음악"],
facts: [["필요한 것", "이미지 1장"], ["확인", "1회"], ["시간", "약 3분"], ["비용", "14크레딧"]],
cta: "포스터 올리기", hot: false, badge: null,
},
{
href: "/playreel", icon: "URL", title: "공연상품페이지로 시작하기",
desc: "상품페이지 주소 하나로 캐스팅·일정·줄거리까지 담긴 30초 예고편",
flow: ["포스터 무빙 훅 8초", "상세페이지 스크롤 (줄거리·캐스트·캐스팅 스케줄·할인)", "예매 안내 밴드 + QR"],
facts: [["필요한 것", "상품페이지 URL"], ["확인", "5회"], ["시간", "약 15분"], ["비용", "16크레딧"]],
cta: "주소 넣기", hot: true, badge: "상세페이지까지",
},
] as const;
type Recent = { id: string; name: string; href: string; kind: "이미지" | "상품페이지"; label: string; tone: string };
const F1_STATUS: Record<string, string> = {
queued: "대기 중", running: "만드는 중", awaiting_review: "검수 대기", failed: "실패", done: "완료",
};
function tone(status: string) {
return status === "failed" ? "#ff7a7a" : status === "done" ? "var(--color-mint)" : status === "awaiting_review" ? "#ffd27a" : "var(--color-text-gray-400)";
}
export default function HomePage() {
const [recent, setRecent] = useState<Recent[]>([]);
useEffect(() => {
// 두 갈래의 최근 작업을 합쳐 최신순 6개. 한쪽 API가 없어도(playreel 미구현) 다른 쪽은 보인다.
Promise.all([
apiFetch<Job[]>("/api/f1/jobs").catch(() => [] as Job[]),
apiFetch<PlayreelJob[]>("/api/playreel/jobs").catch(() => [] as PlayreelJob[]),
]).then(([f1, pr]) => {
const a: (Recent & { t: number })[] = [
...f1.map((j) => ({ id: j.id, name: j.name, href: `/poster/${j.id}`, kind: "이미지" as const, label: F1_STATUS[j.status] ?? j.status, tone: tone(j.status), t: j.created_at })),
...pr.map((j) => ({ id: j.id, name: j.name, href: `/playreel/${j.id}`, kind: "상품페이지" as const,
label: j.status === "awaiting_review" && j.gate ? `${GATE_META[j.gate].name} 대기` : (F1_STATUS[j.status] ?? j.status), tone: tone(j.status), t: j.created_at })),
];
setRecent(a.sort((x, y) => y.t - x.t).slice(0, 6));
});
}, []);
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", paddingTop: "1.5rem" }}>
<h1 className="page-title">어떻게 시작할까요?</h1>
<p className="page-subtitle">가진 것에 따라 고르세요. 둘 다 9:16 세로 영상으로 완성됩니다.</p>
<div className="entry-stack">
{ENTRIES.map((e) => (
<div key={e.href} className={`card entry${e.hot ? " entry--hot" : ""}`}>
{e.badge && <span className="entry-badge">{e.badge}</span>}
<div className="entry-head">
<div className="entry-icon">{e.icon}</div>
<div>
<h2 className="entry-title">{e.title}</h2>
<p className="entry-desc">{e.desc}</p>
</div>
</div>
<div className="entry-flow">
{e.flow.map((f, i) => (
<span key={f} style={{ display: "contents" }}>
{i > 0 && <em>→</em>}
<span>{f}</span>
</span>
))}
</div>
<div className="entry-facts">
{e.facts.map(([k, v]) => (
<div key={k}><b>{k}</b><span>{v}</span></div>
))}
</div>
<Link href={e.href} className="btn-cta btn-lg" style={{ textDecoration: "none" }}>{e.cta}</Link>
</div>
))}
</div>
{recent.length > 0 && (
<div style={{ width: "100%", maxWidth: 560, marginTop: "2.5rem" }}>
<p className="field-label" style={{ margin: "0 0 0.75rem" }}>최근 작업</p>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))", gap: "0.75rem" }}>
{recent.map((j) => (
<Link key={j.href} href={j.href} style={{ textDecoration: "none", color: "inherit" }}>
<div className="card-inner" style={{ padding: "1rem" }}>
<span className="tag" style={{ height: 20, padding: "0 8px", fontSize: "var(--text-xs)" }}>{j.kind}</span>
<p style={{ margin: "0.5rem 0 0", fontSize: "var(--text-sm)", fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{j.name}</p>
<p style={{ margin: "0.35rem 0 0", fontSize: "var(--text-xs)", fontWeight: 600, color: j.tone }}>{j.label}</p>
</div>
</Link>
))}
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,333 @@
"use client";
import { use, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import StageStepper, { stageProgress } from "@/components/stage-stepper";
import MetadataCard from "@/components/metadata-card";
import { apiFetch, useJob } from "@/lib/api";
const SUBTITLES: Record<string, string> = {
queued: "대기 중입니다. 앞선 작업이 끝나면 자동으로 시작됩니다",
running: "AI 분석 및 편집을 통해 콘텐츠를 만들고 있습니다",
awaiting_review: "나레이션과 모션을 확인하고 승인하면 음성·음악·렌더가 이어집니다",
failed: "작업이 중단되었습니다. 오류를 확인하고 다시 시도해 주세요",
done: "AI 분석 및 편집을 통해 최적화된 콘텐츠가 완성되었습니다",
};
// scripts/motion_plan.py의 MOTION_PHRASE 화이트리스트와 1:1. 여기 없는 키는 서버가 422로 거른다.
const MOTION_LABELS: Record<string, string> = {
firework: "불꽃놀이", water: "물살", wave: "파도", cloud: "구름", smoke: "연기",
moon: "달", sun: "해", star: "별", light: "조명", flag: "깃발·천",
foliage: "나뭇잎·풀", wheel: "바퀴·관람차", vessel: "배", crowd: "사람 무리",
};
export default function JobPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const router = useRouter();
const { job, error, refresh } = useJob("f1", id);
const [lines, setLines] = useState<string[]>([]);
const [motions, setMotions] = useState<string[] | null>(null);
const [meta, setMeta] = useState({ event_name: "", date_text: "", place: "" });
const [metaLoaded, setMetaLoaded] = useState(false);
const [busy, setBusy] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
// 아래 3개 effect: 서버 잡이 처음 도착했을 때 편집 상태를 한 번 시딩한다(이후 사용자 편집 우선).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- 최초 1회 시딩
if (job?.narration && lines.length === 0) setLines(job.narration);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [job?.narration]);
useEffect(() => {
// 0종([])도 유효한 초기값이므로 null(미로딩)로만 판별한다
if (job && motions === null && job.motion_elements !== undefined && job.motion_elements !== null)
// eslint-disable-next-line react-hooks/set-state-in-effect -- 최초 1회 시딩
setMotions(job.motion_elements);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [job?.motion_elements]);
useEffect(() => {
if (job?.metadata && !metaLoaded) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- 최초 1회 시딩
setMeta({
event_name: job.metadata.event_name ?? "",
date_text: job.metadata.date_text ?? "",
place: job.metadata.place ?? "",
});
setMetaLoaded(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [job?.metadata]);
if (error) return <p style={{ color: "#ff7a7a" }}>{error}</p>;
if (!job) return <p style={{ color: "var(--color-text-gray-400)" }}>불러오는 중…</p>;
const approve = async () => {
setBusy(true);
setActionError(null);
try {
if (JSON.stringify(lines) !== JSON.stringify(job.narration)) {
await apiFetch(`/api/f1/jobs/${id}/narration`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ narration: lines }),
});
}
const md = job.metadata;
if (md && (md.event_name !== meta.event_name || md.date_text !== meta.date_text || md.place !== meta.place)) {
await apiFetch(`/api/f1/jobs/${id}/metadata`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(meta),
});
}
const motionsChanged = motions !== null &&
JSON.stringify([...motions].sort()) !== JSON.stringify([...(job.motion_elements ?? [])].sort());
await apiFetch(`/api/f1/jobs/${id}/approve`, {
method: "POST",
...(motionsChanged ? {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ motions }),
} : {}),
});
refresh();
} catch (e) {
setActionError((e as Error).message);
} finally {
setBusy(false);
}
};
const retry = async () => {
setBusy(true);
setActionError(null);
try {
const motionsChanged = motions !== null &&
JSON.stringify([...motions].sort()) !== JSON.stringify([...(job!.motion_elements ?? [])].sort());
await apiFetch(`/api/f1/jobs/${id}/retry`, {
method: "POST",
...(motionsChanged ? {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ motions }),
} : {}),
});
refresh();
} catch (e) {
setActionError((e as Error).message);
} finally {
setBusy(false);
}
};
const remove = async () => {
if (!confirm(`"${job.name}" 작업과 만들어진 영상·음성·분석 파일을 모두 지웁니다. 되돌릴 수 없습니다.`)) return;
setBusy(true);
setActionError(null);
try {
await apiFetch(`/api/f1/jobs/${id}`, { method: "DELETE" });
router.push("/");
} catch (e) {
setActionError((e as Error).message);
setBusy(false);
}
};
const pct = stageProgress(job);
return (
<div>
<div style={{ display: "flex", alignItems: "flex-start" }}>
<Link href="/" className="btn-back">‹ 뒤로가기</Link>
{job.status !== "running" && (
<button onClick={remove} disabled={busy} className="btn-back"
style={{ marginLeft: "auto", color: "#ff8c8c", borderColor: "rgba(255,140,140,0.4)" }}>
작업 삭제
</button>
)}
</div>
<div className="stepper-scroll"><StageStepper job={job} /></div>
<h1 className="page-title">
{job.status === "done" ? "콘텐츠 제작 완료" : job.name}
</h1>
<p className="page-subtitle">{SUBTITLES[job.status]}</p>
<div className="card" style={{ marginTop: "2rem", padding: "var(--spacing-page-md)" }}>
{(job.status === "running" || job.status === "queued") && (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", padding: "3rem 0", gap: "1.5rem" }}>
<div className="gen-spinner" />
<p style={{ margin: 0, fontSize: "var(--text-base)", color: "var(--color-text-gray-300)" }}>
생성 중 (음악 단계는 몇 분 걸릴 수 있습니다)
</p>
<div style={{ width: 320 }}>
<div className="progress-bar-container">
<div className="progress-bar-fill" style={{ width: `${pct}%` }} />
</div>
<p style={{ textAlign: "center", margin: "0.5rem 0 0", fontSize: "var(--text-sm)", color: "var(--color-text-gray-400)" }}>{pct}%</p>
</div>
{job.narration && (
<div className="card-inner" style={{ maxWidth: 520, width: "100%" }}>
<p className="field-label" style={{ margin: "0 0 0.5rem" }}>나레이션</p>
{job.narration.map((s, i) => (
<p key={i} style={{ margin: "0.25rem 0", fontSize: "var(--text-base)", color: "var(--color-text-gray-300)" }}>{s}</p>
))}
</div>
)}
</div>
)}
{job.status === "awaiting_review" && (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--spacing-page-md)", alignItems: "start" }}>
<div>
<p className="eyebrow" style={{ margin: "0 0 1rem" }}>나레이션 검수</p>
<p style={{ fontSize: "var(--text-base)", color: "var(--color-text-gray-400)", margin: "0 0 1rem", lineHeight: 1.6 }}>
자동 작성된 3문장을 확인하고 필요하면 고쳐주세요.
</p>
{lines.map((s, i) => (
<input key={i} className="input" value={s} style={{ marginBottom: "0.5rem" }}
onChange={(e) => setLines(lines.map((v, j) => (j === i ? e.target.value : v)))} />
))}
<p className="eyebrow" style={{ margin: "1.5rem 0 0.5rem" }}>움직일 요소</p>
<p style={{ fontSize: "var(--text-sm)", color: "var(--color-text-gray-400)", margin: "0 0 0.75rem", lineHeight: 1.6 }}>
{motions !== null && motions.length === 0
? "포스터에서 움직일 요소를 찾지 못했습니다. 직접 골라 추가하거나, 비워두면 애니메이션 없이 중단됩니다."
: "AI가 포스터에서 찾은 요소입니다. 빼거나 더할 수 있습니다."}
</p>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
{Object.entries(MOTION_LABELS).map(([key, label]) => {
const on = motions?.includes(key) ?? false;
return (
<button key={key} type="button"
onClick={() => setMotions(on ? (motions ?? []).filter((m) => m !== key)
: [...(motions ?? []), key])}
style={{
padding: "0.35rem 0.8rem", borderRadius: 999, cursor: "pointer",
fontSize: "var(--text-sm)",
border: on ? "1px solid var(--color-mint)" : "1px solid var(--color-border-white-10)",
background: on ? "var(--color-mint-20)" : "transparent",
color: on ? "var(--color-mint)" : "var(--color-text-gray-400)",
}}>
{on ? "✓ " : "+ "}{label}
</button>
);
})}
</div>
<p className="eyebrow" style={{ margin: "1.5rem 0 0.5rem" }}>메타태그</p>
<p style={{ fontSize: "var(--text-sm)", color: "var(--color-text-gray-400)", margin: "0 0 0.75rem", lineHeight: 1.6 }}>
아카이브에 영구 저장됩니다. 연도나 지명이 잘못 읽혔는지 포스터와 대조해 주세요.
</p>
{([
["event_name", "행사명"],
["date_text", "일시"],
["place", "장소"],
] as const).map(([key, label]) => (
<div key={key} style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.5rem" }}>
<span style={{ width: 52, flexShrink: 0, fontSize: "var(--text-sm)", color: "var(--color-text-gray-400)" }}>{label}</span>
<input className="input" value={meta[key]}
onChange={(e) => setMeta({ ...meta, [key]: e.target.value })} />
</div>
))}
<button className="btn-cta btn-lg" style={{ marginTop: "1rem" }} disabled={busy} onClick={approve}>
승인하고 계속
</button>
{actionError && <p style={{ color: "#ff7a7a", fontSize: "var(--text-sm)", marginTop: "0.75rem" }}>{actionError}</p>}
</div>
<div>
<p className="eyebrow" style={{ margin: "0 0 1rem" }}>포스터 분석 결과</p>
{job.artifacts.check_jpg && (
// eslint-disable-next-line @next/next/no-img-element
<img src={job.artifacts.check_jpg} alt="영역 분석 확인 이미지" className="card-inner"
style={{ width: "100%", padding: 0, display: "block" }} />
)}
</div>
</div>
)}
{job.status === "failed" && job.error && (
<div style={{ maxWidth: 640, margin: "0 auto" }}>
<p className="eyebrow" style={{ color: "#ff8c8c", margin: "0 0 1rem" }}>
실패 · {job.error.stage}
</p>
<pre className="card-inner" style={{
fontSize: "var(--text-sm)", whiteSpace: "pre-wrap", wordBreak: "break-all",
maxHeight: 280, overflow: "auto", color: "var(--color-text-gray-400)", margin: 0,
}}>{job.error.detail}</pre>
{job.error.stage === "i2v" && motions !== null && (
<div style={{ marginTop: "1.25rem" }}>
<p style={{ fontSize: "var(--text-sm)", color: "var(--color-text-gray-400)", margin: "0 0 0.6rem" }}>
움직일 요소를 바꿔 다시 시도할 수 있습니다:
</p>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
{Object.entries(MOTION_LABELS).map(([key, label]) => {
const on = motions.includes(key);
return (
<button key={key} type="button"
onClick={() => setMotions(on ? motions.filter((m) => m !== key) : [...motions, key])}
style={{
padding: "0.35rem 0.8rem", borderRadius: 999, cursor: "pointer",
fontSize: "var(--text-sm)",
border: on ? "1px solid var(--color-mint)" : "1px solid var(--color-border-white-10)",
background: on ? "var(--color-mint-20)" : "transparent",
color: on ? "var(--color-mint)" : "var(--color-text-gray-400)",
}}>
{on ? "✓ " : "+ "}{label}
</button>
);
})}
</div>
</div>
)}
<div style={{ display: "flex", justifyContent: "center", marginTop: "1.5rem" }}>
<button className="btn-outline" disabled={busy} onClick={retry}>재시도</button>
</div>
{actionError && <p style={{ color: "#ff7a7a", fontSize: "var(--text-sm)", marginTop: "0.75rem", textAlign: "center" }}>{actionError}</p>}
</div>
)}
{job.status === "done" && job.artifacts.video && (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--spacing-page-md)", alignItems: "stretch" }}>
<div className="card-inner" style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
<video src={job.artifacts.video} controls
style={{ width: "100%", maxHeight: 560, borderRadius: "var(--radius-md)", display: "block" }} />
</div>
<div style={{ display: "flex", flexDirection: "column", borderLeft: "2px solid var(--color-mint-20)", paddingLeft: "var(--spacing-page)" }}>
<p className="field-label" style={{ margin: 0 }}>파일명</p>
<p style={{ margin: "0.25rem 0 1rem", fontSize: "var(--text-xl)", fontWeight: 700, lineHeight: 1.35 }}>
{job.name}.mp4
</p>
{job.metadata && (
<div style={{ borderTop: "1px solid var(--color-border-white-10)", paddingTop: "1rem" }}>
<MetadataCard meta={job.metadata} bare />
</div>
)}
{job.narration && (
<div style={{ borderTop: "1px solid var(--color-border-white-10)", marginTop: "1rem", paddingTop: "1rem" }}>
<p className="field-label" style={{ margin: "0 0 0.5rem" }}>나레이션</p>
{job.narration.map((s, i) => (
<p key={i} style={{ margin: "0.35rem 0", fontSize: "var(--text-base)", color: "var(--color-text-gray-300)" }}>{s}</p>
))}
</div>
)}
<div style={{ marginTop: "auto", paddingTop: "1.5rem", display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
{job.artifacts.thumbnail ? (
<a href={job.artifacts.thumbnail} download className="btn-tonal-mint">정지 컷 JPG</a>
) : <span />}
<a href={job.artifacts.video} download className="btn-cta"
style={{ padding: "0.75rem 1rem", fontSize: "var(--text-base)", borderRadius: "var(--radius-xl)", textDecoration: "none" }}>
MP4 다운로드
</a>
</div>
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,55 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import PosterDropzone from "@/components/poster-dropzone";
import { apiFetch } from "@/lib/api";
export default function HomePage() {
const router = useRouter();
const [file, setFile] = useState<File | null>(null);
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async () => {
if (!file) return;
setBusy(true);
setError(null);
try {
const fd = new FormData();
fd.append("poster", file);
fd.append("name", name);
const { id } = await apiFetch<{ id: string }>("/api/f1/jobs", { method: "POST", body: fd });
router.push(`/poster/${id}`);
} catch (e) {
setError((e as Error).message);
setBusy(false);
}
};
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", paddingTop: "0.5rem" }}>
<Link href="/" className="btn-back" style={{ alignSelf: "flex-start" }}>‹ 시작 방법 고르기</Link>
<h1 className="page-title" style={{ marginTop: "1.25rem" }}>포스터를 올려주세요</h1>
<p className="page-subtitle">나레이션·음악·연출까지 자동으로 만들어 9:16 숏폼으로 완성합니다</p>
<div style={{ width: "100%", maxWidth: 480, marginTop: "2.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
<PosterDropzone file={file} onFile={setFile} />
<input
className="input input--center"
placeholder="행사 이름을 입력하세요 (비우면 자동 추출)"
value={name}
onChange={(e) => setName(e.target.value)}
/>
{error && <p style={{ color: "#ff7a7a", fontSize: "var(--text-sm)", textAlign: "center", margin: 0 }}>{error}</p>}
<button className="btn-cta btn-lg" disabled={!file || busy} onClick={submit}>
{busy ? "업로드 중…" : <>숏폼 만들기 <span className="btn-sub">14크레딧</span></>}
</button>
<p className="note">공연 상품페이지 주소가 있나요? <Link href="/playreel">상품페이지로 시작하면 캐스팅·일정까지 담깁니다 →</Link></p>
</div>
</div>
);
}

View File

@ -0,0 +1,311 @@
"use client";
import DisabledNotice from "@/components/disabled-notice";
import { FEATURES } from "@/lib/features";
import { useEffect, useRef, useState } from "react";
import PosterDropzone from "@/components/poster-dropzone";
import {
InternalOnlyWarning, LicenseBadge, UserReferenceNotice,
} from "@/components/license-badge";
import { apiFetch, useJob, type F2Category, type F2Template } from "@/lib/api";
interface F2Format { id: string; label: string }
interface UploadHint { enabled: boolean; min_long_edge: number }
const USER_CATEGORY = "user";
function StudioPageInner() {
const [templates, setTemplates] = useState<F2Template[]>([]);
const [categories, setCategories] = useState<F2Category[]>([]);
const [formats, setFormats] = useState<F2Format[]>([]);
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 [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [tplOpen, setTplOpen] = useState(true);
const [hint, setHint] = useState<UploadHint | null>(null);
const [refBusy, setRefBusy] = useState(false);
const refInput = useRef<HTMLInputElement>(null);
const { job } = useJob("f2", jobId);
const selectedTpl = templates.find((t) => t.id === selected) ?? null;
const loadTemplates = () =>
apiFetch<F2Template[]>("/api/f2/templates").then(setTemplates);
useEffect(() => {
loadTemplates().catch((e) => setError(e.message));
apiFetch<F2Category[]>("/api/f2/categories").then(setCategories).catch(() => {});
apiFetch<F2Format[]>("/api/f2/formats").then(setFormats).catch(() => {});
apiFetch<UploadHint>("/api/f2/upload-hint").then(setHint).catch(() => {});
}, []);
/** 레퍼런스 업로드 — 저장 + vision 분석까지 서버가 동기로 끝낸다(10초 안팎) */
const uploadReference = async (f: File) => {
setRefBusy(true);
setError(null);
try {
const fd = new FormData();
fd.append("reference", f);
fd.append("name", f.name.replace(/\.[^.]+$/, "").slice(0, 40));
const t = await apiFetch<F2Template>("/api/f2/templates", { method: "POST", body: fd });
await loadTemplates();
setSelected(t.id); // 방금 올린 것을 바로 고른 상태로
} catch (e) {
setError((e as Error).message);
} finally {
setRefBusy(false);
if (refInput.current) refInput.current.value = "";
}
};
const removeReference = async (id: string) => {
if (!confirm("이 레퍼런스를 삭제할까요? 되돌릴 수 없습니다.")) return;
try {
await apiFetch(`/api/f2/templates/${id}`, { method: "DELETE" });
if (selected === id) setSelected(null);
await loadTemplates();
} catch (e) {
setError((e as Error).message);
}
};
const submit = async () => {
if (!file || !selected) return;
setBusy(true);
setError(null);
try {
const fd = new FormData();
fd.append("poster", file);
fd.append("template_id", selected);
fd.append("format", format);
const { id } = await apiFetch<{ id: string }>("/api/f2/jobs", { method: "POST", body: fd });
setJobId(id);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
};
const renderCard = (t: F2Template) => (
<div key={t.id} style={{ position: "relative" }}>
<button onClick={() => setSelected(t.id)}
className={selected === t.id ? "btn-select selected" : "btn-select"}
style={{ width: "100%", height: "100%", padding: "0.5rem", display: "flex", flexDirection: "column", gap: "0.5rem", alignItems: "stretch" }}>
{/* 명화는 가로 그림도 있다. cover로 자르면 파도가 잘려 무엇인지 알 수 없다 */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={t.thumb_url} alt={t.name_ko}
style={{
width: "100%", aspectRatio: "2/3", objectFit: "contain", display: "block",
borderRadius: "var(--radius-md)", background: "var(--color-bg-darker)",
}} />
<span style={{ fontSize: "var(--text-sm)", fontWeight: 700, whiteSpace: "normal", lineHeight: 1.3 }}>
{t.name_ko}
</span>
{/* 제목이 1줄인 카드와 2줄인 카드가 섞인다. 그리드가 높이를 맞춰주므로
배지를 아래로 밀어붙이면 한 행의 배지가 같은 선에 선다 */}
<span style={{ display: "flex", marginTop: "auto" }}>
<LicenseBadge license={t.license} />
</span>
</button>
{t.removable && (
<button onClick={() => removeReference(t.id)} title="레퍼런스 삭제"
style={{
position: "absolute", top: 10, right: 10, width: 26, height: 26,
borderRadius: "var(--radius-full)", border: "1px solid var(--color-border-white-10)",
background: "rgba(0,0,0,0.62)", color: "var(--color-text-gray-300)",
cursor: "pointer", fontSize: 15, lineHeight: 1, fontFamily: "var(--font)",
}}>×</button>
)}
</div>
);
const running = job && (job.status === "queued" || job.status === "running");
return (
<div>
<h1 className="page-title">포스터 스타일링</h1>
<p className="page-subtitle">
내 포스터를 명화·고전화·영화 포스터의 화법으로 재해석합니다. 행사명·날짜·장소 텍스트는 그대로 유지됩니다.
</p>
<div className="card" style={{ maxWidth: 880, margin: "1.5rem auto 0", padding: "var(--spacing-page-md)" }}>
{/* 스타일 템플릿 — 접이식, 종류가 늘어나도 접어둘 수 있다 */}
<button
onClick={() => setTplOpen(!tplOpen)}
style={{
width: "100%", display: "flex", alignItems: "center", gap: "0.75rem",
background: "none", border: "none", cursor: "pointer", padding: 0,
fontFamily: "var(--font)", color: "var(--color-text-white)", textAlign: "left",
}}
>
<span className="eyebrow">스타일 템플릿</span>
{!tplOpen && selectedTpl && (
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.5rem" }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={selectedTpl.thumb_url} alt="" style={{ width: 22, height: 30, objectFit: "cover", borderRadius: 4 }} />
<span style={{ fontSize: "var(--text-base)", fontWeight: 700, color: "var(--color-mint)" }}>{selectedTpl.name_ko}</span>
<LicenseBadge license={selectedTpl.license} />
</span>
)}
{!tplOpen && !selectedTpl && (
<span style={{ fontSize: "var(--text-base)", color: "var(--color-text-gray-500)" }}>템플릿을 선택하세요</span>
)}
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
strokeLinecap="round" strokeLinejoin="round"
style={{ marginLeft: "auto", color: "var(--color-text-gray-400)", transition: "transform 0.2s", transform: tplOpen ? "rotate(180deg)" : "none" }}>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
{tplOpen && categories.filter((c) => c.id !== USER_CATEGORY).map((c) => {
const items = templates.filter((t) => t.category === c.id);
if (items.length === 0) return null;
return (
<div key={c.id} style={{ marginTop: "1.25rem" }}>
<p style={{
margin: "0 0 0.6rem", fontSize: "var(--text-sm)", fontWeight: 700,
color: "var(--color-text-gray-400)",
}}>
{c.label}
<span style={{ marginLeft: "0.5rem", fontWeight: 500, color: "var(--color-text-gray-500)" }}>
{items.length}종
</span>
</p>
<div style={{ display: "grid", gridTemplateColumns: "repeat(5, 1fr)", gap: "0.75rem" }}>
{items.map(renderCard)}
</div>
</div>
);
})}
{/* 내 레퍼런스 — 목록이 비어도 업로드 타일은 보여야 하므로 /categories가 아니라
upload-hint의 enabled로 렌더한다 */}
{tplOpen && hint?.enabled && (
<div style={{ marginTop: "1.25rem" }}>
<p style={{
margin: "0 0 0.6rem", fontSize: "var(--text-sm)", fontWeight: 700,
color: "var(--color-text-gray-400)",
}}>
내 레퍼런스
<span style={{ marginLeft: "0.5rem", fontWeight: 500, color: "var(--color-text-gray-500)" }}>
직접 올린 이미지의 화법으로 변환합니다
</span>
</p>
<div style={{ display: "grid", gridTemplateColumns: "repeat(5, 1fr)", gap: "0.75rem" }}>
{templates.filter((t) => t.category === USER_CATEGORY).map(renderCard)}
<button onClick={() => refInput.current?.click()} disabled={refBusy}
className="btn-select"
style={{
padding: "0.5rem", display: "flex", flexDirection: "column",
alignItems: "center", justifyContent: "center", gap: "0.6rem",
minHeight: 200, borderStyle: "dashed",
}}>
{refBusy ? (
<>
<div className="gen-spinner" style={{ width: 26, height: 26, borderWidth: 3 }} />
<span style={{ fontSize: "var(--text-sm)", fontWeight: 600 }}>화풍 분석 중…</span>
</>
) : (
<>
<span style={{ fontSize: 26, lineHeight: 1, fontWeight: 300 }}>+</span>
<span style={{ fontSize: "var(--text-sm)", fontWeight: 700 }}>레퍼런스 추가</span>
<span style={{ fontSize: "var(--text-xs)", color: "var(--color-text-gray-500)", whiteSpace: "normal", lineHeight: 1.4 }}>
긴 변 {hint.min_long_edge}px 이상 권장
</span>
</>
)}
</button>
<input ref={refInput} type="file" accept="image/jpeg,image/png,image/webp,image/gif"
style={{ display: "none" }}
onChange={(e) => { const f = e.target.files?.[0]; if (f) uploadReference(f); }} />
</div>
<div style={{ marginTop: "0.75rem" }}><UserReferenceNotice /></div>
</div>
)}
{/* 경고는 실제로 고른 순간에만. 항상 떠 있으면 아무도 안 읽는다 */}
{selectedTpl && selectedTpl.license === "internal-only" && (
<div style={{ marginTop: "1.25rem" }}>
<InternalOnlyWarning note={selectedTpl.license_note} />
</div>
)}
{selectedTpl?.attribution && (
<p style={{
margin: "0.75rem 0 0", fontSize: "var(--text-xs)",
color: "var(--color-text-gray-500)", lineHeight: 1.6,
}}>
레퍼런스 출처 · {selectedTpl.attribution}
</p>
)}
{/* 입력(=원본)과 결과, 같은 크기의 4:5 박스 */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--spacing-page-md)", marginTop: "2rem" }}>
<div>
<p className="eyebrow" style={{ margin: "0 0 0.75rem" }}>내 포스터</p>
<PosterDropzone file={file} onFile={setFile} />
</div>
<div>
<p className="eyebrow" style={{ margin: "0 0 0.75rem" }}>결과</p>
{job?.status === "done" && job.artifacts.image ? (
<div className="card-inner" style={{
aspectRatio: "4 / 5", maxWidth: 400, margin: "0 auto", overflow: "hidden",
display: "flex", alignItems: "center", justifyContent: "center", padding: 0,
}}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={job.artifacts.image} alt="변환 결과" style={{ width: "100%", height: "100%", objectFit: "contain" }} />
</div>
) : (
<div className="card-inner" style={{
aspectRatio: "4 / 5", maxWidth: 400, margin: "0 auto",
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: "1rem",
}}>
{running && <div className="gen-spinner" style={{ width: 48, height: 48, borderWidth: 4 }} />}
<span style={{ fontSize: "var(--text-base)", color: running ? "var(--color-text-gray-300)" : "var(--color-text-gray-500)" }}>
{running ? "변환 중 (30초 안팎)" : "변환 결과가 여기에 표시됩니다"}
</span>
</div>
)}
{job?.status === "done" && job.artifacts.image && (
<div style={{ display: "flex", justifyContent: "center", marginTop: "0.75rem" }}>
<a href={job.artifacts.image} download className="btn-tonal-mint">PNG 다운로드</a>
</div>
)}
</div>
</div>
<p className="eyebrow" style={{ margin: "1.5rem 0 0.75rem" }}>출력 포맷</p>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: "0.75rem" }}>
{formats.map((f) => (
<button key={f.id} onClick={() => setFormat(f.id)}
className={format === f.id ? "btn-select selected" : "btn-select"}>
{f.label}
</button>
))}
</div>
<div style={{ display: "flex", justifyContent: "center", marginTop: "2rem" }}>
<button className="btn-cta" disabled={!file || !selected || busy || !!running} onClick={submit}>
{running ? "변환 중…" : busy ? "업로드 중…" : "스타일 변환"}
</button>
</div>
{error && <p style={{ color: "#ff7a7a", fontSize: "var(--text-sm)", marginTop: "0.75rem", textAlign: "center" }}>{error}</p>}
{job?.status === "failed" && job.error && (
<pre className="card-inner" style={{
fontSize: "var(--text-sm)", whiteSpace: "pre-wrap", color: "var(--color-text-gray-400)",
marginTop: "1rem", maxHeight: 200, overflow: "auto",
}}>{job.error.detail}</pre>
)}
</div>
</div>
);
}
export default function StudioPage() {
if (!FEATURES.styling) return <DisabledNotice title="포스터 스타일링" backHref="/" />;
return <StudioPageInner />;
}

View File

@ -0,0 +1,45 @@
import type { Metadata } from "next";
import Link from "next/link";
import Ado2Logo from "@/components/ado2-logo";
import NavLink from "@/components/nav-link";
import { FEATURES } from "@/lib/features";
/* Playreel 자체 셸.
* ADO2 BI 는 넣는다 — Playreel 은 별도 URL 로 나가지만 ADO2 제품이고,
* 서브도메인(playreel.o2osolution.ai)을 권한 이유도 소속이 드러나는 편이 B2B 에 유리해서다.
* 하우스 락업 문법은 (ado2) 셸과 같다: ADO2 로고 + 제품 워드마크.
* 다만 여기서는 Playreel 이 서비스 이름이라 워드마크에 더 무게를 준다.
*
* ADO2 "화면"으로 건너가는 링크는 두지 않는다 — 별도 서비스가 자기 화면에서
* 남의 서비스를 광고할 이유가 없다. 소속 표기와 이동 링크는 다른 문제다. */
export const metadata: Metadata = {
title: "Playreel — 공연 예고편 · ADO2",
description: "공연 상품페이지 주소 하나로 30초 세로 예고편을 만듭니다",
};
export default function PlayreelLayout({ children }: { children: React.ReactNode }) {
return (
<>
<aside className="sidebar">
<div className="sidebar-logo">
<Link href="/playreel" className="pr-wordmark">
<Ado2Logo height={24} />
<span className="pr-line">
<span className="pr-mark">PLAYREEL</span>
<span className="pr-mark-ko">플레이릴</span>
</span>
</Link>
</div>
<nav className="sidebar-menu">
<NavLink href="/playreel" icon="video">예고편 만들기</NavLink>
{FEATURES.archive && <NavLink href="/playreel/archive" icon="folder">아카이브</NavLink>}
</nav>
<div className="sidebar-foot">Playreel · an ADO2 product · 내부 빌드</div>
</aside>
<main className="main-content">
<div style={{ maxWidth: 1080, margin: "0 auto" }}>{children}</div>
</main>
</>
);
}

View File

@ -0,0 +1,181 @@
"use client";
import { use, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import StageStepper, { stageProgress } from "@/components/stage-stepper";
import { GateBody, GateShell } from "@/components/gate-card";
import { apiFetch } from "@/lib/api";
import { GATE_META, PLAYREEL_STAGE_LABELS, usePlayreelJob } from "@/lib/playreel";
import { useAutoApprove } from "@/lib/prefs";
import { isAutoApprovable } from "@/components/auto-approve";
const RUNNING_COPY: Record<string, string> = {
fetch: "상세페이지에서 포스터와 정보를 가져오는 중", split: "상세페이지를 섹션으로 나누는 중",
upscale: "포스터 화질을 보정하는 중 (약 1분)", analyze: "포스터에서 움직일 요소를 찾는 중",
motion: "연출 프롬프트를 쓰는 중", narration: "나레이션 문장을 쓰는 중",
tts: "나레이션을 읽는 중", bgm: "배경음악을 만드는 중 (몇 분 걸릴 수 있습니다)",
i2v: "포스터를 움직이는 중 (5~8분)", hybrid: "원본 글자를 다시 덮는 중",
compose: "상세페이지 스크롤과 함께 조립하는 중", review: "자동 검사 중",
};
export default function PlayreelJobPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const router = useRouter();
const mock = useSearchParams().get("mock");
const { job, error, refresh } = usePlayreelJob(id, mock);
const edits = useRef<unknown>(null);
const [busy, setBusy] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const prefs = useAutoApprove();
const autoFired = useRef<Set<string>>(new Set()); // 같은 게이트에 두 번 보내지 않는다
const [autoNote, setAutoNote] = useState<string | null>(null);
// 게이트 ①·③ 자동 승인 — 폴링이 awaiting_review 를 보는 순간 한 번만 approve. mock 은 화면 확인용이라 제외.
const autoGate = !mock && job?.status === "awaiting_review" && isAutoApprovable(job.gate) && prefs[job.gate] ? job.gate : null;
useEffect(() => {
if (!autoGate || autoFired.current.has(autoGate)) return;
autoFired.current.add(autoGate);
const g = GATE_META[autoGate];
apiFetch(`/api/playreel/jobs/${id}/approve`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ gate: autoGate }),
}).then(() => {
setAutoNote(`${g.step}단계 ${g.name}을 자동 승인했습니다.`);
refresh();
}).catch((e) => {
autoFired.current.delete(autoGate); // 실패하면 사람이 누를 수 있게 카드가 그대로 남는다
setActionError(`자동 승인 실패 — 직접 확인해 주세요. ${(e as Error).message}`);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoGate, id]);
if (error) return <p style={{ color: "#ff7a7a" }}>{error}</p>;
if (!job) return <p style={{ color: "var(--color-text-gray-400)" }}>불러오는 중…</p>;
const post = async (path: string, body?: unknown) => {
setBusy(true);
setActionError(null);
try {
if (mock) {
// 서버 없이 화면만 볼 때 — 다음 게이트로 넘긴다
const i = job.gate ? Object.keys(GATE_META).indexOf(job.gate) : -1;
const next = Object.keys(GATE_META)[i + 1] ?? "done";
router.push(`/playreel/mock?mock=${path.endsWith("back") ? Object.keys(GATE_META)[Math.max(0, i - 1)] : next}`);
return;
}
await apiFetch(`/api/playreel/jobs/${id}/${path}`, {
method: "POST",
...(body ? { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) } : {}),
});
edits.current = null;
refresh();
} catch (e) {
setActionError((e as Error).message);
} finally {
setBusy(false);
}
};
const remove = async () => {
if (!confirm(`"${job.name}" 작업과 만들어진 파일을 모두 지웁니다. 되돌릴 수 없습니다.`)) return;
await apiFetch(`/api/playreel/jobs/${id}`, { method: "DELETE" }).catch((e) => setActionError((e as Error).message));
router.push("/");
};
const pct = stageProgress(job);
const gate = job.gate ? GATE_META[job.gate] : null;
return (
<div>
<div style={{ display: "flex", alignItems: "flex-start" }}>
<Link href="/" className="btn-back">‹ 뒤로가기</Link>
{job.status !== "running" && !mock && (
<button onClick={remove} disabled={busy} className="btn-back" style={{ marginLeft: "auto", color: "#ff8c8c", borderColor: "rgba(255,140,140,0.4)" }}>작업 삭제</button>
)}
</div>
<div className="stepper-scroll"><StageStepper job={job} labels={PLAYREEL_STAGE_LABELS} /></div>
<h1 className="page-title">{job.status === "done" ? "예고편 완성" : job.name}</h1>
<p className="page-subtitle">
{job.status === "awaiting_review" && gate ? `확인 ${gate.step}/5 · ${gate.name}이 필요합니다` :
job.status === "running" ? (RUNNING_COPY[job.stage ?? ""] ?? "만드는 중") :
job.status === "queued" ? `대기 중 · 앞에 ${job.queue_size ?? 0}편` :
job.status === "failed" ? "작업이 중단되었습니다" : "아카이브에 저장되었습니다"}
{job.credits_used > 0 && <span style={{ color: "var(--color-text-gray-500)" }}> · 사용 {job.credits_used}크레딧</span>}
</p>
<div className="card" style={{ marginTop: "2rem", padding: "var(--spacing-page-md)" }}>
{(job.status === "running" || job.status === "queued") && (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", padding: "3rem 0", gap: "1.5rem" }}>
<div className="gen-spinner" />
<p style={{ margin: 0, color: "var(--color-text-gray-300)" }}>{RUNNING_COPY[job.stage ?? ""] ?? "준비 중"}</p>
<div style={{ width: 320 }}>
<div className="progress-bar-container"><div className="progress-bar-fill" style={{ width: `${pct}%` }} /></div>
<p style={{ textAlign: "center", margin: "0.5rem 0 0", fontSize: "var(--text-sm)", color: "var(--color-text-gray-400)" }}>{pct}%</p>
</div>
<p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--color-text-gray-500)" }}>
다음 확인 요청이 오면 이 화면에 나타납니다. 창을 닫아도 작업은 계속됩니다.
</p>
{autoNote && <p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--color-mint)" }}>{autoNote} <Link href="/playreel" style={{ color: "inherit" }}>설정 바꾸기 ›</Link></p>}
</div>
)}
{autoGate && !actionError && (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", padding: "3rem 0", gap: "1rem" }}>
<div className="gen-spinner" />
<p style={{ margin: 0, color: "var(--color-text-gray-300)" }}>{GATE_META[autoGate].step}단계 {GATE_META[autoGate].name}을 자동 승인하는 중</p>
<p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--color-text-gray-500)" }}>시작 화면에서 끌 수 있습니다.</p>
</div>
)}
{job.status === "awaiting_review" && job.gate && job.review && !(autoGate && !actionError) && (
<GateShell gate={job.gate} busy={busy} error={actionError}
onApprove={() => post("approve", { gate: job.gate, edits: edits.current })}
onBack={gate?.canBack ? () => post("back") : undefined}
approveLabel={job.gate === "final_confirm" ? "승인하고 저장" : job.gate === "clip_confirm" ? "이 클립으로 계속" : undefined}>
<GateBody review={job.review} onEdits={(e) => { edits.current = e; }} />
{job.gate === "clip_confirm" && (
<button className="btn-outline btn-lg" style={{ marginTop: "0.75rem" }} disabled={busy} onClick={() => post("retry")}>
다시 만들기 (재과금)
</button>
)}
</GateShell>
)}
{job.status === "failed" && job.error && (
<div style={{ maxWidth: 640, margin: "0 auto" }}>
<p className="eyebrow" style={{ color: "#ff8c8c", margin: "0 0 1rem" }}>실패 · {PLAYREEL_STAGE_LABELS[job.error.stage] ?? job.error.stage}</p>
<pre className="card-inner" style={{ fontSize: "var(--text-sm)", whiteSpace: "pre-wrap", wordBreak: "break-all", maxHeight: 280, overflow: "auto", color: "var(--color-text-gray-400)", margin: 0 }}>{job.error.detail}</pre>
<div className="fail-actions">
<button className="btn-outline" disabled={busy} onClick={() => post("retry")}>다시 시도</button>
{job.error.stage === "i2v" && (
<button className="btn-tonal-mint" disabled={busy} onClick={() => post("force")}>클립은 쓸 만함 — 이대로 진행</button>
)}
</div>
{actionError && <p style={{ color: "#ff7a7a", fontSize: "var(--text-sm)", marginTop: "0.75rem", textAlign: "center" }}>{actionError}</p>}
</div>
)}
{job.status === "done" && (
<div className="result-grid">
<div className="card-inner result-video">
{job.artifacts.video
? <video src={job.artifacts.video} controls style={{ width: "100%", maxHeight: 560, borderRadius: "var(--radius-md)", display: "block" }} />
: <p style={{ color: "var(--color-text-gray-500)", fontSize: "var(--text-sm)" }}>영상</p>}
</div>
<div className="result-meta">
<p className="field-label" style={{ margin: 0 }}>파일명</p>
<p style={{ margin: "0.25rem 0 1rem", fontSize: "var(--text-xl)", fontWeight: 700 }}>{job.source.slug}_30_v{job.version}.mp4</p>
<p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--color-text-gray-400)" }}>출처 · <a href={job.source.url} target="_blank" rel="noreferrer" style={{ color: "var(--color-mint)" }}>공연 상품페이지 열기 →</a></p>
<div className="result-actions">
<Link href="/" className="btn-tonal-mint" style={{ textDecoration: "none", textAlign: "center" }}>새 버전 만들기</Link>
<a href={job.artifacts.video} download className="btn-cta" style={{ padding: "0.75rem 1rem", fontSize: "var(--text-base)", borderRadius: "var(--radius-xl)", textDecoration: "none" }}>MP4 다운로드</a>
</div>
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,88 @@
"use client";
import DisabledNotice from "@/components/disabled-notice";
import { FEATURES } from "@/lib/features";
import { useEffect, useState } from "react";
import Link from "next/link";
import { apiFetch } from "@/lib/api";
/* Playreel 전용 아카이브.
* (ado2) 그룹의 /archive 로 보내면 독립 셸이 깨진다 — 같은 데이터를 여기서 보여주고
* playreel 갈래만 걸러낸다. 저장소는 공유다(요금 기획서 §8-⑤ 권장: 통합 유지). */
type Entry = {
slug: string;
kind?: string;
name: string;
poster_url?: string | null;
video_url?: string | null;
thumbnail_url?: string | null;
version?: number | null;
credits_used?: number | null;
source?: { url?: string } | null;
created_at?: number;
};
function PlayreelArchivePageInner() {
const [items, setItems] = useState<Entry[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
apiFetch<Entry[]>("/api/archive")
// kind 가 없던 옛 엔트리는 source 유무로 추정한다(프론트 archiveKind 와 같은 규칙)
.then((all) => setItems(all.filter((e) => e.kind === "playreel" || (!e.kind && e.source))))
.catch((e) => setError((e as Error).message));
}, []);
return (
<div style={{ paddingTop: "0.5rem" }}>
<h1 className="page-title">아카이브</h1>
<p className="page-subtitle">완성한 예고편이 여기 쌓입니다. 버전은 덮어쓰지 않습니다.</p>
{error && <p style={{ color: "#ff7a7a", textAlign: "center", marginTop: "1.5rem" }}>{error}</p>}
{items && items.length === 0 && (
<p className="page-subtitle" style={{ marginTop: "2.5rem" }}>
아직 완성한 예고편이 없습니다. <Link href="/playreel">첫 예고편 만들기 →</Link>
</p>
)}
{items && items.length > 0 && (
<div className="pr-archive">
{items.map((e) => (
<div key={e.slug} className="card-inner" style={{ padding: "0.85rem" }}>
{e.video_url ? (
<video
src={e.video_url}
poster={e.thumbnail_url ?? undefined}
controls
preload="none"
style={{ width: "100%", borderRadius: "var(--radius-md)", display: "block", background: "#000" }}
/>
) : (
<div className="pr-archive-blank">영상 없음</div>
)}
<p style={{ margin: "0.6rem 0 0", fontSize: "var(--text-sm)", fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{e.name}
</p>
<p style={{ margin: "0.3rem 0 0", fontSize: "var(--text-xs)", color: "var(--color-text-gray-500)" }}>
{e.version ? `v${e.version}` : "—"}
{e.credits_used != null && ` · ${e.credits_used}크레딧`}
</p>
{e.video_url && (
<a href={e.video_url} download className="btn-outline" style={{ display: "block", textAlign: "center", marginTop: "0.6rem", padding: "0.5rem", fontSize: "var(--text-sm)", textDecoration: "none" }}>
MP4 다운로드
</a>
)}
</div>
))}
</div>
)}
</div>
);
}
export default function PlayreelArchivePage() {
if (!FEATURES.archive) return <DisabledNotice title="아카이브" backHref="/playreel" />;
return <PlayreelArchivePageInner />;
}

View File

@ -0,0 +1,134 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { GATE_META, parseGoodsId, type PlayreelJob } from "@/lib/playreel";
import { AutoApprovePanel } from "@/components/auto-approve";
const STATUS_LABEL: Record<string, string> = {
queued: "대기 중", running: "만드는 중", awaiting_review: "검수 대기", failed: "실패", done: "완료",
};
function statusTone(s: string) {
return s === "failed" ? "#ff7a7a"
: s === "done" ? "var(--color-mint)"
: s === "awaiting_review" ? "#ffd27a"
: "var(--color-text-gray-400)";
}
export default function PlayreelStartPage() {
const router = useRouter();
const [url, setUrl] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const goodsId = useMemo(() => parseGoodsId(url), [url]);
// 독립 서비스가 되면서 ADO2 홈의 "최근 작업"을 잃는다. 여기서 대신 보여준다.
const [recent, setRecent] = useState<PlayreelJob[]>([]);
useEffect(() => {
apiFetch<PlayreelJob[]>("/api/playreel/jobs")
.then((j) => setRecent(j.slice(0, 6)))
.catch(() => setRecent([]));
}, []);
const submit = async () => {
if (!goodsId) return;
setBusy(true);
setError(null);
try {
const { id } = await apiFetch<{ id: string }>("/api/playreel/jobs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: url.trim() }),
});
router.push(`/playreel/${id}`);
} catch (e) {
setError((e as Error).message);
setBusy(false);
}
};
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", paddingTop: "0.5rem" }}>
<h1 className="page-title">공연 상세페이지 주소만 넣으세요</h1>
<p className="page-subtitle" style={{ lineHeight: 1.7 }}>
포스터 · 캐스팅 · 일정 · 줄거리를 읽어<br />
30초 세로 예고 영상을 만듭니다. 중요한 순간마다 확인을 요청합니다.
</p>
<div style={{ width: "100%", maxWidth: 560, marginTop: "2rem", display: "flex", flexDirection: "column", gap: "0.75rem" }}>
<div style={{ position: "relative" }}>
<input
className="input"
placeholder="공연 상품페이지 주소를 붙여넣으세요"
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") submit(); }}
style={{ paddingRight: goodsId ? 150 : undefined }}
autoFocus
/>
{goodsId && (
<span className="tag" style={{ position: "absolute", right: 14, top: "50%", transform: "translateY(-50%)", background: "var(--color-mint)", color: "var(--color-bg-dark)" }}>
상품 {goodsId} 인식됨
</span>
)}
</div>
{url.trim() && !goodsId && (
<p style={{ margin: 0, textAlign: "center", fontSize: "var(--text-sm)", color: "#ffb37a" }}>
아직 지원하지 않는 주소입니다. 공연 상품페이지 주소를 넣어주세요.
</p>
)}
{error && <p style={{ color: "#ff7a7a", fontSize: "var(--text-sm)", textAlign: "center", margin: 0 }}>{error}</p>}
<button className="btn-cta btn-lg" disabled={!goodsId || busy} onClick={submit}>
{busy ? "상세페이지 읽는 중…" : "예고편 만들기"}
</button>
</div>
{/* 게이트 ①·③ 자동 승인 — 시작 전에 정하고, 되돌리는 자리도 여기 */}
<div style={{ width: "100%", maxWidth: 720, marginTop: "3rem" }}>
<AutoApprovePanel />
</div>
{/* 어떻게 진행되나 — ICP가 처음 볼 때 확인 5회를 미리 알게 한다 */}
<div style={{ width: "100%", maxWidth: 720, marginTop: "2rem" }}>
<p className="field-label" style={{ margin: "0 0 0.75rem" }}>진행 순서 · 확인이 필요한 순간 5번</p>
<div className="playreel-steps">
{(Object.keys(GATE_META) as (keyof typeof GATE_META)[]).map((k) => {
const g = GATE_META[k];
return (
<div key={k} className="card-inner" style={{ padding: "0.85rem" }}>
<p style={{ margin: 0, fontSize: "var(--text-xs)", color: "var(--color-mint)", fontWeight: 700 }}>{g.step}단계</p>
<p style={{ margin: "0.25rem 0 0", fontSize: "var(--text-sm)", fontWeight: 700 }}>{g.name}</p>
<p style={{ margin: "0.35rem 0 0", fontSize: "var(--text-xs)", color: "var(--color-text-gray-500)" }}>
{g.credits > 0 ? `${g.credits}크레딧` : "무료"} · {g.eta}
</p>
</div>
);
})}
</div>
</div>
{recent.length > 0 && (
<div style={{ width: "100%", maxWidth: 720, marginTop: "2.5rem" }}>
<p className="field-label" style={{ margin: "0 0 0.75rem" }}>최근 작업</p>
<div className="playreel-recent">
{recent.map((j) => {
const label = j.status === "awaiting_review" && j.gate
? `${GATE_META[j.gate].name} 대기`
: STATUS_LABEL[j.status] ?? j.status;
return (
<Link key={j.id} href={`/playreel/${j.id}`} style={{ textDecoration: "none", color: "inherit" }}>
<div className="card-inner" style={{ padding: "0.85rem" }}>
<p style={{ margin: 0, fontSize: "var(--text-sm)", fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{j.name}</p>
<p style={{ margin: "0.35rem 0 0", fontSize: "var(--text-xs)", fontWeight: 600, color: statusTone(j.status) }}>{label}</p>
</div>
</Link>
);
})}
</div>
</div>
)}
</div>
);
}

BIN
frontend/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

517
frontend/app/globals.css Normal file
View File

@ -0,0 +1,517 @@
@import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css");
@import "tailwindcss";
/* ADO2 실제 프로덕트 토큰 — ado2.o2osolution.ai/assets/index.*.css 에서 추출 (2026-08-12) */
:root {
--color-bg-darker: #001a1c;
--color-bg-dark: #002224;
--color-bg-card: #003538;
--color-bg-card-inner: #004548;
--color-mint: #a6ffea;
--color-mint-hover: #8affda;
--color-mint-10: rgba(166, 255, 234, 0.1);
--color-mint-20: rgba(166, 255, 234, 0.2);
--color-mint-30: rgba(166, 255, 234, 0.3);
--color-mint-glow: rgba(166, 255, 234, 0.6);
--color-purple: #a682ff;
--color-purple-hover: #9570f0;
--color-purple-glow: rgba(166, 130, 255, 0.2);
--color-text-white: #ffffff;
--color-text-gray-300: #d1d5db;
--color-text-gray-400: #9ca3af;
--color-text-gray-500: #6b7280;
--color-border-white-5: rgba(255, 255, 255, 0.05);
--color-border-white-10: rgba(255, 255, 255, 0.1);
--color-border-gray-600: #4b5563;
--color-border-gray-700: #374151;
--radius-sm: 0.5rem;
--radius-md: 0.75rem;
--radius-xl: 1.25rem;
--radius-2xl: 1rem;
--radius-3xl: 1.5rem;
--radius-full: 9999px;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 1.875rem;
--text-4xl: 2.25rem;
--shadow-mint-glow: 0 0 10px rgba(166, 255, 234, 0.6);
--shadow-purple: 0 10px 15px -3px rgba(166, 130, 255, 0.2);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
--spacing-page: 1.5rem;
--spacing-page-md: 2rem;
--transition-fast: 0.15s;
--transition-normal: 0.2s;
--transition-slow: 0.3s;
/* 기존 코드 호환 별칭 */
--bg-0: var(--color-bg-darker);
--bg-1: var(--color-bg-darker);
--bg-2: var(--color-bg-dark);
--bg-3: var(--color-bg-card);
--bg-4: var(--color-bg-card-inner);
--stroke-1: var(--color-border-gray-700);
--stroke-2: var(--color-border-white-5);
--ado-mint: var(--color-mint);
--ado-mint-2: var(--color-mint);
--ado-mint-11: var(--color-mint-10);
--ado-purple: var(--color-purple);
--ado-purple-light: var(--color-purple);
--text-white: var(--color-text-white);
--text-mint-soft: var(--color-text-gray-300);
--text-mint-mid: var(--color-text-gray-300);
--text-teal-1: var(--color-text-gray-400);
--text-teal-2: var(--color-text-gray-400);
--text-teal-3: var(--color-text-gray-500);
--text-mute-1: var(--color-text-gray-400);
--text-onpurple: var(--color-text-white);
--r-xs: 0.25rem; --r-sm: var(--radius-sm); --r-md: var(--radius-md);
--r-lg: var(--radius-3xl); --r-pill: var(--radius-full);
--s-1: 4px; --s-2: 8px; --s-3: 12px; --s-4: 16px; --s-5: 24px; --s-6: 32px; --s-7: 48px;
--font: "Pretendard", "Apple SD Gothic Neo", -apple-system, BlinkMacSystemFont,
"Helvetica Neue", "Segoe UI", system-ui, sans-serif;
--shadow-card: var(--shadow-xl);
}
html, body {
background: var(--color-bg-darker);
color: var(--color-text-white);
font-family: var(--font);
font-size: 16px;
}
body { margin: 0; -webkit-font-smoothing: antialiased; letter-spacing: -0.006em; }
* { box-sizing: border-box; }
/* ── 레이아웃: 사이드바 (프로덕트 .sidebar / .sidebar-item) ── */
.sidebar {
position: fixed;
top: 0;
left: 0;
width: 240px;
height: 100vh;
display: flex;
flex-direction: column;
background-color: var(--color-bg-dark);
border-right: 1px solid var(--color-border-white-5);
z-index: 50;
}
.sidebar-logo { padding: 1.5rem 1.5rem 0.5rem; }
.sidebar-menu { flex: 1; padding: 0 0.75rem; margin-top: 1rem; overflow-y: auto; }
.sidebar-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: var(--radius-xl);
transition: all var(--transition-normal);
cursor: pointer;
margin-bottom: 0.25rem;
text-decoration: none;
color: var(--color-text-gray-400);
font-size: 16px;
font-weight: 700;
white-space: nowrap;
}
.sidebar-item:not(.active):hover { background-color: rgba(255, 255, 255, 0.05); color: var(--color-text-white); }
.sidebar-item.active { background-color: var(--color-mint); color: var(--color-bg-dark); }
.sidebar-foot { padding: 1rem 1.5rem; font-size: var(--text-xs); color: var(--color-text-gray-500); }
.main-content { margin-left: 240px; min-height: 100vh; padding: var(--spacing-page-md); }
/* ── 카드 패널 (프로덕트 .card / .card-inner) ── */
.card {
background-color: var(--color-bg-card);
border-radius: var(--radius-3xl);
padding: var(--spacing-page);
border: 1px solid var(--color-border-white-5);
box-shadow: var(--shadow-xl);
}
.card-inner {
background-color: rgba(18, 26, 29, 0.5);
border-radius: var(--radius-2xl);
border: 1px solid var(--color-border-white-5);
padding: 1rem;
}
/* ── 타이틀 (프로덕트 .page-title) ── */
.page-title {
font-size: var(--text-3xl);
font-weight: 700;
margin: 0 0 0.5rem;
letter-spacing: -0.025em;
text-align: center;
}
.page-subtitle {
font-size: var(--text-base);
color: var(--color-text-gray-400);
text-align: center;
margin: 0;
}
/* 섹션 라벨 — 굵은 흰색, 장식 없음 */
.eyebrow {
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-text-white);
display: block;
}
.field-label { font-size: var(--text-base); font-weight: 600; color: var(--color-text-gray-300); }
/* ── 버튼 (프로덕트 .btn-*) ── */
.btn-cta {
background-color: var(--color-purple);
color: var(--color-text-white);
font-weight: 700;
padding: 1rem 4rem;
border-radius: var(--radius-full);
transition: all var(--transition-normal);
box-shadow: var(--shadow-purple);
font-size: var(--text-lg);
font-family: var(--font);
border: none;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
}
.btn-cta:hover:not(:disabled) { background-color: var(--color-purple-hover); }
.btn-cta:disabled { opacity: 0.45; cursor: default; }
.btn-mint {
background-color: var(--color-mint);
color: var(--color-bg-dark);
font-weight: 700;
padding: 1rem;
border-radius: var(--radius-xl);
transition: all var(--transition-normal);
font-size: var(--text-base);
font-family: var(--font);
border: none;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
}
.btn-mint:hover:not(:disabled) { background-color: var(--color-mint-hover); }
.btn-mint:disabled { opacity: 0.45; cursor: default; }
/* 보조 행동 (프로덕트 .btn-regenerate 계열: mint tonal) */
.btn-tonal-mint {
background-color: var(--color-mint-20);
color: var(--color-mint);
border: 1px solid var(--color-mint-30);
font-weight: 700;
padding: 0.75rem 1.25rem;
border-radius: var(--radius-xl);
transition: background-color var(--transition-normal);
font-size: var(--text-base);
font-family: var(--font);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
text-decoration: none;
}
.btn-tonal-mint:hover { background-color: var(--color-mint-30); }
.btn-outline {
background-color: transparent;
border: 1px solid var(--color-border-gray-600);
color: var(--color-text-white);
font-weight: 700;
padding: 0.75rem 2rem;
border-radius: var(--radius-full);
transition: all var(--transition-normal);
font-size: var(--text-base);
font-family: var(--font);
cursor: pointer;
white-space: nowrap;
}
.btn-outline:hover:not(:disabled) { background-color: rgba(255, 255, 255, 0.05); }
.btn-outline:disabled { opacity: 0.45; cursor: default; }
.btn-back {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1.25rem;
border-radius: var(--radius-full);
border: 1px solid var(--color-border-gray-600);
background-color: transparent;
color: var(--color-text-gray-300);
font-size: var(--text-base);
font-family: var(--font);
font-weight: 600;
transition: background-color var(--transition-normal);
cursor: pointer;
text-decoration: none;
}
.btn-back:hover { background-color: rgba(255, 255, 255, 0.05); }
/* 선택 옵션 (프로덕트 .btn-select) */
.btn-select {
padding: 0.75rem;
border-radius: var(--radius-xl);
border: 1px solid var(--color-border-gray-700);
background-color: rgba(18, 26, 29, 0.4);
color: var(--color-text-gray-400);
font-size: var(--text-sm);
font-weight: 700;
font-family: var(--font);
transition: all var(--transition-normal);
cursor: pointer;
white-space: nowrap;
}
.btn-select:hover { color: var(--color-text-white); }
.btn-select.selected {
border: 1.5px solid var(--color-mint);
color: var(--color-text-white);
background-color: var(--color-mint-10);
}
/* 정보 태그 — 클릭 안 됨 */
.tag {
display: inline-flex;
align-items: center;
height: 26px;
padding: 0 12px;
border-radius: var(--radius-full);
background: var(--color-mint-10);
color: var(--color-mint);
font-size: var(--text-sm);
font-weight: 600;
letter-spacing: -0.006em;
}
/* ── 입력 (프로덕트 흰 pill 입력) ── */
.input {
height: 56px;
background: var(--color-text-white);
color: var(--color-bg-dark);
border: 1.5px solid transparent;
border-radius: var(--radius-full);
padding: 0 1.5rem;
font: 600 var(--text-base)/1 var(--font);
letter-spacing: -0.006em;
width: 100%;
}
.input::placeholder { color: #3a8f86; font-weight: 700; }
.input--center { text-align: center; }
.input--center::placeholder { text-align: center; }
.input:focus { outline: none; border-color: var(--color-mint); }
/* ── 위저드 스텝퍼 (프로덕트 .wizard-*) ── */
.wizard-stepper {
display: flex;
align-items: flex-start;
padding: 1.5rem 2rem 3rem;
width: 100%;
max-width: 720px;
margin: 0 auto;
box-sizing: border-box;
}
.wizard-step { flex: none; width: 2rem; position: relative; display: flex; justify-content: center; z-index: 1; }
.wizard-step-line {
flex: 1;
height: 1.5px;
background-color: rgba(255, 255, 255, 0.15);
margin-top: calc(1rem - 0.75px);
transition: background-color 0.3s;
}
.wizard-step-line.done { background-color: var(--color-mint); opacity: 0.8; }
.wizard-stepper-node {
width: 2rem;
height: 2rem;
flex-shrink: 0;
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 600;
transition: background-color 0.3s, box-shadow 0.3s, color 0.3s;
}
.wizard-step.pending .wizard-stepper-node {
background-color: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.35);
border: 1.5px solid rgba(255, 255, 255, 0.15);
}
.wizard-step.current .wizard-stepper-node {
background-color: transparent;
color: var(--color-mint);
border: 2px solid var(--color-mint);
box-shadow: 0 0 10px rgba(166, 255, 234, 0.45);
}
.wizard-step.done .wizard-stepper-node {
background-color: var(--color-mint);
color: var(--color-bg-dark);
border: none;
}
.wizard-step.failed .wizard-stepper-node {
background-color: transparent;
color: #ff7a7a;
border: 2px solid #ff7a7a;
}
.wizard-stepper-label {
position: absolute;
top: 2.5rem;
left: 50%;
transform: translate(-50%);
font-size: 14px;
font-weight: 400;
color: rgba(255, 255, 255, 0.35);
white-space: nowrap;
text-align: center;
transition: color 0.3s, font-weight 0.3s;
}
.wizard-step.current .wizard-stepper-label { color: var(--color-mint); font-weight: 600; }
.wizard-step.done .wizard-stepper-label { color: rgba(255, 255, 255, 0.55); }
.wizard-step.failed .wizard-stepper-label { color: #ff7a7a; font-weight: 600; }
/* ── 진행 바 (프로덕트 .progress-bar-*) ── */
.progress-bar-container {
width: 100%;
height: 0.375rem;
background-color: #1f2937;
border-radius: var(--radius-full);
position: relative;
overflow: hidden;
}
.progress-bar-fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
background-color: var(--color-mint);
border-radius: var(--radius-full);
transition: width 0.5s ease;
}
/* 생성 중 원형 스피너 */
.gen-spinner {
width: 72px;
height: 72px;
border-radius: 50%;
border: 5px solid rgba(166, 130, 255, 0.18);
border-top-color: var(--color-purple);
animation: spin 1.1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.dot-running { animation: pulse-dot 1.2s ease-in-out infinite; }
/* ── 무빙포스터 진입 분기 (2026-08-28) — 버튼 한 규격 · 진입 카드 · 모바일 ── */
.btn-lg { height: 52px; width: 100%; padding: 0 1.5rem; font-size: var(--text-base); border-radius: var(--radius-full); gap: 0.5rem; }
.btn-sub { font-weight: 500; opacity: 0.8; font-size: var(--text-sm); }
.note { color: var(--color-text-gray-500); font-size: var(--text-sm); margin: 0; text-align: center; }
.note a { color: var(--color-mint); }
.sidebar-product { font-size: 10px; font-weight: 800; letter-spacing: 0.1em; color: var(--color-mint); white-space: nowrap; }
.entry-stack { display: flex; flex-direction: column; gap: 0.75rem; width: 100%; max-width: 560px; margin-top: 1.5rem; }
.entry { display: flex; flex-direction: column; gap: 0.85rem; padding: 1.25rem; position: relative; }
.entry--hot { border-color: var(--color-mint-30); }
.entry-badge { align-self: flex-start; background: var(--color-mint); color: var(--color-bg-dark); font-size: 11px; font-weight: 800; padding: 3px 10px; border-radius: var(--radius-full); letter-spacing: 0.04em; margin-bottom: -0.35rem; }
.entry-head { display: flex; align-items: center; gap: 0.75rem; }
.entry-icon { width: 44px; height: 44px; flex: none; border-radius: var(--radius-md); background: var(--color-mint-10); color: var(--color-mint); display: grid; place-items: center; font-weight: 800; font-size: 12px; letter-spacing: 0.04em; }
.entry-title { margin: 0; font-size: var(--text-lg); font-weight: 800; letter-spacing: -0.02em; }
.entry-desc { margin: 0.15rem 0 0; color: var(--color-text-gray-400); font-size: var(--text-sm); }
.entry-flow { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; font-size: var(--text-xs); color: var(--color-text-gray-300); }
.entry-flow span { background: var(--color-bg-card-inner); border: 1px solid var(--color-border-white-5); padding: 0.3rem 0.6rem; border-radius: var(--radius-full); }
.entry-flow em { font-style: normal; color: var(--color-text-gray-500); }
.entry-facts { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.5rem; }
.entry-facts div { background: rgba(18, 26, 29, 0.5); border: 1px solid var(--color-border-white-5); border-radius: var(--radius-md); padding: 0.5rem 0.6rem; }
.entry-facts b { display: block; font-size: var(--text-xs); color: var(--color-text-gray-500); font-weight: 600; }
.entry-facts span { font-size: var(--text-sm); font-weight: 700; font-variant-numeric: tabular-nums; }
.gate-actions { display: grid; grid-template-columns: 1fr; gap: 0.75rem; margin-top: 1.75rem; }
@media (min-width: 640px) { .gate-actions { grid-template-columns: 1fr auto; } .gate-actions .btn-outline { width: auto; grid-row: 1; } .gate-actions .btn-cta { grid-column: 2; } }
/* 결과·실패·나레이션 레이아웃 기본값(데스크톱) */
.playreel-steps { display: grid; grid-template-columns: repeat(5, 1fr); gap: 0.5rem; }
.result-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--spacing-page-md); align-items: stretch; }
.result-grid .result-video { display: flex; align-items: center; justify-content: center; min-height: 420px; }
.result-grid .result-meta { display: flex; flex-direction: column; border-left: 2px solid var(--color-mint-20); padding-left: var(--spacing-page); }
.result-actions { margin-top: auto; padding-top: 1.5rem; display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
.fail-actions { display: flex; justify-content: center; gap: 0.75rem; margin-top: 1.5rem; }
.narr-row { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.45rem; }
.archive-detail { display: grid; grid-template-columns: 300px 1fr; gap: var(--s-6); align-items: start; }
.narr-row .narr-slot { width: 84px; flex-shrink: 0; font-size: var(--text-xs); font-weight: 700; }
/* 스테퍼: 좁은 화면에서 가로 스크롤 */
.stepper-scroll { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: none; }
.stepper-scroll::-webkit-scrollbar { display: none; }
.stepper-scroll .wizard-stepper { min-width: 560px; }
@media (max-width: 767px) {
.sidebar { position: sticky; top: 0; width: 100%; height: auto; flex-direction: row; align-items: center; border-right: none; border-bottom: 1px solid var(--color-border-white-5); }
.sidebar-logo { padding: 0.75rem 1rem; }
.sidebar-menu { display: flex; margin: 0; padding: 0 0.5rem; gap: 0.25rem; }
.sidebar-item { padding: 0.5rem 0.75rem; font-size: 14px; margin: 0; }
.sidebar-item svg { display: none; }
.sidebar-foot { display: none; }
.main-content { margin-left: 0; padding: var(--spacing-page); }
.entry-facts { grid-template-columns: repeat(2, 1fr); }
.page-title { font-size: var(--text-2xl); }
.stepper-scroll .wizard-stepper { padding: 1rem 1rem 2.5rem; }
/* 상단 메뉴 행: 로고는 고정, 메뉴는 가로 스크롤(넘치는 항목이 잘리지 않게) */
.sidebar { overflow: hidden; }
.sidebar-logo { flex: none; }
.sidebar-menu { flex: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; }
.sidebar-menu::-webkit-scrollbar { display: none; }
.sidebar-product { display: none; }
.playreel-steps { grid-template-columns: repeat(2, 1fr); }
.result-grid { grid-template-columns: 1fr; }
.result-grid .result-video { min-height: 240px; }
.result-grid .result-meta { border-left: none; padding-left: 0; }
.result-actions { grid-template-columns: 1fr; }
.fail-actions { flex-direction: column; }
.fail-actions .btn-outline, .fail-actions .btn-tonal-mint { width: 100%; }
.narr-row { flex-direction: column; align-items: stretch; gap: 0.25rem; }
.narr-row .input { padding: 0 1rem; }
.archive-detail { grid-template-columns: 1fr; }
}
/* 자동 승인 환경설정 — /playreel 패널·게이트 카드 체크 */
.pref-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.7rem 0; }
.pref-row + .pref-row { border-top: 1px solid var(--color-border-white-5); }
.switch { flex: none; width: 42px; height: 24px; border-radius: 9999px; border: 1px solid var(--color-border-white-10); background: var(--color-border-white-10); position: relative; cursor: pointer; padding: 0; transition: background-color 0.15s; }
.switch-knob { position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: var(--color-text-gray-400); transition: transform 0.15s, background-color 0.15s; }
.switch.on { background: var(--color-mint); border-color: var(--color-mint); }
.switch.on .switch-knob { transform: translateX(18px); background: var(--color-bg-dark); }
.switch:focus-visible { outline: 2px solid var(--color-mint); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .switch, .switch-knob { transition: none; } }
/* ── Playreel 자체 셸 워드마크 (별도 서비스 URL — 2026-09-01) ── */
/* ADO2 로고 위 · Playreel 워드마크 아래. (ado2) 셸의 락업과 같은 문법이되
여기서는 Playreel 이 서비스 이름이라 아래쪽에 무게를 준다. */
.pr-wordmark { display: inline-flex; flex-direction: column; gap: 6px; text-decoration: none; align-items: flex-start; }
/* 두 번째 줄을 가로로 — 로고 폭과 맞춰 락업이 사각형으로 앉는다 */
.pr-line { display: flex; align-items: baseline; gap: 7px; }
.pr-wordmark svg { color: var(--color-text-gray-300); }
.pr-mark {
font-size: 18px; font-weight: 800; letter-spacing: 0.14em;
color: var(--color-text-white); line-height: 1;
}
.pr-mark-ko {
font-size: 10px; font-weight: 700; letter-spacing: 0.22em;
color: var(--color-mint);
}
.playreel-recent { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 0.75rem; }
.pr-archive { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 1rem; margin-top: 2rem; }
.pr-archive-blank {
aspect-ratio: 9 / 16; display: flex; align-items: center; justify-content: center;
background: var(--color-bg-dark); border-radius: var(--radius-md);
font-size: var(--text-xs); color: var(--color-text-gray-500);
}

16
frontend/app/layout.tsx Normal file
View File

@ -0,0 +1,16 @@
import "./globals.css";
/* 루트 레이아웃은 html/body 만 든다.
* 셸(사이드바·워드마크)은 라우트 그룹이 각자 가진다 —
* (ado2) ADO2 무빙포스터 · 포스터 스타일링 · 아카이브
* (playreel) Playreel — 별도 서비스 URL 로 나가므로 자기 이름을 쓴다
* 2026-09-01 결정. MOVING_POSTER_ENTRY_PLAN.md §5 의 "Playreel 워드마크 노출 금지"는
* 폐기됐다 — 주소창에 뜨는 이름을 화면에서 숨길 수는 없다. */
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body>{children}</body>
</html>
);
}

View File

@ -0,0 +1,19 @@
/** ADO2 공식 워드마크 — higgsfield POC/ADO2 Design System/assets/logo.svg (currentColor) */
export default function Ado2Logo({ height = 16 }: { height?: number }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={(149 / 22) * height}
height={height}
viewBox="0 0 149 22"
fill="none"
aria-label="ADO2"
>
<path
d="M 8.85 21.647 L 11.455 18.754 L 26.244 18.754 L 19.042 3.985 L 4.064 21.654 L 0 21.654 L 17.329 1.378 C 17.902 0.712 18.755 0.072 19.804 0.072 C 20.852 0.072 21.484 0.647 21.836 1.378 L 31.864 21.654 L 8.857 21.654 L 8.85 21.647 Z M 34.052 21.647 L 36.371 8.374 L 39.829 8.374 L 38.018 18.754 L 47.761 18.754 C 53.153 18.754 57.724 14.867 57.724 9.837 C 57.724 5.983 54.84 3.279 50.489 3.279 L 37.289 3.279 L 40.018 0.353 L 50.997 0.353 C 57.151 0.353 61.182 3.985 61.182 9.269 C 61.182 16.18 54.833 21.654 47.253 21.654 L 34.052 21.654 L 34.052 21.647 Z M 75.848 22 C 67.499 22 63.279 19.198 63.279 13.116 C 63.279 4.298 68.645 0 79.716 0 C 88.065 0 92.285 2.77 92.285 8.851 C 92.285 17.669 86.951 22 75.848 22 Z M 79.215 2.9 C 70.072 2.9 66.776 5.735 66.776 12.77 C 66.776 17.388 69.824 19.106 76.363 19.106 C 85.473 19.106 88.801 16.239 88.801 9.204 C 88.801 4.586 85.753 2.9 79.215 2.9 Z M 92.865 21.647 L 93.978 15.344 C 94.642 11.523 97.599 9.681 102.418 9.681 L 111.685 9.681 C 115.586 9.681 117.175 8.341 117.175 6.049 C 117.175 3.945 115.078 3.279 111.079 3.279 L 97.338 3.279 L 100.1 0.353 L 113.013 0.353 C 118.438 0.353 120.568 2.358 120.568 5.35 C 120.568 9.262 118.028 12.228 111.555 12.228 L 102.288 12.228 C 99.37 12.228 97.716 13.149 97.306 15.318 L 96.733 18.754 L 118.601 18.754 L 115.872 21.647 L 92.865 21.647 Z M 121.688 21.504 C 121.141 21.504 120.73 21.184 120.73 20.667 C 120.73 19.955 121.349 19.55 122.039 19.55 C 122.606 19.55 123.029 19.851 123.029 20.367 C 123.029 21.079 122.411 21.504 121.681 21.504 L 121.688 21.504 Z M 129.769 21.347 L 131.222 19.727 L 139.479 19.727 L 135.455 11.477 L 127.086 21.347 L 124.82 21.347 L 134.497 10.02 C 134.816 9.648 135.292 9.289 135.878 9.289 C 136.464 9.289 136.816 9.609 137.011 10.02 L 142.611 21.347 L 129.763 21.347 L 129.769 21.347 Z M 144.975 21.347 L 147.085 9.452 L 149 9.452 L 146.89 21.347 L 144.975 21.347 Z"
fill="currentColor"
fillRule="nonzero"
/>
</svg>
);
}

View File

@ -0,0 +1,62 @@
"use client";
/**
* 게이트 ①·③ 자동 승인 — (a) 시작 화면 옵션으로 확정(2026-08-28).
* <AutoApprovePanel /> /playreel 시작 화면: 스위치 2개 + 잠긴 2·4·5단계 안내. 되돌리는 자리도 여기.
* 아이콘은 SVG만(이모지 금지).
*/
import { GATE_META, type GateKey } from "@/lib/playreel";
import { AUTO_APPROVABLE, setAutoApprove, useAutoApprove, type AutoApprovable } from "@/lib/prefs";
export const isAutoApprovable = (g: GateKey | null | undefined): g is AutoApprovable =>
!!g && (AUTO_APPROVABLE as readonly string[]).includes(g);
const AUTO_COPY: Record<AutoApprovable, string> = {
fetch_confirm: "무료 · 상세페이지에서 읽은 정보와 기본 섹션을 그대로 사용",
narration_confirm: "무료 · 자동 생성 문장과 기본 목소리로 바로 진행",
};
const Lock = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden style={{ flexShrink: 0 }}>
<rect x="4" y="11" width="16" height="10" rx="2" /><path d="M8 11V7a4 4 0 0 1 8 0v4" />
</svg>
);
function Switch({ on, onChange, label }: { on: boolean; onChange: (v: boolean) => void; label: string }) {
return (
<button type="button" role="switch" aria-checked={on} aria-label={label} className={`switch${on ? " on" : ""}`} onClick={() => onChange(!on)}>
<span className="switch-knob" />
</button>
);
}
export function AutoApprovePanel() {
const prefs = useAutoApprove();
return (
<div>
<p className="field-label" style={{ margin: "0 0 0.75rem" }}>자동화 승인 단계 설정</p>
<div className="card" style={{ padding: "0.5rem 1rem" }}>
{AUTO_APPROVABLE.map((g) => {
const m = GATE_META[g];
return (
<div key={g} className="pref-row">
<div style={{ minWidth: 0 }}>
<p style={{ margin: 0, fontSize: "var(--text-sm)", fontWeight: 600 }}>{m.step}단계 · {m.name}</p>
<p style={{ margin: "2px 0 0", fontSize: "var(--text-xs)", color: "var(--color-text-gray-500)" }}>{AUTO_COPY[g]}</p>
</div>
<Switch on={!!prefs[g]} onChange={(v) => setAutoApprove(g, v)} label={`${m.name} 자동 승인`} />
</div>
);
})}
<div className="pref-row" style={{ opacity: 0.55 }}>
<div style={{ minWidth: 0 }}>
<p style={{ margin: 0, fontSize: "var(--text-sm)", fontWeight: 600 }}>2 · 4 · 5단계</p>
<p style={{ margin: "2px 0 0", fontSize: "var(--text-xs)", color: "var(--color-text-gray-500)" }}>크레딧이 들거나 되돌릴 수 없어 항상 확인합니다</p>
</div>
<span style={{ color: "var(--color-text-gray-500)" }}><Lock /></span>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,14 @@
import Link from "next/link";
/** 꺼둔 화면의 자리. lib/features.ts 에서 켠다. */
export default function DisabledNotice({ title, backHref = "/" }: {
title: string; backHref?: string;
}) {
return (
<div>
<Link href={backHref} className="btn-back">‹ 뒤로가기</Link>
<h1 className="page-title">{title}</h1>
<p className="page-subtitle">이 화면은 아직 열지 않았습니다.</p>
</div>
);
}

View File

@ -0,0 +1,268 @@
"use client";
/**
* 컨펌 게이트 카드 — PLAYREEL_JOURNEY.md §2 의 공통 구조.
* [eyebrow] n/5 · 이름 [title] [why] [body] [actions] [cost]
* 게이트별 본문은 아래 5개 컴포넌트. 편집값은 `edits` 로 부모에게 올려 approve 시 함께 보낸다.
*/
import { useState } from "react";
import {
GATE_META, type AnalysisReview, type ClipReview, type FetchReview, type FinalReview,
type GateKey, type GateReview, type NarrationReview,
} from "@/lib/playreel";
// ── 공통 셸 ──────────────────────────────────────────────────────────
export function GateShell({ gate, children, onApprove, onBack, busy, error, approveLabel }: {
gate: GateKey; children: React.ReactNode;
onApprove: () => void; onBack?: () => void; busy: boolean; error: string | null; approveLabel?: string;
}) {
const g = GATE_META[gate];
return (
<div>
<p className="eyebrow" style={{ margin: 0, color: "var(--color-mint)", fontSize: "var(--text-sm)" }}>
확인 {g.step} / 5 · {g.name}
</p>
<h2 style={{ margin: "0.4rem 0 0.5rem", fontSize: "var(--text-2xl)", fontWeight: 700, letterSpacing: "-0.02em" }}>{g.title}</h2>
<p style={{ margin: "0 0 1.5rem", fontSize: "var(--text-base)", color: "var(--color-text-gray-400)", lineHeight: 1.6 }}>{g.why}</p>
{children}
<div className="gate-actions">
<button className="btn-cta btn-lg" disabled={busy} onClick={onApprove}>
{busy ? "처리 중…" : (approveLabel ?? "승인하고 계속")}
</button>
{g.canBack && onBack && (
<button className="btn-outline btn-lg" disabled={busy} onClick={onBack}>‹ 이전 단계로</button>
)}
</div>
<p style={{ margin: "0.75rem 0 0", fontSize: "var(--text-sm)", color: "var(--color-text-gray-500)" }}>
다음 단계 · {g.next} · {g.eta}
{g.credits > 0 && <> · <strong style={{ color: "#ffd27a" }}>{g.credits}크레딧</strong></>}
</p>
{error && <p style={{ color: "#ff7a7a", fontSize: "var(--text-sm)", marginTop: "0.75rem" }}>{error}</p>}
</div>
);
}
// ── 작은 공용 조각 (아이콘은 SVG만 — 이모지 금지) ─────────────────────
const Ico = ({ d, size = 12 }: { d: string; size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" aria-hidden style={{ flexShrink: 0 }}>
<path d={d} />
</svg>
);
const CHECK = "M20 6 9 17l-5-5";
const CROSS = "M18 6 6 18M6 6l12 12";
const PLUS = "M12 5v14M5 12h14";
const Lock = ({ size = 12 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden style={{ flexShrink: 0 }}>
<rect x="4" y="11" width="16" height="10" rx="2" /><path d="M8 11V7a4 4 0 0 1 8 0v4" />
</svg>
);
function Chip({ on, onClick, children, locked }: { on: boolean; onClick?: () => void; children: React.ReactNode; locked?: boolean }) {
return (
<button type="button" onClick={locked ? undefined : onClick}
style={{
display: "inline-flex", alignItems: "center", gap: 6,
padding: "0.35rem 0.8rem", borderRadius: 999, cursor: locked ? "default" : "pointer", fontSize: "var(--text-sm)",
border: on ? "1px solid var(--color-mint)" : "1px solid var(--color-border-white-10)",
background: on ? "var(--color-mint-20)" : "transparent",
color: on ? "var(--color-mint)" : "var(--color-text-gray-400)", opacity: locked ? 0.85 : 1,
}}>
{locked ? <Lock /> : on ? <Ico d={CHECK} /> : <Ico d={PLUS} />}{children}
</button>
);
}
function Warn({ children }: { children: React.ReactNode }) {
return (
<p style={{ margin: "0.75rem 0 0", padding: "0.6rem 0.9rem", borderRadius: "var(--radius-md)", background: "rgba(255,210,122,0.1)", border: "1px solid rgba(255,210,122,0.35)", color: "#ffd27a", fontSize: "var(--text-sm)", lineHeight: 1.5 }}>
{children}
</p>
);
}
const two = { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: "var(--spacing-page-md)", alignItems: "start" } as const;
// ── ① 수집 확인 ───────────────────────────────────────────────────────
export function FetchGate({ data, onChange }: { data: FetchReview; onChange: (edits: { sections: string[]; meta: FetchReview["meta"] }) => void }) {
const [sections, setSections] = useState(data.sections);
const [meta, setMeta] = useState(data.meta);
const emit = (s = sections, m = meta) => onChange({ sections: s.filter((x) => x.selected).map((x) => x.id), meta: m });
return (
<div style={two}>
<div>
<p className="field-label" style={{ margin: "0 0 0.5rem" }}>공연 정보</p>
{([["title", "공연명"], ["date_text", "일시"], ["place", "장소"]] as const).map(([k, label]) => (
<div key={k} style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.5rem" }}>
<span style={{ width: 52, flexShrink: 0, fontSize: "var(--text-sm)", color: "var(--color-text-gray-400)" }}>{label}</span>
<input className="input" style={{ height: 44 }} value={meta[k]}
onChange={(e) => { const m = { ...meta, [k]: e.target.value }; setMeta(m); emit(sections, m); }} />
</div>
))}
<p style={{ margin: "0.5rem 0 0", fontSize: "var(--text-sm)", color: "var(--color-text-gray-400)" }}>
캐스트 · {meta.cast.join(", ")}
</p>
<p className="field-label" style={{ margin: "1.5rem 0 0.5rem" }}>영상에 넣을 상세페이지 부분</p>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
{sections.map((s) => (
<Chip key={s.id} on={s.selected} locked={s.required}
onClick={() => { const n = sections.map((x) => x.id === s.id ? { ...x, selected: !x.selected } : x); setSections(n); emit(n); }}>
{s.label}
</Chip>
))}
</div>
<p style={{ margin: "0.6rem 0 0", fontSize: "var(--text-xs)", color: "var(--color-text-gray-500)", display: "flex", alignItems: "center", gap: 6 }}>
<Lock size={11} /> 캐스팅 스케줄은 항상 포함됩니다.
</p>
{data.poster_width <= 800 && (
<Warn>포스터가 {data.poster_width}px로 작습니다. 다음 단계에서 화질을 2배 보정합니다(2크레딧). 원본 파일이 있으면 무빙포스터 경로에서 직접 올리는 편이 더 선명합니다.</Warn>
)}
</div>
<div>
<p className="field-label" style={{ margin: "0 0 0.5rem" }}>포스터</p>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={data.poster_url} alt="포스터" className="card-inner" style={{ width: "100%", maxWidth: 300, padding: 0, display: "block" }} />
</div>
</div>
);
}
// ── ② 연출 확인 ───────────────────────────────────────────────────────
export function AnalysisGate({ data, onChange }: { data: AnalysisReview; onChange: (edits: { movable: string[]; fixed: string[]; model: string }) => void }) {
const [movable, setMovable] = useState(data.movable);
const [fixed, setFixed] = useState(data.fixed);
const emit = (m = movable, f = fixed) => onChange({ movable: m.filter((x) => x.on).map((x) => x.key), fixed: f.filter((x) => x.on).map((x) => x.key), model: data.model });
return (
<div style={two}>
<div>
<p className="field-label" style={{ margin: "0 0 0.5rem" }}>움직일 요소</p>
<p style={{ margin: "0 0 0.6rem", fontSize: "var(--text-sm)", color: "var(--color-text-gray-400)" }}>카메라는 움직이지 않습니다. 빛·안개·천 같은 요소만 살아납니다.</p>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
{movable.map((m) => (
<Chip key={m.key} on={m.on} onClick={() => { const n = movable.map((x) => x.key === m.key ? { ...x, on: !x.on } : x); setMovable(n); emit(n); }}>{m.label}</Chip>
))}
</div>
<p className="field-label" style={{ margin: "1.5rem 0 0.5rem" }}>원본 그대로 고정</p>
<p style={{ margin: "0 0 0.6rem", fontSize: "var(--text-sm)", color: "var(--color-text-gray-400)" }}>생성된 화면 위에 원본 픽셀을 다시 덮습니다. 제목은 한 픽셀도 바뀌지 않습니다.</p>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
{fixed.map((f) => (
<Chip key={f.key} on={f.on} locked={f.key === "title"} onClick={() => { const n = fixed.map((x) => x.key === f.key ? { ...x, on: !x.on } : x); setFixed(n); emit(movable, n); }}>{f.label}</Chip>
))}
</div>
<p className="field-label" style={{ margin: "1.5rem 0 0.5rem" }}>생성 모델</p>
<p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--color-text-gray-300)" }}>Kling 3.0 pro · 8초 · 14크레딧</p>
{data.ip_risk && <Warn>알려진 IP(디즈니 등) 작품입니다. 다른 모델은 저작권 필터로 거부된 이력이 있어 Kling으로만 진행합니다. 실패하면 아트워크 푸시인으로 대체됩니다.</Warn>}
{!data.has_qr && <p style={{ margin: "0.6rem 0 0", fontSize: "var(--text-xs)", color: "var(--color-text-gray-500)" }}>포스터에 QR이 없어 엔딩 밴드 QR은 상품 페이지 링크로 자동 생성합니다.</p>}
</div>
<div>
<p className="field-label" style={{ margin: "0 0 0.5rem" }}>요소 분석 (5% 격자)</p>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={data.grid_url} alt="격자 오버레이" className="card-inner" style={{ width: "100%", maxWidth: 300, padding: 0, display: "block" }} />
</div>
</div>
);
}
// ── ③ 나레이션·보이스 확인 ────────────────────────────────────────────
export function NarrationGate({ data, onChange }: { data: NarrationReview; onChange: (edits: { lines: string[]; voice_id: string }) => void }) {
const [lines, setLines] = useState(data.lines);
const lenOk = data.est_seconds >= 28 && data.est_seconds <= 31;
return (
<div style={two}>
<div>
<p className="field-label" style={{ margin: "0 0 0.5rem" }}>나레이션 8문장</p>
{lines.map((l, i) => (
<div key={i} className="narr-row">
<span className="narr-slot" style={{ color: l.slot === "캐스팅 스케줄" ? "var(--color-mint)" : "var(--color-text-gray-400)" }}>{l.slot}</span>
<input className="input" style={{ height: 42, fontSize: "var(--text-sm)" }} value={l.text}
onChange={(e) => { const n = lines.map((x, j) => j === i ? { ...x, text: e.target.value } : x); setLines(n); onChange({ lines: n.map((x) => x.text), voice_id: data.voice.id }); }} />
</div>
))}
</div>
<div>
<p className="field-label" style={{ margin: "0 0 0.5rem" }}>목소리</p>
<div className="card-inner">
<p style={{ margin: 0, fontWeight: 700 }}>{data.voice.name}</p>
{data.voice.sample_url
? <audio controls src={data.voice.sample_url} style={{ width: "100%", marginTop: "0.6rem" }} />
: <p style={{ margin: "0.5rem 0 0", fontSize: "var(--text-sm)", color: "var(--color-text-gray-500)" }}>샘플 준비 중</p>}
</div>
<p className="field-label" style={{ margin: "1.25rem 0 0.5rem" }}>예상 길이</p>
<p style={{ margin: 0, fontSize: "var(--text-2xl)", fontWeight: 700, color: lenOk ? "var(--color-mint)" : "#ffd27a" }}>
{data.est_seconds.toFixed(1)}초
</p>
<p style={{ margin: "0.25rem 0 0", fontSize: "var(--text-sm)", color: "var(--color-text-gray-500)" }}>목표 28~31초{!lenOk && " — 문장을 줄이거나 늘려주세요"}</p>
</div>
</div>
);
}
// ── ④ 클립 검수 ───────────────────────────────────────────────────────
export function ClipGate({ data }: { data: ClipReview }) {
return (
<div style={two}>
<div className="card-inner" style={{ display: "flex", alignItems: "center", justifyContent: "center", minHeight: 320 }}>
{data.clip_url
? <video src={data.clip_url} controls style={{ width: "100%", maxHeight: 520, borderRadius: "var(--radius-md)" }} />
: <p style={{ color: "var(--color-text-gray-500)", fontSize: "var(--text-sm)" }}>생성 클립 8초</p>}
</div>
<div>
<p className="field-label" style={{ margin: "0 0 0.5rem" }}>제목 훼손 검사</p>
<div className="card-inner" style={{ borderColor: data.gate_passed ? "var(--color-mint-30)" : "rgba(255,122,122,0.4)" }}>
<p style={{ margin: 0, fontSize: "var(--text-xl)", fontWeight: 700, color: data.gate_passed ? "var(--color-mint)" : "#ff8c8c" }}>
{data.gate_passed ? "통과" : "실패"} · 제목대 편차 {data.title_mae.toFixed(2)}
</p>
<p style={{ margin: "0.35rem 0 0", fontSize: "var(--text-sm)", color: "var(--color-text-gray-400)" }}>기준 4.0 이하. 원본 제목과 픽셀 단위로 비교한 값입니다.</p>
{data.gate_reason && <p style={{ margin: "0.5rem 0 0", fontSize: "var(--text-sm)", color: "#ffb37a" }}>{data.gate_reason}</p>}
</div>
<p className="field-label" style={{ margin: "1.25rem 0 0.5rem" }}>5시점 프레임</p>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={data.frames_url} alt="프레임 시트" className="card-inner" style={{ width: "100%", padding: 0, display: "block" }} />
<Warn>다시 만들기는 영상 생성을 처음부터 반복하며 {data.retry_credits}크레딧이 다시 듭니다. 이 클립 위에 원본 글자를 덮는 합성은 무료입니다.</Warn>
</div>
</div>
);
}
// ── ⑤ 최종 검수 ───────────────────────────────────────────────────────
export function FinalGate({ data }: { data: FinalReview }) {
return (
<div style={two}>
<div className="card-inner" style={{ display: "flex", alignItems: "center", justifyContent: "center", minHeight: 420 }}>
{data.video_url
? <video src={data.video_url} controls style={{ width: "100%", maxHeight: 560, borderRadius: "var(--radius-md)" }} />
: <p style={{ color: "var(--color-text-gray-500)", fontSize: "var(--text-sm)" }}>완성본 {data.duration.toFixed(1)}초</p>}
</div>
<div>
<p className="field-label" style={{ margin: "0 0 0.5rem" }}>자동 확인 항목</p>
{data.checks.map((c) => (
<p key={c.key} style={{ margin: "0.3rem 0", fontSize: "var(--text-base)", color: c.ok ? "var(--color-text-gray-300)" : "#ff8c8c", display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ color: c.ok ? "var(--color-mint)" : "#ff8c8c", display: "inline-flex" }}><Ico d={c.ok ? CHECK : CROSS} size={14} /></span>{c.label}
</p>
))}
<p className="field-label" style={{ margin: "1.25rem 0 0.5rem" }}>proof 시트</p>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={data.proof_url} alt="proof" className="card-inner" style={{ width: "100%", padding: 0, display: "block" }} />
<p style={{ margin: "0.75rem 0 0", fontSize: "var(--text-sm)", color: "var(--color-text-gray-500)" }}>승인하면 v{data.version}으로 고정됩니다.</p>
</div>
</div>
);
}
/** review 페이로드 → 게이트 본문. edits 콜백은 승인 시 부모가 모아 보낸다. */
export function GateBody({ review, onEdits }: { review: GateReview; onEdits: (e: unknown) => void }) {
switch (review.gate) {
case "fetch_confirm": return <FetchGate data={review.data} onChange={onEdits} />;
case "analysis_confirm": return <AnalysisGate data={review.data} onChange={onEdits} />;
case "narration_confirm": return <NarrationGate data={review.data} onChange={onEdits} />;
case "clip_confirm": return <ClipGate data={review.data} />;
case "final_confirm": return <FinalGate data={review.data} />;
}
}

View File

@ -0,0 +1,88 @@
/** 템플릿 배포 등급 표시.
*
* 페이지 전체 배너를 쓰던 때는 전 템플릿이 내부 전용이라 그걸로 충분했다.
* 퍼블릭 도메인 명화가 섞이면 배너로는 어느 결과물이 공개 가능한지 구분이 안 되므로
* 등급을 **카드 단위로** 붙이고, 경고는 내부 전용을 실제로 고른 순간에만 띄운다.
*/
export type License = "public-domain" | "internal-only" | "user-uploaded";
const STYLES: Record<License, { label: string; fg: string; bg: string; bd: string }> = {
"public-domain": {
label: "공개 가능",
fg: "var(--color-mint)",
bg: "var(--color-mint-10)",
bd: "var(--color-mint-30)",
},
"internal-only": {
label: "내부 전용",
fg: "#ffc9a3",
bg: "rgba(255, 154, 90, 0.12)",
bd: "rgba(255, 154, 90, 0.35)",
},
// 내부 전용과 색을 나눈다 — 끄는 조건이 다르고, 책임 주체도 다르다
"user-uploaded": {
label: "권리 미확인",
fg: "#c9b6ff",
bg: "rgba(166, 130, 255, 0.14)",
bd: "rgba(166, 130, 255, 0.38)",
},
};
export function LicenseBadge({ license }: { license: License }) {
const s = STYLES[license] ?? STYLES["internal-only"];
return (
<span style={{
display: "inline-block",
padding: "0.1rem 0.45rem",
borderRadius: "var(--radius-full)",
border: `1px solid ${s.bd}`,
background: s.bg,
color: s.fg,
fontSize: "var(--text-xs)",
fontWeight: 700,
whiteSpace: "nowrap",
lineHeight: 1.5,
}}>
{s.label}
</span>
);
}
/** 내부 전용을 고른 순간에만 뜨는 경고. 항상 떠 있으면 아무도 안 읽는다. */
export function InternalOnlyWarning({ note }: { note?: string }) {
return (
<div style={{
background: STYLES["internal-only"].bg,
border: `1px solid ${STYLES["internal-only"].bd}`,
borderRadius: "var(--radius-xl)",
padding: "0.75rem 1.25rem",
fontSize: "var(--text-base)",
color: "var(--color-text-gray-300)",
lineHeight: 1.6,
}}>
<strong style={{ color: STYLES["internal-only"].fg }}>내부 전용 템플릿</strong>
{" — "}
{note || "이 레퍼런스로 만든 결과물은 외부 공개·상업 사용을 금합니다."}
</div>
);
}
/** 사용자가 올린 레퍼런스에 대한 고지. 남의 저작물일 수 있다. */
export function UserReferenceNotice() {
return (
<div style={{
background: STYLES["user-uploaded"].bg,
border: `1px solid ${STYLES["user-uploaded"].bd}`,
borderRadius: "var(--radius-xl)",
padding: "0.75rem 1.25rem",
fontSize: "var(--text-sm)",
color: "var(--color-text-gray-300)",
lineHeight: 1.6,
}}>
직접 올린 레퍼런스는 <strong style={{ color: STYLES["user-uploaded"].fg }}>권리를 가진 이미지만</strong> 사용하세요.
타인의 저작물을 올려 생긴 문제의 책임은 업로드한 사람에게 있습니다.
결과물은 권리 미확인으로 분류됩니다. 긴 변 600px 이상이면 결과가 좋습니다.
</div>
);
}

View File

@ -0,0 +1,27 @@
import type { PosterMeta } from "@/lib/api";
/** bare=true면 카드 래핑 없이 내용만 (완료 화면 우측 정보 컬럼용) */
export default function MetadataCard({ meta, bare = false }: { meta: PosterMeta; bare?: boolean }) {
const body = (
<>
<p className="field-label" style={{ margin: 0 }}>메타태그</p>
<h3 style={{ margin: "0.25rem 0 0", fontSize: "var(--text-lg)", fontWeight: 700 }}>{meta.event_name}</h3>
<dl style={{
margin: "0.75rem 0 0", display: "grid", gridTemplateColumns: "64px 1fr",
rowGap: "0.4rem", fontSize: "var(--text-base)",
}}>
<dt style={{ color: "var(--color-text-gray-400)" }}>일시</dt>
<dd style={{ margin: 0, whiteSpace: "pre-line" }}>{meta.date_text}</dd>
<dt style={{ color: "var(--color-text-gray-400)" }}>장소</dt>
<dd style={{ margin: 0 }}>{meta.place}</dd>
<dt style={{ color: "var(--color-text-gray-400)" }}>분류</dt>
<dd style={{ margin: 0 }}>{meta.category}{meta.region_guess ? ` · ${meta.region_guess}` : ""}</dd>
</dl>
<div style={{ marginTop: "0.75rem", display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
{meta.keywords.map((k) => <span key={k} className="tag">{k}</span>)}
</div>
</>
);
if (bare) return <div>{body}</div>;
return <div className="card">{body}</div>;
}

View File

@ -0,0 +1,38 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
const ICONS: Record<string, React.ReactNode> = {
video: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="5" width="14" height="14" rx="2" />
<path d="M22 8.5 16 12l6 3.5v-7Z" />
</svg>
),
image: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-4.5-4.5L7 20" />
</svg>
),
folder: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.7-.9L9.2 3.9A2 2 0 0 0 7.5 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
</svg>
),
};
export default function NavLink({ href, icon, children }: {
href: string; icon?: string; children: React.ReactNode;
}) {
const pathname = usePathname();
const active = href === "/" ? pathname === "/" || pathname.startsWith("/poster") || pathname.startsWith("/playreel") : pathname.startsWith(href);
return (
<Link href={href} className={active ? "sidebar-item active" : "sidebar-item"}>
{icon && ICONS[icon]}
<span>{children}</span>
</Link>
);
}

View File

@ -0,0 +1,106 @@
"use client";
import { useCallback, useRef, useState } from "react";
interface Props {
file: File | null;
onFile: (f: File | null) => void;
}
/* 고해상도 원본 수급이 어려운 현실을 반영해 해상도는 차단하지 않는다.
낮으면 알려만 주고 업로드는 그대로 진행시킨다. */
const SOFT_LONG_EDGE = 1000;
export default function PosterDropzone({ file, onFile }: Props) {
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);
const accept = useCallback((f: File) => {
if (!/^image\/(jpeg|png|webp|gif)$/.test(f.type)) {
setError("JPG / PNG / WEBP / GIF 파일만 올릴 수 있습니다.");
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
? `긴 변 ${edge}px입니다. 그대로 진행할 수 있고, 깊은 줌 구간만 다소 부드럽게 보일 수 있습니다.`
: null);
setPreview(url);
onFile(f);
};
img.src = url;
}, [onFile]);
return (
<div>
<div
className="card-inner"
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);
}}
style={{
// 포스터는 세로물 — 입력창도 4:5 세로 비율이어야 직관적
aspectRatio: "4 / 5",
maxWidth: 400,
margin: "0 auto",
display: "flex",
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
padding: preview ? 0 : "var(--s-5)",
textAlign: "center",
cursor: "pointer",
borderStyle: preview ? "solid" : "dashed",
borderColor: dragging ? "var(--color-mint)" : "var(--color-border-gray-700)",
background: dragging ? "var(--color-mint-10)" : "rgba(18, 26, 29, 0.4)",
}}
>
{preview ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={preview} alt="포스터 미리보기" style={{ width: "100%", height: "100%", objectFit: "contain" }} />
) : (
<div>
<p style={{ fontSize: 16, fontWeight: 700, margin: 0, lineHeight: 1.5 }}>
포스터를 끌어다 놓거나<br />클릭해서 선택
</p>
<p style={{ fontSize: "var(--text-sm)", color: "var(--color-text-gray-400)", marginTop: "0.75rem" }}>
JPG · PNG · WEBP · GIF<br />해상도 제한 없음
</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 style={{ fontSize: "var(--text-sm)", color: "#ff7a7a", marginTop: "0.75rem" }}>{error}</p>
)}
{notice && (
<p style={{ fontSize: "var(--text-sm)", color: "var(--color-text-gray-400)", marginTop: "0.75rem", lineHeight: 1.5 }}>
{notice}
</p>
)}
{file && (
<p style={{ fontSize: "var(--text-sm)", color: "var(--color-text-gray-400)", marginTop: "0.5rem" }}>
{file.name} · {(file.size / 1024 / 1024).toFixed(1)}MB
</p>
)}
</div>
);
}

View File

@ -0,0 +1,63 @@
"use client";
import type { Job } from "@/lib/api";
/* 프로덕트 wizard-stepper 패턴: 완료=민트 체크, 현재=민트 링+글로우, 대기=회색 번호 */
const STAGE_LABELS: Record<string, string> = {
detect: "분석",
narration_text: "나레이션",
tts: "음성",
bgm: "음악",
motion: "모션 선정",
i2v: "애니메이션",
render: "렌더",
transfer: "변환",
};
const Check = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6 9 17l-5-5" />
</svg>
);
export default function StageStepper({ job, labels }: { job: Pick<Job, "stages" | "status">; labels?: Record<string, string> }) {
const L = labels ?? STAGE_LABELS;
const entries = Object.entries(job.stages);
// 검수 대기 중에는 "아직 끝나지 않은 첫 스테이지"가 현재다.
// 스테이지명을 박아두면 게이트가 늘 때마다 여기가 조용히 틀어진다.
const firstPending = entries.find(([, st]) => st.status !== "done")?.[0];
return (
<div className="wizard-stepper">
{entries.map(([key, st], i) => {
const cls =
st.status === "done" ? "done" :
st.status === "failed" ? "failed" :
st.status === "running" || (job.status === "awaiting_review" && key === firstPending) ? "current" :
"pending";
return (
<div key={key} style={{ display: "contents" }}>
{i > 0 && (
<div className={entries[i - 1][1].status === "done" ? "wizard-step-line done" : "wizard-step-line"} />
)}
<div className={`wizard-step ${cls}`}>
<div className="wizard-stepper-node">
{st.status === "done" ? <Check /> : i + 1}
</div>
<div className="wizard-stepper-label">{L[key] ?? key}</div>
</div>
</div>
);
})}
</div>
);
}
/** 스테이지 진행률 (%) — 선형 진행바용 */
export function stageProgress(job: Pick<Job, "stages">): number {
const sts = Object.values(job.stages);
const done = sts.filter((s) => s.status === "done").length;
const running = sts.some((s) => s.status === "running") ? 0.5 : 0;
return Math.round(((done + running) / sts.length) * 100);
}

View File

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

141
frontend/lib/api.ts Normal file
View File

@ -0,0 +1,141 @@
"use client";
import { useEffect, useRef, useState } from "react";
export interface StageState {
status: "idle" | "running" | "done" | "failed";
started: number | null;
ended: number | null;
}
export interface Job {
id: string;
kind: "f1" | "f2";
name: string;
status: "queued" | "running" | "awaiting_review" | "failed" | "done";
stage: string | null;
stages: Record<string, StageState>;
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;
}
export interface PosterMeta {
event_name: string;
date_text: string;
place: string;
category: string;
keywords: string[];
region_guess?: string;
}
export interface ArchiveEntry {
slug: string;
name: string;
/** 갈래. 서버가 아직 안 주면(구 엔트리) archiveKind()가 source 유무로 추정한다 — 백엔드 요청 항목(PLAYREEL_JOURNEY §5) */
kind?: "f1" | "playreel";
/** 갈래 2(상품페이지)일 때만 존재 */
source?: { url: string; goods_id: string; slug: string } | null;
metadata: PosterMeta | null;
narration: string[] | null;
poster_url: string;
video_url: string | null;
f2_variants: {
template_id: string;
image_url: string;
name_ko?: string;
license?: "public-domain" | "internal-only" | "user-uploaded";
attribution?: string;
}[];
created_at: number;
}
/** 아카이브 카드 배지 — 홈 최근 작업과 같은 이름("이미지" / "상품페이지") */
export function archiveKind(e: ArchiveEntry): "이미지" | "상품페이지" {
if (e.kind) return e.kind === "playreel" ? "상품페이지" : "이미지";
return e.source?.url ? "상품페이지" : "이미지";
}
export interface F2Template {
id: string;
name_ko: string;
thumb_url: string;
category: string;
/** 배포 등급 — public-domain만 외부 공개 가능 */
license: "public-domain" | "internal-only" | "user-uploaded";
attribution: string;
license_note: string;
/** 사용자가 올린 레퍼런스인가 (삭제 가능) */
removable: boolean;
/** 레퍼런스가 작아 결과가 나빠질 수 있음 */
small_ref: boolean;
}
export interface F2Category {
id: string;
label: string;
}
/** 서버가 본문 없이 실패했을 때의 안내. Next 리라이트가 백엔드에 못 붙으면 500/502가 빈 본문으로 온다. */
function friendlyStatus(status: number, path: string): string {
if (status === 404) return `요청한 경로가 서버에 없습니다 (${path})`;
if (status >= 500) return "API 서버(:30101)에 연결할 수 없습니다. 서버가 켜져 있는지 확인해 주세요.";
return `요청 실패 (HTTP ${status})`;
}
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, init);
if (!res.ok) {
let detail: string | null = null;
try {
const body = await res.json();
detail = body.detail ?? JSON.stringify(body);
} catch { /* 본문 없는 에러 — 아래 상태별 안내로 */ }
// FastAPI 기본 404 본문("Not Found")은 안내가 안 되므로 상태별 문구로 대체
throw new Error(detail && detail !== "Not Found" ? detail : friendlyStatus(res.status, path));
}
return res.json();
}
const F1_ACTIVE = new Set(["queued", "running"]);
/** 잡 폴링 훅 — done/failed/awaiting_review에서 폴링 중단 (approve 후 재개는 refresh()) */
export function useJob(kind: "f1" | "f2", id: string | null, intervalMs = 2500) {
const [job, setJob] = useState<Job | null>(null);
const [error, setError] = useState<string | null>(null);
const timer = useRef<ReturnType<typeof setInterval> | null>(null);
const refresh = async () => {
if (!id) return;
try {
const j = await apiFetch<Job>(`/api/${kind}/jobs/${id}`);
setJob(j);
setError(null);
if (!F1_ACTIVE.has(j.status) && timer.current) {
clearInterval(timer.current);
timer.current = null;
}
} catch (e) {
setError((e as Error).message);
}
};
useEffect(() => {
if (!id) return;
// eslint-disable-next-line react-hooks/set-state-in-effect -- 폴링 시작: setState는 fetch 완료 후 비동기로만 일어난다
refresh();
timer.current = setInterval(refresh, intervalMs);
return () => { if (timer.current) clearInterval(timer.current); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
return { job, error, refresh: () => {
refresh();
if (!timer.current) timer.current = setInterval(refresh, intervalMs);
} };
}

12
frontend/lib/features.ts Normal file
View File

@ -0,0 +1,12 @@
/**
* 화면 켜고 끄기.
* 백엔드에 대응하는 API가 없는 화면을 여기서 끈다 — 페이지 코드는 그대로 두고
* 네비게이션에서 감추고 본문 대신 안내를 띄운다.
*
* styling /studio — /api/f2 없음
* archive /archive · /playreel/archive — /api/archive 없음
*/
export const FEATURES = {
styling: false,
archive: false,
} as const;

271
frontend/lib/playreel.ts Normal file
View File

@ -0,0 +1,271 @@
"use client";
/**
* Playreel 잡 계약 — product/PLAYREEL_JOURNEY.md §3 과 1:1.
* 서버(/api/playreel)가 아직 없으므로 `?mock=<gate>` 로 각 게이트 화면을 띄울 수 있다.
*/
import { useEffect, useRef, useState } from "react";
import { apiFetch, type Job, type StageState } from "./api";
export type GateKey =
| "fetch_confirm"
| "analysis_confirm"
| "narration_confirm"
| "clip_confirm"
| "final_confirm";
export const GATE_ORDER: GateKey[] = [
"fetch_confirm", "analysis_confirm", "narration_confirm", "clip_confirm", "final_confirm",
];
/** 스테퍼 순서 — 롱컷 파이프라인 (product_integration.md) */
export const PLAYREEL_STAGES = [
"fetch", "split", "upscale", "analyze", "motion", "narration", "tts", "bgm", "i2v", "hybrid", "compose", "review",
] as const;
export const PLAYREEL_STAGE_LABELS: Record<string, string> = {
fetch: "수집", split: "섹션", upscale: "화질", analyze: "요소", motion: "연출",
narration: "나레이션", tts: "음성", bgm: "음악", i2v: "생성", hybrid: "합성", compose: "조립", review: "검수",
};
// ── 게이트별 검수 페이로드 ──────────────────────────────────────────
export interface DetailSection {
id: string;
tag: string; // split_detail.py --tag 12태그 어휘
label: string; // 한글 라벨
thumb_url: string;
height: number;
required?: boolean; // 캐스팅 스케줄 = 항상 포함
selected: boolean;
}
export interface FetchReview {
poster_url: string;
poster_width: number; // 750이면 저해상 경고
meta: { title: string; date_text: string; place: string; cast: string[]; genre: string };
sections: DetailSection[];
}
export interface AnalysisReview {
grid_url: string; // 5% 격자 오버레이
movable: { key: string; label: string; on: boolean }[]; // motion_plan MOTION_PHRASE 어휘
fixed: { key: string; label: string; on: boolean }[]; // 제목·로고·인물 …
model: "kling3_0" | "veo3_1";
ip_risk: boolean; // 디즈니 등 — veo 거부 이력
has_qr: boolean;
}
export interface NarrationReview {
lines: { slot: string; text: string }[]; // 8문장, slot = 훅1·훅2·수상·넘버·캐스팅스케줄·일시·CTA…
voice: { id: string; name: string; sample_url: string };
est_seconds: number; // 28~31 정상
}
export interface ClipReview {
clip_url: string;
frames_url: string; // 5시점 프레임 시트
title_mae: number; // ≤4 통과
gate_passed: boolean;
gate_reason: string | null; // VLM 2단 사유
retry_credits: number; // 재생성 시 재과금
}
export interface FinalReview {
video_url: string;
proof_url: string;
duration: number;
checks: { key: string; label: string; ok: boolean }[];
version: number;
}
export type GateReview =
| { gate: "fetch_confirm"; data: FetchReview }
| { gate: "analysis_confirm"; data: AnalysisReview }
| { gate: "narration_confirm"; data: NarrationReview }
| { gate: "clip_confirm"; data: ClipReview }
| { gate: "final_confirm"; data: FinalReview };
export interface PlayreelJob extends Omit<Job, "kind"> {
kind: "playreel";
source: { url: string; goods_id: string; slug: string };
gate: GateKey | null;
review: GateReview | null;
credits_used: number;
version: number;
}
// ── URL 인식 ────────────────────────────────────────────────────────
/** NOL티켓/인터파크 상품 URL에서 goodsId를 뽑는다. 못 뽑으면 null. */
export function parseGoodsId(url: string): string | null {
const s = url.trim();
if (!s) return null;
const m =
s.match(/tickets\.interpark\.com\/goods\/(\d{5,})/i) ??
s.match(/nol\.interpark\.com\/[^?]*?(\d{8,})/i) ??
s.match(/[?&]goodsCode=(\d{5,})/i) ??
s.match(/^(\d{8,})$/);
return m ? m[1] : null;
}
// ── 게이트 메타 (카드 헤더·비용 문구) ───────────────────────────────
export const GATE_META: Record<GateKey, {
step: number; name: string; title: string; why: string; next: string; credits: number; eta: string; canBack: boolean;
}> = {
fetch_confirm: {
step: 1, name: "수집 확인", title: "이 공연이 맞는지, 어떤 부분을 넣을지 확인해 주세요",
why: "상세페이지에서 가져온 정보로 영상의 뼈대를 만듭니다. 캐스팅 스케줄은 항상 들어갑니다.",
next: "포스터 화질 보정 · 요소 분석", credits: 2, eta: "약 2분", canBack: false,
},
analysis_confirm: {
step: 2, name: "연출 확인", title: "포스터에서 무엇을 움직일지 정해주세요",
why: "승인하면 영상 생성이 시작되고 되돌릴 수 없습니다. 제목·로고·인물은 원본 그대로 고정됩니다.",
next: "Kling 3.0 영상 생성", credits: 14, eta: "약 5~8분", canBack: true,
},
narration_confirm: {
step: 3, name: "나레이션 확인", title: "나레이션 문장과 목소리를 확인해 주세요",
why: "이 문장이 그대로 읽힙니다. 캐스팅 스케줄 안내와 일시·CTA 문장은 꼭 필요합니다.",
next: "음성 합성 · 배경음악 생성", credits: 0, eta: "약 3분", canBack: true,
},
clip_confirm: {
step: 4, name: "클립 검수", title: "생성된 장면을 확인해 주세요",
why: "제목이 깨지지 않았는지 자동 검사한 결과입니다. 다시 만들면 크레딧이 다시 듭니다.",
next: "원본 글자 합성 · 상세페이지 스크롤 조립", credits: 0, eta: "약 5분", canBack: false,
},
final_confirm: {
step: 5, name: "최종 검수", title: "완성된 예고편을 확인해 주세요",
why: "승인하면 이 버전이 고정되어 아카이브에 저장됩니다. 이후 수정은 새 버전으로 만들어집니다.",
next: "아카이브 저장 · 다운로드", credits: 0, eta: "즉시", canBack: false,
},
};
// ── 목 데이터 — 서버 없이 게이트 화면을 보기 위한 것 ─────────────────
function stages(doneUpTo: number, running?: string): Record<string, StageState> {
const out: Record<string, StageState> = {};
PLAYREEL_STAGES.forEach((k, i) => {
out[k] = {
status: i < doneUpTo ? "done" : k === running ? "running" : "idle",
started: null, ended: null,
};
});
return out;
}
const PH = (w: number, h: number, text: string) =>
`data:image/svg+xml;utf8,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}"><rect width="100%" height="100%" fill="#003538"/><text x="50%" y="50%" fill="#a6ffea" font-size="20" font-family="sans-serif" text-anchor="middle" dominant-baseline="middle">${text}</text></svg>`,
)}`;
export function mockJob(gate: GateKey | "running" | "done" | "failed"): PlayreelJob {
const base: PlayreelJob = {
id: "mock", kind: "playreel", name: "뮤지컬 〈겨울왕국〉", status: "awaiting_review", stage: null,
stages: stages(0), narration: null, metadata: null, error: null, artifacts: {}, created_at: 0,
source: { url: "https://tickets.interpark.com/goods/25012345", goods_id: "25012345", slug: "frozen" },
gate: null, review: null, credits_used: 0, version: 0, queue_size: 0,
};
switch (gate) {
case "running":
return { ...base, status: "running", stage: "i2v", stages: stages(8, "i2v"), credits_used: 16, gate: null };
case "failed":
return { ...base, status: "failed", stages: { ...stages(8), i2v: { status: "failed", started: null, ended: null } },
error: { stage: "i2v", detail: "제목 훼손 게이트 실패 — t=7.5s 제목 'N' 가려짐 (VLM 2단)" }, credits_used: 16 };
case "done":
return { ...base, status: "done", stages: stages(12), credits_used: 16, version: 1,
artifacts: { video: "", thumbnail: "" } };
case "fetch_confirm":
return { ...base, gate, stages: stages(2), review: { gate, data: {
poster_url: PH(300, 420, "포스터 750px"), poster_width: 750,
meta: { title: "뮤지컬 〈겨울왕국〉", date_text: "2026.11.25 ~ 2027.03.01", place: "샤롯데씨어터", cast: ["박혜나", "정선아", "이지혜"], genre: "뮤지컬" },
sections: [
{ id: "s1", tag: "story", label: "작품 소개", thumb_url: PH(160, 90, "소개"), height: 1800, selected: true },
{ id: "s2", tag: "awards", label: "수상·세계관", thumb_url: PH(160, 90, "수상"), height: 900, selected: true },
{ id: "s3", tag: "cast", label: "캐스트", thumb_url: PH(160, 90, "캐스트"), height: 1400, selected: true },
{ id: "s4", tag: "schedule", label: "캐스팅 스케줄", thumb_url: PH(160, 90, "스케줄"), height: 2200, required: true, selected: true },
{ id: "s5", tag: "discount", label: "할인 안내", thumb_url: PH(160, 90, "할인"), height: 700, selected: true },
{ id: "s6", tag: "notice", label: "유의사항", thumb_url: PH(160, 90, "유의"), height: 1200, selected: false },
],
} } };
case "analysis_confirm":
return { ...base, gate, stages: stages(4), credits_used: 2, review: { gate, data: {
grid_url: PH(300, 420, "5% 격자"),
movable: [
{ key: "light", label: "빛줄기·조명", on: true }, { key: "smoke", label: "안개·연기", on: true },
{ key: "star", label: "별·눈 결정", on: true }, { key: "cloud", label: "구름", on: false },
{ key: "flag", label: "천·망토", on: false }, { key: "crowd", label: "인물", on: false },
],
fixed: [
{ key: "title", label: "제목", on: true }, { key: "logo", label: "로고·후원바", on: true },
{ key: "figure", label: "인물 실루엣", on: true }, { key: "date", label: "일시·장소", on: true },
],
model: "kling3_0", ip_risk: true, has_qr: false,
} } };
case "narration_confirm":
return { ...base, gate, stages: stages(6), credits_used: 16, review: { gate, data: {
lines: [
{ slot: "훅 1", text: "얼어붙은 왕국이 무대 위에서 깨어납니다." },
{ slot: "훅 2", text: "전 세계를 사로잡은 그 이야기, 이제 눈앞에서." },
{ slot: "수상·세계관", text: "토니상 노미네이트, 브로드웨이 오리지널 프로덕션." },
{ slot: "넘버·캐스트", text: "'Let It Go'를 박혜나, 정선아, 이지혜가 부릅니다." },
{ slot: "캐스팅 스케줄", text: "회차별 캐스팅은 상세페이지 캐스팅 스케줄에서 확인하세요." },
{ slot: "일시", text: "11월 25일부터 샤롯데씨어터에서." },
{ slot: "할인", text: "얼리버드 예매 시 최대 30% 할인." },
{ slot: "CTA", text: "지금 예매 페이지에서 예매하세요." },
],
voice: { id: "tc_61e748d0", name: "Yena (여성 · 또렷한 안내톤)", sample_url: "" },
est_seconds: 30.4,
} } };
case "clip_confirm":
return { ...base, gate, stages: stages(9), credits_used: 16, review: { gate, data: {
clip_url: "", frames_url: PH(720, 200, "5시점 프레임 시트"),
title_mae: 3.04, gate_passed: true, gate_reason: null, retry_credits: 14,
} } };
case "final_confirm":
return { ...base, gate, stages: stages(11), credits_used: 16, review: { gate, data: {
video_url: "", proof_url: PH(720, 240, "proof 시트"), duration: 30.6, version: 1,
checks: [
{ key: "first", label: "첫 프레임 = 포스터 원본", ok: true },
{ key: "title", label: "제목 온전 (MAE 3.0)", ok: true },
{ key: "scroll", label: "상세페이지 풀프레임 스크롤", ok: true },
{ key: "schedule", label: "캐스팅 스케줄 포함", ok: true },
{ key: "band", label: "예매 안내 밴드 + QR", ok: true },
{ key: "duck", label: "BGM 덕킹", ok: true },
],
} } };
}
}
// ── 폴링 훅 (api.ts useJob 과 같은 규약, mock 지원) ───────────────────
const ACTIVE = new Set(["queued", "running"]);
export function usePlayreelJob(id: string | null, mock: string | null, intervalMs = 2500) {
const [job, setJob] = useState<PlayreelJob | null>(null);
const [error, setError] = useState<string | null>(null);
const timer = useRef<ReturnType<typeof setInterval> | null>(null);
const refresh = async () => {
if (!id) return;
if (mock) { await Promise.resolve(); setJob(mockJob(mock as GateKey)); return; }
try {
const j = await apiFetch<PlayreelJob>(`/api/playreel/jobs/${id}`);
setJob(j);
setError(null);
if (!ACTIVE.has(j.status) && timer.current) { clearInterval(timer.current); timer.current = null; }
} catch (e) {
setError((e as Error).message);
}
};
useEffect(() => {
if (!id) return;
// eslint-disable-next-line react-hooks/set-state-in-effect -- 폴링 시작: api.ts useJob 과 같은 규약
void refresh();
if (!mock) timer.current = setInterval(refresh, intervalMs);
return () => { if (timer.current) clearInterval(timer.current); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, mock]);
return { job, error, refresh: () => {
refresh();
if (!mock && !timer.current) timer.current = setInterval(refresh, intervalMs);
} };
}

53
frontend/lib/prefs.ts Normal file
View File

@ -0,0 +1,53 @@
"use client";
/**
* 브라우저 로컬 환경설정 — 게이트 자동 승인.
* 키 `playreel.autoApprove` = { fetch_confirm?: true, narration_confirm?: true }
* 대상은 크레딧이 들지 않고 되돌릴 수 있는 게이트(①·③)뿐. ②·④·⑤는 GATE_META.credits/비가역이라 제외.
* 서버에는 저장하지 않는다(사용자별 계정 개념이 아직 없음). 서버 `approve` 자동 호출은 /playreel/[id] 에서.
*/
import { useSyncExternalStore } from "react";
import type { GateKey } from "@/lib/playreel";
export const AUTO_APPROVABLE = ["fetch_confirm", "narration_confirm"] as const satisfies readonly GateKey[];
export type AutoApprovable = (typeof AUTO_APPROVABLE)[number];
export type AutoApprovePrefs = Partial<Record<AutoApprovable, boolean>>;
const KEY = "playreel.autoApprove";
const EVT = "playreel:prefs";
const EMPTY: AutoApprovePrefs = {};
let cache: { raw: string | null; value: AutoApprovePrefs } = { raw: null, value: EMPTY };
export function readAutoApprove(): AutoApprovePrefs {
if (typeof window === "undefined") return EMPTY;
let raw: string | null = null;
try { raw = window.localStorage.getItem(KEY); } catch { return EMPTY; }
if (raw === cache.raw) return cache.value; // useSyncExternalStore 는 참조 안정성이 필요
let value: AutoApprovePrefs = EMPTY;
try {
const parsed = raw ? JSON.parse(raw) : {};
value = Object.fromEntries(AUTO_APPROVABLE.filter((g) => parsed?.[g] === true).map((g) => [g, true]));
} catch { /* 깨진 값은 비활성으로 */ }
cache = { raw, value };
return value;
}
export function setAutoApprove(gate: AutoApprovable, on: boolean) {
if (typeof window === "undefined") return;
const next = { ...readAutoApprove() };
if (on) next[gate] = true; else delete next[gate];
try { window.localStorage.setItem(KEY, JSON.stringify(next)); } catch { /* 프라이빗 모드 등 — 이 세션만 유지 안 됨 */ }
window.dispatchEvent(new Event(EVT));
}
function subscribe(cb: () => void) {
window.addEventListener(EVT, cb);
window.addEventListener("storage", cb); // 다른 탭에서 바꾼 경우
return () => { window.removeEventListener(EVT, cb); window.removeEventListener("storage", cb); };
}
/** 렌더 중 읽는 훅. SSR·하이드레이션 첫 렌더는 항상 비활성(EMPTY)으로 맞춘다. */
export function useAutoApprove(): AutoApprovePrefs {
return useSyncExternalStore(subscribe, readAutoApprove, () => EMPTY);
}

12
frontend/next.config.ts Normal file
View File

@ -0,0 +1,12 @@
import type { NextConfig } from "next";
// /api/* 프록시는 proxy.ts 에 있다 — 목적지를 런타임에 정해야 해서다
const nextConfig: NextConfig = {
experimental: {
// 기본 10MB에서는 포스터 업로드가 잘려 백엔드가 깨진 multipart를 받는다.
// 백엔드 상한이 30MB라 multipart 오버헤드만큼 여유를 둔다
proxyClientMaxBodySize: "32mb",
},
};
export default nextConfig;

6781
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
frontend/package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"next": "16.3.0",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.3.0",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

22
frontend/proxy.ts Normal file
View File

@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
/**
* /api/* 를 백엔드로 넘긴다. CORS를 쓰지 않는 이유가 이것이다.
*
* next.config.ts 의 rewrites 로는 안 된다 — Next가 빌드할 때 목적지를 평가해
* routes-manifest.json 에 박아버려서, 이미지를 구운 뒤에는 환경변수를 바꿔도 안 먹는다.
* proxy 는 Node 런타임에서 요청마다 돌아 그때 환경변수를 읽는다.
*
* 컨테이너에서는 서비스명(http://backend:30101), 로컬에서는 localhost.
*/
const API_ORIGIN = process.env.API_ORIGIN ?? "http://localhost:30101";
export function proxy(request: NextRequest) {
const { pathname, search } = request.nextUrl;
return NextResponse.rewrite(new URL(pathname + search, API_ORIGIN));
}
export const config = {
matcher: "/api/:path*",
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="149" height="22" viewBox="0 0 149 22" fill="none">
<path d="M 8.85 21.647 L 11.455 18.754 L 26.244 18.754 L 19.042 3.985 L 4.064 21.654 L 0 21.654 L 17.329 1.378 C 17.902 0.712 18.755 0.072 19.804 0.072 C 20.852 0.072 21.484 0.647 21.836 1.378 L 31.864 21.654 L 8.857 21.654 L 8.85 21.647 Z M 34.052 21.647 L 36.371 8.374 L 39.829 8.374 L 38.018 18.754 L 47.761 18.754 C 53.153 18.754 57.724 14.867 57.724 9.837 C 57.724 5.983 54.84 3.279 50.489 3.279 L 37.289 3.279 L 40.018 0.353 L 50.997 0.353 C 57.151 0.353 61.182 3.985 61.182 9.269 C 61.182 16.18 54.833 21.654 47.253 21.654 L 34.052 21.654 L 34.052 21.647 Z M 75.848 22 C 67.499 22 63.279 19.198 63.279 13.116 C 63.279 4.298 68.645 0 79.716 0 C 88.065 0 92.285 2.77 92.285 8.851 C 92.285 17.669 86.951 22 75.848 22 Z M 79.215 2.9 C 70.072 2.9 66.776 5.735 66.776 12.77 C 66.776 17.388 69.824 19.106 76.363 19.106 C 85.473 19.106 88.801 16.239 88.801 9.204 C 88.801 4.586 85.753 2.9 79.215 2.9 Z M 92.865 21.647 L 93.978 15.344 C 94.642 11.523 97.599 9.681 102.418 9.681 L 111.685 9.681 C 115.586 9.681 117.175 8.341 117.175 6.049 C 117.175 3.945 115.078 3.279 111.079 3.279 L 97.338 3.279 L 100.1 0.353 L 113.013 0.353 C 118.438 0.353 120.568 2.358 120.568 5.35 C 120.568 9.262 118.028 12.228 111.555 12.228 L 102.288 12.228 C 99.37 12.228 97.716 13.149 97.306 15.318 L 96.733 18.754 L 118.601 18.754 L 115.872 21.647 L 92.865 21.647 Z M 121.688 21.504 C 121.141 21.504 120.73 21.184 120.73 20.667 C 120.73 19.955 121.349 19.55 122.039 19.55 C 122.606 19.55 123.029 19.851 123.029 20.367 C 123.029 21.079 122.411 21.504 121.681 21.504 L 121.688 21.504 Z M 129.769 21.347 L 131.222 19.727 L 139.479 19.727 L 135.455 11.477 L 127.086 21.347 L 124.82 21.347 L 134.497 10.02 C 134.816 9.648 135.292 9.289 135.878 9.289 C 136.464 9.289 136.816 9.609 137.011 10.02 L 142.611 21.347 L 129.763 21.347 L 129.769 21.347 Z M 144.975 21.347 L 147.085 9.452 L 149 9.452 L 146.89 21.347 L 144.975 21.347 Z" fill="currentColor" fill-rule="nonzero"></path>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

1
frontend/public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
frontend/public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

34
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}