feat(render): 5 quality tiers (360p–4K) + ETA on render page; 24h session

- Render page: resolution picker now 360p/540p/720p/1080p/4K; live ETA
  ('تقریباً … باقی مانده') computed from progress rate; preview+progress bar already wired.
- render-schemas: resolution enum + RESOLUTION_DIMENSIONS add 360p/540p.
- render-jobs.mapQuality: 5-tier → render_quality (Low/Medium/High/Full).
- Session: Jwt__AccessTokenMinutes=1440 (24h) via compose so logins persist
  (refresh middleware + 30d refresh token back it up).

(Real per-tier output height still pending: render-svc r_height is hardcoded 1080 →
node ffmpeg scale — next.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-06 23:04:32 +03:30
parent ad8796a25d
commit d7a74daa96
4 changed files with 52 additions and 9 deletions
+1
View File
@@ -72,6 +72,7 @@ services:
Jwt__Secret: "${JWT_SECRET}" Jwt__Secret: "${JWT_SECRET}"
Jwt__Issuer: "flatrender-identity" Jwt__Issuer: "flatrender-identity"
Jwt__Audience: "flatrender" Jwt__Audience: "flatrender"
Jwt__AccessTokenMinutes: "${JWT_ACCESS_MINUTES:-1440}"
ServiceToken: "${SERVICE_TOKEN:-internal-service-secret}" ServiceToken: "${SERVICE_TOKEN:-internal-service-secret}"
ZarinPal__MerchantId: "${ZARINPAL_MERCHANT_ID:-}" ZarinPal__MerchantId: "${ZARINPAL_MERCHANT_ID:-}"
ZarinPal__CallbackUrl: "${ZARINPAL_CALLBACK_URL:-http://localhost:8080/v1/payments/callback/zarinpal}" ZarinPal__CallbackUrl: "${ZARINPAL_CALLBACK_URL:-http://localhost:8080/v1/payments/callback/zarinpal}"
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useParams, useRouter, useSearchParams } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { import {
@@ -35,9 +35,21 @@ interface ActiveRender {
render_progress: number; render_progress: number;
} }
const RESOLUTIONS: RenderSettings["resolution"][] = ["720p", "1080p", "4K"]; const RESOLUTIONS: RenderSettings["resolution"][] = ["360p", "540p", "720p", "1080p", "4K"];
const FPS_OPTIONS: RenderSettings["fps"][] = [24, 30, 60]; const FPS_OPTIONS: RenderSettings["fps"][] = [24, 30, 60];
/** Format seconds as "M:SS" / "Hh Mm" for the ETA label. */
function formatEta(sec: number): string {
if (!isFinite(sec) || sec <= 0) return "—";
const s = Math.round(sec);
if (s < 60) return `${s} ثانیه`;
const m = Math.floor(s / 60);
const r = s % 60;
if (m < 60) return `${m}:${String(r).padStart(2, "0")} دقیقه`;
const h = Math.floor(m / 60);
return `${h} ساعت و ${m % 60} دقیقه`;
}
export default function RenderPage() { export default function RenderPage() {
const router = useRouter(); const router = useRouter();
const params = useParams<{ projectId: string }>(); const params = useParams<{ projectId: string }>();
@@ -56,6 +68,9 @@ export default function RenderPage() {
const [errorMessage, setErrorMessage] = 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). // An active render that belongs to a DIFFERENT project (blocks starting a new one).
const [blockingJobId, setBlockingJobId] = useState<string | null>(null); const [blockingJobId, setBlockingJobId] = useState<string | null>(null);
// ETA: estimate remaining time from the progress rate (first sample where progress > 0).
const [etaSec, setEtaSec] = useState<number | null>(null);
const etaBaseRef = useRef<{ t: number; p: number } | null>(null);
// Apply preset from the query (?preset=full) // Apply preset from the query (?preset=full)
useEffect(() => { useEffect(() => {
@@ -105,10 +120,25 @@ export default function RenderPage() {
setErrorMessage("Could not fetch render status."); setErrorMessage("Could not fetch render status.");
return; return;
} }
setProgress(data.progress ?? 0); const p = data.progress ?? 0;
setProgressMessage(data.progressMessage ?? `Rendering… ${data.progress}%`); setProgress(p);
setProgressMessage(data.progressMessage ?? `Rendering… ${p}%`);
if (data.previewB64) setPreviewB64(data.previewB64); if (data.previewB64) setPreviewB64(data.previewB64);
// ETA from the observed progress rate.
if (p > 0 && p < 100) {
const now = Date.now();
const base = etaBaseRef.current;
if (!base || p < base.p) {
etaBaseRef.current = { t: now, p };
} else if (p > base.p) {
const rate = (p - base.p) / ((now - base.t) / 1000); // %/sec
if (rate > 0) setEtaSec((100 - p) / rate);
}
} else {
setEtaSec(null);
}
if (data.status === "completed") { if (data.status === "completed") {
if (data.outputUrl) setOutputUrl(data.outputUrl); if (data.outputUrl) setOutputUrl(data.outputUrl);
setProgress(100); setProgress(100);
@@ -214,6 +244,11 @@ export default function RenderPage() {
style={{ width: `${progress}%` }} style={{ width: `${progress}%` }}
/> />
</div> </div>
{etaSec != null && (
<p className="mt-1.5 text-center text-[11px] text-gray-400">
تقریباً {formatEta(etaSec)} باقی مانده
</p>
)}
</div> </div>
)} )}
</div> </div>
+9 -4
View File
@@ -37,11 +37,16 @@ export interface RenderJobRow {
// ── Helpers ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
/** Map frontend resolution string to V2 quality enum. */ /** Map frontend resolution string to V2 render_quality enum (Low/Medium/High/Full/Lossless). */
function mapQuality(resolution: string): string { function mapQuality(resolution: string): string {
if (resolution === "4K") return "Full"; switch (resolution) {
if (resolution === "720p") return "Medium"; case "360p": return "Low";
return "High"; // 1080p default case "540p": return "Medium";
case "720p": return "Medium";
case "1080p": return "High";
case "4K": return "Full";
default: return "High";
}
} }
/** Map V2 step → legacy status for frontend compatibility. */ /** Map V2 step → legacy status for frontend compatibility. */
+3 -1
View File
@@ -22,7 +22,7 @@ export const sceneSchema = z.object({
}); });
export const renderSettingsSchema = z.object({ export const renderSettingsSchema = z.object({
resolution: z.enum(["720p", "1080p", "4K"]), resolution: z.enum(["360p", "540p", "720p", "1080p", "4K"]),
format: z.literal("mp4").default("mp4"), format: z.literal("mp4").default("mp4"),
fps: z.union([z.literal(24), z.literal(30), z.literal(60)]), fps: z.union([z.literal(24), z.literal(30), z.literal(60)]),
}); });
@@ -52,6 +52,8 @@ export const RESOLUTION_DIMENSIONS: Record<
RenderSettings["resolution"], RenderSettings["resolution"],
{ width: number; height: number } { width: number; height: number }
> = { > = {
"360p": { width: 640, height: 360 },
"540p": { width: 960, height: 540 },
"720p": { width: 1280, height: 720 }, "720p": { width: 1280, height: 720 },
"1080p": { width: 1920, height: 1080 }, "1080p": { width: 1920, height: 1080 },
"4K": { width: 3840, height: 2160 }, "4K": { width: 3840, height: 2160 },