From 896ce3dfa99c3427c60287b7fb93df33e1efebda Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Mon, 15 Jun 2026 15:17:25 +0330 Subject: [PATCH] =?UTF-8?q?feat(render):=20plan-gate=20quality=20tiers=20?= =?UTF-8?q?=E2=80=94=20free=3D360p=20watermarked,=20paid=3Dall?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../studio/render/[projectId]/page.tsx | 82 +++++++++++++++---- src/app/api/render/limits/route.ts | 20 +++++ src/app/api/render/route.ts | 20 ++++- src/lib/render-jobs.ts | 6 +- src/lib/render-quality.ts | 31 +++++++ 5 files changed, 142 insertions(+), 17 deletions(-) create mode 100644 src/app/api/render/limits/route.ts create mode 100644 src/lib/render-quality.ts diff --git a/src/app/[locale]/studio/render/[projectId]/page.tsx b/src/app/[locale]/studio/render/[projectId]/page.tsx index 00f3201..2890d00 100644 --- a/src/app/[locale]/studio/render/[projectId]/page.tsx +++ b/src/app/[locale]/studio/render/[projectId]/page.tsx @@ -82,6 +82,11 @@ export default function RenderPage() { const [etaSec, setEtaSec] = useState(null); const etaBaseRef = useRef<{ t: number; p: number } | null>(null); + // Plan quality entitlements: Free = 360p only (watermarked); paid = all tiers. + const [allowedRes, setAllowedRes] = useState(RESOLUTIONS); + const [plan, setPlan] = useState(""); + const isFreePlan = plan === "free"; + // Apply preset from the query (?preset=full) useEffect(() => { 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 // running on another project so we can block starting a new one. useEffect(() => { @@ -217,6 +245,14 @@ export default function RenderPage() { setErrorMessage(renderServiceMessage(status, locale, data.error ?? "سرویس رندر در حال حاضر در دسترس نیست.")); 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") { setPhase("config"); setErrorMessage( @@ -383,22 +419,38 @@ export default function RenderPage() {

کیفیت

- {RESOLUTIONS.map((item) => ( - - ))} + {RESOLUTIONS.map((item) => { + const locked = !allowedRes.includes(item); + return ( + + ); + })}
+ {isFreePlan && ( +

+ پلن رایگان فقط ۳۶۰p و با واترمارک رندر می‌شود. برای کیفیت بالاتر و حذف واترمارک{" "} + + ارتقا دهید + + . +

+ )}

نرخ فریم

diff --git a/src/app/api/render/limits/route.ts b/src/app/api/render/limits/route.ts new file mode 100644 index 0000000..f4cd660 --- /dev/null +++ b/src/app/api/render/limits/route.ts @@ -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" } }, + ); +} diff --git a/src/app/api/render/route.ts b/src/app/api/render/route.ts index 6ea6190..ae66a32 100644 --- a/src/app/api/render/route.ts +++ b/src/app/api/render/route.ts @@ -1,7 +1,9 @@ import { NextResponse } from "next/server"; import { getAccessToken } from "@/lib/auth/session"; +import { getUserProfile } from "@/lib/profiles"; import { createRenderJob } from "@/lib/render-jobs"; +import { isResolutionAllowed, isWatermarked } from "@/lib/render-quality"; import { renderRequestSchema } from "@/lib/render-schemas"; 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) { return NextResponse.json({ error: result.error }, { status: 500 }); } diff --git a/src/lib/render-jobs.ts b/src/lib/render-jobs.ts index b7305ec..14fefda 100644 --- a/src/lib/render-jobs.ts +++ b/src/lib/render-jobs.ts @@ -73,7 +73,8 @@ function authHeaders(token: string): Record { */ export async function createRenderJob( payload: RenderRequest, - token: string + token: string, + watermark = false, ): Promise<{ jobId: string } | { error: string }> { const res = await fetch(gatewayUrl("/v1/renders"), { method: "POST", @@ -84,6 +85,9 @@ export async function createRenderJob( quality: mapQuality(payload.settings.resolution), resolution: payload.settings.resolution, 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, }), }); diff --git a/src/lib/render-quality.ts b/src/lib/render-quality.ts new file mode 100644 index 0000000..e10e789 --- /dev/null +++ b/src/lib/render-quality.ts @@ -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"; +}