feat(render+templates): Remotion engine, 16 branded templates (incl. 3D), seconds pricing, coming-soon
CI/CD / CI · Web (tsc) (push) Successful in 1m21s
CI/CD / Deploy · full stack (push) Failing after 20s

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:
soroush.asadi
2026-06-21 15:52:52 +03:30
parent b9b91397b0
commit 4f04f6bf75
137 changed files with 8942 additions and 135 deletions
+2
View File
@@ -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>
+1 -1
View File
@@ -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",
});
+8 -47
View File
@@ -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) {