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
@@ -0,0 +1,120 @@
"use client";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import {
RESOLUTION_ORDER,
renderSecondsCost,
type SecondsPlan,
} from "@/lib/plans-catalog";
interface Props {
plans: SecondsPlan[];
}
/**
* Interactive "how many seconds do I need" helper. The user picks a video length
* and resolution; we show the per-render cost (length × resolution multiplier)
* and how many such videos each paid plan's monthly seconds would cover.
*/
export function SecondsCalculator({ plans }: Props) {
const t = useTranslations("pricing");
const [length, setLength] = useState(15);
const [resolution, setResolution] = useState("720p");
const cost = useMemo(
() => renderSecondsCost(length, resolution),
[length, resolution]
);
const paidPlans = plans.filter((p) => p.priceTomans > 0);
return (
<div className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm sm:p-8">
<h3 className="font-heading text-xl font-bold text-neutral-900">
{t("calcTitle")}
</h3>
<p className="mt-1 text-sm text-neutral-500">{t("calcDesc")}</p>
<div className="mt-6 grid gap-6 sm:grid-cols-2">
{/* Length */}
<div>
<label className="mb-2 flex items-center justify-between text-sm font-medium text-neutral-700">
<span>{t("calcLength")}</span>
<span className="font-bold text-neutral-900">
{length} {t("calcSecondsUnit")}
</span>
</label>
<input
type="range"
min={1}
max={120}
value={length}
onChange={(e) => setLength(Number(e.target.value))}
className="w-full accent-indigo-600"
/>
</div>
{/* Resolution */}
<div>
<label className="mb-2 block text-sm font-medium text-neutral-700">
{t("calcResolution")}
</label>
<div className="flex flex-wrap gap-1.5">
{RESOLUTION_ORDER.map((r) => (
<button
key={r}
type="button"
onClick={() => setResolution(r)}
className={`rounded-lg border px-2.5 py-1.5 text-xs font-medium transition ${
resolution === r
? "border-indigo-600 bg-indigo-600 text-white"
: "border-neutral-200 bg-white text-neutral-600 hover:border-neutral-300"
}`}
>
{r}
</button>
))}
</div>
</div>
</div>
{/* Cost */}
<div className="mt-6 flex flex-wrap items-end justify-between gap-4 rounded-xl bg-neutral-50 p-5">
<div>
<p className="text-sm text-neutral-500">{t("calcCost")}</p>
<p className="mt-1 text-3xl font-extrabold text-neutral-900">
{cost}{" "}
<span className="text-base font-medium text-neutral-500">
{t("calcSecondsUnit")}
</span>
</p>
</div>
{paidPlans.length > 0 && (
<div className="text-end">
<p className="mb-1 text-sm text-neutral-500">
{t("calcRendersWith")}
</p>
<div className="flex flex-wrap justify-end gap-2">
{paidPlans.map((p) => (
<span
key={p.id}
className="rounded-lg border border-neutral-200 bg-white px-2.5 py-1 text-xs text-neutral-700"
>
<span className="font-semibold text-neutral-900">
{p.name}
</span>
{": "}
{t("calcVideosFmt", {
count: Math.floor(p.secondsCharge / Math.max(cost, 1)),
})}
</span>
))}
</div>
</div>
)}
</div>
</div>
);
}