add fronted docker
This commit is contained in:
parent
5cd5ec5649
commit
a18f7a5c8b
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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} />;
|
||||
}
|
||||
|
||||
@ -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 />;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 />;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 />;
|
||||
}
|
||||
|
||||
14
frontend/components/disabled-notice.tsx
Normal file
14
frontend/components/disabled-notice.tsx
Normal 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
12
frontend/lib/features.ts
Normal 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;
|
||||
@ -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
22
frontend/proxy.ts
Normal 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*",
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user