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:
@@ -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>
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user