playreel/frontend/app/(ado2)/poster/[id]/page.tsx
2026-09-08 15:57:35 +09:00

342 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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<string, string> = {
queued: "대기 중입니다. 앞선 작업이 끝나면 자동으로 시작됩니다",
running: "AI 분석 및 편집을 통해 콘텐츠를 만들고 있습니다",
awaiting_review: "나레이션과 모션을 확인하고 승인하면 음성·음악·렌더가 이어집니다",
failed: "작업이 중단되었습니다. 오류를 확인하고 다시 시도해 주세요",
done: "AI 분석 및 편집을 통해 최적화된 콘텐츠가 완성되었습니다",
};
// scripts/motion_plan.py의 MOTION_PHRASE 화이트리스트와 1:1. 여기 없는 키는 서버가 422로 거른다.
const MOTION_LABELS: Record<string, string> = {
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<string[]>([]);
const [motions, setMotions] = useState<string[] | null>(null);
const [meta, setMeta] = useState({ event_name: "", date_text: "", place: "" });
const [metaLoaded, setMetaLoaded] = useState(false);
const [busy, setBusy] = useState(false);
const [actionError, setActionError] = useState<string | null>(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 <p style={{ color: "#ff7a7a" }}>{error}</p>;
if (!job) return <p style={{ color: "var(--color-text-gray-400)" }}>불러오는 중…</p>;
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<Job>(`/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 (
<div>
<div style={{ display: "flex", alignItems: "flex-start" }}>
<Link href="/" className="btn-back">‹ 뒤로가기</Link>
{job.status !== "running" && (
<button onClick={remove} disabled={busy} className="btn-back"
style={{ marginLeft: "auto", color: "#ff8c8c", borderColor: "rgba(255,140,140,0.4)" }}>
작업 삭제
</button>
)}
</div>
<div className="stepper-scroll"><StageStepper job={job} /></div>
<h1 className="page-title">
{job.status === "done" ? "콘텐츠 제작 완료" : job.name}
</h1>
<p className="page-subtitle">{SUBTITLES[job.status]}</p>
<div className="card" style={{ marginTop: "2rem", padding: "var(--spacing-page-md)" }}>
{(job.status === "running" || job.status === "queued") && (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", padding: "3rem 0", gap: "1.5rem" }}>
<div className="gen-spinner" />
<p style={{ margin: 0, fontSize: "var(--text-base)", color: "var(--color-text-gray-300)" }}>
생성 중 (음악 단계는 몇 분 걸릴 수 있습니다)
</p>
<div style={{ width: 320 }}>
<div className="progress-bar-container">
<div className="progress-bar-fill" style={{ width: `${pct}%` }} />
</div>
<p style={{ textAlign: "center", margin: "0.5rem 0 0", fontSize: "var(--text-sm)", color: "var(--color-text-gray-400)" }}>{pct}%</p>
</div>
{job.narration && (
<div className="card-inner" style={{ maxWidth: 520, width: "100%" }}>
<p className="field-label" style={{ margin: "0 0 0.5rem" }}>나레이션</p>
{job.narration.map((s, i) => (
<p key={i} style={{ margin: "0.25rem 0", fontSize: "var(--text-base)", color: "var(--color-text-gray-300)" }}>{s}</p>
))}
</div>
)}
</div>
)}
{job.status === "awaiting_review" && (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--spacing-page-md)", alignItems: "start" }}>
<div>
<p className="eyebrow" style={{ margin: "0 0 1rem" }}>나레이션 검수</p>
<p style={{ fontSize: "var(--text-base)", color: "var(--color-text-gray-400)", margin: "0 0 1rem", lineHeight: 1.6 }}>
자동 작성된 3문장을 확인하고 필요하면 고쳐주세요.
</p>
{lines.map((s, i) => (
<input key={i} className="input" value={s} style={{ marginBottom: "0.5rem" }}
onChange={(e) => setLines(lines.map((v, j) => (j === i ? e.target.value : v)))} />
))}
<p className="eyebrow" style={{ margin: "1.5rem 0 0.5rem" }}>움직일 요소</p>
<p style={{ fontSize: "var(--text-sm)", color: "var(--color-text-gray-400)", margin: "0 0 0.75rem", lineHeight: 1.6 }}>
{motions !== null && motions.length === 0
? "포스터에서 움직일 요소를 찾지 못했습니다. 직접 골라 추가하거나, 비워두면 애니메이션 없이 중단됩니다."
: "AI가 포스터에서 찾은 요소입니다. 빼거나 더할 수 있습니다."}
</p>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
{Object.entries(MOTION_LABELS).map(([key, label]) => {
const on = motions?.includes(key) ?? false;
return (
<button key={key} type="button"
onClick={() => setMotions(on ? (motions ?? []).filter((m) => m !== key)
: [...(motions ?? []), key])}
style={{
padding: "0.35rem 0.8rem", borderRadius: 999, cursor: "pointer",
fontSize: "var(--text-sm)",
border: on ? "1px solid var(--color-mint)" : "1px solid var(--color-border-white-10)",
background: on ? "var(--color-mint-20)" : "transparent",
color: on ? "var(--color-mint)" : "var(--color-text-gray-400)",
}}>
{on ? "✓ " : "+ "}{label}
</button>
);
})}
</div>
<p className="eyebrow" style={{ margin: "1.5rem 0 0.5rem" }}>메타태그</p>
<p style={{ fontSize: "var(--text-sm)", color: "var(--color-text-gray-400)", margin: "0 0 0.75rem", lineHeight: 1.6 }}>
아카이브에 영구 저장됩니다. 연도나 지명이 잘못 읽혔는지 포스터와 대조해 주세요.
</p>
{([
["event_name", "행사명"],
["date_text", "일시"],
["place", "장소"],
] as const).map(([key, label]) => (
<div key={key} style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.5rem" }}>
<span style={{ width: 52, flexShrink: 0, fontSize: "var(--text-sm)", color: "var(--color-text-gray-400)" }}>{label}</span>
<input className="input" value={meta[key]}
onChange={(e) => setMeta({ ...meta, [key]: e.target.value })} />
</div>
))}
<button className="btn-cta btn-lg" style={{ marginTop: "1rem" }} disabled={busy} onClick={approve}>
승인하고 계속
</button>
{actionError && <p style={{ color: "#ff7a7a", fontSize: "var(--text-sm)", marginTop: "0.75rem" }}>{actionError}</p>}
</div>
<div>
<p className="eyebrow" style={{ margin: "0 0 1rem" }}>포스터 분석 결과</p>
{job.artifacts.check_jpg && (
// eslint-disable-next-line @next/next/no-img-element
<img src={job.artifacts.check_jpg} alt="영역 분석 확인 이미지" className="card-inner"
style={{ width: "100%", padding: 0, display: "block" }} />
)}
</div>
</div>
)}
{job.status === "failed" && job.error && (
<div style={{ maxWidth: 640, margin: "0 auto" }}>
<p className="eyebrow" style={{ color: "#ff8c8c", margin: "0 0 1rem" }}>
실패 · {job.error.stage}
</p>
<pre className="card-inner" style={{
fontSize: "var(--text-sm)", whiteSpace: "pre-wrap", wordBreak: "break-all",
maxHeight: 280, overflow: "auto", color: "var(--color-text-gray-400)", margin: 0,
}}>{job.error.detail}</pre>
{job.error.stage === "i2v" && motions !== null && (
<div style={{ marginTop: "1.25rem" }}>
<p style={{ fontSize: "var(--text-sm)", color: "var(--color-text-gray-400)", margin: "0 0 0.6rem" }}>
움직일 요소를 바꿔 다시 시도할 수 있습니다:
</p>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
{Object.entries(MOTION_LABELS).map(([key, label]) => {
const on = motions.includes(key);
return (
<button key={key} type="button"
onClick={() => setMotions(on ? motions.filter((m) => m !== key) : [...motions, key])}
style={{
padding: "0.35rem 0.8rem", borderRadius: 999, cursor: "pointer",
fontSize: "var(--text-sm)",
border: on ? "1px solid var(--color-mint)" : "1px solid var(--color-border-white-10)",
background: on ? "var(--color-mint-20)" : "transparent",
color: on ? "var(--color-mint)" : "var(--color-text-gray-400)",
}}>
{on ? "✓ " : "+ "}{label}
</button>
);
})}
</div>
</div>
)}
<div style={{ display: "flex", justifyContent: "center", marginTop: "1.5rem" }}>
<button className="btn-outline" disabled={busy} onClick={retry}>재시도</button>
</div>
{actionError && <p style={{ color: "#ff7a7a", fontSize: "var(--text-sm)", marginTop: "0.75rem", textAlign: "center" }}>{actionError}</p>}
</div>
)}
{job.status === "done" && job.artifacts.video && (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--spacing-page-md)", alignItems: "stretch" }}>
<div className="card-inner" style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
<video src={job.artifacts.video} controls
style={{ width: "100%", maxHeight: 560, borderRadius: "var(--radius-md)", display: "block" }} />
</div>
<div style={{ display: "flex", flexDirection: "column", borderLeft: "2px solid var(--color-mint-20)", paddingLeft: "var(--spacing-page)" }}>
<p className="field-label" style={{ margin: 0 }}>파일명</p>
<p style={{ margin: "0.25rem 0 1rem", fontSize: "var(--text-xl)", fontWeight: 700, lineHeight: 1.35 }}>
{job.name}.mp4
</p>
{job.metadata && (
<div style={{ borderTop: "1px solid var(--color-border-white-10)", paddingTop: "1rem" }}>
<MetadataCard meta={job.metadata} bare />
</div>
)}
{job.narration && (
<div style={{ borderTop: "1px solid var(--color-border-white-10)", marginTop: "1rem", paddingTop: "1rem" }}>
<p className="field-label" style={{ margin: "0 0 0.5rem" }}>나레이션</p>
{job.narration.map((s, i) => (
<p key={i} style={{ margin: "0.35rem 0", fontSize: "var(--text-base)", color: "var(--color-text-gray-300)" }}>{s}</p>
))}
</div>
)}
<div style={{ marginTop: "auto", paddingTop: "1.5rem", display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
{/* blob에 Content-Disposition이 박혀 있어 주소만 열면 내려받기가 된다.
<a download>은 다른 출처에 안 먹으므로 이 속성에 기대지 않는다 */}
{job.artifacts.thumbnail ? (
<a href={job.artifacts.thumbnail} className="btn-tonal-mint">정지 컷 JPG</a>
) : <span />}
<a href={job.artifacts.video} className="btn-cta"
style={{ padding: "0.75rem 1rem", fontSize: "var(--text-base)", borderRadius: "var(--radius-xl)", textDecoration: "none" }}>
MP4 다운로드
</a>
</div>
</div>
</div>
)}
</div>
</div>
);
}