342 lines
17 KiB
TypeScript
342 lines
17 KiB
TypeScript
"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>
|
||
);
|
||
}
|