142 lines
4.6 KiB
TypeScript
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);
|
|
} };
|
|
}
|