diff --git a/backend/pipelines/poster_alive.py b/backend/pipelines/poster_alive.py index b4974bc..08160bf 100644 --- a/backend/pipelines/poster_alive.py +++ b/backend/pipelines/poster_alive.py @@ -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 diff --git a/backend/pipelines/runner.py b/backend/pipelines/runner.py index 266c4d0..124a275 100644 --- a/backend/pipelines/runner.py +++ b/backend/pipelines/runner.py @@ -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("", 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 diff --git a/backend/utils/blob.py b/backend/utils/blob.py index d800b64..6d3bcc3 100644 --- a/backend/utils/blob.py +++ b/backend/utils/blob.py @@ -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 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 9e7dc60..79473ee 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -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 diff --git a/frontend/app/(ado2)/archive/[slug]/page.tsx b/frontend/app/(ado2)/archive/[slug]/page.tsx index 0279cbc..ca8e669 100644 --- a/frontend/app/(ado2)/archive/[slug]/page.tsx +++ b/frontend/app/(ado2)/archive/[slug]/page.tsx @@ -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(null); const [error, setError] = useState(null); @@ -86,3 +88,9 @@ export default function ArchiveDetailPage({ params }: { params: Promise<{ slug: ); } + + +export default function ArchiveDetailPage(props: Parameters[0]) { + if (!FEATURES.archive) return ; + return ; +} diff --git a/frontend/app/(ado2)/archive/page.tsx b/frontend/app/(ado2)/archive/page.tsx index 3f2bb46..baa5d9f 100644 --- a/frontend/app/(ado2)/archive/page.tsx +++ b/frontend/app/(ado2)/archive/page.tsx @@ -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(null); useEffect(() => { @@ -50,3 +52,9 @@ export default function ArchivePage() { ); } + + +export default function ArchivePage() { + if (!FEATURES.archive) return ; + return ; +} diff --git a/frontend/app/(ado2)/layout.tsx b/frontend/app/(ado2)/layout.tsx index 8b78bcc..91e83d8 100644 --- a/frontend/app/(ado2)/layout.tsx +++ b/frontend/app/(ado2)/layout.tsx @@ -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 })
무빙포스터 · 내부 빌드
diff --git a/frontend/app/(ado2)/studio/page.tsx b/frontend/app/(ado2)/studio/page.tsx index 821f11f..b61510d 100644 --- a/frontend/app/(ado2)/studio/page.tsx +++ b/frontend/app/(ado2)/studio/page.tsx @@ -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([]); const [categories, setCategories] = useState([]); const [formats, setFormats] = useState([]); @@ -301,3 +303,9 @@ export default function StudioPage() { ); } + + +export default function StudioPage() { + if (!FEATURES.styling) return ; + return ; +} diff --git a/frontend/app/(playreel)/layout.tsx b/frontend/app/(playreel)/layout.tsx index 1ee631f..91c03b7 100644 --- a/frontend/app/(playreel)/layout.tsx +++ b/frontend/app/(playreel)/layout.tsx @@ -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
Playreel · an ADO2 product · 내부 빌드
diff --git a/frontend/app/(playreel)/playreel/archive/page.tsx b/frontend/app/(playreel)/playreel/archive/page.tsx index 9a91abc..5f1305c 100644 --- a/frontend/app/(playreel)/playreel/archive/page.tsx +++ b/frontend/app/(playreel)/playreel/archive/page.tsx @@ -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(null); const [error, setError] = useState(null); @@ -78,3 +80,9 @@ export default function PlayreelArchivePage() { ); } + + +export default function PlayreelArchivePage() { + if (!FEATURES.archive) return ; + return ; +} diff --git a/frontend/components/disabled-notice.tsx b/frontend/components/disabled-notice.tsx new file mode 100644 index 0000000..f7e1788 --- /dev/null +++ b/frontend/components/disabled-notice.tsx @@ -0,0 +1,14 @@ +import Link from "next/link"; + +/** 꺼둔 화면의 자리. lib/features.ts 에서 켠다. */ +export default function DisabledNotice({ title, backHref = "/" }: { + title: string; backHref?: string; +}) { + return ( +
+ ‹ 뒤로가기 +

{title}

+

이 화면은 아직 열지 않았습니다.

+
+ ); +} diff --git a/frontend/lib/features.ts b/frontend/lib/features.ts new file mode 100644 index 0000000..0befc30 --- /dev/null +++ b/frontend/lib/features.ts @@ -0,0 +1,12 @@ +/** + * 화면 켜고 끄기. + * 백엔드에 대응하는 API가 없는 화면을 여기서 끈다 — 페이지 코드는 그대로 두고 + * 네비게이션에서 감추고 본문 대신 안내를 띄운다. + * + * styling /studio — /api/f2 없음 + * archive /archive · /playreel/archive — /api/archive 없음 + */ +export const FEATURES = { + styling: false, + archive: false, +} as const; diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 2729b5a..e22f5fe 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -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", }, }; diff --git a/frontend/proxy.ts b/frontend/proxy.ts new file mode 100644 index 0000000..e6f19ea --- /dev/null +++ b/frontend/proxy.ts @@ -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*", +};