playreel/frontend/lib/api.ts
2026-09-08 13:48:20 +09:00

142 lines
4.6 KiB
TypeScript

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