Files
flatrender/src/app/[locale]/studio/render/[projectId]/page.tsx
T
soroush.asadi 2a6bbcd408
Build backend images / build content-svc (push) Failing after 56s
Build backend images / build file-svc (push) Failing after 29s
Build backend images / build gateway (push) Failing after 41s
Build backend images / build identity-svc (push) Failing after 5m32s
Build backend images / build notification-svc (push) Failing after 1m18s
Build backend images / build render-svc (push) Failing after 56s
Build backend images / build studio-svc (push) Failing after 1m5s
fix(render-page): register completion without requiring a download URL
The full-screen render page only transitioned to "completed" when status was
completed AND an outputUrl existed, so dev renders (which produce no export file)
polled forever at 100%. Now completion is driven by status alone; the download/
share buttons render only when a URL is present, otherwise a "dev render, no file"
note is shown. Same guard helps real renders whose export URL resolves a beat late.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 22:13:26 +03:30

351 lines
13 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { 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") {
if (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" ? (
<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">
{outputUrl ? (
<>
<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>
</>
) : (
<p className="rounded-lg border border-[#2a2d3e] bg-[#0c0e1a] px-3 py-2.5 text-xs text-gray-400">
رندر در محیط توسعه کامل شد (بدون فایل خروجی واقعی). در محیط تولید، لینک دانلود اینجا
نمایش داده میشود.
</p>
)}
<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>
);
}