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,6 +1,6 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import {
@@ -35,9 +35,21 @@ interface ActiveRender {
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];
/** 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() {
const router = useRouter();
const params = useParams<{ projectId: string }>();
@@ -56,6 +68,9 @@ export default function RenderPage() {
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);
// 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)
useEffect(() => {
@@ -105,10 +120,25 @@ export default function RenderPage() {
setErrorMessage("Could not fetch render status.");
return;
}
setProgress(data.progress ?? 0);
setProgressMessage(data.progressMessage ?? `Rendering… ${data.progress}%`);
const p = data.progress ?? 0;
setProgress(p);
setProgressMessage(data.progressMessage ?? `Rendering… ${p}%`);
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.outputUrl) setOutputUrl(data.outputUrl);
setProgress(100);
@@ -214,6 +244,11 @@ export default function RenderPage() {
style={{ width: `${progress}%` }}
/>
</div>
{etaSec != null && (
<p className="mt-1.5 text-center text-[11px] text-gray-400">
تقریباً {formatEta(etaSec)} باقی مانده
</p>
)}
</div>
)}
</div>
+9 -4
View File
@@ -37,11 +37,16 @@ export interface RenderJobRow {
// ── 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 {
if (resolution === "4K") return "Full";
if (resolution === "720p") return "Medium";
return "High"; // 1080p default
switch (resolution) {
case "360p": return "Low";
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. */
+3 -1
View File
@@ -22,7 +22,7 @@ export const sceneSchema = 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"),
fps: z.union([z.literal(24), z.literal(30), z.literal(60)]),
});
@@ -52,6 +52,8 @@ export const RESOLUTION_DIMENSIONS: Record<
RenderSettings["resolution"],
{ width: number; height: number }
> = {
"360p": { width: 640, height: 360 },
"540p": { width: 960, height: 540 },
"720p": { width: 1280, height: 720 },
"1080p": { width: 1920, height: 1080 },
"4K": { width: 3840, height: 2160 },