From 81912cac66fd6edf30629bf8f51fc62c5c667789 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Fri, 5 Jun 2026 16:48:05 +0330 Subject: [PATCH] feat(render): full-screen render page, one-active-render limit, app-wide progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Application/Services/AdminService.cs | 13 + .../Application/Services/TokenService.cs | 3 + .../Controllers/AdminController.cs | 8 + .../FlatRender.IdentitySvc/Models/Admin.cs | 1 + services/render/cmd/server/main.go | 1 + services/render/internal/db/db.go | 39 ++ services/render/internal/handlers/renders.go | 47 +++ services/render/internal/middleware/auth.go | 38 +- src/app/[locale]/layout.tsx | 2 + .../studio/render/[projectId]/page.tsx | 341 ++++++++++++++++++ src/app/api/render/active/route.ts | 42 +++ src/components/admin/UserActions.tsx | 10 + .../render/GlobalRenderProgress.tsx | 121 +++++++ src/components/studio/RenderModal.tsx | 325 ----------------- src/components/studio/video/StudioTopBar.tsx | 25 +- 15 files changed, 667 insertions(+), 349 deletions(-) create mode 100644 src/app/[locale]/studio/render/[projectId]/page.tsx create mode 100644 src/app/api/render/active/route.ts create mode 100644 src/components/render/GlobalRenderProgress.tsx delete mode 100644 src/components/studio/RenderModal.tsx diff --git a/services/identity/FlatRender.IdentitySvc/Application/Services/AdminService.cs b/services/identity/FlatRender.IdentitySvc/Application/Services/AdminService.cs index 351cd31..d492f5f 100644 --- a/services/identity/FlatRender.IdentitySvc/Application/Services/AdminService.cs +++ b/services/identity/FlatRender.IdentitySvc/Application/Services/AdminService.cs @@ -182,6 +182,19 @@ public class AdminService(IdentityDbContext db) await db.SaveChangesAsync(); } + /// + /// Set how many renders a user may run concurrently. The render service reads + /// this from the JWT (max_renders claim); takes effect on the user's next token + /// refresh. Clamped to [1, 50]. + /// + public async Task SetRenderSlotsAsync(Guid userId, int ceiling) + { + var u = await RequireUser(userId); + u.ParallelRenderingCeiling = Math.Clamp(ceiling, 1, 50); + u.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + } + public async Task GrantPlanDaysAsync(Guid userId, Guid planId, int days) { var plan = await db.UserPlans diff --git a/services/identity/FlatRender.IdentitySvc/Application/Services/TokenService.cs b/services/identity/FlatRender.IdentitySvc/Application/Services/TokenService.cs index 0f60107..0355a56 100644 --- a/services/identity/FlatRender.IdentitySvc/Application/Services/TokenService.cs +++ b/services/identity/FlatRender.IdentitySvc/Application/Services/TokenService.cs @@ -33,6 +33,9 @@ public class TokenService(IConfiguration config) : ITokenService new("is_admin", user.IsAdmin.ToString().ToLower()), new("is_tenant_admin", user.IsTenantAdmin.ToString().ToLower()), new("role", role), + // Concurrent-render ceiling — render-svc enforces "active renders < max_renders". + // Admin grants or gamification raise ParallelRenderingCeiling; default is 1. + new("max_renders", Math.Max(1, user.ParallelRenderingCeiling).ToString()), }; if (!string.IsNullOrEmpty(user.Email)) diff --git a/services/identity/FlatRender.IdentitySvc/Controllers/AdminController.cs b/services/identity/FlatRender.IdentitySvc/Controllers/AdminController.cs index 98117df..c3ff092 100644 --- a/services/identity/FlatRender.IdentitySvc/Controllers/AdminController.cs +++ b/services/identity/FlatRender.IdentitySvc/Controllers/AdminController.cs @@ -79,6 +79,14 @@ public class AdminController(AdminService svc) : ControllerBase return Ok(new { ok = true }); } + // Grant a user extra concurrent render slots (takes effect on next token refresh). + [HttpPost("v1/users/{userId:guid}/render-slots")] + public async Task RenderSlots(Guid userId, [FromBody] SetRenderSlotsRequest req) + { + await svc.SetRenderSlotsAsync(userId, req.Ceiling); + return Ok(new { ok = true }); + } + [HttpPost("v1/users/{userId:guid}/grant-plan")] public async Task GrantPlan(Guid userId, [FromBody] GrantPlanDaysRequest req) { diff --git a/services/identity/FlatRender.IdentitySvc/Models/Admin.cs b/services/identity/FlatRender.IdentitySvc/Models/Admin.cs index d5c2f7d..bb6bebe 100644 --- a/services/identity/FlatRender.IdentitySvc/Models/Admin.cs +++ b/services/identity/FlatRender.IdentitySvc/Models/Admin.cs @@ -37,3 +37,4 @@ public record ResetPasswordRequest(string NewPassword); public record AddChargeRequest(int Seconds, int RenderCount); // grant render seconds / daily renders public record GrantPlanDaysRequest(Guid PlanId, int Days); public record SetFlagRequest(bool Enabled); +public record SetRenderSlotsRequest(int Ceiling); // concurrent-render ceiling diff --git a/services/render/cmd/server/main.go b/services/render/cmd/server/main.go index c5ab376..004a6c5 100644 --- a/services/render/cmd/server/main.go +++ b/services/render/cmd/server/main.go @@ -94,6 +94,7 @@ func main() { { renders.GET("", renderH.List) renders.POST("", renderH.Create) + renders.GET("/active", renderH.Active) renders.GET("/:job_id", renderH.Get) renders.POST("/:job_id/cancel", renderH.Cancel) renders.POST("/:job_id/stop", admin, renderH.Stop) diff --git a/services/render/internal/db/db.go b/services/render/internal/db/db.go index f32c9b2..5d7c95a 100644 --- a/services/render/internal/db/db.go +++ b/services/render/internal/db/db.go @@ -371,6 +371,45 @@ func (s *Store) GetJobByID(ctx context.Context, id, userID uuid.UUID) (*models.R return jobs[0], nil } +// CountActiveJobs returns how many non-terminal render jobs the user currently has. +// Used to enforce the per-user concurrent-render ceiling. +func (s *Store) CountActiveJobs(ctx context.Context, userID uuid.UUID) (int, error) { + var n int + err := s.pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM render.render_jobs + WHERE user_id = $1 + AND step NOT IN ('Done'::render_step, 'Failed'::render_step, 'Cancelled'::render_step)`, + userID).Scan(&n) + return n, err +} + +// ListActiveJobs returns the user's in-flight render jobs (most recent first), +// lightweight projection for the app-wide mini progress widget. +func (s *Store) ListActiveJobs(ctx context.Context, userID uuid.UUID) ([]*models.RenderJob, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, saved_project_id, name, step, render_progress, image_preview_b64, created_at + FROM render.render_jobs + WHERE user_id = $1 + AND step NOT IN ('Done'::render_step, 'Failed'::render_step, 'Cancelled'::render_step) + ORDER BY created_at DESC`, + userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []*models.RenderJob + for rows.Next() { + j := &models.RenderJob{} + if err := rows.Scan(&j.ID, &j.SavedProjectID, &j.Name, &j.Step, + &j.RenderProgress, &j.ImagePreviewB64, &j.CreatedAt); err != nil { + return nil, err + } + out = append(out, j) + } + return out, rows.Err() +} + func (s *Store) CreateJob(ctx context.Context, userID, tenantID uuid.UUID, req *models.RenderJobCreateRequest) (*models.RenderJob, error) { priceType := "Free" if req.PriceType != nil { diff --git a/services/render/internal/handlers/renders.go b/services/render/internal/handlers/renders.go index e349f9a..192b793 100644 --- a/services/render/internal/handlers/renders.go +++ b/services/render/internal/handlers/renders.go @@ -54,6 +54,39 @@ func (h *RenderHandler) List(c *gin.Context) { }) } +// GET /v1/renders/active +// Lightweight list of the user's in-flight renders + their ceiling — powers the +// app-wide mini progress widget and the "can I start another render?" check. +func (h *RenderHandler) Active(c *gin.Context) { + userID := middleware.GetUserID(c) + jobs, err := h.store.ListActiveJobs(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()}) + return + } + if jobs == nil { + jobs = []*models.RenderJob{} + } + out := make([]gin.H, 0, len(jobs)) + for _, j := range jobs { + out = append(out, gin.H{ + "id": j.ID, + "saved_project_id": j.SavedProjectID, + "name": j.Name, + "step": j.Step, + "render_progress": j.RenderProgress, + "preview_b64": j.ImagePreviewB64, + "created_at": j.CreatedAt, + }) + } + maxRenders := middleware.GetMaxRenders(c) + c.JSON(http.StatusOK, gin.H{ + "active": out, + "max_renders": maxRenders, + "can_start_new": len(out) < maxRenders, + }) +} + // POST /v1/renders func (h *RenderHandler) Create(c *gin.Context) { userID := middleware.GetUserID(c) @@ -65,6 +98,20 @@ func (h *RenderHandler) Create(c *gin.Context) { return } + // Concurrent-render ceiling: a user may run only `max_renders` renders at once + // (default 1; raised by gamification level or an admin grant via the JWT claim). + maxRenders := middleware.GetMaxRenders(c) + active, err := h.store.CountActiveJobs(c.Request.Context(), userID) + if err != nil { + log.Printf("count active jobs failed (allowing render): %v", err) + } else if active >= maxRenders { + c.JSON(http.StatusConflict, models.APIError{ + Code: "active_render_limit", + Message: "شما یک رندر در حال انجام دارید. برای شروع رندر جدید صبر کنید تا رندر فعلی کامل شود.", + }) + return + } + // Daily render-limit: consume one render charge (0 max = unlimited). allowed, err := h.identity.Consume(c.Request.Context(), userID) if err != nil { diff --git a/services/render/internal/middleware/auth.go b/services/render/internal/middleware/auth.go index a266c0d..d3b8e54 100644 --- a/services/render/internal/middleware/auth.go +++ b/services/render/internal/middleware/auth.go @@ -3,6 +3,7 @@ package middleware import ( "fmt" "net/http" + "strconv" "strings" "github.com/flatrender/render-svc/internal/models" @@ -12,10 +13,11 @@ import ( ) const ( - CtxUserID = "user_id" - CtxTenantID = "tenant_id" - CtxIsAdmin = "is_admin" - CtxRole = "role" + CtxUserID = "user_id" + CtxTenantID = "tenant_id" + CtxIsAdmin = "is_admin" + CtxRole = "role" + CtxMaxRenders = "max_renders" ) func JWTAuth(secret string) gin.HandlerFunc { @@ -54,10 +56,25 @@ func JWTAuth(secret string) gin.HandlerFunc { } role, _ := claims["role"].(string) + // max_renders: concurrent-render ceiling. Identity emits it as a string; + // also accept a JSON number. Default 1 when absent/unparseable. + maxRenders := 1 + switch v := claims["max_renders"].(type) { + case string: + if n, err := strconv.Atoi(v); err == nil && n > 0 { + maxRenders = n + } + case float64: + if v >= 1 { + maxRenders = int(v) + } + } + c.Set(CtxUserID, userID) c.Set(CtxTenantID, tenantID) c.Set(CtxIsAdmin, isAdmin) c.Set(CtxRole, role) + c.Set(CtxMaxRenders, maxRenders) c.Next() } } @@ -111,3 +128,16 @@ func GetTenantID(c *gin.Context) uuid.UUID { id, _ := v.(uuid.UUID) return id } + +// GetMaxRenders returns the user's concurrent-render ceiling (default 1). +func GetMaxRenders(c *gin.Context) int { + v, ok := c.Get(CtxMaxRenders) + if !ok { + return 1 + } + n, _ := v.(int) + if n < 1 { + return 1 + } + return n +} diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index 29f5da7..0d70af1 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -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({ {children} + diff --git a/src/app/[locale]/studio/render/[projectId]/page.tsx b/src/app/[locale]/studio/render/[projectId]/page.tsx new file mode 100644 index 0000000..e9d687d --- /dev/null +++ b/src/app/[locale]/studio/render/[projectId]/page.tsx @@ -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("1080p"); + const [fps, setFps] = useState(30); + const [phase, setPhase] = useState("config"); + const [jobId, setJobId] = useState(null); + const [progress, setProgress] = useState(0); + const [progressMessage, setProgressMessage] = useState(""); + const [previewB64, setPreviewB64] = useState(null); + const [outputUrl, setOutputUrl] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + // An active render that belongs to a DIFFERENT project (blocks starting a new one). + const [blockingJobId, setBlockingJobId] = useState(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 ( +
+ {/* Top bar */} +
+ + + بازگشت به استودیو + + خروجی رندر + +
+ +
+ {/* Preview / hero */} +
+ {previewB64 ? ( + // eslint-disable-next-line @next/next/no-img-element + Render preview + ) : ( +
+ {isBusy ? ( + + ) : phase === "completed" ? ( + + ) : ( + پیش‌نمایش رندر اینجا نمایش داده می‌شود + )} +
+ )} + {isBusy && ( +
+
+ {progressMessage || "در حال رندر…"} + {progress}% +
+
+
+
+
+ )} +
+ + {/* State-specific panel */} + {phase === "completed" && outputUrl ? ( +
+

ویدیوی شما آماده است!

+
+ + + دانلود MP4 + + + + لینک اشتراک‌گذاری + + + بازگشت به استودیو + +
+
+ ) : phase === "failed" ? ( +
+

+ {errorMessage ?? "خطایی رخ داد."} +

+ +
+ ) : isBusy ? ( +

+ می‌توانید این صفحه را ببندید؛ رندر در پس‌زمینه ادامه می‌یابد و از هر صفحه‌ای قابل پیگیری است. +

+ ) : ( + // Config +
+ {errorMessage && ( +

+ {errorMessage} +

+ )} + {blockingJobId && ( +
+ شما یک رندر فعال دارید.{" "} + +
+ )} +
+

کیفیت

+
+ {RESOLUTIONS.map((item) => ( + + ))} +
+
+
+

نرخ فریم

+
+ {FPS_OPTIONS.map((item) => ( + + ))} +
+
+ +
+ )} +
+
+ ); +} diff --git a/src/app/api/render/active/route.ts b/src/app/api/render/active/route.ts new file mode 100644 index 0000000..9f106a6 --- /dev/null +++ b/src/app/api/render/active/route.ts @@ -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); +} diff --git a/src/components/admin/UserActions.tsx b/src/components/admin/UserActions.tsx index 0ee9207..02e8492 100644 --- a/src/components/admin/UserActions.tsx +++ b/src/components/admin/UserActions.tsx @@ -19,6 +19,7 @@ export function UserActions({ row }: { row: Record; reload?: () const [pw, setPw] = useState(""); const [seconds, setSeconds] = useState(""); const [renders, setRenders] = useState(""); const [planDays, setPlanDays] = useState(""); + const [slots, setSlots] = useState(String(row.parallel_rendering_ceiling ?? "1")); const [tags, setTags] = useState(""); const [note, setNote] = useState(""); const [status, setStatus] = useState("new"); // discount / affiliate const [dcCode, setDcCode] = useState(""); const [dcKind, setDcKind] = useState("Percentage"); @@ -114,6 +115,15 @@ export function UserActions({ row }: { row: Record; reload?: () +
+ +

پیش‌فرض ۱. با افزایش این مقدار، کاربر می‌تواند چند رندر هم‌زمان اجرا کند (پس از تازه‌سازی توکن اعمال می‌شود).

+
+ setSlots(e.target.value)} /> + +
+
+
دسترسی مدیر (مدراتور)
diff --git a/src/components/render/GlobalRenderProgress.tsx b/src/components/render/GlobalRenderProgress.tsx new file mode 100644 index 0000000..f831c25 --- /dev/null +++ b/src/components/render/GlobalRenderProgress.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { Film, Loader2, X } from "lucide-react"; + +interface ActiveRender { + id: string; + saved_project_id: string; + name: string | null; + step: string; + render_progress: number; + preview_b64: string | null; +} + +/** + * App-wide minimal render progress pill. Mounted globally for authenticated users; + * polls /api/render/active and shows a small floating indicator whenever a render + * is in flight, on ANY page. Click → the full-screen render page for that project. + * + * Self-hides on the render page itself (which already shows full progress) and when + * there is no active render. + */ +export function GlobalRenderProgress({ authed }: { authed: boolean }) { + const router = useRouter(); + const pathname = usePathname(); + const [active, setActive] = useState(null); + const [dismissed, setDismissed] = useState(null); + + // Don't show on the render page itself. + const onRenderPage = /\/studio\/render\//.test(pathname); + + useEffect(() => { + if (!authed) return; + let stop = false; + + const poll = async () => { + if (document.visibilityState === "hidden") return; + try { + const res = await fetch("/api/render/active", { cache: "no-store" }); + if (!res.ok) return; + const data = (await res.json()) as { active?: ActiveRender[] }; + if (stop) return; + setActive(data.active?.[0] ?? null); + } catch { + /* ignore transient errors */ + } + }; + + poll(); + const id = window.setInterval(poll, 4000); + return () => { + stop = true; + window.clearInterval(id); + }; + }, [authed]); + + if (!authed || !active || onRenderPage) return null; + if (dismissed === active.id) return null; + + const pct = Math.max(0, Math.min(100, active.render_progress ?? 0)); + + return ( + + ); +} diff --git a/src/components/studio/RenderModal.tsx b/src/components/studio/RenderModal.tsx deleted file mode 100644 index 0a8f6a6..0000000 --- a/src/components/studio/RenderModal.tsx +++ /dev/null @@ -1,325 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useState } from "react"; -import { useTranslations } from "next-intl"; -import { Download, Link2, Loader2, RefreshCw } from "lucide-react"; - -import { apiFetch } from "@/lib/api/fetch"; - -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import type { RenderExportPreset } from "@/lib/render-presets"; -import { RENDER_EXPORT_PRESETS } from "@/lib/render-presets"; -import type { RenderSettings } from "@/lib/render-schemas"; -import type { Scene } from "@/lib/studio-types"; -import { cn } from "@/lib/utils"; - -interface RenderModalProps { - open: boolean; - onOpenChange: (open: boolean) => void; - projectId: string; - scenes: Scene[]; - preset?: RenderExportPreset | null; -} - -type JobStatus = "idle" | "submitting" | "polling" | "completed" | "failed"; - -interface StatusResponse { - status: string; - progress: number; - outputUrl: string | null; - progressMessage?: string | null; - errorMessage?: string | null; - /** Base64-encoded PNG preview frame from the node agent. */ - previewB64?: string | null; -} - -const RESOLUTIONS: RenderSettings["resolution"][] = ["720p", "1080p", "4K"]; -const FPS_OPTIONS: RenderSettings["fps"][] = [24, 30, 60]; - -export function RenderModal({ - open, - onOpenChange, - projectId, - scenes, - preset = null, -}: RenderModalProps) { - const t = useTranslations("auto.componentsStudioRenderModal"); - const [resolution, setResolution] = - useState("1080p"); - const [fps, setFps] = useState(30); - const [presetLabel, setPresetLabel] = useState(null); - const [jobStatus, setJobStatus] = useState("idle"); - const [jobId, setJobId] = useState(null); - const [progress, setProgress] = useState(0); - const [progressMessage, setProgressMessage] = useState(""); - const [outputUrl, setOutputUrl] = useState(null); - const [errorMessage, setErrorMessage] = useState(null); - const [previewB64, setPreviewB64] = useState(null); - - const reset = useCallback(() => { - setJobStatus("idle"); - setJobId(null); - setProgress(0); - setProgressMessage(""); - setOutputUrl(null); - setErrorMessage(null); - setPreviewB64(null); - }, []); - - useEffect(() => { - if (!open) reset(); - }, [open, reset]); - - useEffect(() => { - if (!open || !preset) return; - const config = RENDER_EXPORT_PRESETS[preset]; - setResolution(config.settings.resolution); - setFps(config.settings.fps); - setPresetLabel(config.label); - }, [open, preset]); - - useEffect(() => { - if (jobStatus !== "polling" || !jobId) return; - - const poll = async () => { - try { - const response = await apiFetch(`/api/render/${jobId}/status`); - const data = (await response.json()) as StatusResponse; - - if (!response.ok) { - setJobStatus("failed"); - setErrorMessage(t("errorFetchStatus")); - return; - } - - setProgress(data.progress ?? 0); - setProgressMessage( - data.progressMessage ?? t("renderingProgress", { progress: data.progress }) - ); - if (data.previewB64) setPreviewB64(data.previewB64); - - if (data.status === "completed" && data.outputUrl) { - setOutputUrl(data.outputUrl); - setJobStatus("completed"); - setProgress(100); - return; - } - - if (data.status === "failed") { - setJobStatus("failed"); - setErrorMessage(data.errorMessage ?? t("errorRenderFailed")); - } - } catch { - setJobStatus("failed"); - setErrorMessage(t("errorNetworkPolling")); - } - }; - - poll(); - const intervalId = window.setInterval(poll, 3000); - return () => window.clearInterval(intervalId); - }, [jobStatus, jobId, t]); - - const startRender = async () => { - setJobStatus("submitting"); - setErrorMessage(null); - - try { - const response = 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 response.json()) as { - jobId?: string; - error?: string; - }; - - if (!response.ok || !data.jobId) { - setJobStatus("failed"); - setErrorMessage(data.error ?? t("errorStartRender")); - return; - } - - setJobId(data.jobId); - setJobStatus("polling"); - setProgress(0); - setProgressMessage(t("queued")); - } catch { - setJobStatus("failed"); - setErrorMessage(t("errorReachApi")); - } - }; - - const isBusy = jobStatus === "submitting" || jobStatus === "polling"; - - return ( - - - - {presetLabel ?? t("dialogTitle")} - - {preset - ? RENDER_EXPORT_PRESETS[preset].description - : t("dialogDescription")} - - - - {jobStatus === "completed" && outputUrl ? ( -
-

{t("videoReady")}

- - -
- ) : jobStatus === "failed" ? ( -
-

- {errorMessage ?? t("errorGeneric")} -

- -
- ) : isBusy ? ( -
- {/* Live preview frame from node agent */} -
- {previewB64 ? ( - // eslint-disable-next-line @next/next/no-img-element - {t("previewAlt")} - ) : ( -
- -
- )} - {/* Step badge */} -
- {progressMessage || t("rendering")} -
-
-
-
- {t("progress")} - {progress}% -
-
-
-
-
-
- ) : ( -
-
-

- {t("resolution")} -

-
- {RESOLUTIONS.map((item) => ( - - ))} -
-
-
-

{t("format")}

-
- MP4 -
-
-
-

{t("fps")}

-
- {FPS_OPTIONS.map((item) => ( - - ))} -
-
- -
- )} - -
- ); -} diff --git a/src/components/studio/video/StudioTopBar.tsx b/src/components/studio/video/StudioTopBar.tsx index e27c9fe..47225dd 100644 --- a/src/components/studio/video/StudioTopBar.tsx +++ b/src/components/studio/video/StudioTopBar.tsx @@ -1,8 +1,8 @@ "use client"; -import { useState } from "react"; import { useTranslations } from "next-intl"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { Camera, ChevronDown, @@ -14,7 +14,6 @@ import { Undo2, } from "lucide-react"; -import { RenderModal } from "@/components/studio/RenderModal"; import { StudioToolbar } from "@/components/studio/StudioToolbar"; import { StudioTopBarSaveBadge } from "@/components/studio/video/StudioTopBarSaveBadge"; import { StudioTopBarTextControls } from "@/components/studio/video/StudioTopBarTextControls"; @@ -58,17 +57,14 @@ export function StudioTopBar({ const isPlaying = useStudioStore((state) => state.isPlaying); const startPlayback = useStudioStore((state) => state.startPlayback); const stopPlayback = useStudioStore((state) => state.stopPlayback); - const scenes = useStudioStore((state) => state.scenes); const selectedLayer = useStudioStore((state) => getSelectedLayer(state)); - const [renderOpen, setRenderOpen] = useState(false); - const [renderPreset, setRenderPreset] = useState( - null - ); + const router = useRouter(); + // Render is now a full-screen page (not a modal). Navigate with the chosen + // export preset; the page handles config → progress → download. const openExport = (preset: RenderExportPreset) => { - setRenderPreset(preset); - setRenderOpen(true); + router.push(`/studio/render/${projectId}?preset=${preset}`); }; const handleSnapshot = () => { @@ -212,17 +208,6 @@ export function StudioTopBar({
- - { - setRenderOpen(open); - if (!open) setRenderPreset(null); - }} - projectId={projectId} - scenes={scenes} - preset={renderPreset} - /> ); }