"use client"; import { use, useEffect, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import StageStepper, { stageProgress } from "@/components/stage-stepper"; import MetadataCard from "@/components/metadata-card"; import { apiFetch, useJob, type Job } from "@/lib/api"; const SUBTITLES: Record = { queued: "대기 중입니다. 앞선 작업이 끝나면 자동으로 시작됩니다", running: "AI 분석 및 편집을 통해 콘텐츠를 만들고 있습니다", awaiting_review: "나레이션과 모션을 확인하고 승인하면 음성·음악·렌더가 이어집니다", failed: "작업이 중단되었습니다. 오류를 확인하고 다시 시도해 주세요", done: "AI 분석 및 편집을 통해 최적화된 콘텐츠가 완성되었습니다", }; // scripts/motion_plan.py의 MOTION_PHRASE 화이트리스트와 1:1. 여기 없는 키는 서버가 422로 거른다. const MOTION_LABELS: Record = { firework: "불꽃놀이", water: "물살", wave: "파도", cloud: "구름", smoke: "연기", moon: "달", sun: "해", star: "별", light: "조명", flag: "깃발·천", foliage: "나뭇잎·풀", wheel: "바퀴·관람차", vessel: "배", crowd: "사람 무리", }; export default function JobPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params); const router = useRouter(); const { job, error, refresh } = useJob("f1", id); const [lines, setLines] = useState([]); const [motions, setMotions] = useState(null); const [meta, setMeta] = useState({ event_name: "", date_text: "", place: "" }); const [metaLoaded, setMetaLoaded] = useState(false); const [busy, setBusy] = useState(false); const [actionError, setActionError] = useState(null); // 아래 3개 effect: 서버 잡이 처음 도착했을 때 편집 상태를 한 번 시딩한다(이후 사용자 편집 우선). useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- 최초 1회 시딩 if (job?.narration && lines.length === 0) setLines(job.narration); // eslint-disable-next-line react-hooks/exhaustive-deps }, [job?.narration]); useEffect(() => { // 0종([])도 유효한 초기값이므로 null(미로딩)로만 판별한다 if (job && motions === null && job.motion_elements !== undefined && job.motion_elements !== null) // eslint-disable-next-line react-hooks/set-state-in-effect -- 최초 1회 시딩 setMotions(job.motion_elements); // eslint-disable-next-line react-hooks/exhaustive-deps }, [job?.motion_elements]); useEffect(() => { if (job?.metadata && !metaLoaded) { // eslint-disable-next-line react-hooks/set-state-in-effect -- 최초 1회 시딩 setMeta({ event_name: job.metadata.event_name ?? "", date_text: job.metadata.date_text ?? "", place: job.metadata.place ?? "", }); setMetaLoaded(true); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [job?.metadata]); if (error) return

{error}

; if (!job) return

불러오는 중…

; const approve = async () => { setBusy(true); setActionError(null); try { if (JSON.stringify(lines) !== JSON.stringify(job.narration)) { await apiFetch(`/api/f1/jobs/${id}/narration`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ narration: lines }), }); } const md = job.metadata; if (md && (md.event_name !== meta.event_name || md.date_text !== meta.date_text || md.place !== meta.place)) { await apiFetch(`/api/f1/jobs/${id}/metadata`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(meta), }); } const motionsChanged = motions !== null && JSON.stringify([...motions].sort()) !== JSON.stringify([...(job.motion_elements ?? [])].sort()); await apiFetch(`/api/f1/jobs/${id}/approve`, { method: "POST", ...(motionsChanged ? { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ motions }), } : {}), }); refresh(); } catch (e) { setActionError((e as Error).message); } finally { setBusy(false); } }; const retry = async () => { setBusy(true); setActionError(null); try { const motionsChanged = motions !== null && JSON.stringify([...motions].sort()) !== JSON.stringify([...(job!.motion_elements ?? [])].sort()); await apiFetch(`/api/f1/jobs/${id}/retry`, { method: "POST", ...(motionsChanged ? { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ motions }), } : {}), }); refresh(); } catch (e) { // 실패 화면을 띄워둔 사이에 끝났을 수 있다. 그때는 에러 대신 결과를 보여준다 const latest = await apiFetch(`/api/f1/jobs/${id}`).catch(() => null); if (latest?.status === "done") { refresh(); return; } setActionError((e as Error).message); } finally { setBusy(false); } }; const remove = async () => { if (!confirm(`"${job.name}" 작업과 만들어진 영상·음성·분석 파일을 모두 지웁니다. 되돌릴 수 없습니다.`)) return; setBusy(true); setActionError(null); try { await apiFetch(`/api/f1/jobs/${id}`, { method: "DELETE" }); router.push("/"); } catch (e) { setActionError((e as Error).message); setBusy(false); } }; const pct = stageProgress(job); return (
‹ 뒤로가기 {job.status !== "running" && ( )}

{job.status === "done" ? "콘텐츠 제작 완료" : job.name}

{SUBTITLES[job.status]}

{(job.status === "running" || job.status === "queued") && (

생성 중 (음악 단계는 몇 분 걸릴 수 있습니다)

{pct}%

{job.narration && (

나레이션

{job.narration.map((s, i) => (

{s}

))}
)}
)} {job.status === "awaiting_review" && (

나레이션 검수

자동 작성된 3문장을 확인하고 필요하면 고쳐주세요.

{lines.map((s, i) => ( setLines(lines.map((v, j) => (j === i ? e.target.value : v)))} /> ))}

움직일 요소

{motions !== null && motions.length === 0 ? "포스터에서 움직일 요소를 찾지 못했습니다. 직접 골라 추가하거나, 비워두면 애니메이션 없이 중단됩니다." : "AI가 포스터에서 찾은 요소입니다. 빼거나 더할 수 있습니다."}

{Object.entries(MOTION_LABELS).map(([key, label]) => { const on = motions?.includes(key) ?? false; return ( ); })}

메타태그

아카이브에 영구 저장됩니다. 연도나 지명이 잘못 읽혔는지 포스터와 대조해 주세요.

{([ ["event_name", "행사명"], ["date_text", "일시"], ["place", "장소"], ] as const).map(([key, label]) => (
{label} setMeta({ ...meta, [key]: e.target.value })} />
))} {actionError &&

{actionError}

}

포스터 분석 결과

{job.artifacts.check_jpg && ( // eslint-disable-next-line @next/next/no-img-element 영역 분석 확인 이미지 )}
)} {job.status === "failed" && job.error && (

실패 · {job.error.stage}

{job.error.detail}
{job.error.stage === "i2v" && motions !== null && (

움직일 요소를 바꿔 다시 시도할 수 있습니다:

{Object.entries(MOTION_LABELS).map(([key, label]) => { const on = motions.includes(key); return ( ); })}
)}
{actionError &&

{actionError}

}
)} {job.status === "done" && job.artifacts.video && (

파일명

{job.name}.mp4

{job.metadata && (
)} {job.narration && (

나레이션

{job.narration.map((s, i) => (

{s}

))}
)}
{/* blob에 Content-Disposition이 박혀 있어 주소만 열면 내려받기가 된다. 은 다른 출처에 안 먹으므로 이 속성에 기대지 않는다 */} {job.artifacts.thumbnail ? ( 정지 컷 JPG ) : } MP4 다운로드
)}
); }