feat(render): plan-gate quality tiers — free=360p watermarked, paid=all
Monetization gate for the template render flow: - render-quality.ts: single source of truth (free -> 360p only + watermark; pro/business -> 540p..4K, no watermark). - /api/render: server-authoritative gate — rejects >360p for free users with 403 quality_locked; passes a watermark flag through createRenderJob -> /v1/renders (render-svc passthrough, wired later). - /api/render/limits: GET endpoint exposing the user's allowed tiers and watermark state to the studio. - render page: locks higher tiers for free users (dashed + lock badge, click routes to /pricing), clamps the selected resolution down, shows the "free = 360p + watermark, upgrade" notice, and handles the 403 quality_locked response. AI-video "no free preview" rule is a future hook (no AI gen yet). Watermark rendering (ffmpeg drawtext on the node) is a follow-up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -82,6 +82,11 @@ export default function RenderPage() {
|
|||||||
const [etaSec, setEtaSec] = useState<number | null>(null);
|
const [etaSec, setEtaSec] = useState<number | null>(null);
|
||||||
const etaBaseRef = useRef<{ t: number; p: number } | null>(null);
|
const etaBaseRef = useRef<{ t: number; p: number } | null>(null);
|
||||||
|
|
||||||
|
// Plan quality entitlements: Free = 360p only (watermarked); paid = all tiers.
|
||||||
|
const [allowedRes, setAllowedRes] = useState<RenderSettings["resolution"][]>(RESOLUTIONS);
|
||||||
|
const [plan, setPlan] = useState<string>("");
|
||||||
|
const isFreePlan = plan === "free";
|
||||||
|
|
||||||
// Apply preset from the query (?preset=full)
|
// Apply preset from the query (?preset=full)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!presetKey || !RENDER_EXPORT_PRESETS[presetKey]) return;
|
if (!presetKey || !RENDER_EXPORT_PRESETS[presetKey]) return;
|
||||||
@@ -107,6 +112,29 @@ export default function RenderPage() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Quality entitlements: free plan caps at 360p. Lock higher tiers + clamp the
|
||||||
|
// selected resolution down if it's above what the plan allows.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/render/limits", { cache: "no-store" });
|
||||||
|
const data = (await res.json()) as { plan: string; allowed: RenderSettings["resolution"][] };
|
||||||
|
if (cancelled || !Array.isArray(data.allowed) || data.allowed.length === 0) return;
|
||||||
|
setPlan(data.plan);
|
||||||
|
setAllowedRes(data.allowed);
|
||||||
|
setResolution((cur) =>
|
||||||
|
data.allowed.includes(cur) ? cur : data.allowed[data.allowed.length - 1],
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* keep defaults (all tiers) on failure */
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// On mount: resume this project's render if one is in flight, or flag a render
|
// 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.
|
// running on another project so we can block starting a new one.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -217,6 +245,14 @@ export default function RenderPage() {
|
|||||||
setErrorMessage(renderServiceMessage(status, locale, data.error ?? "سرویس رندر در حال حاضر در دسترس نیست."));
|
setErrorMessage(renderServiceMessage(status, locale, data.error ?? "سرویس رندر در حال حاضر در دسترس نیست."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (res.status === 403 || data.code === "quality_locked") {
|
||||||
|
setPhase("config");
|
||||||
|
setResolution((cur) => (allowedRes.includes(cur) ? cur : allowedRes[allowedRes.length - 1]));
|
||||||
|
setErrorMessage(
|
||||||
|
data.error ?? "پلن رایگان فقط با کیفیت ۳۶۰p رندر میشود. برای کیفیت بالاتر ارتقا دهید."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (res.status === 409 || data.code === "active_render_limit") {
|
if (res.status === 409 || data.code === "active_render_limit") {
|
||||||
setPhase("config");
|
setPhase("config");
|
||||||
setErrorMessage(
|
setErrorMessage(
|
||||||
@@ -383,22 +419,38 @@ export default function RenderPage() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-xs font-medium text-gray-400">کیفیت</p>
|
<p className="mb-2 text-xs font-medium text-gray-400">کیفیت</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{RESOLUTIONS.map((item) => (
|
{RESOLUTIONS.map((item) => {
|
||||||
|
const locked = !allowedRes.includes(item);
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={item}
|
key={item}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setResolution(item)}
|
onClick={() => (locked ? router.push("/pricing") : setResolution(item))}
|
||||||
|
title={locked ? "ارتقا برای کیفیت بالاتر" : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 rounded-lg border py-2.5 text-sm font-medium",
|
"flex-1 rounded-lg border py-2.5 text-sm font-medium",
|
||||||
resolution === item
|
locked
|
||||||
|
? "border-dashed border-[#2a2d3e] text-[#5a6072] hover:border-[#3d4260]"
|
||||||
|
: resolution === item
|
||||||
? "border-primary-500 bg-primary-600/20 text-white"
|
? "border-primary-500 bg-primary-600/20 text-white"
|
||||||
: "border-[#2a2d3e] text-[#8b91a7] hover:border-[#3d4260]"
|
: "border-[#2a2d3e] text-[#8b91a7] hover:border-[#3d4260]"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item}
|
{item}
|
||||||
|
{locked && <span className="ms-1 align-middle text-[9px]">🔒</span>}
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{isFreePlan && (
|
||||||
|
<p className="mt-2 text-[11px] leading-relaxed text-amber-300/90">
|
||||||
|
پلن رایگان فقط ۳۶۰p و با واترمارک رندر میشود. برای کیفیت بالاتر و حذف واترمارک{" "}
|
||||||
|
<Link href="/pricing" className="font-medium text-amber-200 underline hover:text-amber-100">
|
||||||
|
ارتقا دهید
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-xs font-medium text-gray-400">نرخ فریم</p>
|
<p className="mb-2 text-xs font-medium text-gray-400">نرخ فریم</p>
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { getUserProfile } from "@/lib/profiles";
|
||||||
|
import { allowedResolutions, isWatermarked } from "@/lib/render-quality";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
/** The current user's render quality entitlements, so the studio can lock the
|
||||||
|
* higher tiers for free users and surface the upgrade prompt. */
|
||||||
|
export async function GET() {
|
||||||
|
const profile = await getUserProfile("");
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
plan: profile.plan,
|
||||||
|
allowed: allowedResolutions(profile.plan),
|
||||||
|
watermark: isWatermarked(profile.plan),
|
||||||
|
},
|
||||||
|
{ headers: { "Cache-Control": "no-store" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { getAccessToken } from "@/lib/auth/session";
|
import { getAccessToken } from "@/lib/auth/session";
|
||||||
|
import { getUserProfile } from "@/lib/profiles";
|
||||||
import { createRenderJob } from "@/lib/render-jobs";
|
import { createRenderJob } from "@/lib/render-jobs";
|
||||||
|
import { isResolutionAllowed, isWatermarked } from "@/lib/render-quality";
|
||||||
import { renderRequestSchema } from "@/lib/render-schemas";
|
import { renderRequestSchema } from "@/lib/render-schemas";
|
||||||
import { fetchRenderServiceStatus } from "@/lib/render-service";
|
import { fetchRenderServiceStatus } from "@/lib/render-service";
|
||||||
|
|
||||||
@@ -43,7 +45,23 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await createRenderJob(parsed.data, token);
|
// Plan gate: Free plan renders at 360p only (watermarked); paid plans unlock all.
|
||||||
|
const profile = await getUserProfile("");
|
||||||
|
const resolution = parsed.data.settings.resolution;
|
||||||
|
if (!isResolutionAllowed(profile.plan, resolution)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "Free plan renders at 360p. Upgrade to Pro for higher quality.",
|
||||||
|
code: "quality_locked",
|
||||||
|
plan: profile.plan,
|
||||||
|
maxResolution: "360p",
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const watermark = isWatermarked(profile.plan);
|
||||||
|
|
||||||
|
const result = await createRenderJob(parsed.data, token, watermark);
|
||||||
if ("error" in result) {
|
if ("error" in result) {
|
||||||
return NextResponse.json({ error: result.error }, { status: 500 });
|
return NextResponse.json({ error: result.error }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,8 @@ function authHeaders(token: string): Record<string, string> {
|
|||||||
*/
|
*/
|
||||||
export async function createRenderJob(
|
export async function createRenderJob(
|
||||||
payload: RenderRequest,
|
payload: RenderRequest,
|
||||||
token: string
|
token: string,
|
||||||
|
watermark = false,
|
||||||
): Promise<{ jobId: string } | { error: string }> {
|
): Promise<{ jobId: string } | { error: string }> {
|
||||||
const res = await fetch(gatewayUrl("/v1/renders"), {
|
const res = await fetch(gatewayUrl("/v1/renders"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -84,6 +85,9 @@ export async function createRenderJob(
|
|||||||
quality: mapQuality(payload.settings.resolution),
|
quality: mapQuality(payload.settings.resolution),
|
||||||
resolution: payload.settings.resolution,
|
resolution: payload.settings.resolution,
|
||||||
frame_rate: payload.settings.fps,
|
frame_rate: payload.settings.fps,
|
||||||
|
// Free-plan renders are watermarked previews — passthrough to render-svc/node
|
||||||
|
// (ignored by the orchestrator until the watermark field is wired there).
|
||||||
|
watermark,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Render quality policy (monetization gate).
|
||||||
|
*
|
||||||
|
* Free plan → 360p only, watermarked preview.
|
||||||
|
* Pro/Business → every resolution (540p–4K), no watermark.
|
||||||
|
*
|
||||||
|
* (AI-generated videos will have NO free preview at all — pay/credits before the
|
||||||
|
* result is shown. That rule belongs in the AI-video feature when it's built;
|
||||||
|
* this module covers the normal template render flow.)
|
||||||
|
*/
|
||||||
|
import type { PlanId } from "@/lib/plans";
|
||||||
|
import type { RenderSettings } from "@/lib/render-schemas";
|
||||||
|
|
||||||
|
export type Resolution = RenderSettings["resolution"]; // "360p" | "540p" | "720p" | "1080p" | "4K"
|
||||||
|
|
||||||
|
export const FREE_RESOLUTION: Resolution = "360p";
|
||||||
|
export const ALL_RESOLUTIONS: Resolution[] = ["360p", "540p", "720p", "1080p", "4K"];
|
||||||
|
|
||||||
|
/** Resolutions a plan may render at. Free = 360p only; any paid plan = all. */
|
||||||
|
export function allowedResolutions(plan: PlanId): Resolution[] {
|
||||||
|
return plan === "free" ? [FREE_RESOLUTION] : ALL_RESOLUTIONS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isResolutionAllowed(plan: PlanId, res: Resolution): boolean {
|
||||||
|
return allowedResolutions(plan).includes(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Free renders get a watermarked preview; paid plans get clean output. */
|
||||||
|
export function isWatermarked(plan: PlanId): boolean {
|
||||||
|
return plan === "free";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user