342 lines
13 KiB
TypeScript
342 lines
13 KiB
TypeScript
|
|
"use client";
|
|||
|
|
|
|||
|
|
import { useCallback, useEffect, useState } from "react";
|
|||
|
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
|||
|
|
import Link from "next/link";
|
|||
|
|
import {
|
|||
|
|
ArrowLeft,
|
|||
|
|
CheckCircle2,
|
|||
|
|
Download,
|
|||
|
|
Link2,
|
|||
|
|
Loader2,
|
|||
|
|
RefreshCw,
|
|||
|
|
} from "lucide-react";
|
|||
|
|
|
|||
|
|
import { apiFetch } from "@/lib/api/fetch";
|
|||
|
|
import { RENDER_EXPORT_PRESETS, type RenderExportPreset } from "@/lib/render-presets";
|
|||
|
|
import type { RenderSettings } from "@/lib/render-schemas";
|
|||
|
|
import { cn } from "@/lib/utils";
|
|||
|
|
|
|||
|
|
type Phase = "config" | "submitting" | "polling" | "completed" | "failed";
|
|||
|
|
|
|||
|
|
interface StatusResponse {
|
|||
|
|
status: string;
|
|||
|
|
progress: number;
|
|||
|
|
outputUrl: string | null;
|
|||
|
|
progressMessage?: string | null;
|
|||
|
|
errorMessage?: string | null;
|
|||
|
|
previewB64?: string | null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface ActiveRender {
|
|||
|
|
id: string;
|
|||
|
|
saved_project_id: string;
|
|||
|
|
step: string;
|
|||
|
|
render_progress: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const RESOLUTIONS: RenderSettings["resolution"][] = ["720p", "1080p", "4K"];
|
|||
|
|
const FPS_OPTIONS: RenderSettings["fps"][] = [24, 30, 60];
|
|||
|
|
|
|||
|
|
export default function RenderPage() {
|
|||
|
|
const router = useRouter();
|
|||
|
|
const params = useParams<{ projectId: string }>();
|
|||
|
|
const search = useSearchParams();
|
|||
|
|
const projectId = params.projectId;
|
|||
|
|
const presetKey = search.get("preset") as RenderExportPreset | null;
|
|||
|
|
|
|||
|
|
const [resolution, setResolution] = useState<RenderSettings["resolution"]>("1080p");
|
|||
|
|
const [fps, setFps] = useState<RenderSettings["fps"]>(30);
|
|||
|
|
const [phase, setPhase] = useState<Phase>("config");
|
|||
|
|
const [jobId, setJobId] = useState<string | null>(null);
|
|||
|
|
const [progress, setProgress] = useState(0);
|
|||
|
|
const [progressMessage, setProgressMessage] = useState("");
|
|||
|
|
const [previewB64, setPreviewB64] = useState<string | null>(null);
|
|||
|
|
const [outputUrl, setOutputUrl] = useState<string | null>(null);
|
|||
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|||
|
|
// An active render that belongs to a DIFFERENT project (blocks starting a new one).
|
|||
|
|
const [blockingJobId, setBlockingJobId] = useState<string | null>(null);
|
|||
|
|
|
|||
|
|
// Apply preset from the query (?preset=full)
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (!presetKey || !RENDER_EXPORT_PRESETS[presetKey]) return;
|
|||
|
|
const cfg = RENDER_EXPORT_PRESETS[presetKey];
|
|||
|
|
setResolution(cfg.settings.resolution);
|
|||
|
|
setFps(cfg.settings.fps);
|
|||
|
|
}, [presetKey]);
|
|||
|
|
|
|||
|
|
// On mount: resume this project's render if one is in flight, or flag a render
|
|||
|
|
// running on another project so we can block starting a new one.
|
|||
|
|
useEffect(() => {
|
|||
|
|
let cancelled = false;
|
|||
|
|
(async () => {
|
|||
|
|
try {
|
|||
|
|
const res = await apiFetch("/api/render/active");
|
|||
|
|
const data = (await res.json()) as { active?: ActiveRender[] };
|
|||
|
|
if (cancelled) return;
|
|||
|
|
const mine = data.active?.find((a) => a.saved_project_id === projectId);
|
|||
|
|
if (mine) {
|
|||
|
|
setJobId(mine.id);
|
|||
|
|
setPhase("polling");
|
|||
|
|
setProgress(mine.render_progress ?? 0);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const other = data.active?.[0];
|
|||
|
|
if (other) setBlockingJobId(other.id);
|
|||
|
|
} catch {
|
|||
|
|
/* ignore — config view will show */
|
|||
|
|
}
|
|||
|
|
})();
|
|||
|
|
return () => {
|
|||
|
|
cancelled = true;
|
|||
|
|
};
|
|||
|
|
}, [projectId]);
|
|||
|
|
|
|||
|
|
// Poll status while rendering.
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (phase !== "polling" || !jobId) return;
|
|||
|
|
|
|||
|
|
const poll = async () => {
|
|||
|
|
try {
|
|||
|
|
const res = await apiFetch(`/api/render/${jobId}/status`);
|
|||
|
|
const data = (await res.json()) as StatusResponse;
|
|||
|
|
if (!res.ok) {
|
|||
|
|
setPhase("failed");
|
|||
|
|
setErrorMessage("Could not fetch render status.");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setProgress(data.progress ?? 0);
|
|||
|
|
setProgressMessage(data.progressMessage ?? `Rendering… ${data.progress}%`);
|
|||
|
|
if (data.previewB64) setPreviewB64(data.previewB64);
|
|||
|
|
|
|||
|
|
if (data.status === "completed" && data.outputUrl) {
|
|||
|
|
setOutputUrl(data.outputUrl);
|
|||
|
|
setProgress(100);
|
|||
|
|
setPhase("completed");
|
|||
|
|
} else if (data.status === "failed") {
|
|||
|
|
setPhase("failed");
|
|||
|
|
setErrorMessage(data.errorMessage ?? "Render failed.");
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
setPhase("failed");
|
|||
|
|
setErrorMessage("Network error while polling status.");
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
poll();
|
|||
|
|
const id = window.setInterval(poll, 2500);
|
|||
|
|
return () => window.clearInterval(id);
|
|||
|
|
}, [phase, jobId]);
|
|||
|
|
|
|||
|
|
const startRender = useCallback(async () => {
|
|||
|
|
setPhase("submitting");
|
|||
|
|
setErrorMessage(null);
|
|||
|
|
try {
|
|||
|
|
const res = await apiFetch("/api/render", {
|
|||
|
|
method: "POST",
|
|||
|
|
headers: { "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
projectId,
|
|||
|
|
settings: { resolution, format: "mp4" as const, fps },
|
|||
|
|
}),
|
|||
|
|
});
|
|||
|
|
const data = (await res.json()) as { jobId?: string; error?: string; code?: string };
|
|||
|
|
if (res.status === 409 || data.code === "active_render_limit") {
|
|||
|
|
setPhase("config");
|
|||
|
|
setErrorMessage(
|
|||
|
|
data.error ?? "You already have an active render. Wait for it to finish."
|
|||
|
|
);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (!res.ok || !data.jobId) {
|
|||
|
|
setPhase("failed");
|
|||
|
|
setErrorMessage(data.error ?? "Failed to start render.");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setJobId(data.jobId);
|
|||
|
|
setProgress(0);
|
|||
|
|
setProgressMessage("Queued for rendering…");
|
|||
|
|
setPhase("polling");
|
|||
|
|
} catch {
|
|||
|
|
setPhase("failed");
|
|||
|
|
setErrorMessage("Could not reach the render service.");
|
|||
|
|
}
|
|||
|
|
}, [projectId, resolution, fps]);
|
|||
|
|
|
|||
|
|
const backToStudio = `/studio/video/${projectId}`;
|
|||
|
|
const isBusy = phase === "submitting" || phase === "polling";
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="flex min-h-screen flex-col bg-[#070811] text-gray-100">
|
|||
|
|
{/* Top bar */}
|
|||
|
|
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[#1a1d2e] px-4">
|
|||
|
|
<Link
|
|||
|
|
href={backToStudio}
|
|||
|
|
className="flex items-center gap-2 text-sm text-gray-400 hover:text-white"
|
|||
|
|
>
|
|||
|
|
<ArrowLeft className="h-4 w-4" />
|
|||
|
|
بازگشت به استودیو
|
|||
|
|
</Link>
|
|||
|
|
<span className="text-sm font-medium text-gray-300">خروجی رندر</span>
|
|||
|
|
<span className="w-28" />
|
|||
|
|
</header>
|
|||
|
|
|
|||
|
|
<main className="mx-auto flex w-full max-w-5xl flex-1 flex-col items-center justify-center gap-6 p-6">
|
|||
|
|
{/* Preview / hero */}
|
|||
|
|
<div className="relative aspect-video w-full max-w-3xl overflow-hidden rounded-2xl border border-[#1a1d2e] bg-[#0c0e1a]">
|
|||
|
|
{previewB64 ? (
|
|||
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|||
|
|
<img
|
|||
|
|
src={`data:image/png;base64,${previewB64}`}
|
|||
|
|
alt="Render preview"
|
|||
|
|
className="h-full w-full object-cover"
|
|||
|
|
/>
|
|||
|
|
) : (
|
|||
|
|
<div className="flex h-full w-full items-center justify-center">
|
|||
|
|
{isBusy ? (
|
|||
|
|
<Loader2 className="h-10 w-10 animate-spin text-primary-500/40" />
|
|||
|
|
) : phase === "completed" ? (
|
|||
|
|
<CheckCircle2 className="h-12 w-12 text-emerald-400" />
|
|||
|
|
) : (
|
|||
|
|
<span className="text-sm text-gray-600">پیشنمایش رندر اینجا نمایش داده میشود</span>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
{isBusy && (
|
|||
|
|
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-4">
|
|||
|
|
<div className="mb-1.5 flex justify-between text-xs text-gray-300">
|
|||
|
|
<span>{progressMessage || "در حال رندر…"}</span>
|
|||
|
|
<span>{progress}%</span>
|
|||
|
|
</div>
|
|||
|
|
<div className="h-2 overflow-hidden rounded-full bg-white/10">
|
|||
|
|
<div
|
|||
|
|
className="h-full rounded-full bg-primary-500 transition-all duration-500"
|
|||
|
|
style={{ width: `${progress}%` }}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* State-specific panel */}
|
|||
|
|
{phase === "completed" && outputUrl ? (
|
|||
|
|
<div className="w-full max-w-md space-y-3 text-center">
|
|||
|
|
<p className="text-lg font-semibold text-emerald-400">ویدیوی شما آماده است!</p>
|
|||
|
|
<div className="flex flex-col gap-2">
|
|||
|
|
<a
|
|||
|
|
href={outputUrl}
|
|||
|
|
download
|
|||
|
|
className="inline-flex items-center justify-center gap-2 rounded-lg bg-primary-600 px-4 py-3 text-sm font-medium text-white hover:bg-primary-700"
|
|||
|
|
>
|
|||
|
|
<Download className="h-4 w-4" />
|
|||
|
|
دانلود MP4
|
|||
|
|
</a>
|
|||
|
|
<a
|
|||
|
|
href={outputUrl}
|
|||
|
|
target="_blank"
|
|||
|
|
rel="noopener noreferrer"
|
|||
|
|
className="inline-flex items-center justify-center gap-2 rounded-lg border border-[#2a2d3e] px-4 py-2.5 text-sm text-gray-200 hover:bg-[#161a2b]"
|
|||
|
|
>
|
|||
|
|
<Link2 className="h-4 w-4" />
|
|||
|
|
لینک اشتراکگذاری
|
|||
|
|
</a>
|
|||
|
|
<Link
|
|||
|
|
href={backToStudio}
|
|||
|
|
className="mt-1 text-xs text-gray-500 hover:text-gray-300"
|
|||
|
|
>
|
|||
|
|
بازگشت به استودیو
|
|||
|
|
</Link>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
) : phase === "failed" ? (
|
|||
|
|
<div className="w-full max-w-md space-y-3 text-center">
|
|||
|
|
<p className="rounded-lg border border-red-900/50 bg-red-950/40 px-4 py-3 text-sm text-red-300">
|
|||
|
|
{errorMessage ?? "خطایی رخ داد."}
|
|||
|
|
</p>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={startRender}
|
|||
|
|
className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-primary-700"
|
|||
|
|
>
|
|||
|
|
<RefreshCw className="h-4 w-4" />
|
|||
|
|
تلاش دوباره
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
) : isBusy ? (
|
|||
|
|
<p className="text-sm text-gray-400">
|
|||
|
|
میتوانید این صفحه را ببندید؛ رندر در پسزمینه ادامه مییابد و از هر صفحهای قابل پیگیری است.
|
|||
|
|
</p>
|
|||
|
|
) : (
|
|||
|
|
// Config
|
|||
|
|
<div className="w-full max-w-md space-y-5">
|
|||
|
|
{errorMessage && (
|
|||
|
|
<p className="rounded-lg border border-amber-900/50 bg-amber-950/40 px-3 py-2 text-sm text-amber-300">
|
|||
|
|
{errorMessage}
|
|||
|
|
</p>
|
|||
|
|
)}
|
|||
|
|
{blockingJobId && (
|
|||
|
|
<div className="rounded-lg border border-amber-900/50 bg-amber-950/30 px-3 py-2.5 text-sm text-amber-200">
|
|||
|
|
شما یک رندر فعال دارید.{" "}
|
|||
|
|
<button
|
|||
|
|
className="underline hover:text-white"
|
|||
|
|
onClick={() => router.push(`/studio/render/${projectId}`)}
|
|||
|
|
>
|
|||
|
|
ابتدا آن را کامل کنید.
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
<div>
|
|||
|
|
<p className="mb-2 text-xs font-medium text-gray-400">کیفیت</p>
|
|||
|
|
<div className="flex gap-2">
|
|||
|
|
{RESOLUTIONS.map((item) => (
|
|||
|
|
<button
|
|||
|
|
key={item}
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => setResolution(item)}
|
|||
|
|
className={cn(
|
|||
|
|
"flex-1 rounded-lg border py-2.5 text-sm font-medium",
|
|||
|
|
resolution === item
|
|||
|
|
? "border-primary-500 bg-primary-600/20 text-white"
|
|||
|
|
: "border-[#2a2d3e] text-[#8b91a7] hover:border-[#3d4260]"
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
{item}
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<p className="mb-2 text-xs font-medium text-gray-400">نرخ فریم</p>
|
|||
|
|
<div className="flex gap-2">
|
|||
|
|
{FPS_OPTIONS.map((item) => (
|
|||
|
|
<button
|
|||
|
|
key={item}
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => setFps(item)}
|
|||
|
|
className={cn(
|
|||
|
|
"flex-1 rounded-lg border py-2.5 text-sm font-medium",
|
|||
|
|
fps === item
|
|||
|
|
? "border-primary-500 bg-primary-600/20 text-white"
|
|||
|
|
: "border-[#2a2d3e] text-[#8b91a7] hover:border-[#3d4260]"
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
{item} fps
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={startRender}
|
|||
|
|
disabled={!!blockingJobId}
|
|||
|
|
className="w-full rounded-lg bg-primary-600 px-4 py-3 text-sm font-semibold text-white hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
|
|||
|
|
>
|
|||
|
|
شروع رندر
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</main>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|