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.
# dependencies
# dependencies (root + any nested, e.g. services/remotion/node_modules)
/node_modules
node_modules/
/.pnp
.pnp.js
.yarn/install-state.gz
@@ -55,3 +56,10 @@ node-agent.exe
# node-agent local build + secrets
services/node-agent/dist/
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."
},
"pricing": {
"heading": "Choose your FlatRender plan",
"heading": "Pay by the second, not by the video",
"monthly": "Monthly",
"annual": "Annual",
"saveBadge": "Save up to {percent}%",
"subscribe": "Subscribe",
"freeBannerTitle": "Free plan",
"freeBannerDesc": "Free forever, no credit card required",
"perMonth": "/ mo",
"perMonth": "monthly",
"billedAnnually": "billed annually",
"compareTitle": "Compare all plans",
"allFeatures": "All features",
@@ -161,7 +161,45 @@
"proName": "Pro",
"proDesc": "Become a pro and unlock more powerful video, design and website editing tools for commercial use.",
"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": {
"brandName": "FlatRender",
+41 -3
View File
@@ -144,14 +144,14 @@
"a7": "بله. هر زمان از تنظیمات حساب لغو کنید. دسترسی تا پایان دوره صورت‌حساب باقی می‌ماند و می‌توانید به پلن رایگان برگردید بدون اینکه پروژه‌هایتان از دست بروند."
},
"pricing": {
"heading": لن فلت‌رندر خود را انتخاب کنید",
"heading": رداخت بر اساس ثانیه، نه تعداد ویدیو",
"monthly": "ماهانه",
"annual": "سالانه",
"saveBadge": "تا {percent}٪ صرفه‌جویی",
"subscribe": "اشتراک",
"freeBannerTitle": "پلن رایگان",
"freeBannerDesc": "برای همیشه رایگان، بدون نیاز به کارت اعتباری",
"perMonth": "/ ماه",
"perMonth": "ماهانه",
"billedAnnually": "پرداخت سالانه",
"compareTitle": "مقایسه همه پلن‌ها",
"allFeatures": "همه امکانات",
@@ -161,7 +161,45 @@
"proName": "Pro",
"proDesc": "حرفه‌ای شوید و ابزارهای قدرتمندتر ویدیو، طراحی و وب‌سایت را برای استفاده تجاری باز کنید.",
"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": {
"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,
MaxDurationSec = req.MaxDurationSec, FreeFps = req.FreeFps, ChooseMode = chooseMode,
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
};
@@ -225,6 +227,7 @@ public class TemplateService(ContentDbContext db)
ProjectDurationSec = src.ProjectDurationSec, MinDurationSec = src.MinDurationSec,
MaxDurationSec = src.MaxDurationSec, FreeFps = src.FreeFps, ChooseMode = src.ChooseMode,
Resolution = resolution, VipFactor = src.VipFactor, RenderAepComp = src.RenderAepComp,
RenderEngine = src.RenderEngine, RenderRemotionComp = src.RenderRemotionComp,
SharedLayerImage = src.SharedLayerImage, SharedColorsSvg = src.SharedColorsSvg,
SharedColorPresetsSvg = src.SharedColorPresetsSvg,
IsPublished = false, Sort = src.Sort,
@@ -359,6 +362,8 @@ public class TemplateService(ContentDbContext db)
project.MinDurationSec = req.MinDurationSec; project.MaxDurationSec = req.MaxDurationSec;
project.FreeFps = req.FreeFps; project.ChooseMode = chooseMode; project.Resolution = resolution;
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.SharedColorPresetsSvg = req.SharedColorPresetsSvg;
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.FullDemo != null) project.FullDemo = req.FullDemo;
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;
await db.SaveChangesAsync();
@@ -472,7 +479,8 @@ public class TemplateService(ContentDbContext db)
p.ProjectDurationSec, p.MinDurationSec, p.MaxDurationSec,
p.FreeFps, p.ChooseMode.ToString(), p.Resolution.ToString(),
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>
@@ -489,7 +497,8 @@ public class TemplateService(ContentDbContext db)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(p => new ProjectListItemResponse(
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();
return new PagedResponse<ProjectListItemResponse>(items,
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.AepFileSizeBytes.HasValue) project.AepFileSizeBytes = req.AepFileSizeBytes;
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;
project.AepUploadedAt = DateTime.UtcNow;
project.UpdatedAt = DateTime.UtcNow;
@@ -541,6 +552,7 @@ public class TemplateService(ContentDbContext db)
p.OriginalWidth, p.OriginalHeight, p.Aspect,
p.ProjectDurationSec, p.MinDurationSec, p.MaxDurationSec,
p.FreeFps, p.ChooseMode.ToString(), p.Resolution.ToString(), p.VipFactor, p.RenderAepComp,
p.RenderEngine, p.RenderRemotionComp,
p.SharedLayerImage, p.IsPublished, p.Sort,
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(),
@@ -95,6 +95,11 @@ public class Project
public decimal VipFactor { get; set; } = 1.0m;
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? SharedColorsSvg { 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.VipFactor).HasColumnName("vip_factor");
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.SharedColorsSvg).HasColumnName("shared_colors_svg");
e.Property(x => x.SharedColorPresetsSvg).HasColumnName("shared_color_presets_svg");
@@ -213,7 +213,9 @@ public record CreateProjectRequest(
decimal VipFactor,
string RenderAepComp,
bool IsPublished,
int Sort
int Sort,
string? RenderEngine = null,
string? RenderRemotionComp = null
);
public record UpdateProjectRequest(
@@ -239,7 +241,9 @@ public record UpdateProjectRequest(
string? SharedColorsSvg,
string? SharedColorPresetsSvg,
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
@@ -260,7 +264,9 @@ public record SetAepRequest(
string? AepFileMd5,
long? AepFileSizeBytes,
string? RenderAepComp,
string? Folder
string? Folder,
string? RenderEngine = null,
string? RenderRemotionComp = null
);
public record PatchProjectRequest(
@@ -279,7 +285,9 @@ public record PatchProjectRequest(
int? Sort,
string? Image,
string? FullDemo,
string? SharedColorsSvg
string? SharedColorsSvg,
string? RenderEngine = null,
string? RenderRemotionComp = null
);
// ── CMS ──────────────────────────────────────────────────────────────────────
@@ -131,7 +131,9 @@ public record ProjectResponse(
int Sort,
string? AepFileUrl,
long? AepFileSizeBytes,
string RenderAepComp
string RenderAepComp,
string RenderEngine,
string? RenderRemotionComp
);
public record ProjectListItemResponse(
@@ -146,7 +148,9 @@ public record ProjectListItemResponse(
string? AepFileUrl,
string RenderAepComp,
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);
@@ -171,6 +175,8 @@ public record ProjectDetailResponse(
string Resolution,
decimal VipFactor,
string RenderAepComp,
string RenderEngine,
string? RenderRemotionComp,
string? SharedLayerImage,
bool IsPublished,
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})
}
// 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{
JobID: job.JobID,
SavedProjectID: job.SavedProjectID,
@@ -523,6 +530,8 @@ func (a *Agent) runJob(ctx context.Context, job *client.ClaimedJob) {
FrameRate: job.FrameRate,
HasMusic: job.HasMusic,
HasVoiceover: job.HasVoiceover,
Engine: engine,
RemotionDir: a.cfg.RemotionProjectDir,
AEPFilePath: aepPath,
CompName: job.CompName,
AfterFxPath: a.cfg.AfterFxPath,
@@ -152,6 +152,9 @@ type ClaimedJob struct {
FrameRate int `json:"frame_rate"`
HasMusic bool `json:"has_music"`
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
// (or .zip bundle). Empty when the template has not been uploaded yet — triggers mock render.
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 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 int
@@ -115,6 +119,7 @@ func Load() (*Config, error) {
AEPath: getEnv("AE_PATH", ""),
AfterFxPath: getEnv("AFTERFX_PATH", ""),
WorkDir: getEnv("WORK_DIR", os.TempDir()),
RemotionProjectDir: getEnv("REMOTION_PROJECT_DIR", ""),
AgentVersion: getEnv("AGENT_VERSION", "0.1.0"),
AEVersion: getEnv("AE_VERSION", "2024"),
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
HasMusic 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.
// In a full implementation the agent downloads this from MinIO before calling Run.
AEPFilePath string
@@ -75,6 +80,12 @@ func Run(ctx context.Context, aePath, workDir string, job *Job, onProgress Progr
}
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
// template project to render (AEPFilePath empty — the template bundle wasn't
// 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