feat(render): full-screen render page, one-active-render limit, app-wide progress
Build backend images / build content-svc (push) Failing after 14s
Build backend images / build file-svc (push) Failing after 1m28s
Build backend images / build gateway (push) Failing after 1m43s
Build backend images / build identity-svc (push) Failing after 3m0s
Build backend images / build notification-svc (push) Failing after 51s
Build backend images / build render-svc (push) Failing after 1m3s
Build backend images / build studio-svc (push) Failing after 1m1s

Concurrent-render ceiling (a user runs 1 render at a time unless granted more):
- Identity: TokenService emits max_renders claim from User.ParallelRenderingCeiling
- Identity: admin POST /v1/users/{id}/render-slots (AdminService.SetRenderSlotsAsync,
  clamped 1..50) — gamification or admin raises a user's ceiling
- render-svc: middleware reads max_renders (default 1); CreateJob rejects with 409
  active_render_limit when active jobs >= ceiling
- render-svc: db.CountActiveJobs + ListActiveJobs; GET /v1/renders/active returns
  in-flight renders + can_start_new

Full-screen render page (replaces the modal):
- /studio/render/[projectId]: config (resolution/fps) → live preview + progress →
  download; resumes this project's in-flight render on mount; blocks when another
  render is active; reads ?preset=
- StudioTopBar export menu now navigates to the page; RenderModal deleted (dead)

App-wide minimal progress:
- GlobalRenderProgress pill mounted in the locale layout for authed users; polls
  /api/render/active every 4s, shows thumbnail + step + % on every page, click →
  the render page; hidden on the render page and when idle

Admin: UserActions gains a "concurrent render slots" control.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-05 16:48:05 +03:30
parent 2918b7acbf
commit 81912cac66
15 changed files with 667 additions and 349 deletions
+2
View File
@@ -6,6 +6,7 @@ import { NextIntlClientProvider } from "next-intl";
import { DirectionProvider } from "@/components/layout/DirectionProvider";
import { SiteChrome } from "@/components/layout/SiteChrome";
import { GlobalRenderProgress } from "@/components/render/GlobalRenderProgress";
import { getNavUser } from "@/lib/auth/session";
import { routing } from "@/i18n/routing";
import type { Locale } from "@/i18n/routing";
@@ -115,6 +116,7 @@ export default async function LocaleLayout({
<NextIntlClientProvider messages={messages} locale={locale}>
<DirectionProvider dir={isRtl ? "rtl" : "ltr"}>
<SiteChrome user={navUser}>{children}</SiteChrome>
<GlobalRenderProgress authed={!!navUser} />
</DirectionProvider>
</NextIntlClientProvider>
</body>
@@ -0,0 +1,341 @@
"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>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { gatewayUrl } from "@/lib/api/gateway";
import { getAccessToken } from "@/lib/auth/session";
export const dynamic = "force-dynamic";
export interface ActiveRender {
id: string;
saved_project_id: string;
name: string | null;
step: string;
render_progress: number;
preview_b64: string | null;
created_at: string;
}
export interface ActiveRendersResponse {
active: ActiveRender[];
max_renders: number;
can_start_new: boolean;
}
/**
* The user's in-flight renders + their concurrent-render ceiling. Powers the
* app-wide mini progress widget and the "can I start another render?" gate.
* Returns an empty set (not 401) when signed out, so the widget can mount globally.
*/
export async function GET() {
const token = await getAccessToken();
const empty: ActiveRendersResponse = { active: [], max_renders: 1, can_start_new: true };
if (!token) return NextResponse.json(empty);
const res = await fetch(gatewayUrl("/v1/renders/active"), {
cache: "no-store",
headers: { Accept: "application/json", Authorization: `Bearer ${token}` },
});
if (!res.ok) return NextResponse.json(empty);
const data = (await res.json().catch(() => null)) as ActiveRendersResponse | null;
return NextResponse.json(data ?? empty);
}