4f04f6bf75
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>
121 lines
3.8 KiB
TypeScript
121 lines
3.8 KiB
TypeScript
"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>
|
||
);
|
||
}
|