Files
flatrender/src/lib/plans-catalog.ts
T
soroush.asadi 4f04f6bf75
CI/CD / CI · Web (tsc) (push) Successful in 1m21s
CI/CD / Deploy · full stack (push) Failing after 20s
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>
2026-06-21 15:52:52 +03:30

132 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Server-side catalog of the V2 seconds-based subscription plans.
*
* FlatRender charges by **render-seconds**, not by number of videos. Each plan
* grants a monthly bucket of render-seconds (`secondsCharge`); a render consumes
* seconds equal to the video's length × a resolution multiplier (see
* RESOLUTION_MULTIPLIERS). Plans live in the Identity service and are read here
* from the gateway so prices/quotas are editable in admin without code changes.
*/
import { gatewayUrl } from "@/lib/api/gateway";
// ── Resolution multipliers: render-seconds = videoLengthSec × multiplier ───────
// Baseline is 720p (×1). Higher resolutions cost proportionally more seconds.
export const RESOLUTION_MULTIPLIERS: Record<string, number> = {
"360p": 0.5,
"540p": 0.75,
"720p": 1,
"1080p": 2,
"2K": 3,
"4K": 4,
};
export const RESOLUTION_ORDER = ["360p", "540p", "720p", "1080p", "2K", "4K"];
/** Multiplier for a resolution label, defaulting to 1 for unknown labels. */
export function resolutionMultiplier(resolution: string): number {
return RESOLUTION_MULTIPLIERS[resolution] ?? 1;
}
/** Render-seconds a single render consumes at the given length + resolution. */
export function renderSecondsCost(lengthSec: number, resolution: string): number {
return Math.ceil(lengthSec * resolutionMultiplier(resolution));
}
// ── Types ──────────────────────────────────────────────────────────────────
export interface SecondsPlan {
id: string;
code: string;
name: string;
description?: string | null;
/** Display price in Toman (price_minor is stored in Rial = Toman × 10). */
priceTomans: number;
beforePriceTomans?: number | null;
currency: string;
/** Render-seconds granted per billing period. */
secondsCharge: number;
monthlyRendersQuota?: number | null;
storageGb: number;
parallelRenders: number;
maxResolution: string;
renderSpeedFactor: number;
isFeatured: boolean;
color?: string | null;
/** True when renders are watermarked (free tier). */
watermark: boolean;
}
interface V2PlanRow {
id: string;
code: string;
name: string;
description?: string | null;
price_minor: number;
before_price_minor?: number | null;
currency: string;
seconds_charge: number;
monthly_renders_quota?: number | null;
storage_gb: number;
parallel_renders: number;
max_resolution: string;
render_speed_factor: number | string;
is_featured: boolean;
color?: string | null;
features?: Record<string, unknown> | null;
}
function mapPlan(p: V2PlanRow): SecondsPlan {
return {
id: p.id,
code: p.code,
name: p.name,
description: p.description,
priceTomans: Math.round((p.price_minor ?? 0) / 10),
beforePriceTomans:
p.before_price_minor != null ? Math.round(p.before_price_minor / 10) : null,
currency: p.currency,
secondsCharge: p.seconds_charge,
monthlyRendersQuota: p.monthly_renders_quota,
storageGb: p.storage_gb,
parallelRenders: p.parallel_renders,
maxResolution: p.max_resolution,
renderSpeedFactor: Number(p.render_speed_factor),
isFeatured: p.is_featured,
color: p.color,
watermark: Boolean(p.features?.watermark),
};
}
/**
* Fetch the active plans from the Identity service (public, ISR-cached).
* Returns an empty array when the gateway is unset/unreachable so the page can
* render a graceful empty state instead of throwing.
*/
export async function fetchPlans(): Promise<SecondsPlan[]> {
// Retry once: a single slow/cold gateway response shouldn't blank the page.
for (let attempt = 0; attempt < 2; attempt++) {
try {
const res = await fetch(gatewayUrl("/v1/plans"), {
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(6000),
next: { revalidate: 60 },
});
if (!res.ok) continue;
const json = (await res.json().catch(() => null)) as {
data?: V2PlanRow[];
} | null;
const rows = json?.data ?? [];
return rows.map(mapPlan).sort((a, b) => a.priceTomans - b.priceTomans);
} catch {
// fall through to the next attempt, then to the empty fallback
}
}
return [];
}
/** Format a Toman amount with locale digit grouping. */
export function formatToman(amount: number, locale: string): string {
return new Intl.NumberFormat(locale === "fa" ? "fa-IR" : "en-US").format(amount);
}