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
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:
@@ -182,6 +182,19 @@ public class AdminService(IdentityDbContext db)
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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].
|
||||||
|
/// </summary>
|
||||||
|
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)
|
public async Task GrantPlanDaysAsync(Guid userId, Guid planId, int days)
|
||||||
{
|
{
|
||||||
var plan = await db.UserPlans
|
var plan = await db.UserPlans
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ public class TokenService(IConfiguration config) : ITokenService
|
|||||||
new("is_admin", user.IsAdmin.ToString().ToLower()),
|
new("is_admin", user.IsAdmin.ToString().ToLower()),
|
||||||
new("is_tenant_admin", user.IsTenantAdmin.ToString().ToLower()),
|
new("is_tenant_admin", user.IsTenantAdmin.ToString().ToLower()),
|
||||||
new("role", role),
|
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))
|
if (!string.IsNullOrEmpty(user.Email))
|
||||||
|
|||||||
@@ -79,6 +79,14 @@ public class AdminController(AdminService svc) : ControllerBase
|
|||||||
return Ok(new { ok = true });
|
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<IActionResult> RenderSlots(Guid userId, [FromBody] SetRenderSlotsRequest req)
|
||||||
|
{
|
||||||
|
await svc.SetRenderSlotsAsync(userId, req.Ceiling);
|
||||||
|
return Ok(new { ok = true });
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("v1/users/{userId:guid}/grant-plan")]
|
[HttpPost("v1/users/{userId:guid}/grant-plan")]
|
||||||
public async Task<IActionResult> GrantPlan(Guid userId, [FromBody] GrantPlanDaysRequest req)
|
public async Task<IActionResult> GrantPlan(Guid userId, [FromBody] GrantPlanDaysRequest req)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -37,3 +37,4 @@ public record ResetPasswordRequest(string NewPassword);
|
|||||||
public record AddChargeRequest(int Seconds, int RenderCount); // grant render seconds / daily renders
|
public record AddChargeRequest(int Seconds, int RenderCount); // grant render seconds / daily renders
|
||||||
public record GrantPlanDaysRequest(Guid PlanId, int Days);
|
public record GrantPlanDaysRequest(Guid PlanId, int Days);
|
||||||
public record SetFlagRequest(bool Enabled);
|
public record SetFlagRequest(bool Enabled);
|
||||||
|
public record SetRenderSlotsRequest(int Ceiling); // concurrent-render ceiling
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ func main() {
|
|||||||
{
|
{
|
||||||
renders.GET("", renderH.List)
|
renders.GET("", renderH.List)
|
||||||
renders.POST("", renderH.Create)
|
renders.POST("", renderH.Create)
|
||||||
|
renders.GET("/active", renderH.Active)
|
||||||
renders.GET("/:job_id", renderH.Get)
|
renders.GET("/:job_id", renderH.Get)
|
||||||
renders.POST("/:job_id/cancel", renderH.Cancel)
|
renders.POST("/:job_id/cancel", renderH.Cancel)
|
||||||
renders.POST("/:job_id/stop", admin, renderH.Stop)
|
renders.POST("/:job_id/stop", admin, renderH.Stop)
|
||||||
|
|||||||
@@ -371,6 +371,45 @@ func (s *Store) GetJobByID(ctx context.Context, id, userID uuid.UUID) (*models.R
|
|||||||
return jobs[0], nil
|
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) {
|
func (s *Store) CreateJob(ctx context.Context, userID, tenantID uuid.UUID, req *models.RenderJobCreateRequest) (*models.RenderJob, error) {
|
||||||
priceType := "Free"
|
priceType := "Free"
|
||||||
if req.PriceType != nil {
|
if req.PriceType != nil {
|
||||||
|
|||||||
@@ -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
|
// POST /v1/renders
|
||||||
func (h *RenderHandler) Create(c *gin.Context) {
|
func (h *RenderHandler) Create(c *gin.Context) {
|
||||||
userID := middleware.GetUserID(c)
|
userID := middleware.GetUserID(c)
|
||||||
@@ -65,6 +98,20 @@ func (h *RenderHandler) Create(c *gin.Context) {
|
|||||||
return
|
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).
|
// Daily render-limit: consume one render charge (0 max = unlimited).
|
||||||
allowed, err := h.identity.Consume(c.Request.Context(), userID)
|
allowed, err := h.identity.Consume(c.Request.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/flatrender/render-svc/internal/models"
|
"github.com/flatrender/render-svc/internal/models"
|
||||||
@@ -12,10 +13,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
CtxUserID = "user_id"
|
CtxUserID = "user_id"
|
||||||
CtxTenantID = "tenant_id"
|
CtxTenantID = "tenant_id"
|
||||||
CtxIsAdmin = "is_admin"
|
CtxIsAdmin = "is_admin"
|
||||||
CtxRole = "role"
|
CtxRole = "role"
|
||||||
|
CtxMaxRenders = "max_renders"
|
||||||
)
|
)
|
||||||
|
|
||||||
func JWTAuth(secret string) gin.HandlerFunc {
|
func JWTAuth(secret string) gin.HandlerFunc {
|
||||||
@@ -54,10 +56,25 @@ func JWTAuth(secret string) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
role, _ := claims["role"].(string)
|
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(CtxUserID, userID)
|
||||||
c.Set(CtxTenantID, tenantID)
|
c.Set(CtxTenantID, tenantID)
|
||||||
c.Set(CtxIsAdmin, isAdmin)
|
c.Set(CtxIsAdmin, isAdmin)
|
||||||
c.Set(CtxRole, role)
|
c.Set(CtxRole, role)
|
||||||
|
c.Set(CtxMaxRenders, maxRenders)
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,3 +128,16 @@ func GetTenantID(c *gin.Context) uuid.UUID {
|
|||||||
id, _ := v.(uuid.UUID)
|
id, _ := v.(uuid.UUID)
|
||||||
return id
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { NextIntlClientProvider } from "next-intl";
|
|||||||
|
|
||||||
import { DirectionProvider } from "@/components/layout/DirectionProvider";
|
import { DirectionProvider } from "@/components/layout/DirectionProvider";
|
||||||
import { SiteChrome } from "@/components/layout/SiteChrome";
|
import { SiteChrome } from "@/components/layout/SiteChrome";
|
||||||
|
import { GlobalRenderProgress } from "@/components/render/GlobalRenderProgress";
|
||||||
import { getNavUser } from "@/lib/auth/session";
|
import { getNavUser } from "@/lib/auth/session";
|
||||||
import { routing } from "@/i18n/routing";
|
import { routing } from "@/i18n/routing";
|
||||||
import type { Locale } from "@/i18n/routing";
|
import type { Locale } from "@/i18n/routing";
|
||||||
@@ -115,6 +116,7 @@ export default async function LocaleLayout({
|
|||||||
<NextIntlClientProvider messages={messages} locale={locale}>
|
<NextIntlClientProvider messages={messages} locale={locale}>
|
||||||
<DirectionProvider dir={isRtl ? "rtl" : "ltr"}>
|
<DirectionProvider dir={isRtl ? "rtl" : "ltr"}>
|
||||||
<SiteChrome user={navUser}>{children}</SiteChrome>
|
<SiteChrome user={navUser}>{children}</SiteChrome>
|
||||||
|
<GlobalRenderProgress authed={!!navUser} />
|
||||||
</DirectionProvider>
|
</DirectionProvider>
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ export function UserActions({ row }: { row: Record<string, unknown>; reload?: ()
|
|||||||
const [pw, setPw] = useState("");
|
const [pw, setPw] = useState("");
|
||||||
const [seconds, setSeconds] = useState(""); const [renders, setRenders] = useState("");
|
const [seconds, setSeconds] = useState(""); const [renders, setRenders] = useState("");
|
||||||
const [planDays, setPlanDays] = 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");
|
const [tags, setTags] = useState(""); const [note, setNote] = useState(""); const [status, setStatus] = useState("new");
|
||||||
// discount / affiliate
|
// discount / affiliate
|
||||||
const [dcCode, setDcCode] = useState(""); const [dcKind, setDcKind] = useState("Percentage");
|
const [dcCode, setDcCode] = useState(""); const [dcKind, setDcKind] = useState("Percentage");
|
||||||
@@ -114,6 +115,15 @@ export function UserActions({ row }: { row: Record<string, unknown>; reload?: ()
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={`${card} p-3`}>
|
||||||
|
<label className={lbl}>تعداد رندر همزمان مجاز</label>
|
||||||
|
<p className="mb-1.5 text-[11px] text-gray-500">پیشفرض ۱. با افزایش این مقدار، کاربر میتواند چند رندر همزمان اجرا کند (پس از تازهسازی توکن اعمال میشود).</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input className={`${inp} max-w-[120px]`} type="number" min={1} max={50} value={slots} onChange={(e) => setSlots(e.target.value)} />
|
||||||
|
<button className={btn} disabled={busy || !slots} onClick={() => call(`users/${id}/render-slots`, { ceiling: Number(slots) || 1 }, "ظرفیت رندر همزمان بهروزرسانی شد ✓")}>اعمال</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={`${card} flex items-center justify-between p-3`}>
|
<div className={`${card} flex items-center justify-between p-3`}>
|
||||||
<span className="text-sm text-gray-300">دسترسی مدیر (مدراتور)</span>
|
<span className="text-sm text-gray-300">دسترسی مدیر (مدراتور)</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|||||||
@@ -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<ActiveRender | null>(null);
|
||||||
|
const [dismissed, setDismissed] = useState<string | null>(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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push(`/studio/render/${active.saved_project_id}`)}
|
||||||
|
dir="rtl"
|
||||||
|
className="fixed bottom-4 end-4 z-[70] flex w-64 items-center gap-3 rounded-xl border border-[#262b40] bg-[#0c0e1a]/95 p-3 text-start shadow-2xl backdrop-blur transition-colors hover:border-primary-500"
|
||||||
|
aria-label="مشاهده پیشرفت رندر"
|
||||||
|
>
|
||||||
|
{/* Thumbnail / spinner */}
|
||||||
|
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-lg bg-[#070811]">
|
||||||
|
{active.preview_b64 ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={`data:image/png;base64,${active.preview_b64}`}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<Film className="h-5 w-5 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text + progress */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin text-primary-400" />
|
||||||
|
<span className="truncate text-xs font-medium text-gray-200">
|
||||||
|
{active.name?.trim() || "در حال رندر"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex items-center gap-2">
|
||||||
|
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-white/10">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary-500 transition-all duration-500"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] tabular-nums text-gray-400">{pct}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dismiss (hides until job id changes) */}
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDismissed(active.id);
|
||||||
|
}}
|
||||||
|
className="shrink-0 rounded p-1 text-gray-600 hover:text-gray-300"
|
||||||
|
aria-label="پنهان کردن"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<RenderSettings["resolution"]>("1080p");
|
|
||||||
const [fps, setFps] = useState<RenderSettings["fps"]>(30);
|
|
||||||
const [presetLabel, setPresetLabel] = useState<string | null>(null);
|
|
||||||
const [jobStatus, setJobStatus] = useState<JobStatus>("idle");
|
|
||||||
const [jobId, setJobId] = useState<string | null>(null);
|
|
||||||
const [progress, setProgress] = useState(0);
|
|
||||||
const [progressMessage, setProgressMessage] = useState("");
|
|
||||||
const [outputUrl, setOutputUrl] = useState<string | null>(null);
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
||||||
const [previewB64, setPreviewB64] = useState<string | null>(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 (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{presetLabel ?? t("dialogTitle")}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{preset
|
|
||||||
? RENDER_EXPORT_PRESETS[preset].description
|
|
||||||
: t("dialogDescription")}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{jobStatus === "completed" && outputUrl ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-sm text-green-400">{t("videoReady")}</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-2.5 text-sm font-medium text-white hover:bg-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]"
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
{t("downloadMp4")}
|
|
||||||
</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-[#1f2234] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]"
|
|
||||||
>
|
|
||||||
<Link2 className="h-4 w-4" />
|
|
||||||
{t("shareLink")}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="w-full border-[#2a2d3e]"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
>
|
|
||||||
{t("close")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : jobStatus === "failed" ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="rounded-lg border border-red-900/50 bg-red-950/40 px-3 py-2 text-sm text-red-300">
|
|
||||||
{errorMessage ?? t("errorGeneric")}
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="w-full bg-primary-600 hover:bg-primary-700"
|
|
||||||
onClick={startRender}
|
|
||||||
>
|
|
||||||
<RefreshCw className="h-4 w-4" />
|
|
||||||
{t("retry")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : isBusy ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Live preview frame from node agent */}
|
|
||||||
<div className="relative overflow-hidden rounded-lg bg-[#0f111e] aspect-video">
|
|
||||||
{previewB64 ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={`data:image/png;base64,${previewB64}`}
|
|
||||||
alt={t("previewAlt")}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-primary-400/40" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Step badge */}
|
|
||||||
<div className="absolute bottom-2 right-2 rounded-md bg-black/60 px-2 py-0.5 text-[10px] font-medium text-gray-300 backdrop-blur-sm">
|
|
||||||
{progressMessage || t("rendering")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="mb-1 flex justify-between text-xs text-gray-500">
|
|
||||||
<span>{t("progress")}</span>
|
|
||||||
<span>{progress}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-2 overflow-hidden rounded-full bg-[#1a1d2e]">
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full bg-primary-600 transition-all duration-300"
|
|
||||||
style={{ width: `${progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<p className="mb-2 text-xs font-medium text-gray-400">
|
|
||||||
{t("resolution")}
|
|
||||||
</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 text-xs font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]",
|
|
||||||
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">{t("format")}</p>
|
|
||||||
<div className="rounded-lg border border-primary-500 bg-primary-600/20 px-3 py-2 text-center text-xs font-medium text-white">
|
|
||||||
MP4
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="mb-2 text-xs font-medium text-gray-400">{t("fps")}</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 text-xs font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]",
|
|
||||||
fps === item
|
|
||||||
? "border-primary-500 bg-primary-600/20 text-white"
|
|
||||||
: "border-[#2a2d3e] text-[#8b91a7] hover:border-[#3d4260]"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="w-full bg-primary-600 hover:bg-primary-700"
|
|
||||||
onClick={startRender}
|
|
||||||
disabled={scenes.length === 0}
|
|
||||||
>
|
|
||||||
{t("startRendering")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
Camera,
|
Camera,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
Undo2,
|
Undo2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { RenderModal } from "@/components/studio/RenderModal";
|
|
||||||
import { StudioToolbar } from "@/components/studio/StudioToolbar";
|
import { StudioToolbar } from "@/components/studio/StudioToolbar";
|
||||||
import { StudioTopBarSaveBadge } from "@/components/studio/video/StudioTopBarSaveBadge";
|
import { StudioTopBarSaveBadge } from "@/components/studio/video/StudioTopBarSaveBadge";
|
||||||
import { StudioTopBarTextControls } from "@/components/studio/video/StudioTopBarTextControls";
|
import { StudioTopBarTextControls } from "@/components/studio/video/StudioTopBarTextControls";
|
||||||
@@ -58,17 +57,14 @@ export function StudioTopBar({
|
|||||||
const isPlaying = useStudioStore((state) => state.isPlaying);
|
const isPlaying = useStudioStore((state) => state.isPlaying);
|
||||||
const startPlayback = useStudioStore((state) => state.startPlayback);
|
const startPlayback = useStudioStore((state) => state.startPlayback);
|
||||||
const stopPlayback = useStudioStore((state) => state.stopPlayback);
|
const stopPlayback = useStudioStore((state) => state.stopPlayback);
|
||||||
const scenes = useStudioStore((state) => state.scenes);
|
|
||||||
const selectedLayer = useStudioStore((state) => getSelectedLayer(state));
|
const selectedLayer = useStudioStore((state) => getSelectedLayer(state));
|
||||||
|
|
||||||
const [renderOpen, setRenderOpen] = useState(false);
|
const router = useRouter();
|
||||||
const [renderPreset, setRenderPreset] = useState<RenderExportPreset | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
|
// 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) => {
|
const openExport = (preset: RenderExportPreset) => {
|
||||||
setRenderPreset(preset);
|
router.push(`/studio/render/${projectId}?preset=${preset}`);
|
||||||
setRenderOpen(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSnapshot = () => {
|
const handleSnapshot = () => {
|
||||||
@@ -212,17 +208,6 @@ export function StudioTopBar({
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RenderModal
|
|
||||||
open={renderOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setRenderOpen(open);
|
|
||||||
if (!open) setRenderPreset(null);
|
|
||||||
}}
|
|
||||||
projectId={projectId}
|
|
||||||
scenes={scenes}
|
|
||||||
preset={renderPreset}
|
|
||||||
/>
|
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user