feat(render+templates): Remotion engine, 16 branded templates (incl. 3D), seconds pricing, coming-soon
Render engine - Add Remotion (code-based) as a 2nd render engine alongside After Effects. node-agent dispatches on Job.Engine; RunRemotion maps bindings -> --props, renders native then ffmpeg-scales to the quality tier (aspect-preserving). - content.projects.render_engine + render_remotion_comp (migration 32); render-svc claim resolves engine and routes (skips .aep for Remotion). - Admin TemplatesAdmin gains an engine picker + Remotion composition id field. Template pack (services/remotion) - 16 branded, Persian (Vazirmatn), color- and text-editable templates, each in 3 aspects (16:9 / 1:1 / 9:16): LogoMotion, Opener, InstaPromo, YouTubeIntro, Slideshow, HappyBirthday, SalePromo, QuoteCard, EventInvite, Countdown, GlitterReveal (editable logo image), NowruzGreeting (animated characters), and 4 cinematic 3D templates via @remotion/three (Hero3D, Nowruz3D, Birthday3D, Promo3D) with reflections + bloom/DOF/vignette. - scripts/seed_remotion_templates.py seeds containers/projects/scenes/colors. Pricing - Rewrite /pricing to the seconds-based model (charge = length x resolution), data-driven from /v1/plans, Toman, broker checkout. Coming-soon - Persian experimental-build overlay on all pages (launch date + countdown). Fixes - middleware matcher bypasses all static asset paths; catalog mapping passes cover image + preview video so real thumbnails render. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import { notFound } from "next/navigation";
|
||||
import { getMessages, getTranslations } from "next-intl/server";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
|
||||
import { ComingSoonOverlay } from "@/components/layout/ComingSoonOverlay";
|
||||
import { DirectionProvider } from "@/components/layout/DirectionProvider";
|
||||
import { SiteChrome } from "@/components/layout/SiteChrome";
|
||||
import { GlobalRenderProgress } from "@/components/render/GlobalRenderProgress";
|
||||
@@ -117,6 +118,7 @@ export default async function LocaleLayout({
|
||||
<DirectionProvider dir={isRtl ? "rtl" : "ltr"}>
|
||||
<SiteChrome user={navUser}>{children}</SiteChrome>
|
||||
<GlobalRenderProgress authed={!!navUser} />
|
||||
<ComingSoonOverlay />
|
||||
</DirectionProvider>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createPageMetadata } from "@/lib/metadata";
|
||||
export const metadata: Metadata = createPageMetadata({
|
||||
title: "Pricing",
|
||||
description:
|
||||
"Compare FlatRender Lite, Pro, and Business plans. Monthly or yearly billing with templates, exports, and AI tools for creators.",
|
||||
"FlatRender pricing is by the second, not by the video. Every plan grants a monthly bucket of render-seconds; a render costs the video length × a quality multiplier.",
|
||||
path: "/pricing",
|
||||
});
|
||||
|
||||
|
||||
@@ -7,25 +7,16 @@ import { getAccessToken } from "@/lib/auth/session";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const checkoutSchema = z.object({
|
||||
plan: z.enum(["pro", "business"]),
|
||||
billing: z.enum(["monthly", "annual"]),
|
||||
planId: z.string().uuid(),
|
||||
});
|
||||
|
||||
interface V2Plan {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
billing_period: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a plan purchase through the V2 Identity/payments flow.
|
||||
* Start a seconds-based plan purchase through the V2 Identity/payments flow.
|
||||
*
|
||||
* Replaces the direct Stripe Checkout + Supabase profile loop. We resolve the
|
||||
* requested plan ("pro"/"business" × "monthly"/"annual") to a plan GUID via
|
||||
* `/v1/plans` (codes follow the `pro_monthly` / `business_annual` convention),
|
||||
* then POST `/v1/users/me/plan/purchase`. The payments service owns the gateway
|
||||
* (ZarinPal/Stripe) and returns a redirect URL we hand back to the client.
|
||||
* The pricing page is data-driven (plans come from `/v1/plans`), so the client
|
||||
* sends the chosen plan's GUID directly. We POST `/v1/users/me/plan/purchase`;
|
||||
* the payments service owns the gateway (ZarinPal broker) and returns a redirect
|
||||
* URL we hand back to the client.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const token = await getAccessToken();
|
||||
@@ -45,37 +36,7 @@ export async function POST(request: Request) {
|
||||
|
||||
const parsed = checkoutSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid plan or billing period." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { plan, billing } = parsed.data;
|
||||
const targetCode = `${plan}_${billing === "annual" ? "annual" : "monthly"}`;
|
||||
|
||||
// Resolve plan code → GUID. Plans are public, but pass the token so tenant
|
||||
// overrides resolve correctly.
|
||||
const plansRes = await fetch(gatewayUrl("/v1/plans"), {
|
||||
cache: "no-store",
|
||||
headers: { Accept: "application/json", Authorization: `Bearer ${token}` },
|
||||
});
|
||||
const plansJson = plansRes.ok
|
||||
? ((await plansRes.json().catch(() => null)) as { data?: V2Plan[] } | null)
|
||||
: null;
|
||||
const match = plansJson?.data?.find(
|
||||
(p) => p.code?.toLowerCase() === targetCode
|
||||
);
|
||||
|
||||
if (!match) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"This plan is not available yet. Please try again later or contact support.",
|
||||
code: "PLAN_NOT_AVAILABLE",
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
return NextResponse.json({ error: "Invalid plan." }, { status: 400 });
|
||||
}
|
||||
|
||||
const purchaseRes = await fetch(gatewayUrl("/v1/users/me/plan/purchase"), {
|
||||
@@ -86,7 +47,7 @@ export async function POST(request: Request) {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ plan_id: match.id }),
|
||||
body: JSON.stringify({ plan_id: parsed.data.planId }),
|
||||
});
|
||||
|
||||
if (!purchaseRes.ok) {
|
||||
|
||||
Reference in New Issue
Block a user