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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user