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
+9 -1
View File
@@ -1,7 +1,8 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies # dependencies (root + any nested, e.g. services/remotion/node_modules)
/node_modules /node_modules
node_modules/
/.pnp /.pnp
.pnp.js .pnp.js
.yarn/install-state.gz .yarn/install-state.gz
@@ -55,3 +56,10 @@ node-agent.exe
# node-agent local build + secrets # node-agent local build + secrets
services/node-agent/dist/ services/node-agent/dist/
agent.env agent.env
# remotion render outputs (regenerated; thumbnails/previews live in public/template-media)
services/remotion/out/
# local scratch / agent work
/-w
/.agent-work/
@@ -0,0 +1,16 @@
-- 32_content_render_engine.sql
-- Two render engines per template: After Effects (.aep, rendered by a node-agent)
-- and Remotion (code-based React composition). render_engine selects which; for
-- Remotion templates render_remotion_comp holds the composition id to render
-- (the .aep / render_aep_comp columns stay null for those).
--
-- Apply manually on the live DB (migrations are not auto-run):
-- docker exec -i <postgres> psql -U postgres -d flatrender < 32_content_render_engine.sql
ALTER TABLE content.projects
ADD COLUMN IF NOT EXISTS render_engine TEXT NOT NULL DEFAULT 'AfterEffects';
ALTER TABLE content.projects
ADD COLUMN IF NOT EXISTS render_remotion_comp TEXT;
-- Existing templates are all After Effects; the default already covers them.
+41 -3
View File
@@ -144,14 +144,14 @@
"a7": "Yes. Cancel from your account settings at any time. You keep access through the end of your billing period, and you can downgrade to Free without losing your projects." "a7": "Yes. Cancel from your account settings at any time. You keep access through the end of your billing period, and you can downgrade to Free without losing your projects."
}, },
"pricing": { "pricing": {
"heading": "Choose your FlatRender plan", "heading": "Pay by the second, not by the video",
"monthly": "Monthly", "monthly": "Monthly",
"annual": "Annual", "annual": "Annual",
"saveBadge": "Save up to {percent}%", "saveBadge": "Save up to {percent}%",
"subscribe": "Subscribe", "subscribe": "Subscribe",
"freeBannerTitle": "Free plan", "freeBannerTitle": "Free plan",
"freeBannerDesc": "Free forever, no credit card required", "freeBannerDesc": "Free forever, no credit card required",
"perMonth": "/ mo", "perMonth": "monthly",
"billedAnnually": "billed annually", "billedAnnually": "billed annually",
"compareTitle": "Compare all plans", "compareTitle": "Compare all plans",
"allFeatures": "All features", "allFeatures": "All features",
@@ -161,7 +161,45 @@
"proName": "Pro", "proName": "Pro",
"proDesc": "Become a pro and unlock more powerful video, design and website editing tools for commercial use.", "proDesc": "Become a pro and unlock more powerful video, design and website editing tools for commercial use.",
"businessName": "Business", "businessName": "Business",
"businessDesc": "Advanced level solution for teams and businesses. Includes reseller license." "businessDesc": "Advanced level solution for teams and businesses. Includes reseller license.",
"subheading": "Each render costs render-seconds equal to the video length × a quality multiplier. Every plan gives you a monthly bucket of render-seconds.",
"toman": "Toman",
"free": "Free",
"mostPopular": "Most popular",
"currentPlan": "Current plan",
"choosePlan": "Choose plan",
"startFree": "Start free",
"processing": "Redirecting…",
"signInToBuy": "Sign in to buy",
"emptyState": "No plans are available right now.",
"perMonthSuffix": "/ mo",
"featSeconds": "{seconds} render-seconds / month",
"featResolution": "Up to {res} quality",
"featParallelOne": "1 render at a time",
"featParallel": "{n} parallel renders",
"featStorage": "{gb} GB cloud storage",
"featSpeed": "{factor}× render speed",
"featWatermarkOn": "FlatRender watermark",
"featWatermarkOff": "No watermark",
"calcTitle": "How many seconds do I need?",
"calcDesc": "Pick a video length and quality to see the per-render cost in seconds.",
"calcLength": "Video length",
"calcResolution": "Output quality",
"calcCost": "Cost per render",
"calcSecondsUnit": "seconds",
"calcRendersWith": "With each plan:",
"calcVideosFmt": "≈ {count} videos",
"multiplierTitle": "Quality multiplier",
"multiplierDesc": "Render-seconds per render = video length × the multiplier below.",
"multiplierColRes": "Quality",
"multiplierColMul": "Multiplier",
"faqTitle": "Frequently asked",
"faqQ1": "What is a render-second?",
"faqA1": "Instead of a video-count limit, you buy render-seconds. A 15-second video at 720p uses exactly 15 seconds of your balance.",
"faqQ2": "Why does higher quality cost more seconds?",
"faqA2": "4K rendering is much heavier, so each second of video counts as 4 render-seconds; 1080p counts as 2×.",
"faqQ3": "What if I run out of seconds?",
"faqA3": "Upgrade your plan or wait for the next period. Your max resolution and parallel renders also follow your plan."
}, },
"footer": { "footer": {
"brandName": "FlatRender", "brandName": "FlatRender",
+41 -3
View File
@@ -144,14 +144,14 @@
"a7": "بله. هر زمان از تنظیمات حساب لغو کنید. دسترسی تا پایان دوره صورت‌حساب باقی می‌ماند و می‌توانید به پلن رایگان برگردید بدون اینکه پروژه‌هایتان از دست بروند." "a7": "بله. هر زمان از تنظیمات حساب لغو کنید. دسترسی تا پایان دوره صورت‌حساب باقی می‌ماند و می‌توانید به پلن رایگان برگردید بدون اینکه پروژه‌هایتان از دست بروند."
}, },
"pricing": { "pricing": {
"heading": لن فلت‌رندر خود را انتخاب کنید", "heading": رداخت بر اساس ثانیه، نه تعداد ویدیو",
"monthly": "ماهانه", "monthly": "ماهانه",
"annual": "سالانه", "annual": "سالانه",
"saveBadge": "تا {percent}٪ صرفه‌جویی", "saveBadge": "تا {percent}٪ صرفه‌جویی",
"subscribe": "اشتراک", "subscribe": "اشتراک",
"freeBannerTitle": "پلن رایگان", "freeBannerTitle": "پلن رایگان",
"freeBannerDesc": "برای همیشه رایگان، بدون نیاز به کارت اعتباری", "freeBannerDesc": "برای همیشه رایگان، بدون نیاز به کارت اعتباری",
"perMonth": "/ ماه", "perMonth": "ماهانه",
"billedAnnually": "پرداخت سالانه", "billedAnnually": "پرداخت سالانه",
"compareTitle": "مقایسه همه پلن‌ها", "compareTitle": "مقایسه همه پلن‌ها",
"allFeatures": "همه امکانات", "allFeatures": "همه امکانات",
@@ -161,7 +161,45 @@
"proName": "Pro", "proName": "Pro",
"proDesc": "حرفه‌ای شوید و ابزارهای قدرتمندتر ویدیو، طراحی و وب‌سایت را برای استفاده تجاری باز کنید.", "proDesc": "حرفه‌ای شوید و ابزارهای قدرتمندتر ویدیو، طراحی و وب‌سایت را برای استفاده تجاری باز کنید.",
"businessName": "Business", "businessName": "Business",
"businessDesc": "راه‌حل پیشرفته برای تیم‌ها و کسب‌وکارها. شامل مجوز فروش مجدد." "businessDesc": "راه‌حل پیشرفته برای تیم‌ها و کسب‌وکارها. شامل مجوز فروش مجدد.",
"subheading": "هزینهٔ هر رندر برابر است با طول ویدیو ضربدر ضریب کیفیت. هر پلن ماهانه مقداری «ثانیهٔ رندر» در اختیار شما می‌گذارد.",
"toman": "تومان",
"free": "رایگان",
"mostPopular": "محبوب‌ترین",
"currentPlan": "پلن فعلی",
"choosePlan": "انتخاب پلن",
"startFree": "شروع رایگان",
"processing": "در حال انتقال…",
"signInToBuy": "برای خرید وارد شوید",
"emptyState": "در حال حاضر پلنی برای نمایش وجود ندارد.",
"perMonthSuffix": "/ ماه",
"featSeconds": "{seconds} ثانیهٔ رندر در ماه",
"featResolution": "کیفیت تا {res}",
"featParallelOne": "۱ رندر همزمان",
"featParallel": "{n} رندر همزمان",
"featStorage": "{gb} گیگابایت فضای ابری",
"featSpeed": "سرعت رندر ×{factor}",
"featWatermarkOn": "دارای واترمارک FlatRender",
"featWatermarkOff": "بدون واترمارک",
"calcTitle": "چند ثانیه لازم دارم؟",
"calcDesc": "طول و کیفیت ویدیو را انتخاب کنید تا هزینهٔ ثانیه‌ای هر رندر را ببینید.",
"calcLength": "طول ویدیو",
"calcResolution": "کیفیت خروجی",
"calcCost": "هزینهٔ هر رندر",
"calcSecondsUnit": "ثانیه",
"calcRendersWith": "با هر پلن:",
"calcVideosFmt": "≈ {count} ویدیو",
"multiplierTitle": "ضریب کیفیت",
"multiplierDesc": "ثانیهٔ مصرفی هر رندر = طول ویدیو × ضریب کیفیت زیر.",
"multiplierColRes": "کیفیت",
"multiplierColMul": "ضریب",
"faqTitle": "پرسش‌های پرتکرار",
"faqQ1": "ثانیهٔ رندر یعنی چه؟",
"faqA1": "به‌جای محدودیت تعداد ویدیو، شما مقداری ثانیهٔ رندر می‌خرید. یک ویدیوی ۱۵ ثانیه‌ای با کیفیت ۷۲۰p دقیقاً ۱۵ ثانیه از سهم شما کم می‌کند.",
"faqQ2": "چرا کیفیت بالاتر ثانیهٔ بیشتری می‌برد؟",
"faqA2": "رندر ۴K پردازش سنگین‌تری دارد، بنابراین هر ثانیه ویدیو معادل ۴ ثانیهٔ رندر حساب می‌شود؛ ۱۰۸۰p معادل ۲ برابر.",
"faqQ3": "اگر ثانیه‌هایم تمام شود چه می‌شود؟",
"faqA3": "می‌توانید پلن خود را ارتقا دهید یا تا شروع دورهٔ بعد صبر کنید. سقف کیفیت و رندر همزمان نیز بر اساس پلن شماست."
}, },
"footer": { "footer": {
"brandName": "فلت‌رندر", "brandName": "فلت‌رندر",
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 816 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 781 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 779 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1023 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 663 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1001 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 796 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1000 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 623 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 854 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 781 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 814 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 803 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 974 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 708 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 739 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 772 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
# Generates SQL to seed the 10 branded Remotion templates into content.* so they
# appear on the site. Each template -> 1 container + 3 aspect projects, each with
# a scene, Persian text content-elements (bindable -> Remotion props) and shared
# colours (bindable -> colour props) + a per-scene colour-swatch SVG.
#
# Usage: python scripts/seed_remotion_templates.py | docker exec -i fr2-postgres psql -U postgres -d flatrender
import uuid
# Assets are served from the Next app's public/ folder (relative URLs), so this
# works regardless of MinIO/object-store availability.
MINIO = ""
NS = uuid.UUID("11111111-2222-3333-4444-555555555555")
def uid(s): return str(uuid.uuid5(NS, s))
def q(s): return "'" + str(s).replace("'", "''") + "'"
ASPECTS = [("16x9", 1920, 1080, "16:9"), ("1x1", 1080, 1080, "1:1"), ("9x16", 1080, 1920, "9:16")]
CTITLES = {"accentColor": "رنگ اصلی", "secondaryColor": "رنگ دوم", "backgroundColor": "پس‌زمینه", "textColor": "رنگ متن"}
# id, slug, name(fa), desc(fa), dur, [(textKey,title,value)], (accent,secondary,bg)
T = [
("LogoMotion","fr-logo-motion","موشن لوگو","نمایش حرفه‌ای لوگو و نام برند با درخشش و حرکت",5,
[("brandText","متن لوگو","فلت‌رندر"),("tagline","شعار","موشن، ساده و حرفه‌ای")],("#3ba7ff","#a855f7","#04060f")),
("Opener","fr-opener","تیتراژ آغازین","شروع سینمایی برای ویدیو با عنوان و زیرعنوان",5,
[("kicker","پیش‌متن","تقدیم می‌کند"),("title","عنوان","یک شروع تازه"),("subtitle","زیرعنوان","داستان شما از همین‌جا آغاز می‌شود")],("#22d3ee","#6366f1","#0a0a12")),
("InstaPromo","fr-insta-promo","تبلیغ پیج اینستاگرام","معرفی و تبلیغ صفحهٔ اینستاگرام با دعوت به فالو",5,
[("handle","آیدی پیج","@flatrender"),("headline","عنوان","پیج ما را دنبال کنید"),("subtext","توضیح","هر روز محتوای تازه و الهام‌بخش"),("cta","دکمه","فالو کنید")],("#fb7185","#f59e0b","#140a12")),
("YouTubeIntro","fr-youtube-intro","اینترو کانال یوتیوب","اینترو حرفه‌ای کانال یوتیوب با دکمهٔ سابسکرایب",5,
[("channelName","نام کانال","کانال فلت‌رندر"),("subtitle","زیرعنوان","آموزش، ترفند و انگیزه"),("cta","دکمه","سابسکرایب کنید")],("#ff4d4d","#a855f7","#0c0810")),
("Slideshow","fr-slideshow","اسلایدشو","نمایش پشت‌سرهم چند پیام یا ویژگی به‌صورت اسلاید",9,
[("title","عنوان","چرا فلت‌رندر؟"),("slide1","اسلاید ۱","ساخت ویدیو در چند دقیقه"),("slide2","اسلاید ۲","بدون نیاز به دانش فنی"),("slide3","اسلاید ۳","خروجی با کیفیت حرفه‌ای")],("#34d399","#3b82f6","#060b0a")),
("HappyBirthday","fr-happy-birthday","تولدت مبارک","کارت تبریک تولد با کاغذرنگی و نام شخص",6,
[("greeting","تبریک","تولدت مبارک"),("name","نام","سارا"),("message","پیام","بهترین‌ها را برایت آرزومندیم 🎉")],("#fb7185","#fde047","#140a18")),
("SalePromo","fr-sale-promo","فروش ویژه","بنر تبلیغاتی فروش و تخفیف با دعوت به خرید",5,
[("badge","نشان تخفیف","۵۰٪ تخفیف"),("headline","عنوان","فروش ویژهٔ پایان فصل"),("subtext","توضیح","فقط تا پایان همین هفته"),("cta","دکمه","همین حالا خرید کنید")],("#f59e0b","#fb7185","#120a08")),
("QuoteCard","fr-quote-card","کارت نقل‌قول","نمایش جملهٔ انگیزشی یا نقل‌قول با نام گوینده",6,
[("quote","نقل‌قول","موفقیت، مجموع تلاش‌های کوچکِ هر روز است."),("author","گوینده","فلت‌رندر")],("#22d3ee","#6366f1","#0a0a12")),
("EventInvite","fr-event-invite","دعوت‌نامهٔ رویداد","دعوت‌نامهٔ شیک برای رویداد با تاریخ و مکان",6,
[("kicker","پیش‌متن","دعوت‌نامه"),("eventTitle","عنوان رویداد","همایش سالانهٔ نوآوری"),("date","تاریخ","۱۵ مهر ۱۴۰۳"),("location","مکان","تهران، سالن همایش‌ها"),("cta","دکمه","ثبت‌نام کنید")],("#a855f7","#3ba7ff","#0a0814")),
("Countdown","fr-countdown","شمارش معکوس","شمارش معکوس هیجان‌انگیز برای شروع یک رویداد",8,
[("title","عنوان","شروع رویداد تا"),("startNumber","عدد شروع","5"),("goText","متن پایان","شروع!"),("subtitle","زیرعنوان","آماده‌اید؟")],("#3ba7ff","#22d3ee","#04060f")),
("GlitterReveal","fr-glitter-reveal","نمایش لوگو با غبار درخشان","نمایش جادویی لوگو با ذرات درخشان؛ لوگو و متن قابل ویرایش",6,
[("brandText","نام برند","فلت‌رندر"),("tagline","شعار","موشن، ساده و حرفه‌ای")],("#3ba7ff","#a855f7","#05040e")),
("NowruzGreeting","fr-nowruz","تبریک نوروز","صحنهٔ بهاری نوروز با شخصیت‌های متحرک؛ حاجی‌فیروز، ماهی قرمز و سبزه",8,
[("greeting","متن تبریک","نوروز مبارک"),("subtitle","زیرعنوان","سال نو پیروز و شادمان"),("message","پیام / سال","۱۴۰۶")],("#f5b942","#e23b3b","#1fb6b0")),
("Hero3D","fr-hero-3d","نمایش سه‌بعدی برند","نمایش حرفه‌ای و سه‌بعدی لوگو و برند با نورپردازی و جلوه‌های واقعی",6,
[("brandText","نام برند","فلت‌رندر"),("tagline","شعار","موشن، ساده و حرفه‌ای")],("#3ba7ff","#a855f7","#04060f")),
("Nowruz3D","fr-nowruz-3d","تبریک نوروز سه‌بعدی","صحنهٔ سه‌بعدی نوروز با حاجی‌فیروز، سفرهٔ هفت‌سین و نورپردازی سینمایی",7,
[("greeting","متن تبریک","نوروز مبارک"),("subtitle","زیرعنوان","سال نو پیروز و شادمان"),("message","پیام / سال","۱۴۰۶")],("#f5c542","#e23b3b","#1a1228")),
("Birthday3D","fr-birthday-3d","تولد سه‌بعدی","صحنهٔ سه‌بعدی تولد با کیک و شمع‌های روشن، بادکنک و کاغذرنگی",6,
[("greeting","تبریک","تولدت مبارک"),("name","نام","سارا"),("message","پیام","بهترین‌ها را برایت آرزومندیم 🎉")],("#fb7185","#a855f7","#1a1226")),
("Promo3D","fr-promo-3d","فروش ویژه سه‌بعدی","تبلیغ سه‌بعدی فروش و تخفیف با جعبه‌های هدیه و نورپردازی سینمایی",6,
[("badge","نشان تخفیف","۵۰٪ تخفیف"),("headline","عنوان","فروش ویژهٔ پایان فصل"),("subtext","توضیح","فقط تا پایان همین هفته"),("cta","دکمه","همین حالا خرید کنید")],("#f59e0b","#fb7185","#140e1f")),
]
# Optional Media (image) content elements per template — these surface in the
# studio as upload/replace fields. key = the Remotion prop the image binds to.
MEDIA = {
"GlitterReveal": [("logoUrl", "لوگو (تصویر دلخواه)")],
}
def swatch_svg(colors):
rects = "".join(f'<rect x="{i*50}" y="0" width="50" height="40" fill="{c}"/>' for i, c in enumerate(colors))
return f'<svg xmlns="http://www.w3.org/2000/svg" width="200" height="40">{rects}</svg>'
def icon_svg(hex):
return f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="{hex}"/></svg>'
out = []
out.append("BEGIN;")
slugs = ",".join(q(t[1]) for t in T)
out.append(f"DELETE FROM content.project_containers WHERE slug IN ({slugs});")
for idx, (tid, slug, name, desc, dur, texts, (accent, sec, bg)) in enumerate(T):
cid = uid("c-" + tid)
thumb16 = f"{MINIO}/template-media/{tid}-16x9.png"
preview = f"{MINIO}/template-media/{tid}.mp4"
colors = [("accentColor", accent), ("secondaryColor", sec), ("backgroundColor", bg), ("textColor", "#ffffff")]
out.append(
"INSERT INTO content.project_containers (id,tenant_id,slug,name,description,image,demo,full_demo,mini_demo,"
"is_published,is_premium,is_mockup,primary_mode,sort) VALUES ("
f"{q(cid)},NULL,{q(slug)},{q(name)},{q(desc)},{q(thumb16)},{q(preview)},{q(preview)},{q(preview)},"
f"TRUE,FALSE,FALSE,'FLEXIBLE',{idx});")
for (asp, w, h, aspstr) in ASPECTS:
pid = uid(f"p-{tid}-{asp}")
sid = uid(f"s-{tid}-{asp}")
thumb = f"{MINIO}/template-media/{tid}-{asp}.png"
out.append(
"INSERT INTO content.projects (id,container_id,name,image,full_demo,original_width,original_height,aspect,"
"project_duration_sec,free_fps,choose_mode,resolution,render_engine,render_remotion_comp,is_published,sort) VALUES ("
f"{q(pid)},{q(cid)},{q(aspstr)},{q(thumb)},{q(preview)},{w},{h},{q(aspstr)},"
f"{dur},30,'FLEXIBLE','FullHD','Remotion',{q(tid+'-'+asp)},TRUE,0);")
out.append(
"INSERT INTO content.scenes (id,project_id,key,title,scene_color_svg,default_duration_sec,sort) VALUES ("
f"{q(sid)},{q(pid)},'c1','صحنه ۱',{q(swatch_svg([accent,sec,bg,'#ffffff']))},{dur},0);")
for pos, (k, title, val) in enumerate(texts):
out.append(
"INSERT INTO content.scene_content_elements (id,scene_id,key,title,type,default_value,position_in_container,direction_layer_value) VALUES ("
f"{q(uid(f'ce-{tid}-{asp}-{k}'))},{q(sid)},{q(k)},{q(title)},'Text',{q(val)},{pos},1);")
for mpos, (k, title) in enumerate(MEDIA.get(tid, [])):
out.append(
"INSERT INTO content.scene_content_elements (id,scene_id,key,title,type,default_value,position_in_container,direction_layer_value) VALUES ("
f"{q(uid(f'ce-{tid}-{asp}-{k}'))},{q(sid)},{q(k)},{q(title)},'Media','',{len(texts)+mpos},0);")
for si, (k, hexv) in enumerate(colors):
out.append(
"INSERT INTO content.shared_colors (id,project_id,element_key,title,icon,attr_value,default_color,sort) VALUES ("
f"{q(uid(f'sc-{tid}-{asp}-{k}'))},{q(pid)},{q(k)},{q(CTITLES[k])},{q(icon_svg(hexv))},'fill',{q(hexv)},{si});")
out.append("COMMIT;")
out.append("SELECT count(*) AS containers FROM content.project_containers WHERE slug LIKE 'fr-%';")
print("\n".join(out))
@@ -176,6 +176,8 @@ public class TemplateService(ContentDbContext db)
ProjectDurationSec = req.ProjectDurationSec, MinDurationSec = req.MinDurationSec, ProjectDurationSec = req.ProjectDurationSec, MinDurationSec = req.MinDurationSec,
MaxDurationSec = req.MaxDurationSec, FreeFps = req.FreeFps, ChooseMode = chooseMode, MaxDurationSec = req.MaxDurationSec, FreeFps = req.FreeFps, ChooseMode = chooseMode,
Resolution = resolution, VipFactor = req.VipFactor, RenderAepComp = req.RenderAepComp, Resolution = resolution, VipFactor = req.VipFactor, RenderAepComp = req.RenderAepComp,
RenderEngine = string.IsNullOrWhiteSpace(req.RenderEngine) ? "AfterEffects" : req.RenderEngine,
RenderRemotionComp = req.RenderRemotionComp,
IsPublished = req.IsPublished, Sort = req.Sort IsPublished = req.IsPublished, Sort = req.Sort
}; };
@@ -225,6 +227,7 @@ public class TemplateService(ContentDbContext db)
ProjectDurationSec = src.ProjectDurationSec, MinDurationSec = src.MinDurationSec, ProjectDurationSec = src.ProjectDurationSec, MinDurationSec = src.MinDurationSec,
MaxDurationSec = src.MaxDurationSec, FreeFps = src.FreeFps, ChooseMode = src.ChooseMode, MaxDurationSec = src.MaxDurationSec, FreeFps = src.FreeFps, ChooseMode = src.ChooseMode,
Resolution = resolution, VipFactor = src.VipFactor, RenderAepComp = src.RenderAepComp, Resolution = resolution, VipFactor = src.VipFactor, RenderAepComp = src.RenderAepComp,
RenderEngine = src.RenderEngine, RenderRemotionComp = src.RenderRemotionComp,
SharedLayerImage = src.SharedLayerImage, SharedColorsSvg = src.SharedColorsSvg, SharedLayerImage = src.SharedLayerImage, SharedColorsSvg = src.SharedColorsSvg,
SharedColorPresetsSvg = src.SharedColorPresetsSvg, SharedColorPresetsSvg = src.SharedColorPresetsSvg,
IsPublished = false, Sort = src.Sort, IsPublished = false, Sort = src.Sort,
@@ -359,6 +362,8 @@ public class TemplateService(ContentDbContext db)
project.MinDurationSec = req.MinDurationSec; project.MaxDurationSec = req.MaxDurationSec; project.MinDurationSec = req.MinDurationSec; project.MaxDurationSec = req.MaxDurationSec;
project.FreeFps = req.FreeFps; project.ChooseMode = chooseMode; project.Resolution = resolution; project.FreeFps = req.FreeFps; project.ChooseMode = chooseMode; project.Resolution = resolution;
project.VipFactor = req.VipFactor; project.RenderAepComp = req.RenderAepComp; project.VipFactor = req.VipFactor; project.RenderAepComp = req.RenderAepComp;
if (!string.IsNullOrWhiteSpace(req.RenderEngine)) project.RenderEngine = req.RenderEngine;
if (req.RenderRemotionComp != null) project.RenderRemotionComp = req.RenderRemotionComp;
project.SharedLayerImage = req.SharedLayerImage; project.SharedColorsSvg = req.SharedColorsSvg; project.SharedLayerImage = req.SharedLayerImage; project.SharedColorsSvg = req.SharedColorsSvg;
project.SharedColorPresetsSvg = req.SharedColorPresetsSvg; project.SharedColorPresetsSvg = req.SharedColorPresetsSvg;
project.IsPublished = req.IsPublished; project.Sort = req.Sort; project.IsPublished = req.IsPublished; project.Sort = req.Sort;
@@ -402,6 +407,8 @@ public class TemplateService(ContentDbContext db)
if (req.Image != null) project.Image = req.Image; if (req.Image != null) project.Image = req.Image;
if (req.FullDemo != null) project.FullDemo = req.FullDemo; if (req.FullDemo != null) project.FullDemo = req.FullDemo;
if (req.SharedColorsSvg != null) project.SharedColorsSvg = req.SharedColorsSvg; if (req.SharedColorsSvg != null) project.SharedColorsSvg = req.SharedColorsSvg;
if (!string.IsNullOrWhiteSpace(req.RenderEngine)) project.RenderEngine = req.RenderEngine;
if (req.RenderRemotionComp != null) project.RenderRemotionComp = req.RenderRemotionComp;
project.UpdatedAt = DateTime.UtcNow; project.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -472,7 +479,8 @@ public class TemplateService(ContentDbContext db)
p.ProjectDurationSec, p.MinDurationSec, p.MaxDurationSec, p.ProjectDurationSec, p.MinDurationSec, p.MaxDurationSec,
p.FreeFps, p.ChooseMode.ToString(), p.Resolution.ToString(), p.FreeFps, p.ChooseMode.ToString(), p.Resolution.ToString(),
p.IsPublished, p.Sort, p.IsPublished, p.Sort,
p.AepFileUrl, p.AepFileSizeBytes, p.RenderAepComp p.AepFileUrl, p.AepFileSizeBytes, p.RenderAepComp,
p.RenderEngine, p.RenderRemotionComp
); );
/// <summary>Browse/search all projects (template items) across containers.</summary> /// <summary>Browse/search all projects (template items) across containers.</summary>
@@ -489,7 +497,8 @@ public class TemplateService(ContentDbContext db)
.Skip((page - 1) * pageSize).Take(pageSize) .Skip((page - 1) * pageSize).Take(pageSize)
.Select(p => new ProjectListItemResponse( .Select(p => new ProjectListItemResponse(
p.Id, p.ContainerId, p.Container.Name, p.Container.Slug, p.Name, p.Image, p.Id, p.ContainerId, p.Container.Name, p.Container.Slug, p.Name, p.Image,
p.Aspect, p.Resolution.ToString(), p.AepFileUrl, p.RenderAepComp, p.IsPublished, p.Sort)) p.Aspect, p.Resolution.ToString(), p.AepFileUrl, p.RenderAepComp, p.IsPublished, p.Sort,
p.RenderEngine, p.RenderRemotionComp))
.ToListAsync(); .ToListAsync();
return new PagedResponse<ProjectListItemResponse>(items, return new PagedResponse<ProjectListItemResponse>(items,
new PaginationMeta(page, pageSize, total, (int)Math.Ceiling((double)total / pageSize))); new PaginationMeta(page, pageSize, total, (int)Math.Ceiling((double)total / pageSize)));
@@ -529,6 +538,8 @@ public class TemplateService(ContentDbContext db)
if (req.AepFileMd5 != null) project.AepFileMd5 = req.AepFileMd5; if (req.AepFileMd5 != null) project.AepFileMd5 = req.AepFileMd5;
if (req.AepFileSizeBytes.HasValue) project.AepFileSizeBytes = req.AepFileSizeBytes; if (req.AepFileSizeBytes.HasValue) project.AepFileSizeBytes = req.AepFileSizeBytes;
if (!string.IsNullOrWhiteSpace(req.RenderAepComp)) project.RenderAepComp = req.RenderAepComp; if (!string.IsNullOrWhiteSpace(req.RenderAepComp)) project.RenderAepComp = req.RenderAepComp;
if (!string.IsNullOrWhiteSpace(req.RenderEngine)) project.RenderEngine = req.RenderEngine;
if (req.RenderRemotionComp != null) project.RenderRemotionComp = req.RenderRemotionComp;
if (req.Folder != null) project.Folder = req.Folder; if (req.Folder != null) project.Folder = req.Folder;
project.AepUploadedAt = DateTime.UtcNow; project.AepUploadedAt = DateTime.UtcNow;
project.UpdatedAt = DateTime.UtcNow; project.UpdatedAt = DateTime.UtcNow;
@@ -541,6 +552,7 @@ public class TemplateService(ContentDbContext db)
p.OriginalWidth, p.OriginalHeight, p.Aspect, p.OriginalWidth, p.OriginalHeight, p.Aspect,
p.ProjectDurationSec, p.MinDurationSec, p.MaxDurationSec, p.ProjectDurationSec, p.MinDurationSec, p.MaxDurationSec,
p.FreeFps, p.ChooseMode.ToString(), p.Resolution.ToString(), p.VipFactor, p.RenderAepComp, p.FreeFps, p.ChooseMode.ToString(), p.Resolution.ToString(), p.VipFactor, p.RenderAepComp,
p.RenderEngine, p.RenderRemotionComp,
p.SharedLayerImage, p.IsPublished, p.Sort, p.SharedLayerImage, p.IsPublished, p.Sort,
p.Scenes.Select(MapScene).ToList(), p.Scenes.Select(MapScene).ToList(),
p.SharedColors.Select(sc => new SharedColorResponse(sc.Id, sc.ElementKey, sc.Title, sc.Icon, sc.AttrValue.ToString(), sc.DefaultColor, sc.Sort)).ToList(), p.SharedColors.Select(sc => new SharedColorResponse(sc.Id, sc.ElementKey, sc.Title, sc.Icon, sc.AttrValue.ToString(), sc.DefaultColor, sc.Sort)).ToList(),
@@ -95,6 +95,11 @@ public class Project
public decimal VipFactor { get; set; } = 1.0m; public decimal VipFactor { get; set; } = 1.0m;
public string RenderAepComp { get; set; } = "flatrender"; public string RenderAepComp { get; set; } = "flatrender";
/// <summary>Render engine for this template: "AfterEffects" (default) or "Remotion".</summary>
public string RenderEngine { get; set; } = "AfterEffects";
/// <summary>For Remotion templates, the composition id to render (e.g. "KineticQuote").</summary>
public string? RenderRemotionComp { get; set; }
public string? SharedLayerImage { get; set; } public string? SharedLayerImage { get; set; }
public string? SharedColorsSvg { get; set; } public string? SharedColorsSvg { get; set; }
public string? SharedColorPresetsSvg { get; set; } public string? SharedColorPresetsSvg { get; set; }
@@ -307,6 +307,8 @@ public class ContentDbContext(DbContextOptions<ContentDbContext> options) : DbCo
e.Property(x => x.Resolution).HasColumnName("resolution"); e.Property(x => x.Resolution).HasColumnName("resolution");
e.Property(x => x.VipFactor).HasColumnName("vip_factor"); e.Property(x => x.VipFactor).HasColumnName("vip_factor");
e.Property(x => x.RenderAepComp).HasColumnName("render_aep_comp"); e.Property(x => x.RenderAepComp).HasColumnName("render_aep_comp");
e.Property(x => x.RenderEngine).HasColumnName("render_engine");
e.Property(x => x.RenderRemotionComp).HasColumnName("render_remotion_comp");
e.Property(x => x.SharedLayerImage).HasColumnName("shared_layer_image"); e.Property(x => x.SharedLayerImage).HasColumnName("shared_layer_image");
e.Property(x => x.SharedColorsSvg).HasColumnName("shared_colors_svg"); e.Property(x => x.SharedColorsSvg).HasColumnName("shared_colors_svg");
e.Property(x => x.SharedColorPresetsSvg).HasColumnName("shared_color_presets_svg"); e.Property(x => x.SharedColorPresetsSvg).HasColumnName("shared_color_presets_svg");
@@ -213,7 +213,9 @@ public record CreateProjectRequest(
decimal VipFactor, decimal VipFactor,
string RenderAepComp, string RenderAepComp,
bool IsPublished, bool IsPublished,
int Sort int Sort,
string? RenderEngine = null,
string? RenderRemotionComp = null
); );
public record UpdateProjectRequest( public record UpdateProjectRequest(
@@ -239,7 +241,9 @@ public record UpdateProjectRequest(
string? SharedColorsSvg, string? SharedColorsSvg,
string? SharedColorPresetsSvg, string? SharedColorPresetsSvg,
bool IsPublished, bool IsPublished,
int Sort int Sort,
string? RenderEngine = null,
string? RenderRemotionComp = null
); );
// Partial update — only non-null fields are applied, so editing an aspect/resolution // Partial update — only non-null fields are applied, so editing an aspect/resolution
@@ -260,7 +264,9 @@ public record SetAepRequest(
string? AepFileMd5, string? AepFileMd5,
long? AepFileSizeBytes, long? AepFileSizeBytes,
string? RenderAepComp, string? RenderAepComp,
string? Folder string? Folder,
string? RenderEngine = null,
string? RenderRemotionComp = null
); );
public record PatchProjectRequest( public record PatchProjectRequest(
@@ -279,7 +285,9 @@ public record PatchProjectRequest(
int? Sort, int? Sort,
string? Image, string? Image,
string? FullDemo, string? FullDemo,
string? SharedColorsSvg string? SharedColorsSvg,
string? RenderEngine = null,
string? RenderRemotionComp = null
); );
// ── CMS ────────────────────────────────────────────────────────────────────── // ── CMS ──────────────────────────────────────────────────────────────────────
@@ -131,7 +131,9 @@ public record ProjectResponse(
int Sort, int Sort,
string? AepFileUrl, string? AepFileUrl,
long? AepFileSizeBytes, long? AepFileSizeBytes,
string RenderAepComp string RenderAepComp,
string RenderEngine,
string? RenderRemotionComp
); );
public record ProjectListItemResponse( public record ProjectListItemResponse(
@@ -146,7 +148,9 @@ public record ProjectListItemResponse(
string? AepFileUrl, string? AepFileUrl,
string RenderAepComp, string RenderAepComp,
bool IsPublished, bool IsPublished,
int Sort int Sort,
string RenderEngine,
string? RenderRemotionComp
); );
public record ProjectAssetResponse(Guid Id, Guid ProjectId, string Name, string Kind, string Url, long? SizeBytes, int Sort); public record ProjectAssetResponse(Guid Id, Guid ProjectId, string Name, string Kind, string Url, long? SizeBytes, int Sort);
@@ -171,6 +175,8 @@ public record ProjectDetailResponse(
string Resolution, string Resolution,
decimal VipFactor, decimal VipFactor,
string RenderAepComp, string RenderAepComp,
string RenderEngine,
string? RenderRemotionComp,
string? SharedLayerImage, string? SharedLayerImage,
bool IsPublished, bool IsPublished,
int Sort, int Sort,
+9
View File
@@ -515,6 +515,13 @@ func (a *Agent) runJob(ctx context.Context, job *client.ClaimedJob) {
binds = append(binds, runner.Binding{Key: b.Key, Type: b.Type, Value: b.Value}) binds = append(binds, runner.Binding{Key: b.Key, Type: b.Type, Value: b.Value})
} }
// Default empty engine to AfterEffects for backwards-compat with older
// orchestrators that don't send the field yet.
engine := job.Engine
if engine == "" {
engine = runner.EngineAfterEffects
}
rJob := &runner.Job{ rJob := &runner.Job{
JobID: job.JobID, JobID: job.JobID,
SavedProjectID: job.SavedProjectID, SavedProjectID: job.SavedProjectID,
@@ -523,6 +530,8 @@ func (a *Agent) runJob(ctx context.Context, job *client.ClaimedJob) {
FrameRate: job.FrameRate, FrameRate: job.FrameRate,
HasMusic: job.HasMusic, HasMusic: job.HasMusic,
HasVoiceover: job.HasVoiceover, HasVoiceover: job.HasVoiceover,
Engine: engine,
RemotionDir: a.cfg.RemotionProjectDir,
AEPFilePath: aepPath, AEPFilePath: aepPath,
CompName: job.CompName, CompName: job.CompName,
AfterFxPath: a.cfg.AfterFxPath, AfterFxPath: a.cfg.AfterFxPath,
@@ -152,6 +152,9 @@ type ClaimedJob struct {
FrameRate int `json:"frame_rate"` FrameRate int `json:"frame_rate"`
HasMusic bool `json:"has_music"` HasMusic bool `json:"has_music"`
HasVoiceover bool `json:"has_voiceover"` HasVoiceover bool `json:"has_voiceover"`
// Engine selects the render engine: "AfterEffects" (default) or "Remotion".
// For Remotion jobs CompName is the composition id and AEPDownloadURL is empty.
Engine string `json:"engine,omitempty"`
// AEPDownloadURL is a presigned MinIO GET URL for the .aep template file // AEPDownloadURL is a presigned MinIO GET URL for the .aep template file
// (or .zip bundle). Empty when the template has not been uploaded yet — triggers mock render. // (or .zip bundle). Empty when the template has not been uploaded yet — triggers mock render.
AEPDownloadURL string `json:"aep_download_url,omitempty"` AEPDownloadURL string `json:"aep_download_url,omitempty"`
@@ -85,6 +85,10 @@ type Config struct {
// WorkDir is the scratch directory for render temp files and AE project copies. // WorkDir is the scratch directory for render temp files and AE project copies.
WorkDir string WorkDir string
// RemotionProjectDir is the Remotion project root (package.json + src/index.ts)
// used by the code-based render engine. Empty disables Remotion jobs on this node.
RemotionProjectDir string
// HeartbeatIntervalSec is how often the agent sends a heartbeat to the orchestrator. // HeartbeatIntervalSec is how often the agent sends a heartbeat to the orchestrator.
HeartbeatIntervalSec int HeartbeatIntervalSec int
@@ -115,6 +119,7 @@ func Load() (*Config, error) {
AEPath: getEnv("AE_PATH", ""), AEPath: getEnv("AE_PATH", ""),
AfterFxPath: getEnv("AFTERFX_PATH", ""), AfterFxPath: getEnv("AFTERFX_PATH", ""),
WorkDir: getEnv("WORK_DIR", os.TempDir()), WorkDir: getEnv("WORK_DIR", os.TempDir()),
RemotionProjectDir: getEnv("REMOTION_PROJECT_DIR", ""),
AgentVersion: getEnv("AGENT_VERSION", "0.1.0"), AgentVersion: getEnv("AGENT_VERSION", "0.1.0"),
AEVersion: getEnv("AE_VERSION", "2024"), AEVersion: getEnv("AE_VERSION", "2024"),
HeartbeatIntervalSec: getInt("HEARTBEAT_INTERVAL_SEC", 5), HeartbeatIntervalSec: getInt("HEARTBEAT_INTERVAL_SEC", 5),
@@ -0,0 +1,248 @@
// Remotion render engine.
//
// FlatRender supports two template engines that both produce a web-playable MP4:
//
// - AfterEffects (EngineAfterEffects) — aerender.exe renders a .aep template,
// bindings are written into the project first; see runner.go / binder.go.
// - Remotion (EngineRemotion) — a code-based React/Remotion composition
// is rendered with `npx remotion render`; bindings become --props; this file.
//
// The two engines are interchangeable from the job loop's point of view: Run()
// dispatches on Job.Engine and each returns the path to an MP4 on disk.
package runner
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"sync/atomic"
"time"
)
// Engine identifiers. These mirror the values the orchestrator stores per
// template (content.templates.render_engine) and sends on the claimed job.
const (
EngineAfterEffects = "AfterEffects"
EngineRemotion = "Remotion"
)
// Remotion prints "Rendered <done>/<total>" while drawing frames and
// "Stitched <done>/<total>" while muxing them into the MP4. We parse both to
// build a real percentage.
var (
reRemRendered = regexp.MustCompile(`Rendered\s+(\d+)/(\d+)`)
reRemStitched = regexp.MustCompile(`Stitched\s+(\d+)/(\d+)`)
)
// npxCmd returns the platform-appropriate npx launcher.
func npxCmd() string {
if runtime.GOOS == "windows" {
return "npx.cmd"
}
return "npx"
}
// remotionProps maps the user's bindings into a Remotion props JSON object.
// For code-based templates the binding Key is the composition's schema field
// (logoText, accentColor, …) and Value is the user's edited string. Anything the
// user didn't touch falls back to the composition's defaultProps.
func remotionProps(job *Job) (string, error) {
props := make(map[string]string, len(job.Bindings))
for _, b := range job.Bindings {
if b.Key == "" {
continue
}
props[b.Key] = b.Value
}
data, err := json.Marshal(props)
if err != nil {
return "", err
}
return string(data), nil
}
// crlfSplit is a bufio.SplitFunc that breaks on either \n or \r so we capture
// each progress-bar repaint (Remotion redraws the bar with \r, not \n).
func crlfSplit(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
for i, b := range data {
if b == '\n' || b == '\r' {
return i + 1, data[:i], nil
}
}
if atEOF {
return len(data), data, nil
}
return 0, nil, nil // request more data
}
// RunRemotion renders a code-based (Remotion) template to MP4.
//
// - remotionDir is the Remotion project root (has package.json + src/index.ts).
// - job.CompName is the Remotion composition id (e.g. "KineticQuote").
// - job.Bindings become --props.
// - job.Resolution selects an output height tier (free=360p … 4k).
//
// Returns the path to the rendered MP4. Progress + periodic previews are streamed
// through the same callbacks the AE engine uses, so the UI is engine-agnostic.
func RunRemotion(ctx context.Context, remotionDir string, job *Job, outputPath string, onProgress ProgressFn, onPreview PreviewFn) (string, error) {
if remotionDir == "" {
return "", fmt.Errorf("remotion project dir not set (REMOTION_PROJECT_DIR)")
}
if job.CompName == "" {
return "", fmt.Errorf("remotion render requires a composition id (CompName)")
}
if st, err := os.Stat(remotionDir); err != nil || !st.IsDir() {
return "", fmt.Errorf("remotion project dir not found: %s", remotionDir)
}
propsJSON, err := remotionProps(job)
if err != nil {
return "", fmt.Errorf("build props: %w", err)
}
// Render at the composition's native resolution, then downscale to the quality
// tier with ffmpeg (scale=-2:h preserves aspect). Remotion's --height flag
// overrides height but keeps the native width, which squishes non-matching
// aspect ratios — so we deliberately scale in the same ffmpeg post-step the AE
// engine uses. This also keeps one place to stamp the free-tier watermark later.
nativePath := strings.TrimSuffix(outputPath, filepath.Ext(outputPath)) + ".native.mp4"
entry := filepath.Join("src", "index.ts")
args := []string{
"remotion", "render", entry, job.CompName, nativePath,
"--props=" + propsJSON,
"--log=info",
}
log.Printf("[remotion] job %s → comp %q, props %s (cwd=%s)", job.JobID, job.CompName, propsJSON, remotionDir)
cmd := exec.CommandContext(ctx, npxCmd(), args...)
cmd.Dir = remotionDir
// Merge stdout+stderr into one pipe — Remotion writes the progress bar to
// stderr and structured logs to stdout; we want both.
pr, pw := io.Pipe()
cmd.Stdout = pw
cmd.Stderr = pw
var curFrame, totalFrames, stitched, totalStitch int64
var phase atomic.Int32 // 0=bundling 1=rendering 2=stitching
go func() {
sc := bufio.NewScanner(pr)
sc.Buffer(make([]byte, 64*1024), 1024*1024)
sc.Split(crlfSplit)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
_, _ = io.WriteString(os.Stdout, "[remotion] "+line+"\n")
if m := reRemRendered.FindStringSubmatch(line); m != nil {
cur, _ := strconv.ParseInt(m[1], 10, 64)
tot, _ := strconv.ParseInt(m[2], 10, 64)
atomic.StoreInt64(&curFrame, cur)
atomic.StoreInt64(&totalFrames, tot)
phase.Store(1)
}
if m := reRemStitched.FindStringSubmatch(line); m != nil {
cur, _ := strconv.ParseInt(m[1], 10, 64)
tot, _ := strconv.ParseInt(m[2], 10, 64)
atomic.StoreInt64(&stitched, cur)
atomic.StoreInt64(&totalStitch, tot)
phase.Store(2)
}
}
}()
if err := cmd.Start(); err != nil {
_ = pw.Close()
return "", fmt.Errorf("start remotion: %w", err)
}
done := make(chan error, 1)
go func() {
werr := cmd.Wait()
_ = pw.Close() // unblock the scanner goroutine
done <- werr
}()
_ = onProgress(ctx, 4, "در حال آماده‌سازی قالب…") // "Preparing template…"
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
lastPreview := time.Time{}
for {
select {
case werr := <-done:
if werr != nil {
return "", fmt.Errorf("remotion render exit: %w", werr)
}
if st, serr := os.Stat(nativePath); serr != nil || st.Size() == 0 {
return "", fmt.Errorf("remotion finished but produced no output at %s", nativePath)
}
// Downscale to the quality tier (aspect-preserving). When ffmpeg is
// missing or the tier is unknown, ship the native render unchanged.
h := resolutionHeight(job.Resolution)
if h > 0 && ffmpegPath() != "" {
_ = onProgress(ctx, 96, "در حال بهینه‌سازی کیفیت…") // "Optimizing quality…"
mp4, terr := transcodeToMP4(ctx, nativePath, outputPath, h)
if terr != nil {
log.Printf("[remotion] tier transcode failed (%v) — shipping native render", terr)
_ = onProgress(ctx, 98, "اتمام رندر")
return nativePath, nil
}
_ = os.Remove(nativePath)
_ = onProgress(ctx, 98, "اتمام رندر")
return mp4, nil
}
_ = onProgress(ctx, 98, "اتمام رندر")
return nativePath, nil
case <-ticker.C:
pct, msg := remotionProgress(phase.Load(),
atomic.LoadInt64(&curFrame), atomic.LoadInt64(&totalFrames),
atomic.LoadInt64(&stitched), atomic.LoadInt64(&totalStitch))
_ = onProgress(ctx, pct, msg)
if onPreview != nil && time.Since(lastPreview) >= 8*time.Second {
lastPreview = time.Now()
if perr := onPreview(ctx, GeneratePreviewB64(pct, job.Quality, job.Resolution)); perr != nil {
log.Printf("[remotion] preview push error: %v", perr)
}
}
case <-ctx.Done():
_ = cmd.Process.Kill()
return "", ctx.Err()
}
}
}
// remotionProgress maps the render phase + frame counts to a 496 percentage
// (leaving headroom for the orchestrator's upload step) plus a Persian message.
func remotionProgress(phase int32, cur, total, stch, stchTotal int64) (int, string) {
switch phase {
case 2: // stitching → 70..96
if stchTotal > 0 {
frac := float64(stch) / float64(stchTotal)
return 70 + int(frac*26), fmt.Sprintf("در حال ساخت ویدیو… %d از %d", stch, stchTotal)
}
return 70, "در حال ساخت ویدیو…"
case 1: // rendering frames → 8..70
if total > 0 {
frac := float64(cur) / float64(total)
return 8 + int(frac*62), fmt.Sprintf("در حال رندر… فریم %d از %d", cur, total)
}
return 8, "در حال رندر…"
default: // bundling
return 5, "در حال کامپایل قالب…"
}
}
@@ -0,0 +1,163 @@
package runner
import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
)
// remotionProjectDir resolves the repo's services/remotion directory relative to
// this test package (services/node-agent/internal/runner), or skips the test when
// it (or npx) is unavailable — keeps the test green on CI nodes without the
// Remotion project checked out.
func remotionProjectDir(t *testing.T) string {
t.Helper()
if v := os.Getenv("REMOTION_PROJECT_DIR"); v != "" {
return v
}
dir, err := filepath.Abs(filepath.Join("..", "..", "..", "remotion"))
if err != nil {
t.Fatalf("abs: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "package.json")); err != nil {
t.Skipf("remotion project not found at %s (skipping)", dir)
}
return dir
}
func TestRemotionProps(t *testing.T) {
job := &Job{Bindings: []Binding{
{Key: "logoText", Value: "HELLO"},
{Key: "accentColor", Value: "#22d3ee"},
{Key: "", Value: "ignored"}, // empty keys are dropped
}}
got, err := remotionProps(job)
if err != nil {
t.Fatalf("remotionProps: %v", err)
}
want := `{"accentColor":"#22d3ee","logoText":"HELLO"}`
if got != want {
t.Fatalf("props = %s, want %s", got, want)
}
}
func TestRemotionProgress(t *testing.T) {
cases := []struct {
phase int32
cur, total, stch, stchTot int64
wantMin, wantMax int
}{
{0, 0, 0, 0, 0, 5, 5}, // bundling
{1, 90, 180, 0, 0, 30, 45}, // half the frames rendered
{2, 90, 180, 90, 180, 80, 90}, // half stitched
}
for _, c := range cases {
pct, _ := remotionProgress(c.phase, c.cur, c.total, c.stch, c.stchTot)
if pct < c.wantMin || pct > c.wantMax {
t.Errorf("phase %d: pct %d not in [%d,%d]", c.phase, pct, c.wantMin, c.wantMax)
}
}
}
// TestRunRemotion_EndToEnd renders a real composition through the engine and
// asserts an MP4 lands on disk. Slow (spawns Chrome) — run with `go test -run
// RunRemotion -timeout 6m`. Skipped automatically without the project or npx.
func TestRunRemotion_EndToEnd(t *testing.T) {
if testing.Short() {
t.Skip("skipping end-to-end render in -short mode")
}
remDir := remotionProjectDir(t)
if _, err := exec.LookPath(npxCmd()); err != nil {
t.Skipf("%s not on PATH (skipping)", npxCmd())
}
out := filepath.Join(t.TempDir(), "engine-out.mp4")
job := &Job{
JobID: "test-remotion-e2e",
Engine: EngineRemotion,
CompName: "KineticQuote",
Quality: "free",
Resolution: "360p", // exercises the height tier mapping
Bindings: []Binding{
{Key: "quote", Value: "Two engines, one output."},
{Key: "author", Value: "Engine Test"},
{Key: "accentColor", Value: "#22d3ee"},
},
}
var lastPct int
onProgress := func(_ context.Context, pct int, _ string) error { lastPct = pct; return nil }
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute)
defer cancel()
got, err := RunRemotion(ctx, remDir, job, out, onProgress, nil)
if err != nil {
t.Fatalf("RunRemotion: %v", err)
}
st, err := os.Stat(got)
if err != nil {
t.Fatalf("stat output: %v", err)
}
if st.Size() == 0 {
t.Fatal("output file is empty")
}
if lastPct < 90 {
t.Errorf("final progress only reached %d%%", lastPct)
}
t.Logf("rendered %s (%d bytes), final progress %d%%", got, st.Size(), lastPct)
}
// TestRun_RemotionEngine exercises the real integration point the node-agent uses:
// runner.Run() dispatching on Job.Engine. With Engine=Remotion and an empty AE path
// (which would otherwise trigger the AE mock), it must route to the Remotion engine
// and produce a real MP4.
func TestRun_RemotionEngine(t *testing.T) {
if testing.Short() {
t.Skip("skipping end-to-end render in -short mode")
}
remDir := remotionProjectDir(t)
if _, err := exec.LookPath(npxCmd()); err != nil {
t.Skipf("%s not on PATH (skipping)", npxCmd())
}
job := &Job{
JobID: "test-run-dispatch",
Engine: EngineRemotion,
RemotionDir: remDir,
CompName: "KineticQuote",
Quality: "free",
Resolution: "360p",
Bindings: []Binding{{Key: "author", Value: "Dispatch Test"}},
}
noop := func(context.Context, int, string) error { return nil }
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute)
defer cancel()
// aePath empty: an AE job would mock here; a Remotion job must still render for real.
got, err := Run(ctx, "", t.TempDir(), job, noop, nil)
if err != nil {
t.Fatalf("Run (remotion engine): %v", err)
}
st, err := os.Stat(got)
if err != nil || st.Size() == 0 {
t.Fatalf("no output from Run: %v", err)
}
if string(mustRead(t, got)[:4]) == "mock" {
t.Fatal("Run produced the AE mock output instead of a real Remotion render")
}
t.Logf("Run dispatched to Remotion → %s (%d bytes)", got, st.Size())
}
func mustRead(t *testing.T, path string) []byte {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return b
}
@@ -46,6 +46,11 @@ type Job struct {
FrameRate int FrameRate int
HasMusic bool HasMusic bool
HasVoiceover bool HasVoiceover bool
// Engine selects the render engine: EngineAfterEffects (default, "" treated as
// AE for backwards-compat) or EngineRemotion (code-based React templates).
Engine string
// RemotionDir is the Remotion project root, used only when Engine == EngineRemotion.
RemotionDir string
// AEPFilePath is the local path to the downloaded .aep project file. // AEPFilePath is the local path to the downloaded .aep project file.
// In a full implementation the agent downloads this from MinIO before calling Run. // In a full implementation the agent downloads this from MinIO before calling Run.
AEPFilePath string AEPFilePath string
@@ -75,6 +80,12 @@ func Run(ctx context.Context, aePath, workDir string, job *Job, onProgress Progr
} }
outputPath := filepath.Join(outputDir, "output.mp4") outputPath := filepath.Join(outputDir, "output.mp4")
// Engine dispatch. Remotion is fully self-contained (Node + Chrome), so it
// never touches the AE / mock paths below.
if strings.EqualFold(job.Engine, EngineRemotion) {
return RunRemotion(ctx, job.RemotionDir, job, outputPath, onProgress, onPreview)
}
// Mock render when AE isn't installed (aePath empty) OR when this job has no // Mock render when AE isn't installed (aePath empty) OR when this job has no
// template project to render (AEPFilePath empty — the template bundle wasn't // template project to render (AEPFilePath empty — the template bundle wasn't
// uploaded/promoted yet). Mock drives progress+preview to completion so the job // uploaded/promoted yet). Mock drives progress+preview to completion so the job
+3319
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "flatrender-remotion",
"version": "0.1.0",
"private": true,
"description": "FlatRender code-based (Remotion) video template renderer",
"scripts": {
"dev": "remotion studio",
"render": "remotion render",
"still": "remotion still",
"upgrade": "remotion upgrade"
},
"dependencies": {
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.1.2",
"@react-three/postprocessing": "^3.0.4",
"@remotion/cli": "4.0.290",
"@remotion/three": "^4.0.290",
"@remotion/zod-types": "4.0.290",
"@types/three": "^0.171.0",
"postprocessing": "^6.39.1",
"react": "19.0.0",
"react-dom": "19.0.0",
"remotion": "4.0.290",
"three": "^0.171.0",
"zod": "3.22.3"
},
"devDependencies": {
"@types/react": "19.0.0",
"typescript": "5.5.4"
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+18
View File
@@ -0,0 +1,18 @@
import { Config } from "@remotion/cli/config";
Config.setVideoImageFormat("jpeg");
Config.setOverwriteOutput(true);
// Higher quality concurrency defaults for the logo-intro previews.
Config.setConcurrency(4);
// Remotion's bundled Chrome Headless Shell download is geo-blocked (403) from
// Iran, so point it at the locally-installed Chrome instead. Override with the
// REMOTION_BROWSER env var on machines where Chrome lives elsewhere.
Config.setBrowserExecutable(
process.env.REMOTION_BROWSER ??
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
);
// Required for WebGL / Three.js (@remotion/three) templates to render headless.
// "angle" works with the local Chrome; the node-agent inherits this from config.
Config.setChromiumOpenGlRenderer("angle");
+135
View File
@@ -0,0 +1,135 @@
import { Composition } from "remotion";
import { ASPECTS } from "./lib/aspect";
import { TEMPLATES } from "./templates";
import { Three3DTest } from "./compositions/Three3DTest";
import {
IlluminatedCircles,
illuminatedCirclesSchema,
} from "./compositions/IlluminatedCircles";
import {
KineticQuote,
kineticQuoteSchema,
} from "./compositions/KineticQuote";
import {
GradientPromo,
gradientPromoSchema,
} from "./compositions/GradientPromo";
import {
VerticalStory,
verticalStorySchema,
} from "./compositions/VerticalStory";
const FPS = 30;
export const RemotionRoot: React.FC = () => {
return (
<>
{/* Logo intro — 16:9 */}
<Composition
id="IlluminatedCircles"
component={IlluminatedCircles}
durationInFrames={FPS * 6}
fps={FPS}
width={1920}
height={1080}
schema={illuminatedCirclesSchema}
defaultProps={{
logoText: "FLATRENDER",
tagline: "MOTION MADE SIMPLE",
accentColor: "#3ba7ff",
secondaryColor: "#a855f7",
backgroundColor: "#04060f",
}}
/>
{/* Kinetic typography quote — 1:1 social */}
<Composition
id="KineticQuote"
component={KineticQuote}
durationInFrames={FPS * 7}
fps={FPS}
width={1080}
height={1080}
schema={kineticQuoteSchema}
defaultProps={{
quote: "Great motion design is felt long before it is noticed.",
author: "FlatRender Studio",
accentColor: "#22d3ee",
secondaryColor: "#6366f1",
backgroundColor: "#0a0a12",
}}
/>
{/* Marketing / sale promo — 16:9 */}
<Composition
id="GradientPromo"
component={GradientPromo}
durationInFrames={FPS * 6}
fps={FPS}
width={1920}
height={1080}
schema={gradientPromoSchema}
defaultProps={{
eyebrow: "Limited time offer",
headline: "Make videos that move people.",
subheadline:
"Customizable code-based templates, rendered in the cloud in minutes.",
ctaText: "Start free →",
badgeText: "50% OFF",
accentColor: "#fb7185",
secondaryColor: "#f59e0b",
backgroundColor: "#0c0a14",
}}
/>
{/* Vertical social story — 9:16 */}
<Composition
id="VerticalStory"
component={VerticalStory}
durationInFrames={FPS * 6}
fps={FPS}
width={1080}
height={1920}
schema={verticalStorySchema}
defaultProps={{
kicker: "New drop",
line1: "Your story.",
line2: "Your style.",
line3: "One tap.",
ctaText: "Swipe up",
accentColor: "#34d399",
secondaryColor: "#3b82f6",
backgroundColor: "#060b0a",
}}
/>
{/* 3D feasibility test */}
<Composition
id="Three3DTest"
component={Three3DTest}
durationInFrames={120}
fps={30}
width={1280}
height={720}
/>
{/* Branded templates — each registered in all three aspects. */}
{TEMPLATES.flatMap((tpl) =>
ASPECTS.map((a) => (
<Composition
key={`${tpl.id}-${a.id}`}
id={`${tpl.id}-${a.id}`}
component={tpl.component}
durationInFrames={Math.round(FPS * tpl.durationSec)}
fps={FPS}
width={a.width}
height={a.height}
schema={tpl.schema}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
defaultProps={tpl.defaultProps as any}
/>
))
)}
</>
);
};
@@ -0,0 +1,179 @@
import React from "react";
import {
AbsoluteFill,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import { ThreeCanvas } from "@remotion/three";
import * as THREE from "three";
import { z } from "zod";
import { colorSchema } from "../lib/branding";
import { FONT } from "../lib/fonts";
import { useLayout } from "../lib/aspect";
import { hexToRgba, mixHex, rand } from "../lib/anim";
import { StudioEnv, StudioFloor, StudioLights, StudioEffects, Confetti3D } from "../lib/three-kit";
export const birthday3DSchema = z.object({
greeting: z.string(),
name: z.string(),
message: z.string(),
...colorSchema,
});
type Props = z.infer<typeof birthday3DSchema>;
const Candle: React.FC<{ x: number; z: number; i: number; accent: string }> = ({ x, z, i, accent }) => {
const frame = useCurrentFrame();
const flick = 1 + Math.sin(frame / 4 + i) * 0.18;
return (
<group position={[x, 0.95, z]}>
<mesh castShadow>
<cylinderGeometry args={[0.04, 0.045, 0.4, 16]} />
<meshStandardMaterial color={i % 2 ? "#ffffff" : accent} roughness={0.5} />
</mesh>
<mesh position={[0, 0.28, 0]} scale={[1, flick, 1]}>
<coneGeometry args={[0.04, 0.15, 16]} />
<meshStandardMaterial color="#ffd27a" emissive="#ffae3b" emissiveIntensity={3} toneMapped={false} />
</mesh>
<pointLight position={[0, 0.34, 0]} intensity={1.6 * flick} color="#ffb14d" distance={2.5} />
</group>
);
};
const Cake: React.FC<{ accent: string; secondary: string }> = ({ accent, secondary }) => {
const cream = "#fbeede";
const frost = accent;
const candleN = 5;
return (
<group position={[0, -0.55, 0]}>
{/* plate */}
<mesh position={[0, 0.04, 0]} receiveShadow castShadow>
<cylinderGeometry args={[1.15, 1.15, 0.08, 48]} />
<meshStandardMaterial color="#e8e8ee" roughness={0.25} metalness={0.4} />
</mesh>
{/* tier 1 */}
<mesh position={[0, 0.34, 0]} castShadow>
<cylinderGeometry args={[0.92, 0.95, 0.52, 48]} />
<meshStandardMaterial color={cream} roughness={0.6} />
</mesh>
<mesh position={[0, 0.6, 0]}>
<torusGeometry args={[0.92, 0.07, 16, 48]} />
<meshStandardMaterial color={frost} roughness={0.45} />
</mesh>
{/* tier 2 */}
<mesh position={[0, 0.82, 0]} castShadow>
<cylinderGeometry args={[0.62, 0.66, 0.46, 48]} />
<meshStandardMaterial color={mixHex(cream, frost, 0.15)} roughness={0.6} />
</mesh>
<mesh position={[0, 1.05, 0]}>
<torusGeometry args={[0.62, 0.06, 16, 48]} />
<meshStandardMaterial color={secondary} roughness={0.45} />
</mesh>
{/* cherries */}
{Array.from({ length: 8 }).map((_, i) => (
<mesh key={i} position={[Math.cos((i / 8) * Math.PI * 2) * 0.62, 1.06, Math.sin((i / 8) * Math.PI * 2) * 0.62]}>
<sphereGeometry args={[0.05, 16, 16]} />
<meshStandardMaterial color="#e23b3b" roughness={0.3} />
</mesh>
))}
{/* candles */}
{Array.from({ length: candleN }).map((_, i) => {
const a = (i / candleN) * Math.PI * 2;
return <Candle key={i} i={i} x={Math.cos(a) * 0.32} z={Math.sin(a) * 0.32} accent={accent} />;
})}
</group>
);
};
const Balloon: React.FC<{ x: number; z: number; i: number; color: string }> = ({ x, z, i, color }) => {
const frame = useCurrentFrame();
const bob = Math.sin(frame / 30 + i) * 0.25;
const sway = Math.sin(frame / 40 + i * 2) * 0.1;
const baseY = 1.4 + (i % 3) * 0.5;
return (
<group position={[x + sway, baseY + bob, z]}>
<mesh castShadow>
<sphereGeometry args={[0.38, 24, 24]} />
<meshStandardMaterial color={color} roughness={0.25} metalness={0.05} emissive={color} emissiveIntensity={0.06} />
</mesh>
<mesh position={[0, -0.4, 0]}>
<coneGeometry args={[0.05, 0.1, 12]} />
<meshStandardMaterial color={color} roughness={0.3} />
</mesh>
<mesh position={[0, -1.0, 0]}>
<cylinderGeometry args={[0.005, 0.005, 1.1, 6]} />
<meshStandardMaterial color="#ffffff" opacity={0.5} transparent />
</mesh>
</group>
);
};
const Scene: React.FC<{ accent: string; secondary: string }> = ({ accent, secondary }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const enter = spring({ frame: frame - 8, fps, config: { damping: 14, stiffness: 60 } });
const orbit = Math.sin(frame / 110) * 0.2;
const balloonColors = [accent, secondary, "#fde047", "#34d399", "#60a5fa"];
return (
<group rotation={[0, orbit, 0]} scale={enter}>
<StudioLights accent={accent} secondary={secondary} />
<StudioEnv />
<StudioFloor color="#241d33" />
<Cake accent={accent} secondary={secondary} />
{[[-2.2, -0.5], [2.2, -0.6], [-1.7, -1.6], [1.8, -1.4], [0, -2.2]].map((p, i) => (
<Balloon key={i} i={i} x={p[0]} z={p[1]} color={balloonColors[i % balloonColors.length]} />
))}
<Confetti3D colors={[accent, secondary, "#fde047", "#34d399", "#ffffff"]} />
</group>
);
};
export const Birthday3D: React.FC<Props> = ({
greeting,
name,
message,
accentColor,
secondaryColor,
backgroundColor,
textColor,
}) => {
const frame = useCurrentFrame();
const { width, height, fps } = useVideoConfig();
const L = useLayout();
const gOp = interpolate(frame, [12, 30], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const namePop = spring({ frame: frame - 30, fps, config: { damping: 10, stiffness: 120 } });
const nameScale = interpolate(namePop, [0, 1], [0.4, 1]);
const msgOp = interpolate(frame, [150, 172], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
return (
<AbsoluteFill style={{ backgroundColor }}>
<AbsoluteFill style={{ background: `radial-gradient(circle at 50% 35%, ${hexToRgba(accentColor, 0.22)} 0%, ${hexToRgba(secondaryColor, 0.08)} 40%, ${backgroundColor} 74%)` }} />
<ThreeCanvas
width={width}
height={height}
camera={{ position: [0, 2.3, 5.7], fov: 50 }}
shadows
style={{ position: "absolute", inset: 0 }}
gl={{ toneMapping: THREE.ACESFilmicToneMapping, antialias: true }}
>
<Scene accent={accentColor} secondary={secondaryColor} />
<StudioEffects bloom={0.6} focus={0.014} bokeh={3} vignette={0.55} />
</ThreeCanvas>
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl", alignItems: "center", justifyContent: "flex-start", paddingTop: height * 0.05 }}>
<div style={{ opacity: gOp, fontWeight: 700, fontSize: L.vmin(44), color: textColor, textShadow: `0 ${L.vmin(2)}px ${L.vmin(6)}px ${hexToRgba("#1a0a14", 0.7)}` }}>
{greeting}
</div>
<div style={{ transform: `scale(${nameScale})`, margin: `${L.vmin(6)}px 0`, fontWeight: 900, fontSize: L.vmin(100), lineHeight: 1.05, backgroundImage: `linear-gradient(120deg, ${accentColor}, ${secondaryColor})`, WebkitBackgroundClip: "text", backgroundClip: "text", WebkitTextFillColor: "transparent", filter: `drop-shadow(0 ${L.vmin(3)}px ${L.vmin(10)}px ${hexToRgba("#1a0a14", 0.6)})` }}>
{name}
</div>
<div style={{ opacity: msgOp, fontWeight: 600, fontSize: L.vmin(28), color: hexToRgba(textColor, 0.92), textShadow: `0 ${L.vmin(2)}px ${L.vmin(6)}px ${hexToRgba("#1a0a14", 0.7)}` }}>
{message}
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};
@@ -0,0 +1,84 @@
import React from "react";
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
import { z } from "zod";
import { colorSchema } from "../lib/branding";
import { FONT } from "../lib/fonts";
import { useLayout } from "../lib/aspect";
import { BrandBackground, useReveal } from "../lib/kit";
import { hexToRgba } from "../lib/anim";
export const countdownSchema = z.object({
title: z.string(),
// coerce so a string binding ("5") from the studio still validates as a number
startNumber: z.coerce.number().int().min(1).max(9),
goText: z.string(),
subtitle: z.string(),
...colorSchema,
});
type Props = z.infer<typeof countdownSchema>;
export const Countdown: React.FC<Props> = ({
title,
startNumber,
goText,
subtitle,
accentColor,
secondaryColor,
backgroundColor,
textColor,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const L = useLayout();
const titleR = useReveal(6, { from: 24 });
// Count down one number per second after a short intro.
const introF = Math.round(fps * 1.2);
const elapsed = Math.max(0, frame - introF);
const sec = Math.floor(elapsed / fps);
const current = startNumber - sec; // >0 → number, <=0 → GO
const localInSec = (elapsed % fps) / fps;
// Each tick pops in and fades/scales out.
const pop = spring({ frame: (elapsed % fps), fps, config: { damping: 12, stiffness: 130, mass: 0.7 } });
const scaleIn = interpolate(pop, [0, 1], [0.4, 1]);
const scaleOut = interpolate(localInSec, [0.7, 1], [1, 1.4], { extrapolateLeft: "clamp" });
const fadeOut = interpolate(localInSec, [0.75, 1], [1, 0], { extrapolateLeft: "clamp" });
const isGo = current <= 0;
const ringProgress = 1 - localInSec;
const ringR = L.vmin(220);
const circ = 2 * Math.PI * ringR;
const sub = useReveal(introF + 4, { from: 24 });
return (
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl" }}>
<BrandBackground bg={backgroundColor} accent={accentColor} secondary={secondaryColor} particles={18} />
<div style={{ position: "absolute", top: L.vmin(120), left: 0, right: 0, textAlign: "center", opacity: titleR.opacity, transform: `translateY(${titleR.y}px)`, fontWeight: 800, fontSize: L.vmin(44), color: hexToRgba(textColor, 0.9) }}>
{title}
</div>
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
{/* Progress ring */}
{!isGo && (
<svg width={ringR * 2.4} height={ringR * 2.4} viewBox={`${-ringR * 1.2} ${-ringR * 1.2} ${ringR * 2.4} ${ringR * 2.4}`} style={{ position: "absolute" }}>
<circle cx={0} cy={0} r={ringR} fill="none" stroke={hexToRgba(textColor, 0.12)} strokeWidth={L.vmin(6)} />
<circle cx={0} cy={0} r={ringR} fill="none" stroke={accentColor} strokeWidth={L.vmin(6)} strokeLinecap="round" strokeDasharray={`${circ * ringProgress} ${circ}`} transform="rotate(-90)" style={{ filter: `drop-shadow(0 0 ${L.vmin(8)}px ${accentColor})` }} />
</svg>
)}
<div style={{ transform: `scale(${isGo ? scaleIn : scaleIn * scaleOut})`, opacity: isGo ? 1 : fadeOut, fontWeight: 900, fontSize: isGo ? L.vmin(150) : L.vmin(260), lineHeight: 1, backgroundImage: `linear-gradient(135deg, ${accentColor}, ${secondaryColor})`, WebkitBackgroundClip: "text", backgroundClip: "text", WebkitTextFillColor: "transparent", filter: `drop-shadow(0 0 ${L.vmin(30)}px ${hexToRgba(accentColor, 0.6)})` }}>
{isGo ? goText : current}
</div>
</AbsoluteFill>
<div style={{ position: "absolute", bottom: L.vmin(140), left: 0, right: 0, textAlign: "center", opacity: sub.opacity, transform: `translateY(${sub.y}px)`, fontWeight: 500, fontSize: L.vmin(30), color: hexToRgba(textColor, 0.78) }}>
{subtitle}
</div>
</AbsoluteFill>
);
};
@@ -0,0 +1,78 @@
import React from "react";
import { AbsoluteFill, interpolate, useCurrentFrame, Easing } from "remotion";
import { z } from "zod";
import { colorSchema } from "../lib/branding";
import { FONT } from "../lib/fonts";
import { useLayout } from "../lib/aspect";
import { BrandBackground, useReveal } from "../lib/kit";
import { hexToRgba } from "../lib/anim";
export const eventInviteSchema = z.object({
kicker: z.string(),
eventTitle: z.string(),
date: z.string(),
location: z.string(),
cta: z.string(),
...colorSchema,
});
type Props = z.infer<typeof eventInviteSchema>;
export const EventInvite: React.FC<Props> = ({
kicker,
eventTitle,
date,
location,
cta,
accentColor,
secondaryColor,
backgroundColor,
textColor,
}) => {
const frame = useCurrentFrame();
const L = useLayout();
const kick = useReveal(8, { from: 22 });
const title = useReveal(22, { from: 44 });
const meta = useReveal(44, { from: 26 });
const ctaR = useReveal(64, { from: 22, damping: 12 });
// Elegant double border that draws in.
const borderInset = interpolate(frame, [0, 30], [L.vmin(40), L.vmin(70)], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.cubic) });
const borderOp = interpolate(frame, [0, 24], [0, 1], { extrapolateRight: "clamp" });
return (
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl" }}>
<BrandBackground bg={backgroundColor} accent={accentColor} secondary={secondaryColor} particles={12} nebula />
{/* Ornamental frame */}
<div style={{ position: "absolute", inset: borderInset, border: `${L.vmin(2)}px solid ${hexToRgba(accentColor, 0.5)}`, borderRadius: L.vmin(10), opacity: borderOp }} />
<div style={{ position: "absolute", inset: borderInset + L.vmin(10), border: `${L.vmin(1)}px solid ${hexToRgba(secondaryColor, 0.35)}`, borderRadius: L.vmin(8), opacity: borderOp }} />
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", flexDirection: "column", padding: L.vmin(110) }}>
<div style={{ opacity: kick.opacity, transform: `translateY(${kick.y}px)`, fontWeight: 600, fontSize: L.vmin(26), letterSpacing: L.vmin(8), color: accentColor, marginBottom: L.vmin(22) }}>
{kicker}
</div>
<div style={{ opacity: title.opacity, transform: `translateY(${title.y}px)`, fontWeight: 900, fontSize: L.vmin(92), lineHeight: 1.1, color: textColor, textAlign: "center", maxWidth: L.vmin(880), textShadow: `0 ${L.vmin(6)}px ${L.vmin(36)}px ${hexToRgba(accentColor, 0.4)}` }}>
{eventTitle}
</div>
<div style={{ marginTop: L.vmin(40), opacity: meta.opacity, transform: `translateY(${meta.y}px)`, display: "flex", gap: L.vmin(40), flexWrap: "wrap", justifyContent: "center" }}>
<Meta L={L} icon="📅" label={date} color={textColor} accent={accentColor} />
<Meta L={L} icon="📍" label={location} color={textColor} accent={accentColor} />
</div>
<div style={{ marginTop: L.vmin(52), opacity: ctaR.opacity, transform: `scale(${ctaR.scale})`, padding: `${L.vmin(20)}px ${L.vmin(56)}px`, borderRadius: 999, background: `linear-gradient(135deg, ${accentColor}, ${secondaryColor})`, boxShadow: `0 0 ${L.vmin(40)}px ${hexToRgba(accentColor, 0.55)}`, fontWeight: 800, fontSize: L.vmin(32), color: "#fff" }}>
{cta}
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};
const Meta: React.FC<{ L: ReturnType<typeof useLayout>; icon: string; label: string; color: string; accent: string }> = ({ L, icon, label, color, accent }) => (
<div style={{ display: "flex", alignItems: "center", gap: L.vmin(12), padding: `${L.vmin(12)}px ${L.vmin(24)}px`, borderRadius: 999, background: hexToRgba(accent, 0.1), border: `${L.vmin(1.5)}px solid ${hexToRgba(accent, 0.3)}`, fontWeight: 600, fontSize: L.vmin(28), color }}>
<span style={{ fontSize: L.vmin(30) }}>{icon}</span>
{label}
</div>
);
@@ -0,0 +1,196 @@
import React from "react";
import {
AbsoluteFill,
Img,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
Easing,
} from "remotion";
import { z } from "zod";
import { colorSchema } from "../lib/branding";
import { FONT } from "../lib/fonts";
import { useLayout } from "../lib/aspect";
import { hexToRgba, mixHex, rand } from "../lib/anim";
export const glitterRevealSchema = z.object({
brandText: z.string(),
tagline: z.string(),
/** Optional logo image URL. When empty the FlatRender brand mark is used. */
logoUrl: z.string(),
...colorSchema,
});
type Props = z.infer<typeof glitterRevealSchema>;
// ── Default FlatRender brand mark (used when the user hasn't uploaded a logo) ──
const DefaultLogo: React.FC<{ size: number }> = ({ size }) => (
<svg width={size} height={size} viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" rx="12" fill="#2563EB" />
<rect x="16" y="13" width="3.6" height="22" rx="1.8" fill="white" />
<rect x="16" y="13" width="16" height="3.6" rx="1.8" fill="white" />
<rect x="16" y="22.2" width="11" height="3.6" rx="1.8" fill="white" fillOpacity="0.75" />
<path d="M30 29L35.5 32L30 35Z" fill="white" fillOpacity="0.9" />
</svg>
);
// Deterministic glitter field — each particle flies in from the edge, gathers at
// the logo, then disperses into an ambient orbit (the classic glitter-dust reveal).
const GLITTER = Array.from({ length: 150 }).map((_, i) => ({
i,
angleIn: rand(i) * Math.PI * 2,
distIn: 520 + rand(i + 7) * 460,
// gather target: a tight cluster over the logo
tx: (rand(i + 11) - 0.5) * 360,
ty: (rand(i + 19) - 0.5) * 240,
// ambient orbit it settles into
ambAngle: rand(i + 23) * Math.PI * 2,
ambR: 230 + rand(i + 29) * 320,
size: 1.6 + rand(i + 3) * 4.5,
delay: (i % 18) * 0.9,
speed: 0.4 + rand(i + 5) * 1.2,
}));
const Glitter: React.FC<{ accent: string; secondary: string; gold: string }> = ({
accent,
secondary,
gold,
}) => {
const frame = useCurrentFrame();
const { width, height } = useVideoConfig();
const L = useLayout();
const cx = width / 2;
const cy = height / 2;
return (
<AbsoluteFill>
<svg width={width} height={height} style={{ overflow: "visible" }}>
{GLITTER.map((p) => {
const conv = interpolate(frame, [p.delay, p.delay + 34], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
const disp = interpolate(frame, [46, 86], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.inOut(Easing.quad),
});
// start (far out) → gather cluster → ambient orbit
const sx = cx + Math.cos(p.angleIn) * L.vmin(p.distIn);
const sy = cy + Math.sin(p.angleIn) * L.vmin(p.distIn);
const gx = cx + L.vmin(p.tx);
const gy = cy + L.vmin(p.ty);
const ax = cx + Math.cos(p.ambAngle + frame * 0.004 * p.speed) * L.vmin(p.ambR);
const ay = cy + Math.sin(p.ambAngle + frame * 0.004 * p.speed) * L.vmin(p.ambR);
const tgtX = gx + (ax - gx) * disp;
const tgtY = gy + (ay - gy) * disp;
const x = sx + (tgtX - sx) * conv;
const y = sy + (tgtY - sy) * conv;
const twinkle = 0.3 + 0.7 * Math.abs(Math.sin((frame + p.i * 13) / (6 + (p.i % 5))));
const appear = interpolate(frame, [p.delay, p.delay + 10], [0, 1], { extrapolateRight: "clamp" });
const c = p.i % 4 === 0 ? gold : p.i % 3 === 0 ? secondary : accent;
const r = L.vmin(p.size) * (0.7 + conv * 0.5);
return (
<circle
key={p.i}
cx={x}
cy={y}
r={r}
fill={c}
opacity={twinkle * appear}
style={{ filter: `drop-shadow(0 0 ${r * 2.6}px ${c})` }}
/>
);
})}
</svg>
</AbsoluteFill>
);
};
export const GlitterReveal: React.FC<Props> = ({
brandText,
tagline,
logoUrl,
accentColor,
secondaryColor,
backgroundColor,
textColor,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const L = useLayout();
const gold = "#fcd34d";
// Logo reveal (the glitter gathers ~frame 44, then the logo emerges).
const logoSpring = spring({ frame: frame - 42, fps, config: { damping: 13, stiffness: 95, mass: 0.9 } });
const logoScale = interpolate(logoSpring, [0, 1], [0.55, 1]);
const logoOpacity = interpolate(frame, [42, 60], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
// Bright convergence flash.
const flash = interpolate(frame, [40, 47, 60], [0, 0.85, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
// Core glow that breathes behind the logo.
const glow = interpolate(frame, [44, 70], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const breathe = 1 + 0.05 * Math.sin(frame / 16);
// Shine sweep across the logo at reveal.
const sweepX = interpolate(frame, [58, 88], [-L.vmin(360), L.vmin(360)], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.inOut(Easing.cubic) });
const sweepOp = interpolate(frame, [58, 66, 82, 90], [0, 0.9, 0.9, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
// Text.
const brandY = interpolate(frame, [70, 92], [L.vmin(70), 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.cubic) });
const brandOpacity = interpolate(frame, [70, 90], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const tagOpacity = interpolate(frame, [92, 112], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const tagSpacing = interpolate(frame, [92, 120], [L.vmin(14), L.vmin(6)], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const logoSize = L.vmin(240);
const hasLogo = Boolean(logoUrl && logoUrl.trim().length > 0);
return (
<AbsoluteFill style={{ backgroundColor, fontFamily: FONT, direction: "rtl" }}>
{/* Deep radial backdrop */}
<AbsoluteFill style={{ background: `radial-gradient(circle at 50% 45%, ${hexToRgba(accentColor, 0.16)} 0%, ${hexToRgba(secondaryColor, 0.06)} 32%, ${backgroundColor} 66%)` }} />
{/* Core glow */}
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
<div style={{ width: logoSize * 2.2 * glow * breathe, height: logoSize * 2.2 * glow * breathe, borderRadius: "50%", background: `radial-gradient(circle, ${hexToRgba(accentColor, 0.5)} 0%, ${hexToRgba(gold, 0.18)} 35%, transparent 70%)`, filter: `blur(${L.vmin(10)}px)` }} />
</AbsoluteFill>
<Glitter accent={accentColor} secondary={secondaryColor} gold={gold} />
{/* Logo */}
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
<div style={{ transform: `scale(${logoScale})`, opacity: logoOpacity, filter: `drop-shadow(0 0 ${L.vmin(24)}px ${hexToRgba(accentColor, 0.7)})`, display: "flex", alignItems: "center", justifyContent: "center", width: logoSize, height: logoSize }}>
{hasLogo ? (
<Img src={logoUrl} style={{ maxWidth: logoSize, maxHeight: logoSize, objectFit: "contain" }} />
) : (
<DefaultLogo size={logoSize} />
)}
</div>
</AbsoluteFill>
{/* Convergence flash */}
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", pointerEvents: "none" }}>
<div style={{ width: logoSize * 2.4, height: logoSize * 2.4, borderRadius: "50%", background: `radial-gradient(circle, ${hexToRgba("#ffffff", flash)} 0%, ${hexToRgba(gold, flash * 0.6)} 25%, transparent 60%)`, mixBlendMode: "screen" }} />
</AbsoluteFill>
{/* Shine sweep */}
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", overflow: "hidden" }}>
<div style={{ position: "absolute", width: L.vmin(140), height: logoSize * 1.4, transform: `translateX(${sweepX}px) rotate(18deg)`, background: `linear-gradient(90deg, transparent, ${hexToRgba(mixHex(textColor, gold, 0.4), 0.95)}, transparent)`, filter: `blur(${L.vmin(18)}px)`, opacity: sweepOp, mixBlendMode: "screen" }} />
</AbsoluteFill>
{/* Brand text + tagline */}
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "center", flexDirection: "column", paddingBottom: L.vmin(130) }}>
<div style={{ transform: `translateY(${brandY}px)`, opacity: brandOpacity, fontWeight: 900, fontSize: L.vmin(82), color: textColor, textAlign: "center", textShadow: `0 0 ${L.vmin(16)}px ${hexToRgba(accentColor, 0.7)}` }}>
{brandText}
</div>
<div style={{ marginTop: L.vmin(18), opacity: tagOpacity, fontWeight: 500, fontSize: L.vmin(26), letterSpacing: tagSpacing, color: hexToRgba(textColor, 0.8), textAlign: "center" }}>
{tagline}
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};
@@ -0,0 +1,245 @@
import React from "react";
import {
AbsoluteFill,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
Easing,
} from "remotion";
import { zColor } from "@remotion/zod-types";
import { z } from "zod";
import { hexToRgba, mixHex, rand } from "../lib/anim";
export const gradientPromoSchema = z.object({
eyebrow: z.string(),
headline: z.string(),
subheadline: z.string(),
ctaText: z.string(),
badgeText: z.string(),
accentColor: zColor(),
secondaryColor: zColor(),
backgroundColor: zColor(),
});
type Props = z.infer<typeof gradientPromoSchema>;
// ── Drifting mesh-gradient blobs ─────────────────────────────────────────────
const BLOBS = [
{ baseX: 0.2, baseY: 0.3, r: 520, useAccent: true },
{ baseX: 0.78, baseY: 0.28, r: 460, useAccent: false },
{ baseX: 0.62, baseY: 0.8, r: 580, useAccent: true },
{ baseX: 0.12, baseY: 0.82, r: 420, useAccent: false },
];
const MeshBackground: React.FC<{
bg: string;
accent: string;
secondary: string;
}> = ({ bg, accent, secondary }) => {
const frame = useCurrentFrame();
const { width, height } = useVideoConfig();
return (
<AbsoluteFill style={{ backgroundColor: bg, overflow: "hidden" }}>
{BLOBS.map((b, i) => {
const dx = Math.sin(frame / (50 + i * 12) + rand(i) * 6) * 70;
const dy = Math.cos(frame / (60 + i * 9) + rand(i + 4) * 6) * 60;
const color = b.useAccent ? accent : secondary;
return (
<div
key={i}
style={{
position: "absolute",
left: b.baseX * width - b.r / 2 + dx,
top: b.baseY * height - b.r / 2 + dy,
width: b.r,
height: b.r,
borderRadius: "50%",
background: `radial-gradient(circle, ${hexToRgba(
color,
0.5
)} 0%, transparent 68%)`,
filter: "blur(40px)",
}}
/>
);
})}
{/* Subtle grain/vignette to ground the gradients */}
<AbsoluteFill
style={{ boxShadow: "inset 0 0 600px 180px rgba(0,0,0,0.55)" }}
/>
</AbsoluteFill>
);
};
// ── Spinning offer badge in the corner ───────────────────────────────────────
const Badge: React.FC<{ text: string; accent: string; secondary: string }> = ({
text,
accent,
secondary,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const pop = spring({
frame: frame - 26,
fps,
config: { damping: 11, mass: 0.6, stiffness: 140 },
});
const scale = interpolate(pop, [0, 1], [0, 1]);
const wobble = Math.sin(frame / 16) * 6;
return (
<div
style={{
position: "absolute",
top: 90,
right: 130,
width: 190,
height: 190,
transform: `scale(${scale}) rotate(${wobble - 12}deg)`,
borderRadius: "50%",
background: `linear-gradient(135deg, ${accent}, ${secondary})`,
display: "flex",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
boxShadow: `0 0 50px ${hexToRgba(accent, 0.6)}`,
fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif",
fontWeight: 800,
fontSize: 30,
lineHeight: 1.1,
letterSpacing: 1,
color: "#fff",
padding: 18,
}}
>
{text}
</div>
);
};
export const GradientPromo: React.FC<Props> = ({
eyebrow,
headline,
subheadline,
ctaText,
badgeText,
accentColor,
secondaryColor,
backgroundColor,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const reveal = (delay: number) =>
spring({ frame: frame - delay, fps, config: { damping: 18, stiffness: 90 } });
const eyebrowOp = interpolate(reveal(6), [0, 1], [0, 1]);
const eyebrowX = interpolate(reveal(6), [0, 1], [-40, 0]);
const headSpring = reveal(14);
const headY = interpolate(headSpring, [0, 1], [60, 0]);
const headOp = interpolate(headSpring, [0, 1], [0, 1]);
const subOp = interpolate(reveal(28), [0, 1], [0, 1]);
const subY = interpolate(reveal(28), [0, 1], [30, 0]);
const ctaSpring = reveal(40);
const ctaScale = interpolate(ctaSpring, [0, 1], [0.7, 1]);
const ctaOp = interpolate(ctaSpring, [0, 1], [0, 1]);
const ctaGlow = 0.4 + 0.3 * Math.sin(frame / 12);
return (
<AbsoluteFill>
<MeshBackground
bg={backgroundColor}
accent={accentColor}
secondary={secondaryColor}
/>
<Badge text={badgeText} accent={accentColor} secondary={secondaryColor} />
<AbsoluteFill
style={{
justifyContent: "center",
flexDirection: "column",
paddingLeft: 150,
paddingRight: 150,
}}
>
<div
style={{
transform: `translateX(${eyebrowX}px)`,
opacity: eyebrowOp,
fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif",
fontWeight: 700,
fontSize: 26,
letterSpacing: 8,
textTransform: "uppercase",
color: mixHex(accentColor, secondaryColor, 0.5),
marginBottom: 24,
}}
>
{eyebrow}
</div>
<div
style={{
transform: `translateY(${headY}px)`,
opacity: headOp,
fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif",
fontWeight: 800,
fontSize: 110,
lineHeight: 1.02,
letterSpacing: -2,
color: "#fff",
maxWidth: 1100,
textShadow: `0 6px 40px ${hexToRgba(accentColor, 0.4)}`,
}}
>
{headline}
</div>
<div
style={{
transform: `translateY(${subY}px)`,
opacity: subOp,
marginTop: 30,
fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif",
fontWeight: 400,
fontSize: 32,
lineHeight: 1.4,
color: hexToRgba("#ffffff", 0.75),
maxWidth: 820,
}}
>
{subheadline}
</div>
<div
style={{
marginTop: 56,
transform: `scale(${ctaScale})`,
transformOrigin: "left center",
opacity: ctaOp,
alignSelf: "flex-start",
padding: "22px 56px",
borderRadius: 999,
background: `linear-gradient(135deg, ${accentColor}, ${secondaryColor})`,
boxShadow: `0 0 ${30 + ctaGlow * 40}px ${hexToRgba(
accentColor,
ctaGlow
)}`,
fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif",
fontWeight: 700,
fontSize: 30,
letterSpacing: 1,
color: "#fff",
}}
>
{ctaText}
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};
@@ -0,0 +1,71 @@
import React from "react";
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
import { z } from "zod";
import { colorSchema } from "../lib/branding";
import { FONT } from "../lib/fonts";
import { useLayout } from "../lib/aspect";
import { BrandBackground, useReveal } from "../lib/kit";
import { hexToRgba, rand } from "../lib/anim";
export const happyBirthdaySchema = z.object({
greeting: z.string(),
name: z.string(),
message: z.string(),
...colorSchema,
});
type Props = z.infer<typeof happyBirthdaySchema>;
const CONFETTI = Array.from({ length: 60 });
export const HappyBirthday: React.FC<Props> = ({
greeting,
name,
message,
accentColor,
secondaryColor,
backgroundColor,
textColor,
}) => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
const L = useLayout();
const greet = useReveal(8, { from: 30 });
const namePop = spring({ frame: frame - 26, fps, config: { damping: 10, stiffness: 120, mass: 0.8 } });
const nameScale = interpolate(namePop, [0, 1], [0.3, 1]);
const msg = useReveal(56, { from: 24 });
const colors = [accentColor, secondaryColor, "#fde047", "#fb7185", "#34d399"];
return (
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl" }}>
<BrandBackground bg={backgroundColor} accent={accentColor} secondary={secondaryColor} particles={0} />
{/* Confetti rain */}
<AbsoluteFill>
{CONFETTI.map((_, i) => {
const startDelay = (i % 12) * 2;
const t = Math.max(0, frame - startDelay);
const x = rand(i) * width + Math.sin((frame + i * 20) / 18) * L.vmin(30);
const y = ((rand(i + 5) * height) + t * (2 + rand(i) * 3) * L.unit) % (height + 40) - 20;
const sz = L.vmin(8 + (i % 4) * 4);
const rot = (frame + i * 30) * (i % 2 ? 4 : -4);
return <div key={i} style={{ position: "absolute", left: x, top: y, width: sz, height: sz * 0.6, background: colors[i % colors.length], transform: `rotate(${rot}deg)`, opacity: 0.9, borderRadius: i % 3 === 0 ? "50%" : 2 }} />;
})}
</AbsoluteFill>
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", flexDirection: "column", padding: L.vmin(70) }}>
<div style={{ fontSize: L.vmin(70), marginBottom: L.vmin(10) }}>🎂</div>
<div style={{ opacity: greet.opacity, transform: `translateY(${greet.y}px)`, fontWeight: 700, fontSize: L.vmin(48), color: hexToRgba(textColor, 0.9), textAlign: "center" }}>
{greeting}
</div>
<div style={{ transform: `scale(${nameScale})`, margin: `${L.vmin(14)}px 0`, fontWeight: 900, fontSize: L.vmin(120), lineHeight: 1.05, textAlign: "center", color: textColor, backgroundImage: `linear-gradient(120deg, ${accentColor}, ${secondaryColor})`, WebkitBackgroundClip: "text", backgroundClip: "text", WebkitTextFillColor: "transparent", filter: `drop-shadow(0 ${L.vmin(6)}px ${L.vmin(30)}px ${hexToRgba(accentColor, 0.5)})` }}>
{name}
</div>
<div style={{ opacity: msg.opacity, transform: `translateY(${msg.y}px)`, fontWeight: 500, fontSize: L.vmin(32), color: hexToRgba(textColor, 0.82), textAlign: "center", maxWidth: L.vmin(820) }}>
{message}
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};
@@ -0,0 +1,160 @@
import React, { useMemo } from "react";
import {
AbsoluteFill,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
Easing,
} from "remotion";
import { ThreeCanvas } from "@remotion/three";
import * as THREE from "three";
import { z } from "zod";
import { colorSchema } from "../lib/branding";
import { FONT } from "../lib/fonts";
import { useLayout } from "../lib/aspect";
import { hexToRgba, mixHex, rand } from "../lib/anim";
export const hero3DSchema = z.object({
brandText: z.string(),
tagline: z.string(),
...colorSchema,
});
type Props = z.infer<typeof hero3DSchema>;
const SHAPES = ["icosa", "octa", "dodeca", "box", "torus"] as const;
// One floating polyhedron, drifting + self-rotating (animated off the timeline,
// not R3F's render loop, so renders stay deterministic).
const FloatingShape: React.FC<{ i: number; accent: string; secondary: string }> = ({ i, accent, secondary }) => {
const frame = useCurrentFrame();
const kind = SHAPES[i % SHAPES.length];
const ang = rand(i) * Math.PI * 2;
const radius = 2.6 + rand(i + 5) * 2.4;
const depth = -1 - rand(i + 9) * 4;
const x = Math.cos(ang + frame * 0.004 * (0.5 + rand(i) * 0.6)) * radius;
const y = Math.sin(ang * 1.7 + frame * 0.006) * (1.4 + rand(i + 3) * 1.4);
const s = 0.18 + rand(i + 7) * 0.35;
const col = i % 2 === 0 ? accent : secondary;
const appear = interpolate(frame, [8 + i * 2, 36 + i * 2], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
return (
<mesh position={[x, y, depth]} rotation={[frame * 0.02 * (1 + rand(i)), frame * 0.025, 0]} scale={s * appear}>
{kind === "icosa" && <icosahedronGeometry args={[1, 0]} />}
{kind === "octa" && <octahedronGeometry args={[1, 0]} />}
{kind === "dodeca" && <dodecahedronGeometry args={[1, 0]} />}
{kind === "box" && <boxGeometry args={[1.4, 1.4, 1.4]} />}
{kind === "torus" && <torusGeometry args={[0.9, 0.32, 16, 32]} />}
<meshStandardMaterial color={col} metalness={0.5} roughness={0.25} flatShading emissive={col} emissiveIntensity={0.12} />
</mesh>
);
};
const Bokeh: React.FC<{ accent: string; secondary: string }> = ({ accent, secondary }) => {
const frame = useCurrentFrame();
return (
<group>
{Array.from({ length: 16 }).map((_, i) => {
const x = (rand(i) - 0.5) * 12;
const y = (rand(i + 11) - 0.5) * 7;
const z = -6 - rand(i + 4) * 5;
const tw = 0.3 + 0.5 * Math.abs(Math.sin((frame + i * 20) / 25));
const col = i % 3 === 0 ? secondary : accent;
return (
<mesh key={i} position={[x, y, z]} scale={0.25 + rand(i + 2) * 0.5}>
<sphereGeometry args={[1, 12, 12]} />
<meshBasicMaterial color={col} transparent opacity={tw * 0.5} />
</mesh>
);
})}
</group>
);
};
const Scene: React.FC<{ accent: string; secondary: string }> = ({ accent, secondary }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const pop = spring({ frame: frame - 6, fps, config: { damping: 12, stiffness: 80, mass: 1 } });
const heroScale = interpolate(pop, [0, 1], [0, 1.35]);
const heroSpin = frame * 0.02;
const heroColor = useMemo(() => mixHex(accent, secondary, 0.25), [accent, secondary]);
return (
<group rotation={[0, Math.sin(frame / 90) * 0.25, 0]}>
<ambientLight intensity={0.45} />
<directionalLight position={[4, 6, 6]} intensity={2.2} color="#ffffff" />
<pointLight position={[-5, -1, 4]} intensity={45} color={secondary} />
<pointLight position={[5, 2, 2]} intensity={35} color={accent} />
<Bokeh accent={accent} secondary={secondary} />
{Array.from({ length: 10 }).map((_, i) => (
<FloatingShape key={i} i={i} accent={accent} secondary={secondary} />
))}
{/* Hero faceted gem */}
<mesh rotation={[heroSpin * 0.6, heroSpin, heroSpin * 0.2]} scale={heroScale}>
<icosahedronGeometry args={[1, 0]} />
<meshStandardMaterial
color={heroColor}
metalness={0.55}
roughness={0.14}
flatShading
emissive={accent}
emissiveIntensity={0.18}
/>
</mesh>
{/* Inner glow core */}
<mesh scale={heroScale * 0.55}>
<sphereGeometry args={[1, 24, 24]} />
<meshBasicMaterial color={mixHex(accent, "#ffffff", 0.4)} transparent opacity={0.5} />
</mesh>
</group>
);
};
export const Hero3D: React.FC<Props> = ({
brandText,
tagline,
accentColor,
secondaryColor,
backgroundColor,
textColor,
}) => {
const frame = useCurrentFrame();
const { width, height } = useVideoConfig();
const L = useLayout();
const brandY = interpolate(frame, [70, 92], [L.vmin(60), 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.cubic) });
const brandOp = interpolate(frame, [70, 90], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const tagOp = interpolate(frame, [92, 114], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const tagSpacing = interpolate(frame, [92, 122], [L.vmin(14), L.vmin(6)], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
return (
<AbsoluteFill style={{ backgroundColor }}>
{/* gradient vignette behind the 3D */}
<AbsoluteFill style={{ background: `radial-gradient(circle at 50% 42%, ${hexToRgba(accentColor, 0.18)} 0%, ${hexToRgba(secondaryColor, 0.05)} 35%, ${backgroundColor} 70%)` }} />
<ThreeCanvas
width={width}
height={height}
camera={{ position: [0, 0, 7], fov: 55 }}
style={{ position: "absolute", inset: 0 }}
gl={{ toneMapping: THREE.ACESFilmicToneMapping, antialias: true }}
>
<Scene accent={accentColor} secondary={secondaryColor} />
</ThreeCanvas>
{/* 2D text overlay (crisp Persian via Vazirmatn) */}
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl", alignItems: "center", justifyContent: "flex-end", paddingBottom: L.vmin(170) }}>
<div style={{ transform: `translateY(${brandY}px)`, opacity: brandOp, fontWeight: 900, fontSize: L.vmin(92), color: textColor, textShadow: `0 0 ${L.vmin(24)}px ${hexToRgba(accentColor, 0.7)}` }}>
{brandText}
</div>
<div style={{ marginTop: L.vmin(18), opacity: tagOp, fontWeight: 500, fontSize: L.vmin(28), letterSpacing: tagSpacing, color: hexToRgba(textColor, 0.82) }}>
{tagline}
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};
@@ -0,0 +1,388 @@
import React from "react";
import {
AbsoluteFill,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
Easing,
} from "remotion";
import { zColor } from "@remotion/zod-types";
import { z } from "zod";
export const illuminatedCirclesSchema = z.object({
logoText: z.string(),
tagline: z.string(),
accentColor: zColor(),
secondaryColor: zColor(),
backgroundColor: zColor(),
});
type Props = z.infer<typeof illuminatedCirclesSchema>;
// ── Small helpers ────────────────────────────────────────────────────────────
/** Mix two hex colors by t (0..1). Cheap linear blend, good enough for glows. */
function mixHex(a: string, b: string, t: number): string {
const pa = a.replace("#", "");
const pb = b.replace("#", "");
const ai = parseInt(pa, 16);
const bi = parseInt(pb, 16);
const ar = (ai >> 16) & 255;
const ag = (ai >> 8) & 255;
const ab = ai & 255;
const br = (bi >> 16) & 255;
const bg = (bi >> 8) & 255;
const bb = bi & 255;
const r = Math.round(ar + (br - ar) * t);
const g = Math.round(ag + (bg - ag) * t);
const bl = Math.round(ab + (bb - ab) * t);
return `rgb(${r}, ${g}, ${bl})`;
}
function hexToRgba(hex: string, alpha: number): string {
const p = hex.replace("#", "");
const i = parseInt(p, 16);
const r = (i >> 16) & 255;
const g = (i >> 8) & 255;
const b = i & 255;
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
// ── Background: deep radial gradient + drifting nebula + vignette ─────────────
const Background: React.FC<{ bg: string; accent: string; secondary: string }> = ({
bg,
accent,
secondary,
}) => {
const frame = useCurrentFrame();
const drift = Math.sin(frame / 60) * 40;
return (
<AbsoluteFill style={{ backgroundColor: bg }}>
<AbsoluteFill
style={{
background: `radial-gradient(circle at 50% 46%, ${hexToRgba(
accent,
0.18
)} 0%, ${hexToRgba(secondary, 0.06)} 28%, ${bg} 62%)`,
}}
/>
<AbsoluteFill
style={{
background: `radial-gradient(circle at ${50 + drift / 20}% 70%, ${hexToRgba(
secondary,
0.1
)} 0%, transparent 45%)`,
}}
/>
{/* Vignette */}
<AbsoluteFill
style={{
boxShadow: "inset 0 0 600px 200px rgba(0,0,0,0.85)",
}}
/>
</AbsoluteFill>
);
};
// ── Concentric illuminated rings ─────────────────────────────────────────────
const RING_DEFS = [
{ r: 150, speed: 0.5, dash: "2 14", width: 2, op: 0.9 },
{ r: 230, speed: -0.32, dash: "1 22", width: 1.5, op: 0.7 },
{ r: 320, speed: 0.22, dash: "3 28", width: 2.5, op: 0.85 },
{ r: 420, speed: -0.16, dash: "1 40", width: 1.5, op: 0.55 },
{ r: 520, speed: 0.12, dash: "2 60", width: 1.5, op: 0.4 },
];
const Rings: React.FC<{ accent: string; secondary: string }> = ({
accent,
secondary,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const entrance = spring({
frame,
fps,
config: { damping: 14, mass: 0.9, stiffness: 90 },
});
const scale = interpolate(entrance, [0, 1], [0.55, 1]);
const groupOpacity = interpolate(frame, [0, 28], [0, 1], {
extrapolateRight: "clamp",
});
return (
<AbsoluteFill
style={{
justifyContent: "center",
alignItems: "center",
opacity: groupOpacity,
transform: `scale(${scale})`,
}}
>
<svg
width={1200}
height={1200}
viewBox="-600 -600 1200 1200"
style={{ overflow: "visible" }}
>
<defs>
<linearGradient id="ringGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor={accent} />
<stop offset="55%" stopColor={mixHex(accent, secondary, 0.5)} />
<stop offset="100%" stopColor={secondary} />
</linearGradient>
</defs>
{RING_DEFS.map((ring, i) => {
const rot = frame * ring.speed;
// Each ring reveals its dash over the first ~30 frames.
const circ = 2 * Math.PI * ring.r;
const draw = interpolate(frame, [4 + i * 4, 34 + i * 4], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
return (
<g key={i} transform={`rotate(${rot})`}>
<circle
cx={0}
cy={0}
r={ring.r}
fill="none"
stroke="url(#ringGrad)"
strokeWidth={ring.width}
strokeDasharray={`${circ * draw} ${circ}`}
strokeLinecap="round"
opacity={ring.op}
style={{
filter: `drop-shadow(0 0 6px ${hexToRgba(accent, 0.9)})`,
}}
/>
</g>
);
})}
</svg>
</AbsoluteFill>
);
};
// ── Orbiting illuminated particles ───────────────────────────────────────────
const PARTICLES = Array.from({ length: 28 }).map((_, i) => {
// Deterministic pseudo-random placement (no Math.random — keeps renders stable).
const a = (i * 137.508 * Math.PI) / 180; // golden angle
const ringRadius = 150 + ((i * 53) % 380);
const size = 2 + ((i * 17) % 5);
const speed = 0.15 + ((i % 5) * 0.06) * (i % 2 === 0 ? 1 : -1);
const phase = (i * 41) % 360;
return { a, ringRadius, size, speed, phase, idx: i };
});
const Particles: React.FC<{ accent: string; secondary: string }> = ({
accent,
secondary,
}) => {
const frame = useCurrentFrame();
const appear = interpolate(frame, [18, 50], [0, 1], {
extrapolateRight: "clamp",
});
return (
<AbsoluteFill
style={{ justifyContent: "center", alignItems: "center", opacity: appear }}
>
<svg width={1400} height={1400} viewBox="-700 -700 1400 1400">
{PARTICLES.map((p) => {
const ang = p.a + (frame * p.speed * Math.PI) / 180;
const x = Math.cos(ang) * p.ringRadius;
const y = Math.sin(ang) * p.ringRadius;
const twinkle =
0.4 + 0.6 * Math.abs(Math.sin((frame + p.phase) / 9));
const color = p.idx % 3 === 0 ? secondary : accent;
return (
<circle
key={p.idx}
cx={x}
cy={y}
r={p.size}
fill={color}
opacity={twinkle}
style={{ filter: `drop-shadow(0 0 ${p.size * 2.5}px ${color})` }}
/>
);
})}
</svg>
</AbsoluteFill>
);
};
// ── Central core glow that pulses behind the logo ────────────────────────────
const CoreGlow: React.FC<{ accent: string; secondary: string }> = ({
accent,
secondary,
}) => {
const frame = useCurrentFrame();
const grow = interpolate(frame, [30, 70], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
const breathe = 1 + 0.06 * Math.sin(frame / 14);
const size = 460 * grow * breathe;
return (
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
<div
style={{
width: size,
height: size,
borderRadius: "50%",
background: `radial-gradient(circle, ${hexToRgba(
accent,
0.55
)} 0%, ${hexToRgba(secondary, 0.25)} 35%, transparent 70%)`,
filter: "blur(8px)",
}}
/>
</AbsoluteFill>
);
};
// ── Sweeping light flare across the logo at reveal ───────────────────────────
const LightSweep: React.FC = () => {
const frame = useCurrentFrame();
const x = interpolate(frame, [62, 92], [-900, 900], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.inOut(Easing.cubic),
});
const op = interpolate(frame, [62, 70, 88, 96], [0, 0.85, 0.85, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
<div
style={{
position: "absolute",
width: 220,
height: 420,
transform: `translateX(${x}px) rotate(18deg)`,
background:
"linear-gradient(90deg, transparent, rgba(255,255,255,0.9), transparent)",
filter: "blur(26px)",
opacity: op,
mixBlendMode: "screen",
}}
/>
</AbsoluteFill>
);
};
// ── Logo + tagline reveal ────────────────────────────────────────────────────
const LogoReveal: React.FC<{ logoText: string; tagline: string; accent: string }> = ({
logoText,
tagline,
accent,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const logoSpring = spring({
frame: frame - 55,
fps,
config: { damping: 16, mass: 1, stiffness: 80 },
});
const logoScale = interpolate(logoSpring, [0, 1], [1.25, 1]);
const logoOpacity = interpolate(frame, [55, 78], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const blur = interpolate(frame, [55, 84], [26, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
const tagOpacity = interpolate(frame, [92, 116], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const tagSpacing = interpolate(frame, [92, 130], [22, 10], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
return (
<AbsoluteFill
style={{
justifyContent: "center",
alignItems: "center",
flexDirection: "column",
}}
>
<div
style={{
transform: `scale(${logoScale})`,
opacity: logoOpacity,
filter: `blur(${blur}px)`,
fontFamily:
"'Helvetica Neue', Helvetica, Arial, sans-serif",
fontWeight: 800,
fontSize: 116,
letterSpacing: 8,
color: "#ffffff",
textShadow: `0 0 18px ${hexToRgba(accent, 0.9)}, 0 0 48px ${hexToRgba(
accent,
0.6
)}`,
}}
>
{logoText}
</div>
<div
style={{
marginTop: 26,
opacity: tagOpacity,
fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif",
fontWeight: 400,
fontSize: 26,
letterSpacing: tagSpacing,
color: hexToRgba("#ffffff", 0.82),
textTransform: "uppercase",
}}
>
{tagline}
</div>
</AbsoluteFill>
);
};
// ── Composition root ─────────────────────────────────────────────────────────
export const IlluminatedCircles: React.FC<Props> = ({
logoText,
tagline,
accentColor,
secondaryColor,
backgroundColor,
}) => {
return (
<AbsoluteFill>
<Background
bg={backgroundColor}
accent={accentColor}
secondary={secondaryColor}
/>
<CoreGlow accent={accentColor} secondary={secondaryColor} />
<Rings accent={accentColor} secondary={secondaryColor} />
<Particles accent={accentColor} secondary={secondaryColor} />
<LogoReveal logoText={logoText} tagline={tagline} accent={accentColor} />
<LightSweep />
</AbsoluteFill>
);
};
@@ -0,0 +1,67 @@
import React from "react";
import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion";
import { z } from "zod";
import { colorSchema } from "../lib/branding";
import { FONT } from "../lib/fonts";
import { useLayout } from "../lib/aspect";
import { BrandBackground, useReveal } from "../lib/kit";
import { hexToRgba } from "../lib/anim";
export const instaPromoSchema = z.object({
handle: z.string(),
headline: z.string(),
subtext: z.string(),
cta: z.string(),
...colorSchema,
});
type Props = z.infer<typeof instaPromoSchema>;
export const InstaPromo: React.FC<Props> = ({
handle,
headline,
subtext,
cta,
accentColor,
secondaryColor,
backgroundColor,
textColor,
}) => {
const frame = useCurrentFrame();
const L = useLayout();
const card = useReveal(8, { from: 60, damping: 14 });
const head = useReveal(26, { from: 36 });
const ctaR = useReveal(52, { from: 24, damping: 12 });
const heart = interpolate(frame % 60, [0, 15, 30], [1, 1.25, 1]);
return (
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl" }}>
<BrandBackground bg={backgroundColor} accent={accentColor} secondary={secondaryColor} particles={18} />
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", flexDirection: "column" }}>
{/* Profile chip */}
<div style={{ opacity: card.opacity, transform: `scale(${card.scale})`, display: "flex", alignItems: "center", gap: L.vmin(16), padding: `${L.vmin(14)}px ${L.vmin(26)}px`, borderRadius: 999, background: hexToRgba(textColor, 0.06), border: `${L.vmin(1.5)}px solid ${hexToRgba(textColor, 0.15)}` }}>
<div style={{ width: L.vmin(56), height: L.vmin(56), borderRadius: "50%", background: `conic-gradient(from ${frame * 2}deg, ${accentColor}, ${secondaryColor}, ${accentColor})`, display: "flex", alignItems: "center", justifyContent: "center" }}>
<div style={{ width: L.vmin(44), height: L.vmin(44), borderRadius: "50%", background: backgroundColor, display: "flex", alignItems: "center", justifyContent: "center", fontSize: L.vmin(24) }}>📸</div>
</div>
<span style={{ fontWeight: 800, fontSize: L.vmin(30), color: textColor, direction: "ltr" }}>{handle}</span>
</div>
<div style={{ marginTop: L.vmin(48), opacity: head.opacity, transform: `translateY(${head.y}px)`, fontWeight: 900, fontSize: L.vmin(78), lineHeight: 1.1, color: textColor, textAlign: "center", maxWidth: L.vmin(820), textShadow: `0 ${L.vmin(6)}px ${L.vmin(36)}px ${hexToRgba(accentColor, 0.4)}` }}>
{headline}
</div>
<div style={{ marginTop: L.vmin(22), opacity: head.opacity, fontWeight: 500, fontSize: L.vmin(30), color: hexToRgba(textColor, 0.78), textAlign: "center", maxWidth: L.vmin(720) }}>
{subtext}
</div>
{/* Floating reactions */}
<div style={{ position: "absolute", top: `calc(50% - ${L.vmin(220)}px)`, right: `calc(50% - ${L.vmin(360)}px)`, fontSize: L.vmin(48), transform: `scale(${heart})` }}></div>
<div style={{ marginTop: L.vmin(54), opacity: ctaR.opacity, transform: `scale(${ctaR.scale})`, padding: `${L.vmin(20)}px ${L.vmin(56)}px`, borderRadius: 999, background: `linear-gradient(135deg, ${accentColor}, ${secondaryColor})`, boxShadow: `0 0 ${L.vmin(40)}px ${hexToRgba(accentColor, 0.6)}`, fontWeight: 800, fontSize: L.vmin(32), color: "#fff" }}>
{cta}
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};
@@ -0,0 +1,194 @@
import React from "react";
import {
AbsoluteFill,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
Easing,
} from "remotion";
import { zColor } from "@remotion/zod-types";
import { z } from "zod";
import { hexToRgba, mixHex } from "../lib/anim";
export const kineticQuoteSchema = z.object({
quote: z.string(),
author: z.string(),
accentColor: zColor(),
secondaryColor: zColor(),
backgroundColor: zColor(),
});
type Props = z.infer<typeof kineticQuoteSchema>;
// ── Slowly rotating gradient sheen behind the text ───────────────────────────
const SheenBackground: React.FC<{
bg: string;
accent: string;
secondary: string;
}> = ({ bg, accent, secondary }) => {
const frame = useCurrentFrame();
const angle = (frame * 0.4) % 360;
return (
<AbsoluteFill style={{ backgroundColor: bg }}>
<AbsoluteFill
style={{
background: `linear-gradient(${angle}deg, ${hexToRgba(
accent,
0.16
)}, transparent 55%, ${hexToRgba(secondary, 0.14)})`,
}}
/>
{/* Soft top glow */}
<AbsoluteFill
style={{
background: `radial-gradient(circle at 50% 18%, ${hexToRgba(
accent,
0.22
)} 0%, transparent 50%)`,
}}
/>
<AbsoluteFill
style={{ boxShadow: "inset 0 0 500px 160px rgba(0,0,0,0.7)" }}
/>
</AbsoluteFill>
);
};
// ── Word-by-word reveal of the quote ─────────────────────────────────────────
const Quote: React.FC<{ quote: string; accent: string; secondary: string }> = ({
quote,
accent,
secondary,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const words = quote.split(/\s+/).filter(Boolean);
return (
<div
style={{
display: "flex",
flexWrap: "wrap",
justifyContent: "center",
maxWidth: 880,
gap: "0 18px",
fontFamily: "'Georgia', 'Times New Roman', serif",
fontWeight: 600,
fontSize: 64,
lineHeight: 1.28,
color: "#fff",
textAlign: "center",
}}
>
{words.map((w, i) => {
const start = 12 + i * 4;
const s = spring({
frame: frame - start,
fps,
config: { damping: 18, mass: 0.7, stiffness: 110 },
});
const y = interpolate(s, [0, 1], [28, 0]);
const op = interpolate(s, [0, 1], [0, 1]);
return (
<span
key={i}
style={{
display: "inline-block",
transform: `translateY(${y}px)`,
opacity: op,
color: i % 5 === 2 ? mixHex(accent, secondary, 0.4) : "#fff",
}}
>
{w}
</span>
);
})}
</div>
);
};
export const KineticQuote: React.FC<Props> = ({
quote,
author,
accentColor,
secondaryColor,
backgroundColor,
}) => {
const frame = useCurrentFrame();
const words = quote.split(/\s+/).filter(Boolean);
// The decorative rule + author appear once the quote has finished landing.
const tail = 12 + words.length * 4 + 8;
const ruleW = interpolate(frame, [tail, tail + 18], [0, 120], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
const authorOp = interpolate(frame, [tail + 10, tail + 30], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<AbsoluteFill>
<SheenBackground
bg={backgroundColor}
accent={accentColor}
secondary={secondaryColor}
/>
<AbsoluteFill
style={{
justifyContent: "center",
alignItems: "center",
flexDirection: "column",
padding: 80,
}}
>
{/* Opening quotation mark */}
<div
style={{
fontFamily: "'Georgia', serif",
fontSize: 160,
lineHeight: 0.4,
marginBottom: 36,
color: hexToRgba(accentColor, 0.85),
opacity: interpolate(frame, [0, 14], [0, 1], {
extrapolateRight: "clamp",
}),
}}
>
&ldquo;
</div>
<Quote quote={quote} accent={accentColor} secondary={secondaryColor} />
<div
style={{
width: ruleW,
height: 3,
marginTop: 48,
borderRadius: 2,
background: `linear-gradient(90deg, ${accentColor}, ${secondaryColor})`,
}}
/>
<div
style={{
marginTop: 22,
opacity: authorOp,
fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif",
fontWeight: 500,
fontSize: 28,
letterSpacing: 4,
textTransform: "uppercase",
color: hexToRgba("#ffffff", 0.78),
}}
>
{author}
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};
@@ -0,0 +1,154 @@
import React from "react";
import {
AbsoluteFill,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
Easing,
} from "remotion";
import { z } from "zod";
import { colorSchema } from "../lib/branding";
import { FONT } from "../lib/fonts";
import { useLayout } from "../lib/aspect";
import { hexToRgba, mixHex, rand } from "../lib/anim";
export const logoMotionSchema = z.object({
brandText: z.string(),
tagline: z.string(),
...colorSchema,
});
type Props = z.infer<typeof logoMotionSchema>;
export const LogoMotion: React.FC<Props> = ({
brandText,
tagline,
accentColor,
secondaryColor,
backgroundColor,
textColor,
}) => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
const L = useLayout();
// Background: radial brand glow + drifting nebula.
const drift = Math.sin(frame / 50) * 30;
// Logo entrance.
const logoSpring = spring({ frame, fps, config: { damping: 14, stiffness: 90, mass: 0.9 } });
const ringScale = interpolate(logoSpring, [0, 1], [0.4, 1]);
const ringOpacity = interpolate(frame, [0, 22], [0, 1], { extrapolateRight: "clamp" });
const wordSpring = spring({ frame: frame - 22, fps, config: { damping: 16, stiffness: 80 } });
const wordScale = interpolate(wordSpring, [0, 1], [1.18, 1]);
const wordOpacity = interpolate(frame, [22, 42], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const wordBlur = interpolate(frame, [22, 46], [16, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.cubic) });
const tagOpacity = interpolate(frame, [50, 72], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const tagSpacing = interpolate(frame, [50, 80], [L.vmin(14), L.vmin(6)], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.cubic) });
// Light sweep across the wordmark at reveal.
const sweepX = interpolate(frame, [44, 74], [-L.vmin(700), L.vmin(700)], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.inOut(Easing.cubic) });
const sweepOp = interpolate(frame, [44, 52, 70, 78], [0, 0.8, 0.8, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const ringR = L.vmin(190);
return (
<AbsoluteFill style={{ backgroundColor, fontFamily: FONT, direction: "rtl" }}>
{/* Brand glow */}
<AbsoluteFill
style={{
background: `radial-gradient(circle at 50% 46%, ${hexToRgba(accentColor, 0.22)} 0%, ${hexToRgba(secondaryColor, 0.08)} 30%, ${backgroundColor} 64%)`,
}}
/>
<AbsoluteFill
style={{
background: `radial-gradient(circle at ${50 + drift / 18}% 72%, ${hexToRgba(secondaryColor, 0.12)} 0%, transparent 45%)`,
}}
/>
{/* Orbiting sparks */}
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
<svg width={width} height={height} style={{ overflow: "visible" }}>
{Array.from({ length: 26 }).map((_, i) => {
const ang = (i * 137.5 * Math.PI) / 180 + (frame * (0.1 + (i % 4) * 0.04) * Math.PI) / 180;
const rr = L.vmin(150) + ((i * 47) % L.vmin(360));
const cx = width / 2 + Math.cos(ang) * rr;
const cy = height / 2 + Math.sin(ang) * rr;
const tw = 0.3 + 0.6 * Math.abs(Math.sin((frame + i * 18) / 10));
const appear = interpolate(frame, [16, 44], [0, 1], { extrapolateRight: "clamp" });
const c = i % 3 === 0 ? secondaryColor : accentColor;
const s = L.vmin(2 + (i % 4));
return <circle key={i} cx={cx} cy={cy} r={s} fill={c} opacity={tw * appear} style={{ filter: `drop-shadow(0 0 ${s * 2.5}px ${c})` }} />;
})}
</svg>
</AbsoluteFill>
{/* Concentric brand ring */}
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", opacity: ringOpacity, transform: `scale(${ringScale})` }}>
<svg width={ringR * 3} height={ringR * 3} viewBox={`${-ringR * 1.5} ${-ringR * 1.5} ${ringR * 3} ${ringR * 3}`} style={{ overflow: "visible" }}>
<defs>
<linearGradient id="lm-grad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor={accentColor} />
<stop offset="100%" stopColor={secondaryColor} />
</linearGradient>
</defs>
{[ringR, ringR * 0.74].map((r, i) => {
const circ = 2 * Math.PI * r;
const draw = interpolate(frame, [4 + i * 5, 30 + i * 5], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.cubic) });
return (
<circle key={i} cx={0} cy={0} r={r} fill="none" stroke="url(#lm-grad)" strokeWidth={L.vmin(2.5 - i)} strokeDasharray={`${circ * draw} ${circ}`} strokeLinecap="round" transform={`rotate(${frame * (i ? -0.4 : 0.3)})`} style={{ filter: `drop-shadow(0 0 ${L.vmin(6)}px ${hexToRgba(accentColor, 0.8)})` }} />
);
})}
</svg>
</AbsoluteFill>
{/* Wordmark + tagline */}
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", flexDirection: "column" }}>
<div
style={{
transform: `scale(${wordScale})`,
opacity: wordOpacity,
filter: `blur(${wordBlur}px)`,
fontWeight: 900,
fontSize: L.vmin(108),
color: textColor,
textShadow: `0 0 ${L.vmin(16)}px ${hexToRgba(accentColor, 0.9)}, 0 0 ${L.vmin(42)}px ${hexToRgba(accentColor, 0.55)}`,
lineHeight: 1.1,
}}
>
{brandText}
</div>
<div
style={{
marginTop: L.vmin(22),
opacity: tagOpacity,
fontWeight: 500,
fontSize: L.vmin(28),
letterSpacing: tagSpacing,
color: hexToRgba(textColor, 0.82),
}}
>
{tagline}
</div>
</AbsoluteFill>
{/* Light sweep */}
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", overflow: "hidden" }}>
<div
style={{
position: "absolute",
width: L.vmin(180),
height: L.vmin(420),
transform: `translateX(${sweepX}px) rotate(18deg)`,
background: `linear-gradient(90deg, transparent, ${mixHex(textColor, accentColor, 0.2)}, transparent)`,
filter: `blur(${L.vmin(24)}px)`,
opacity: sweepOp,
mixBlendMode: "screen",
}}
/>
</AbsoluteFill>
</AbsoluteFill>
);
};

Some files were not shown because too many files have changed in this diff Show More