add fronted docker

This commit is contained in:
jaehwang 2026-09-08 15:00:42 +09:00
parent 5cd5ec5649
commit a18f7a5c8b
14 changed files with 124 additions and 24 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

@ -8,7 +8,6 @@ FROM node:22-slim AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# 빌드 시점엔 API가 없어도 되도록 rewrite 대상은 런타임 환경변수로 받는다
RUN npm run build
FROM node:22-slim AS run

View File

@ -1,12 +1,14 @@
"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";
export default function ArchiveDetailPage({ params }: { params: Promise<{ slug: string }> }) {
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);
@ -86,3 +88,9 @@ export default function ArchiveDetailPage({ params }: { params: Promise<{ slug:
</div>
);
}
export default function ArchiveDetailPage(props: Parameters<typeof ArchiveDetailPageInner>[0]) {
if (!FEATURES.archive) return <DisabledNotice title="아카이브" backHref="/" />;
return <ArchiveDetailPageInner {...props} />;
}

View File

@ -1,10 +1,12 @@
"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";
export default function ArchivePage() {
function ArchivePageInner() {
const [entries, setEntries] = useState<ArchiveEntry[] | null>(null);
useEffect(() => {
@ -50,3 +52,9 @@ export default function ArchivePage() {
</div>
);
}
export default function ArchivePage() {
if (!FEATURES.archive) return <DisabledNotice title="아카이브" backHref="/" />;
return <ArchivePageInner />;
}

View File

@ -2,6 +2,7 @@ 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 무빙포스터",
@ -20,8 +21,8 @@ export default function Ado2Layout({ children }: { children: React.ReactNode })
</div>
<nav className="sidebar-menu">
<NavLink href="/" icon="video"></NavLink>
<NavLink href="/studio" icon="image"> </NavLink>
<NavLink href="/archive" icon="folder"></NavLink>
{FEATURES.styling && <NavLink href="/studio" icon="image"> </NavLink>}
{FEATURES.archive && <NavLink href="/archive" icon="folder"></NavLink>}
</nav>
<div className="sidebar-foot"> · </div>
</aside>

View File

@ -1,5 +1,7 @@
"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 {
@ -12,7 +14,7 @@ interface UploadHint { enabled: boolean; min_long_edge: number }
const USER_CATEGORY = "user";
export default function StudioPage() {
function StudioPageInner() {
const [templates, setTemplates] = useState<F2Template[]>([]);
const [categories, setCategories] = useState<F2Category[]>([]);
const [formats, setFormats] = useState<F2Format[]>([]);
@ -301,3 +303,9 @@ export default function StudioPage() {
</div>
);
}
export default function StudioPage() {
if (!FEATURES.styling) return <DisabledNotice title="포스터 스타일링" backHref="/" />;
return <StudioPageInner />;
}

View File

@ -2,6 +2,7 @@ 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 ,
@ -32,7 +33,7 @@ export default function PlayreelLayout({ children }: { children: React.ReactNode
</div>
<nav className="sidebar-menu">
<NavLink href="/playreel" icon="video"> </NavLink>
<NavLink href="/playreel/archive" icon="folder"></NavLink>
{FEATURES.archive && <NavLink href="/playreel/archive" icon="folder"></NavLink>}
</nav>
<div className="sidebar-foot">Playreel · an ADO2 product · </div>
</aside>

View File

@ -1,5 +1,7 @@
"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";
@ -21,7 +23,7 @@ type Entry = {
created_at?: number;
};
export default function PlayreelArchivePage() {
function PlayreelArchivePageInner() {
const [items, setItems] = useState<Entry[] | null>(null);
const [error, setError] = useState<string | null>(null);
@ -78,3 +80,9 @@ export default function PlayreelArchivePage() {
</div>
);
}
export default function PlayreelArchivePage() {
if (!FEATURES.archive) return <DisabledNotice title="아카이브" backHref="/playreel" />;
return <PlayreelArchivePageInner />;
}

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>
);
}

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;

View File

@ -1,13 +1,11 @@
import type { NextConfig } from "next";
// 컨테이너에서는 서비스명(http://backend:30101)으로, 로컬에서는 localhost로 붙는다
const API = process.env.API_ORIGIN ?? "http://localhost:30101";
// /api/* 프록시는 proxy.ts 에 있다 — 목적지를 런타임에 정해야 해서다
const nextConfig: NextConfig = {
async rewrites() {
return [
{ source: "/api/:path*", destination: `${API}/api/:path*` },
];
experimental: {
// 기본 10MB에서는 포스터 업로드가 잘려 백엔드가 깨진 multipart를 받는다.
// 백엔드 상한이 30MB라 multipart 오버헤드만큼 여유를 둔다
proxyClientMaxBodySize: "32mb",
},
};

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*",
};