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