feat(render+templates): Remotion engine, 16 branded templates (incl. 3D), seconds pricing, coming-soon
Render engine - Add Remotion (code-based) as a 2nd render engine alongside After Effects. node-agent dispatches on Job.Engine; RunRemotion maps bindings -> --props, renders native then ffmpeg-scales to the quality tier (aspect-preserving). - content.projects.render_engine + render_remotion_comp (migration 32); render-svc claim resolves engine and routes (skips .aep for Remotion). - Admin TemplatesAdmin gains an engine picker + Remotion composition id field. Template pack (services/remotion) - 16 branded, Persian (Vazirmatn), color- and text-editable templates, each in 3 aspects (16:9 / 1:1 / 9:16): LogoMotion, Opener, InstaPromo, YouTubeIntro, Slideshow, HappyBirthday, SalePromo, QuoteCard, EventInvite, Countdown, GlitterReveal (editable logo image), NowruzGreeting (animated characters), and 4 cinematic 3D templates via @remotion/three (Hero3D, Nowruz3D, Birthday3D, Promo3D) with reflections + bloom/DOF/vignette. - scripts/seed_remotion_templates.py seeds containers/projects/scenes/colors. Pricing - Rewrite /pricing to the seconds-based model (charge = length x resolution), data-driven from /v1/plans, Toman, broker checkout. Coming-soon - Persian experimental-build overlay on all pages (launch date + countdown). Fixes - middleware matcher bypasses all static asset paths; catalog mapping passes cover image + preview video so real thumbnails render. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@@ -1,7 +1,8 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
# dependencies
|
# dependencies (root + any nested, e.g. services/remotion/node_modules)
|
||||||
/node_modules
|
/node_modules
|
||||||
|
node_modules/
|
||||||
/.pnp
|
/.pnp
|
||||||
.pnp.js
|
.pnp.js
|
||||||
.yarn/install-state.gz
|
.yarn/install-state.gz
|
||||||
@@ -55,3 +56,10 @@ node-agent.exe
|
|||||||
# node-agent local build + secrets
|
# node-agent local build + secrets
|
||||||
services/node-agent/dist/
|
services/node-agent/dist/
|
||||||
agent.env
|
agent.env
|
||||||
|
|
||||||
|
# remotion render outputs (regenerated; thumbnails/previews live in public/template-media)
|
||||||
|
services/remotion/out/
|
||||||
|
|
||||||
|
# local scratch / agent work
|
||||||
|
/-w
|
||||||
|
/.agent-work/
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- 32_content_render_engine.sql
|
||||||
|
-- Two render engines per template: After Effects (.aep, rendered by a node-agent)
|
||||||
|
-- and Remotion (code-based React composition). render_engine selects which; for
|
||||||
|
-- Remotion templates render_remotion_comp holds the composition id to render
|
||||||
|
-- (the .aep / render_aep_comp columns stay null for those).
|
||||||
|
--
|
||||||
|
-- Apply manually on the live DB (migrations are not auto-run):
|
||||||
|
-- docker exec -i <postgres> psql -U postgres -d flatrender < 32_content_render_engine.sql
|
||||||
|
|
||||||
|
ALTER TABLE content.projects
|
||||||
|
ADD COLUMN IF NOT EXISTS render_engine TEXT NOT NULL DEFAULT 'AfterEffects';
|
||||||
|
|
||||||
|
ALTER TABLE content.projects
|
||||||
|
ADD COLUMN IF NOT EXISTS render_remotion_comp TEXT;
|
||||||
|
|
||||||
|
-- Existing templates are all After Effects; the default already covers them.
|
||||||
@@ -144,14 +144,14 @@
|
|||||||
"a7": "Yes. Cancel from your account settings at any time. You keep access through the end of your billing period, and you can downgrade to Free without losing your projects."
|
"a7": "Yes. Cancel from your account settings at any time. You keep access through the end of your billing period, and you can downgrade to Free without losing your projects."
|
||||||
},
|
},
|
||||||
"pricing": {
|
"pricing": {
|
||||||
"heading": "Choose your FlatRender plan",
|
"heading": "Pay by the second, not by the video",
|
||||||
"monthly": "Monthly",
|
"monthly": "Monthly",
|
||||||
"annual": "Annual",
|
"annual": "Annual",
|
||||||
"saveBadge": "Save up to {percent}%",
|
"saveBadge": "Save up to {percent}%",
|
||||||
"subscribe": "Subscribe",
|
"subscribe": "Subscribe",
|
||||||
"freeBannerTitle": "Free plan",
|
"freeBannerTitle": "Free plan",
|
||||||
"freeBannerDesc": "Free forever, no credit card required",
|
"freeBannerDesc": "Free forever, no credit card required",
|
||||||
"perMonth": "/ mo",
|
"perMonth": "monthly",
|
||||||
"billedAnnually": "billed annually",
|
"billedAnnually": "billed annually",
|
||||||
"compareTitle": "Compare all plans",
|
"compareTitle": "Compare all plans",
|
||||||
"allFeatures": "All features",
|
"allFeatures": "All features",
|
||||||
@@ -161,7 +161,45 @@
|
|||||||
"proName": "Pro",
|
"proName": "Pro",
|
||||||
"proDesc": "Become a pro and unlock more powerful video, design and website editing tools for commercial use.",
|
"proDesc": "Become a pro and unlock more powerful video, design and website editing tools for commercial use.",
|
||||||
"businessName": "Business",
|
"businessName": "Business",
|
||||||
"businessDesc": "Advanced level solution for teams and businesses. Includes reseller license."
|
"businessDesc": "Advanced level solution for teams and businesses. Includes reseller license.",
|
||||||
|
"subheading": "Each render costs render-seconds equal to the video length × a quality multiplier. Every plan gives you a monthly bucket of render-seconds.",
|
||||||
|
"toman": "Toman",
|
||||||
|
"free": "Free",
|
||||||
|
"mostPopular": "Most popular",
|
||||||
|
"currentPlan": "Current plan",
|
||||||
|
"choosePlan": "Choose plan",
|
||||||
|
"startFree": "Start free",
|
||||||
|
"processing": "Redirecting…",
|
||||||
|
"signInToBuy": "Sign in to buy",
|
||||||
|
"emptyState": "No plans are available right now.",
|
||||||
|
"perMonthSuffix": "/ mo",
|
||||||
|
"featSeconds": "{seconds} render-seconds / month",
|
||||||
|
"featResolution": "Up to {res} quality",
|
||||||
|
"featParallelOne": "1 render at a time",
|
||||||
|
"featParallel": "{n} parallel renders",
|
||||||
|
"featStorage": "{gb} GB cloud storage",
|
||||||
|
"featSpeed": "{factor}× render speed",
|
||||||
|
"featWatermarkOn": "FlatRender watermark",
|
||||||
|
"featWatermarkOff": "No watermark",
|
||||||
|
"calcTitle": "How many seconds do I need?",
|
||||||
|
"calcDesc": "Pick a video length and quality to see the per-render cost in seconds.",
|
||||||
|
"calcLength": "Video length",
|
||||||
|
"calcResolution": "Output quality",
|
||||||
|
"calcCost": "Cost per render",
|
||||||
|
"calcSecondsUnit": "seconds",
|
||||||
|
"calcRendersWith": "With each plan:",
|
||||||
|
"calcVideosFmt": "≈ {count} videos",
|
||||||
|
"multiplierTitle": "Quality multiplier",
|
||||||
|
"multiplierDesc": "Render-seconds per render = video length × the multiplier below.",
|
||||||
|
"multiplierColRes": "Quality",
|
||||||
|
"multiplierColMul": "Multiplier",
|
||||||
|
"faqTitle": "Frequently asked",
|
||||||
|
"faqQ1": "What is a render-second?",
|
||||||
|
"faqA1": "Instead of a video-count limit, you buy render-seconds. A 15-second video at 720p uses exactly 15 seconds of your balance.",
|
||||||
|
"faqQ2": "Why does higher quality cost more seconds?",
|
||||||
|
"faqA2": "4K rendering is much heavier, so each second of video counts as 4 render-seconds; 1080p counts as 2×.",
|
||||||
|
"faqQ3": "What if I run out of seconds?",
|
||||||
|
"faqA3": "Upgrade your plan or wait for the next period. Your max resolution and parallel renders also follow your plan."
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"brandName": "FlatRender",
|
"brandName": "FlatRender",
|
||||||
|
|||||||
@@ -144,14 +144,14 @@
|
|||||||
"a7": "بله. هر زمان از تنظیمات حساب لغو کنید. دسترسی تا پایان دوره صورتحساب باقی میماند و میتوانید به پلن رایگان برگردید بدون اینکه پروژههایتان از دست بروند."
|
"a7": "بله. هر زمان از تنظیمات حساب لغو کنید. دسترسی تا پایان دوره صورتحساب باقی میماند و میتوانید به پلن رایگان برگردید بدون اینکه پروژههایتان از دست بروند."
|
||||||
},
|
},
|
||||||
"pricing": {
|
"pricing": {
|
||||||
"heading": "پلن فلترندر خود را انتخاب کنید",
|
"heading": "پرداخت بر اساس ثانیه، نه تعداد ویدیو",
|
||||||
"monthly": "ماهانه",
|
"monthly": "ماهانه",
|
||||||
"annual": "سالانه",
|
"annual": "سالانه",
|
||||||
"saveBadge": "تا {percent}٪ صرفهجویی",
|
"saveBadge": "تا {percent}٪ صرفهجویی",
|
||||||
"subscribe": "اشتراک",
|
"subscribe": "اشتراک",
|
||||||
"freeBannerTitle": "پلن رایگان",
|
"freeBannerTitle": "پلن رایگان",
|
||||||
"freeBannerDesc": "برای همیشه رایگان، بدون نیاز به کارت اعتباری",
|
"freeBannerDesc": "برای همیشه رایگان، بدون نیاز به کارت اعتباری",
|
||||||
"perMonth": "/ ماه",
|
"perMonth": "ماهانه",
|
||||||
"billedAnnually": "پرداخت سالانه",
|
"billedAnnually": "پرداخت سالانه",
|
||||||
"compareTitle": "مقایسه همه پلنها",
|
"compareTitle": "مقایسه همه پلنها",
|
||||||
"allFeatures": "همه امکانات",
|
"allFeatures": "همه امکانات",
|
||||||
@@ -161,7 +161,45 @@
|
|||||||
"proName": "Pro",
|
"proName": "Pro",
|
||||||
"proDesc": "حرفهای شوید و ابزارهای قدرتمندتر ویدیو، طراحی و وبسایت را برای استفاده تجاری باز کنید.",
|
"proDesc": "حرفهای شوید و ابزارهای قدرتمندتر ویدیو، طراحی و وبسایت را برای استفاده تجاری باز کنید.",
|
||||||
"businessName": "Business",
|
"businessName": "Business",
|
||||||
"businessDesc": "راهحل پیشرفته برای تیمها و کسبوکارها. شامل مجوز فروش مجدد."
|
"businessDesc": "راهحل پیشرفته برای تیمها و کسبوکارها. شامل مجوز فروش مجدد.",
|
||||||
|
"subheading": "هزینهٔ هر رندر برابر است با طول ویدیو ضربدر ضریب کیفیت. هر پلن ماهانه مقداری «ثانیهٔ رندر» در اختیار شما میگذارد.",
|
||||||
|
"toman": "تومان",
|
||||||
|
"free": "رایگان",
|
||||||
|
"mostPopular": "محبوبترین",
|
||||||
|
"currentPlan": "پلن فعلی",
|
||||||
|
"choosePlan": "انتخاب پلن",
|
||||||
|
"startFree": "شروع رایگان",
|
||||||
|
"processing": "در حال انتقال…",
|
||||||
|
"signInToBuy": "برای خرید وارد شوید",
|
||||||
|
"emptyState": "در حال حاضر پلنی برای نمایش وجود ندارد.",
|
||||||
|
"perMonthSuffix": "/ ماه",
|
||||||
|
"featSeconds": "{seconds} ثانیهٔ رندر در ماه",
|
||||||
|
"featResolution": "کیفیت تا {res}",
|
||||||
|
"featParallelOne": "۱ رندر همزمان",
|
||||||
|
"featParallel": "{n} رندر همزمان",
|
||||||
|
"featStorage": "{gb} گیگابایت فضای ابری",
|
||||||
|
"featSpeed": "سرعت رندر ×{factor}",
|
||||||
|
"featWatermarkOn": "دارای واترمارک FlatRender",
|
||||||
|
"featWatermarkOff": "بدون واترمارک",
|
||||||
|
"calcTitle": "چند ثانیه لازم دارم؟",
|
||||||
|
"calcDesc": "طول و کیفیت ویدیو را انتخاب کنید تا هزینهٔ ثانیهای هر رندر را ببینید.",
|
||||||
|
"calcLength": "طول ویدیو",
|
||||||
|
"calcResolution": "کیفیت خروجی",
|
||||||
|
"calcCost": "هزینهٔ هر رندر",
|
||||||
|
"calcSecondsUnit": "ثانیه",
|
||||||
|
"calcRendersWith": "با هر پلن:",
|
||||||
|
"calcVideosFmt": "≈ {count} ویدیو",
|
||||||
|
"multiplierTitle": "ضریب کیفیت",
|
||||||
|
"multiplierDesc": "ثانیهٔ مصرفی هر رندر = طول ویدیو × ضریب کیفیت زیر.",
|
||||||
|
"multiplierColRes": "کیفیت",
|
||||||
|
"multiplierColMul": "ضریب",
|
||||||
|
"faqTitle": "پرسشهای پرتکرار",
|
||||||
|
"faqQ1": "ثانیهٔ رندر یعنی چه؟",
|
||||||
|
"faqA1": "بهجای محدودیت تعداد ویدیو، شما مقداری ثانیهٔ رندر میخرید. یک ویدیوی ۱۵ ثانیهای با کیفیت ۷۲۰p دقیقاً ۱۵ ثانیه از سهم شما کم میکند.",
|
||||||
|
"faqQ2": "چرا کیفیت بالاتر ثانیهٔ بیشتری میبرد؟",
|
||||||
|
"faqA2": "رندر ۴K پردازش سنگینتری دارد، بنابراین هر ثانیه ویدیو معادل ۴ ثانیهٔ رندر حساب میشود؛ ۱۰۸۰p معادل ۲ برابر.",
|
||||||
|
"faqQ3": "اگر ثانیههایم تمام شود چه میشود؟",
|
||||||
|
"faqA3": "میتوانید پلن خود را ارتقا دهید یا تا شروع دورهٔ بعد صبر کنید. سقف کیفیت و رندر همزمان نیز بر اساس پلن شماست."
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"brandName": "فلترندر",
|
"brandName": "فلترندر",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 816 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 781 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 779 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1023 KiB |
|
After Width: | Height: | Size: 663 KiB |
|
After Width: | Height: | Size: 1001 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 796 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1000 KiB |
|
After Width: | Height: | Size: 623 KiB |
|
After Width: | Height: | Size: 854 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 781 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 814 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 803 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 272 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 738 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 974 KiB |
|
After Width: | Height: | Size: 708 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 739 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 772 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 738 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 762 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
@@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Generates SQL to seed the 10 branded Remotion templates into content.* so they
|
||||||
|
# appear on the site. Each template -> 1 container + 3 aspect projects, each with
|
||||||
|
# a scene, Persian text content-elements (bindable -> Remotion props) and shared
|
||||||
|
# colours (bindable -> colour props) + a per-scene colour-swatch SVG.
|
||||||
|
#
|
||||||
|
# Usage: python scripts/seed_remotion_templates.py | docker exec -i fr2-postgres psql -U postgres -d flatrender
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
# Assets are served from the Next app's public/ folder (relative URLs), so this
|
||||||
|
# works regardless of MinIO/object-store availability.
|
||||||
|
MINIO = ""
|
||||||
|
NS = uuid.UUID("11111111-2222-3333-4444-555555555555")
|
||||||
|
def uid(s): return str(uuid.uuid5(NS, s))
|
||||||
|
def q(s): return "'" + str(s).replace("'", "''") + "'"
|
||||||
|
|
||||||
|
ASPECTS = [("16x9", 1920, 1080, "16:9"), ("1x1", 1080, 1080, "1:1"), ("9x16", 1080, 1920, "9:16")]
|
||||||
|
CTITLES = {"accentColor": "رنگ اصلی", "secondaryColor": "رنگ دوم", "backgroundColor": "پسزمینه", "textColor": "رنگ متن"}
|
||||||
|
|
||||||
|
# id, slug, name(fa), desc(fa), dur, [(textKey,title,value)], (accent,secondary,bg)
|
||||||
|
T = [
|
||||||
|
("LogoMotion","fr-logo-motion","موشن لوگو","نمایش حرفهای لوگو و نام برند با درخشش و حرکت",5,
|
||||||
|
[("brandText","متن لوگو","فلترندر"),("tagline","شعار","موشن، ساده و حرفهای")],("#3ba7ff","#a855f7","#04060f")),
|
||||||
|
("Opener","fr-opener","تیتراژ آغازین","شروع سینمایی برای ویدیو با عنوان و زیرعنوان",5,
|
||||||
|
[("kicker","پیشمتن","تقدیم میکند"),("title","عنوان","یک شروع تازه"),("subtitle","زیرعنوان","داستان شما از همینجا آغاز میشود")],("#22d3ee","#6366f1","#0a0a12")),
|
||||||
|
("InstaPromo","fr-insta-promo","تبلیغ پیج اینستاگرام","معرفی و تبلیغ صفحهٔ اینستاگرام با دعوت به فالو",5,
|
||||||
|
[("handle","آیدی پیج","@flatrender"),("headline","عنوان","پیج ما را دنبال کنید"),("subtext","توضیح","هر روز محتوای تازه و الهامبخش"),("cta","دکمه","فالو کنید")],("#fb7185","#f59e0b","#140a12")),
|
||||||
|
("YouTubeIntro","fr-youtube-intro","اینترو کانال یوتیوب","اینترو حرفهای کانال یوتیوب با دکمهٔ سابسکرایب",5,
|
||||||
|
[("channelName","نام کانال","کانال فلترندر"),("subtitle","زیرعنوان","آموزش، ترفند و انگیزه"),("cta","دکمه","سابسکرایب کنید")],("#ff4d4d","#a855f7","#0c0810")),
|
||||||
|
("Slideshow","fr-slideshow","اسلایدشو","نمایش پشتسرهم چند پیام یا ویژگی بهصورت اسلاید",9,
|
||||||
|
[("title","عنوان","چرا فلترندر؟"),("slide1","اسلاید ۱","ساخت ویدیو در چند دقیقه"),("slide2","اسلاید ۲","بدون نیاز به دانش فنی"),("slide3","اسلاید ۳","خروجی با کیفیت حرفهای")],("#34d399","#3b82f6","#060b0a")),
|
||||||
|
("HappyBirthday","fr-happy-birthday","تولدت مبارک","کارت تبریک تولد با کاغذرنگی و نام شخص",6,
|
||||||
|
[("greeting","تبریک","تولدت مبارک"),("name","نام","سارا"),("message","پیام","بهترینها را برایت آرزومندیم 🎉")],("#fb7185","#fde047","#140a18")),
|
||||||
|
("SalePromo","fr-sale-promo","فروش ویژه","بنر تبلیغاتی فروش و تخفیف با دعوت به خرید",5,
|
||||||
|
[("badge","نشان تخفیف","۵۰٪ تخفیف"),("headline","عنوان","فروش ویژهٔ پایان فصل"),("subtext","توضیح","فقط تا پایان همین هفته"),("cta","دکمه","همین حالا خرید کنید")],("#f59e0b","#fb7185","#120a08")),
|
||||||
|
("QuoteCard","fr-quote-card","کارت نقلقول","نمایش جملهٔ انگیزشی یا نقلقول با نام گوینده",6,
|
||||||
|
[("quote","نقلقول","موفقیت، مجموع تلاشهای کوچکِ هر روز است."),("author","گوینده","فلترندر")],("#22d3ee","#6366f1","#0a0a12")),
|
||||||
|
("EventInvite","fr-event-invite","دعوتنامهٔ رویداد","دعوتنامهٔ شیک برای رویداد با تاریخ و مکان",6,
|
||||||
|
[("kicker","پیشمتن","دعوتنامه"),("eventTitle","عنوان رویداد","همایش سالانهٔ نوآوری"),("date","تاریخ","۱۵ مهر ۱۴۰۳"),("location","مکان","تهران، سالن همایشها"),("cta","دکمه","ثبتنام کنید")],("#a855f7","#3ba7ff","#0a0814")),
|
||||||
|
("Countdown","fr-countdown","شمارش معکوس","شمارش معکوس هیجانانگیز برای شروع یک رویداد",8,
|
||||||
|
[("title","عنوان","شروع رویداد تا"),("startNumber","عدد شروع","5"),("goText","متن پایان","شروع!"),("subtitle","زیرعنوان","آمادهاید؟")],("#3ba7ff","#22d3ee","#04060f")),
|
||||||
|
("GlitterReveal","fr-glitter-reveal","نمایش لوگو با غبار درخشان","نمایش جادویی لوگو با ذرات درخشان؛ لوگو و متن قابل ویرایش",6,
|
||||||
|
[("brandText","نام برند","فلترندر"),("tagline","شعار","موشن، ساده و حرفهای")],("#3ba7ff","#a855f7","#05040e")),
|
||||||
|
("NowruzGreeting","fr-nowruz","تبریک نوروز","صحنهٔ بهاری نوروز با شخصیتهای متحرک؛ حاجیفیروز، ماهی قرمز و سبزه",8,
|
||||||
|
[("greeting","متن تبریک","نوروز مبارک"),("subtitle","زیرعنوان","سال نو پیروز و شادمان"),("message","پیام / سال","۱۴۰۶")],("#f5b942","#e23b3b","#1fb6b0")),
|
||||||
|
("Hero3D","fr-hero-3d","نمایش سهبعدی برند","نمایش حرفهای و سهبعدی لوگو و برند با نورپردازی و جلوههای واقعی",6,
|
||||||
|
[("brandText","نام برند","فلترندر"),("tagline","شعار","موشن، ساده و حرفهای")],("#3ba7ff","#a855f7","#04060f")),
|
||||||
|
("Nowruz3D","fr-nowruz-3d","تبریک نوروز سهبعدی","صحنهٔ سهبعدی نوروز با حاجیفیروز، سفرهٔ هفتسین و نورپردازی سینمایی",7,
|
||||||
|
[("greeting","متن تبریک","نوروز مبارک"),("subtitle","زیرعنوان","سال نو پیروز و شادمان"),("message","پیام / سال","۱۴۰۶")],("#f5c542","#e23b3b","#1a1228")),
|
||||||
|
("Birthday3D","fr-birthday-3d","تولد سهبعدی","صحنهٔ سهبعدی تولد با کیک و شمعهای روشن، بادکنک و کاغذرنگی",6,
|
||||||
|
[("greeting","تبریک","تولدت مبارک"),("name","نام","سارا"),("message","پیام","بهترینها را برایت آرزومندیم 🎉")],("#fb7185","#a855f7","#1a1226")),
|
||||||
|
("Promo3D","fr-promo-3d","فروش ویژه سهبعدی","تبلیغ سهبعدی فروش و تخفیف با جعبههای هدیه و نورپردازی سینمایی",6,
|
||||||
|
[("badge","نشان تخفیف","۵۰٪ تخفیف"),("headline","عنوان","فروش ویژهٔ پایان فصل"),("subtext","توضیح","فقط تا پایان همین هفته"),("cta","دکمه","همین حالا خرید کنید")],("#f59e0b","#fb7185","#140e1f")),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Optional Media (image) content elements per template — these surface in the
|
||||||
|
# studio as upload/replace fields. key = the Remotion prop the image binds to.
|
||||||
|
MEDIA = {
|
||||||
|
"GlitterReveal": [("logoUrl", "لوگو (تصویر دلخواه)")],
|
||||||
|
}
|
||||||
|
|
||||||
|
def swatch_svg(colors):
|
||||||
|
rects = "".join(f'<rect x="{i*50}" y="0" width="50" height="40" fill="{c}"/>' for i, c in enumerate(colors))
|
||||||
|
return f'<svg xmlns="http://www.w3.org/2000/svg" width="200" height="40">{rects}</svg>'
|
||||||
|
|
||||||
|
def icon_svg(hex):
|
||||||
|
return f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="{hex}"/></svg>'
|
||||||
|
|
||||||
|
out = []
|
||||||
|
out.append("BEGIN;")
|
||||||
|
slugs = ",".join(q(t[1]) for t in T)
|
||||||
|
out.append(f"DELETE FROM content.project_containers WHERE slug IN ({slugs});")
|
||||||
|
|
||||||
|
for idx, (tid, slug, name, desc, dur, texts, (accent, sec, bg)) in enumerate(T):
|
||||||
|
cid = uid("c-" + tid)
|
||||||
|
thumb16 = f"{MINIO}/template-media/{tid}-16x9.png"
|
||||||
|
preview = f"{MINIO}/template-media/{tid}.mp4"
|
||||||
|
colors = [("accentColor", accent), ("secondaryColor", sec), ("backgroundColor", bg), ("textColor", "#ffffff")]
|
||||||
|
out.append(
|
||||||
|
"INSERT INTO content.project_containers (id,tenant_id,slug,name,description,image,demo,full_demo,mini_demo,"
|
||||||
|
"is_published,is_premium,is_mockup,primary_mode,sort) VALUES ("
|
||||||
|
f"{q(cid)},NULL,{q(slug)},{q(name)},{q(desc)},{q(thumb16)},{q(preview)},{q(preview)},{q(preview)},"
|
||||||
|
f"TRUE,FALSE,FALSE,'FLEXIBLE',{idx});")
|
||||||
|
for (asp, w, h, aspstr) in ASPECTS:
|
||||||
|
pid = uid(f"p-{tid}-{asp}")
|
||||||
|
sid = uid(f"s-{tid}-{asp}")
|
||||||
|
thumb = f"{MINIO}/template-media/{tid}-{asp}.png"
|
||||||
|
out.append(
|
||||||
|
"INSERT INTO content.projects (id,container_id,name,image,full_demo,original_width,original_height,aspect,"
|
||||||
|
"project_duration_sec,free_fps,choose_mode,resolution,render_engine,render_remotion_comp,is_published,sort) VALUES ("
|
||||||
|
f"{q(pid)},{q(cid)},{q(aspstr)},{q(thumb)},{q(preview)},{w},{h},{q(aspstr)},"
|
||||||
|
f"{dur},30,'FLEXIBLE','FullHD','Remotion',{q(tid+'-'+asp)},TRUE,0);")
|
||||||
|
out.append(
|
||||||
|
"INSERT INTO content.scenes (id,project_id,key,title,scene_color_svg,default_duration_sec,sort) VALUES ("
|
||||||
|
f"{q(sid)},{q(pid)},'c1','صحنه ۱',{q(swatch_svg([accent,sec,bg,'#ffffff']))},{dur},0);")
|
||||||
|
for pos, (k, title, val) in enumerate(texts):
|
||||||
|
out.append(
|
||||||
|
"INSERT INTO content.scene_content_elements (id,scene_id,key,title,type,default_value,position_in_container,direction_layer_value) VALUES ("
|
||||||
|
f"{q(uid(f'ce-{tid}-{asp}-{k}'))},{q(sid)},{q(k)},{q(title)},'Text',{q(val)},{pos},1);")
|
||||||
|
for mpos, (k, title) in enumerate(MEDIA.get(tid, [])):
|
||||||
|
out.append(
|
||||||
|
"INSERT INTO content.scene_content_elements (id,scene_id,key,title,type,default_value,position_in_container,direction_layer_value) VALUES ("
|
||||||
|
f"{q(uid(f'ce-{tid}-{asp}-{k}'))},{q(sid)},{q(k)},{q(title)},'Media','',{len(texts)+mpos},0);")
|
||||||
|
for si, (k, hexv) in enumerate(colors):
|
||||||
|
out.append(
|
||||||
|
"INSERT INTO content.shared_colors (id,project_id,element_key,title,icon,attr_value,default_color,sort) VALUES ("
|
||||||
|
f"{q(uid(f'sc-{tid}-{asp}-{k}'))},{q(pid)},{q(k)},{q(CTITLES[k])},{q(icon_svg(hexv))},'fill',{q(hexv)},{si});")
|
||||||
|
|
||||||
|
out.append("COMMIT;")
|
||||||
|
out.append("SELECT count(*) AS containers FROM content.project_containers WHERE slug LIKE 'fr-%';")
|
||||||
|
print("\n".join(out))
|
||||||
@@ -176,6 +176,8 @@ public class TemplateService(ContentDbContext db)
|
|||||||
ProjectDurationSec = req.ProjectDurationSec, MinDurationSec = req.MinDurationSec,
|
ProjectDurationSec = req.ProjectDurationSec, MinDurationSec = req.MinDurationSec,
|
||||||
MaxDurationSec = req.MaxDurationSec, FreeFps = req.FreeFps, ChooseMode = chooseMode,
|
MaxDurationSec = req.MaxDurationSec, FreeFps = req.FreeFps, ChooseMode = chooseMode,
|
||||||
Resolution = resolution, VipFactor = req.VipFactor, RenderAepComp = req.RenderAepComp,
|
Resolution = resolution, VipFactor = req.VipFactor, RenderAepComp = req.RenderAepComp,
|
||||||
|
RenderEngine = string.IsNullOrWhiteSpace(req.RenderEngine) ? "AfterEffects" : req.RenderEngine,
|
||||||
|
RenderRemotionComp = req.RenderRemotionComp,
|
||||||
IsPublished = req.IsPublished, Sort = req.Sort
|
IsPublished = req.IsPublished, Sort = req.Sort
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -225,6 +227,7 @@ public class TemplateService(ContentDbContext db)
|
|||||||
ProjectDurationSec = src.ProjectDurationSec, MinDurationSec = src.MinDurationSec,
|
ProjectDurationSec = src.ProjectDurationSec, MinDurationSec = src.MinDurationSec,
|
||||||
MaxDurationSec = src.MaxDurationSec, FreeFps = src.FreeFps, ChooseMode = src.ChooseMode,
|
MaxDurationSec = src.MaxDurationSec, FreeFps = src.FreeFps, ChooseMode = src.ChooseMode,
|
||||||
Resolution = resolution, VipFactor = src.VipFactor, RenderAepComp = src.RenderAepComp,
|
Resolution = resolution, VipFactor = src.VipFactor, RenderAepComp = src.RenderAepComp,
|
||||||
|
RenderEngine = src.RenderEngine, RenderRemotionComp = src.RenderRemotionComp,
|
||||||
SharedLayerImage = src.SharedLayerImage, SharedColorsSvg = src.SharedColorsSvg,
|
SharedLayerImage = src.SharedLayerImage, SharedColorsSvg = src.SharedColorsSvg,
|
||||||
SharedColorPresetsSvg = src.SharedColorPresetsSvg,
|
SharedColorPresetsSvg = src.SharedColorPresetsSvg,
|
||||||
IsPublished = false, Sort = src.Sort,
|
IsPublished = false, Sort = src.Sort,
|
||||||
@@ -359,6 +362,8 @@ public class TemplateService(ContentDbContext db)
|
|||||||
project.MinDurationSec = req.MinDurationSec; project.MaxDurationSec = req.MaxDurationSec;
|
project.MinDurationSec = req.MinDurationSec; project.MaxDurationSec = req.MaxDurationSec;
|
||||||
project.FreeFps = req.FreeFps; project.ChooseMode = chooseMode; project.Resolution = resolution;
|
project.FreeFps = req.FreeFps; project.ChooseMode = chooseMode; project.Resolution = resolution;
|
||||||
project.VipFactor = req.VipFactor; project.RenderAepComp = req.RenderAepComp;
|
project.VipFactor = req.VipFactor; project.RenderAepComp = req.RenderAepComp;
|
||||||
|
if (!string.IsNullOrWhiteSpace(req.RenderEngine)) project.RenderEngine = req.RenderEngine;
|
||||||
|
if (req.RenderRemotionComp != null) project.RenderRemotionComp = req.RenderRemotionComp;
|
||||||
project.SharedLayerImage = req.SharedLayerImage; project.SharedColorsSvg = req.SharedColorsSvg;
|
project.SharedLayerImage = req.SharedLayerImage; project.SharedColorsSvg = req.SharedColorsSvg;
|
||||||
project.SharedColorPresetsSvg = req.SharedColorPresetsSvg;
|
project.SharedColorPresetsSvg = req.SharedColorPresetsSvg;
|
||||||
project.IsPublished = req.IsPublished; project.Sort = req.Sort;
|
project.IsPublished = req.IsPublished; project.Sort = req.Sort;
|
||||||
@@ -402,6 +407,8 @@ public class TemplateService(ContentDbContext db)
|
|||||||
if (req.Image != null) project.Image = req.Image;
|
if (req.Image != null) project.Image = req.Image;
|
||||||
if (req.FullDemo != null) project.FullDemo = req.FullDemo;
|
if (req.FullDemo != null) project.FullDemo = req.FullDemo;
|
||||||
if (req.SharedColorsSvg != null) project.SharedColorsSvg = req.SharedColorsSvg;
|
if (req.SharedColorsSvg != null) project.SharedColorsSvg = req.SharedColorsSvg;
|
||||||
|
if (!string.IsNullOrWhiteSpace(req.RenderEngine)) project.RenderEngine = req.RenderEngine;
|
||||||
|
if (req.RenderRemotionComp != null) project.RenderRemotionComp = req.RenderRemotionComp;
|
||||||
project.UpdatedAt = DateTime.UtcNow;
|
project.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
@@ -472,7 +479,8 @@ public class TemplateService(ContentDbContext db)
|
|||||||
p.ProjectDurationSec, p.MinDurationSec, p.MaxDurationSec,
|
p.ProjectDurationSec, p.MinDurationSec, p.MaxDurationSec,
|
||||||
p.FreeFps, p.ChooseMode.ToString(), p.Resolution.ToString(),
|
p.FreeFps, p.ChooseMode.ToString(), p.Resolution.ToString(),
|
||||||
p.IsPublished, p.Sort,
|
p.IsPublished, p.Sort,
|
||||||
p.AepFileUrl, p.AepFileSizeBytes, p.RenderAepComp
|
p.AepFileUrl, p.AepFileSizeBytes, p.RenderAepComp,
|
||||||
|
p.RenderEngine, p.RenderRemotionComp
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>Browse/search all projects (template items) across containers.</summary>
|
/// <summary>Browse/search all projects (template items) across containers.</summary>
|
||||||
@@ -489,7 +497,8 @@ public class TemplateService(ContentDbContext db)
|
|||||||
.Skip((page - 1) * pageSize).Take(pageSize)
|
.Skip((page - 1) * pageSize).Take(pageSize)
|
||||||
.Select(p => new ProjectListItemResponse(
|
.Select(p => new ProjectListItemResponse(
|
||||||
p.Id, p.ContainerId, p.Container.Name, p.Container.Slug, p.Name, p.Image,
|
p.Id, p.ContainerId, p.Container.Name, p.Container.Slug, p.Name, p.Image,
|
||||||
p.Aspect, p.Resolution.ToString(), p.AepFileUrl, p.RenderAepComp, p.IsPublished, p.Sort))
|
p.Aspect, p.Resolution.ToString(), p.AepFileUrl, p.RenderAepComp, p.IsPublished, p.Sort,
|
||||||
|
p.RenderEngine, p.RenderRemotionComp))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
return new PagedResponse<ProjectListItemResponse>(items,
|
return new PagedResponse<ProjectListItemResponse>(items,
|
||||||
new PaginationMeta(page, pageSize, total, (int)Math.Ceiling((double)total / pageSize)));
|
new PaginationMeta(page, pageSize, total, (int)Math.Ceiling((double)total / pageSize)));
|
||||||
@@ -529,6 +538,8 @@ public class TemplateService(ContentDbContext db)
|
|||||||
if (req.AepFileMd5 != null) project.AepFileMd5 = req.AepFileMd5;
|
if (req.AepFileMd5 != null) project.AepFileMd5 = req.AepFileMd5;
|
||||||
if (req.AepFileSizeBytes.HasValue) project.AepFileSizeBytes = req.AepFileSizeBytes;
|
if (req.AepFileSizeBytes.HasValue) project.AepFileSizeBytes = req.AepFileSizeBytes;
|
||||||
if (!string.IsNullOrWhiteSpace(req.RenderAepComp)) project.RenderAepComp = req.RenderAepComp;
|
if (!string.IsNullOrWhiteSpace(req.RenderAepComp)) project.RenderAepComp = req.RenderAepComp;
|
||||||
|
if (!string.IsNullOrWhiteSpace(req.RenderEngine)) project.RenderEngine = req.RenderEngine;
|
||||||
|
if (req.RenderRemotionComp != null) project.RenderRemotionComp = req.RenderRemotionComp;
|
||||||
if (req.Folder != null) project.Folder = req.Folder;
|
if (req.Folder != null) project.Folder = req.Folder;
|
||||||
project.AepUploadedAt = DateTime.UtcNow;
|
project.AepUploadedAt = DateTime.UtcNow;
|
||||||
project.UpdatedAt = DateTime.UtcNow;
|
project.UpdatedAt = DateTime.UtcNow;
|
||||||
@@ -541,6 +552,7 @@ public class TemplateService(ContentDbContext db)
|
|||||||
p.OriginalWidth, p.OriginalHeight, p.Aspect,
|
p.OriginalWidth, p.OriginalHeight, p.Aspect,
|
||||||
p.ProjectDurationSec, p.MinDurationSec, p.MaxDurationSec,
|
p.ProjectDurationSec, p.MinDurationSec, p.MaxDurationSec,
|
||||||
p.FreeFps, p.ChooseMode.ToString(), p.Resolution.ToString(), p.VipFactor, p.RenderAepComp,
|
p.FreeFps, p.ChooseMode.ToString(), p.Resolution.ToString(), p.VipFactor, p.RenderAepComp,
|
||||||
|
p.RenderEngine, p.RenderRemotionComp,
|
||||||
p.SharedLayerImage, p.IsPublished, p.Sort,
|
p.SharedLayerImage, p.IsPublished, p.Sort,
|
||||||
p.Scenes.Select(MapScene).ToList(),
|
p.Scenes.Select(MapScene).ToList(),
|
||||||
p.SharedColors.Select(sc => new SharedColorResponse(sc.Id, sc.ElementKey, sc.Title, sc.Icon, sc.AttrValue.ToString(), sc.DefaultColor, sc.Sort)).ToList(),
|
p.SharedColors.Select(sc => new SharedColorResponse(sc.Id, sc.ElementKey, sc.Title, sc.Icon, sc.AttrValue.ToString(), sc.DefaultColor, sc.Sort)).ToList(),
|
||||||
|
|||||||
@@ -95,6 +95,11 @@ public class Project
|
|||||||
public decimal VipFactor { get; set; } = 1.0m;
|
public decimal VipFactor { get; set; } = 1.0m;
|
||||||
public string RenderAepComp { get; set; } = "flatrender";
|
public string RenderAepComp { get; set; } = "flatrender";
|
||||||
|
|
||||||
|
/// <summary>Render engine for this template: "AfterEffects" (default) or "Remotion".</summary>
|
||||||
|
public string RenderEngine { get; set; } = "AfterEffects";
|
||||||
|
/// <summary>For Remotion templates, the composition id to render (e.g. "KineticQuote").</summary>
|
||||||
|
public string? RenderRemotionComp { get; set; }
|
||||||
|
|
||||||
public string? SharedLayerImage { get; set; }
|
public string? SharedLayerImage { get; set; }
|
||||||
public string? SharedColorsSvg { get; set; }
|
public string? SharedColorsSvg { get; set; }
|
||||||
public string? SharedColorPresetsSvg { get; set; }
|
public string? SharedColorPresetsSvg { get; set; }
|
||||||
|
|||||||
@@ -307,6 +307,8 @@ public class ContentDbContext(DbContextOptions<ContentDbContext> options) : DbCo
|
|||||||
e.Property(x => x.Resolution).HasColumnName("resolution");
|
e.Property(x => x.Resolution).HasColumnName("resolution");
|
||||||
e.Property(x => x.VipFactor).HasColumnName("vip_factor");
|
e.Property(x => x.VipFactor).HasColumnName("vip_factor");
|
||||||
e.Property(x => x.RenderAepComp).HasColumnName("render_aep_comp");
|
e.Property(x => x.RenderAepComp).HasColumnName("render_aep_comp");
|
||||||
|
e.Property(x => x.RenderEngine).HasColumnName("render_engine");
|
||||||
|
e.Property(x => x.RenderRemotionComp).HasColumnName("render_remotion_comp");
|
||||||
e.Property(x => x.SharedLayerImage).HasColumnName("shared_layer_image");
|
e.Property(x => x.SharedLayerImage).HasColumnName("shared_layer_image");
|
||||||
e.Property(x => x.SharedColorsSvg).HasColumnName("shared_colors_svg");
|
e.Property(x => x.SharedColorsSvg).HasColumnName("shared_colors_svg");
|
||||||
e.Property(x => x.SharedColorPresetsSvg).HasColumnName("shared_color_presets_svg");
|
e.Property(x => x.SharedColorPresetsSvg).HasColumnName("shared_color_presets_svg");
|
||||||
|
|||||||
@@ -213,7 +213,9 @@ public record CreateProjectRequest(
|
|||||||
decimal VipFactor,
|
decimal VipFactor,
|
||||||
string RenderAepComp,
|
string RenderAepComp,
|
||||||
bool IsPublished,
|
bool IsPublished,
|
||||||
int Sort
|
int Sort,
|
||||||
|
string? RenderEngine = null,
|
||||||
|
string? RenderRemotionComp = null
|
||||||
);
|
);
|
||||||
|
|
||||||
public record UpdateProjectRequest(
|
public record UpdateProjectRequest(
|
||||||
@@ -239,7 +241,9 @@ public record UpdateProjectRequest(
|
|||||||
string? SharedColorsSvg,
|
string? SharedColorsSvg,
|
||||||
string? SharedColorPresetsSvg,
|
string? SharedColorPresetsSvg,
|
||||||
bool IsPublished,
|
bool IsPublished,
|
||||||
int Sort
|
int Sort,
|
||||||
|
string? RenderEngine = null,
|
||||||
|
string? RenderRemotionComp = null
|
||||||
);
|
);
|
||||||
|
|
||||||
// Partial update — only non-null fields are applied, so editing an aspect/resolution
|
// Partial update — only non-null fields are applied, so editing an aspect/resolution
|
||||||
@@ -260,7 +264,9 @@ public record SetAepRequest(
|
|||||||
string? AepFileMd5,
|
string? AepFileMd5,
|
||||||
long? AepFileSizeBytes,
|
long? AepFileSizeBytes,
|
||||||
string? RenderAepComp,
|
string? RenderAepComp,
|
||||||
string? Folder
|
string? Folder,
|
||||||
|
string? RenderEngine = null,
|
||||||
|
string? RenderRemotionComp = null
|
||||||
);
|
);
|
||||||
|
|
||||||
public record PatchProjectRequest(
|
public record PatchProjectRequest(
|
||||||
@@ -279,7 +285,9 @@ public record PatchProjectRequest(
|
|||||||
int? Sort,
|
int? Sort,
|
||||||
string? Image,
|
string? Image,
|
||||||
string? FullDemo,
|
string? FullDemo,
|
||||||
string? SharedColorsSvg
|
string? SharedColorsSvg,
|
||||||
|
string? RenderEngine = null,
|
||||||
|
string? RenderRemotionComp = null
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── CMS ──────────────────────────────────────────────────────────────────────
|
// ── CMS ──────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -131,7 +131,9 @@ public record ProjectResponse(
|
|||||||
int Sort,
|
int Sort,
|
||||||
string? AepFileUrl,
|
string? AepFileUrl,
|
||||||
long? AepFileSizeBytes,
|
long? AepFileSizeBytes,
|
||||||
string RenderAepComp
|
string RenderAepComp,
|
||||||
|
string RenderEngine,
|
||||||
|
string? RenderRemotionComp
|
||||||
);
|
);
|
||||||
|
|
||||||
public record ProjectListItemResponse(
|
public record ProjectListItemResponse(
|
||||||
@@ -146,7 +148,9 @@ public record ProjectListItemResponse(
|
|||||||
string? AepFileUrl,
|
string? AepFileUrl,
|
||||||
string RenderAepComp,
|
string RenderAepComp,
|
||||||
bool IsPublished,
|
bool IsPublished,
|
||||||
int Sort
|
int Sort,
|
||||||
|
string RenderEngine,
|
||||||
|
string? RenderRemotionComp
|
||||||
);
|
);
|
||||||
|
|
||||||
public record ProjectAssetResponse(Guid Id, Guid ProjectId, string Name, string Kind, string Url, long? SizeBytes, int Sort);
|
public record ProjectAssetResponse(Guid Id, Guid ProjectId, string Name, string Kind, string Url, long? SizeBytes, int Sort);
|
||||||
@@ -171,6 +175,8 @@ public record ProjectDetailResponse(
|
|||||||
string Resolution,
|
string Resolution,
|
||||||
decimal VipFactor,
|
decimal VipFactor,
|
||||||
string RenderAepComp,
|
string RenderAepComp,
|
||||||
|
string RenderEngine,
|
||||||
|
string? RenderRemotionComp,
|
||||||
string? SharedLayerImage,
|
string? SharedLayerImage,
|
||||||
bool IsPublished,
|
bool IsPublished,
|
||||||
int Sort,
|
int Sort,
|
||||||
|
|||||||
@@ -515,6 +515,13 @@ func (a *Agent) runJob(ctx context.Context, job *client.ClaimedJob) {
|
|||||||
binds = append(binds, runner.Binding{Key: b.Key, Type: b.Type, Value: b.Value})
|
binds = append(binds, runner.Binding{Key: b.Key, Type: b.Type, Value: b.Value})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default empty engine to AfterEffects for backwards-compat with older
|
||||||
|
// orchestrators that don't send the field yet.
|
||||||
|
engine := job.Engine
|
||||||
|
if engine == "" {
|
||||||
|
engine = runner.EngineAfterEffects
|
||||||
|
}
|
||||||
|
|
||||||
rJob := &runner.Job{
|
rJob := &runner.Job{
|
||||||
JobID: job.JobID,
|
JobID: job.JobID,
|
||||||
SavedProjectID: job.SavedProjectID,
|
SavedProjectID: job.SavedProjectID,
|
||||||
@@ -523,6 +530,8 @@ func (a *Agent) runJob(ctx context.Context, job *client.ClaimedJob) {
|
|||||||
FrameRate: job.FrameRate,
|
FrameRate: job.FrameRate,
|
||||||
HasMusic: job.HasMusic,
|
HasMusic: job.HasMusic,
|
||||||
HasVoiceover: job.HasVoiceover,
|
HasVoiceover: job.HasVoiceover,
|
||||||
|
Engine: engine,
|
||||||
|
RemotionDir: a.cfg.RemotionProjectDir,
|
||||||
AEPFilePath: aepPath,
|
AEPFilePath: aepPath,
|
||||||
CompName: job.CompName,
|
CompName: job.CompName,
|
||||||
AfterFxPath: a.cfg.AfterFxPath,
|
AfterFxPath: a.cfg.AfterFxPath,
|
||||||
|
|||||||
@@ -152,6 +152,9 @@ type ClaimedJob struct {
|
|||||||
FrameRate int `json:"frame_rate"`
|
FrameRate int `json:"frame_rate"`
|
||||||
HasMusic bool `json:"has_music"`
|
HasMusic bool `json:"has_music"`
|
||||||
HasVoiceover bool `json:"has_voiceover"`
|
HasVoiceover bool `json:"has_voiceover"`
|
||||||
|
// Engine selects the render engine: "AfterEffects" (default) or "Remotion".
|
||||||
|
// For Remotion jobs CompName is the composition id and AEPDownloadURL is empty.
|
||||||
|
Engine string `json:"engine,omitempty"`
|
||||||
// AEPDownloadURL is a presigned MinIO GET URL for the .aep template file
|
// AEPDownloadURL is a presigned MinIO GET URL for the .aep template file
|
||||||
// (or .zip bundle). Empty when the template has not been uploaded yet — triggers mock render.
|
// (or .zip bundle). Empty when the template has not been uploaded yet — triggers mock render.
|
||||||
AEPDownloadURL string `json:"aep_download_url,omitempty"`
|
AEPDownloadURL string `json:"aep_download_url,omitempty"`
|
||||||
|
|||||||
@@ -85,6 +85,10 @@ type Config struct {
|
|||||||
// WorkDir is the scratch directory for render temp files and AE project copies.
|
// WorkDir is the scratch directory for render temp files and AE project copies.
|
||||||
WorkDir string
|
WorkDir string
|
||||||
|
|
||||||
|
// RemotionProjectDir is the Remotion project root (package.json + src/index.ts)
|
||||||
|
// used by the code-based render engine. Empty disables Remotion jobs on this node.
|
||||||
|
RemotionProjectDir string
|
||||||
|
|
||||||
// HeartbeatIntervalSec is how often the agent sends a heartbeat to the orchestrator.
|
// HeartbeatIntervalSec is how often the agent sends a heartbeat to the orchestrator.
|
||||||
HeartbeatIntervalSec int
|
HeartbeatIntervalSec int
|
||||||
|
|
||||||
@@ -115,6 +119,7 @@ func Load() (*Config, error) {
|
|||||||
AEPath: getEnv("AE_PATH", ""),
|
AEPath: getEnv("AE_PATH", ""),
|
||||||
AfterFxPath: getEnv("AFTERFX_PATH", ""),
|
AfterFxPath: getEnv("AFTERFX_PATH", ""),
|
||||||
WorkDir: getEnv("WORK_DIR", os.TempDir()),
|
WorkDir: getEnv("WORK_DIR", os.TempDir()),
|
||||||
|
RemotionProjectDir: getEnv("REMOTION_PROJECT_DIR", ""),
|
||||||
AgentVersion: getEnv("AGENT_VERSION", "0.1.0"),
|
AgentVersion: getEnv("AGENT_VERSION", "0.1.0"),
|
||||||
AEVersion: getEnv("AE_VERSION", "2024"),
|
AEVersion: getEnv("AE_VERSION", "2024"),
|
||||||
HeartbeatIntervalSec: getInt("HEARTBEAT_INTERVAL_SEC", 5),
|
HeartbeatIntervalSec: getInt("HEARTBEAT_INTERVAL_SEC", 5),
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
// Remotion render engine.
|
||||||
|
//
|
||||||
|
// FlatRender supports two template engines that both produce a web-playable MP4:
|
||||||
|
//
|
||||||
|
// - AfterEffects (EngineAfterEffects) — aerender.exe renders a .aep template,
|
||||||
|
// bindings are written into the project first; see runner.go / binder.go.
|
||||||
|
// - Remotion (EngineRemotion) — a code-based React/Remotion composition
|
||||||
|
// is rendered with `npx remotion render`; bindings become --props; this file.
|
||||||
|
//
|
||||||
|
// The two engines are interchangeable from the job loop's point of view: Run()
|
||||||
|
// dispatches on Job.Engine and each returns the path to an MP4 on disk.
|
||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Engine identifiers. These mirror the values the orchestrator stores per
|
||||||
|
// template (content.templates.render_engine) and sends on the claimed job.
|
||||||
|
const (
|
||||||
|
EngineAfterEffects = "AfterEffects"
|
||||||
|
EngineRemotion = "Remotion"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Remotion prints "Rendered <done>/<total>" while drawing frames and
|
||||||
|
// "Stitched <done>/<total>" while muxing them into the MP4. We parse both to
|
||||||
|
// build a real percentage.
|
||||||
|
var (
|
||||||
|
reRemRendered = regexp.MustCompile(`Rendered\s+(\d+)/(\d+)`)
|
||||||
|
reRemStitched = regexp.MustCompile(`Stitched\s+(\d+)/(\d+)`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// npxCmd returns the platform-appropriate npx launcher.
|
||||||
|
func npxCmd() string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return "npx.cmd"
|
||||||
|
}
|
||||||
|
return "npx"
|
||||||
|
}
|
||||||
|
|
||||||
|
// remotionProps maps the user's bindings into a Remotion props JSON object.
|
||||||
|
// For code-based templates the binding Key is the composition's schema field
|
||||||
|
// (logoText, accentColor, …) and Value is the user's edited string. Anything the
|
||||||
|
// user didn't touch falls back to the composition's defaultProps.
|
||||||
|
func remotionProps(job *Job) (string, error) {
|
||||||
|
props := make(map[string]string, len(job.Bindings))
|
||||||
|
for _, b := range job.Bindings {
|
||||||
|
if b.Key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
props[b.Key] = b.Value
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(props)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// crlfSplit is a bufio.SplitFunc that breaks on either \n or \r so we capture
|
||||||
|
// each progress-bar repaint (Remotion redraws the bar with \r, not \n).
|
||||||
|
func crlfSplit(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
||||||
|
if atEOF && len(data) == 0 {
|
||||||
|
return 0, nil, nil
|
||||||
|
}
|
||||||
|
for i, b := range data {
|
||||||
|
if b == '\n' || b == '\r' {
|
||||||
|
return i + 1, data[:i], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if atEOF {
|
||||||
|
return len(data), data, nil
|
||||||
|
}
|
||||||
|
return 0, nil, nil // request more data
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunRemotion renders a code-based (Remotion) template to MP4.
|
||||||
|
//
|
||||||
|
// - remotionDir is the Remotion project root (has package.json + src/index.ts).
|
||||||
|
// - job.CompName is the Remotion composition id (e.g. "KineticQuote").
|
||||||
|
// - job.Bindings become --props.
|
||||||
|
// - job.Resolution selects an output height tier (free=360p … 4k).
|
||||||
|
//
|
||||||
|
// Returns the path to the rendered MP4. Progress + periodic previews are streamed
|
||||||
|
// through the same callbacks the AE engine uses, so the UI is engine-agnostic.
|
||||||
|
func RunRemotion(ctx context.Context, remotionDir string, job *Job, outputPath string, onProgress ProgressFn, onPreview PreviewFn) (string, error) {
|
||||||
|
if remotionDir == "" {
|
||||||
|
return "", fmt.Errorf("remotion project dir not set (REMOTION_PROJECT_DIR)")
|
||||||
|
}
|
||||||
|
if job.CompName == "" {
|
||||||
|
return "", fmt.Errorf("remotion render requires a composition id (CompName)")
|
||||||
|
}
|
||||||
|
if st, err := os.Stat(remotionDir); err != nil || !st.IsDir() {
|
||||||
|
return "", fmt.Errorf("remotion project dir not found: %s", remotionDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
propsJSON, err := remotionProps(job)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("build props: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render at the composition's native resolution, then downscale to the quality
|
||||||
|
// tier with ffmpeg (scale=-2:h preserves aspect). Remotion's --height flag
|
||||||
|
// overrides height but keeps the native width, which squishes non-matching
|
||||||
|
// aspect ratios — so we deliberately scale in the same ffmpeg post-step the AE
|
||||||
|
// engine uses. This also keeps one place to stamp the free-tier watermark later.
|
||||||
|
nativePath := strings.TrimSuffix(outputPath, filepath.Ext(outputPath)) + ".native.mp4"
|
||||||
|
entry := filepath.Join("src", "index.ts")
|
||||||
|
args := []string{
|
||||||
|
"remotion", "render", entry, job.CompName, nativePath,
|
||||||
|
"--props=" + propsJSON,
|
||||||
|
"--log=info",
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[remotion] job %s → comp %q, props %s (cwd=%s)", job.JobID, job.CompName, propsJSON, remotionDir)
|
||||||
|
cmd := exec.CommandContext(ctx, npxCmd(), args...)
|
||||||
|
cmd.Dir = remotionDir
|
||||||
|
|
||||||
|
// Merge stdout+stderr into one pipe — Remotion writes the progress bar to
|
||||||
|
// stderr and structured logs to stdout; we want both.
|
||||||
|
pr, pw := io.Pipe()
|
||||||
|
cmd.Stdout = pw
|
||||||
|
cmd.Stderr = pw
|
||||||
|
|
||||||
|
var curFrame, totalFrames, stitched, totalStitch int64
|
||||||
|
var phase atomic.Int32 // 0=bundling 1=rendering 2=stitching
|
||||||
|
go func() {
|
||||||
|
sc := bufio.NewScanner(pr)
|
||||||
|
sc.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||||
|
sc.Split(crlfSplit)
|
||||||
|
for sc.Scan() {
|
||||||
|
line := strings.TrimSpace(sc.Text())
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(os.Stdout, "[remotion] "+line+"\n")
|
||||||
|
if m := reRemRendered.FindStringSubmatch(line); m != nil {
|
||||||
|
cur, _ := strconv.ParseInt(m[1], 10, 64)
|
||||||
|
tot, _ := strconv.ParseInt(m[2], 10, 64)
|
||||||
|
atomic.StoreInt64(&curFrame, cur)
|
||||||
|
atomic.StoreInt64(&totalFrames, tot)
|
||||||
|
phase.Store(1)
|
||||||
|
}
|
||||||
|
if m := reRemStitched.FindStringSubmatch(line); m != nil {
|
||||||
|
cur, _ := strconv.ParseInt(m[1], 10, 64)
|
||||||
|
tot, _ := strconv.ParseInt(m[2], 10, 64)
|
||||||
|
atomic.StoreInt64(&stitched, cur)
|
||||||
|
atomic.StoreInt64(&totalStitch, tot)
|
||||||
|
phase.Store(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
_ = pw.Close()
|
||||||
|
return "", fmt.Errorf("start remotion: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
werr := cmd.Wait()
|
||||||
|
_ = pw.Close() // unblock the scanner goroutine
|
||||||
|
done <- werr
|
||||||
|
}()
|
||||||
|
|
||||||
|
_ = onProgress(ctx, 4, "در حال آمادهسازی قالب…") // "Preparing template…"
|
||||||
|
ticker := time.NewTicker(2 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
lastPreview := time.Time{}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case werr := <-done:
|
||||||
|
if werr != nil {
|
||||||
|
return "", fmt.Errorf("remotion render exit: %w", werr)
|
||||||
|
}
|
||||||
|
if st, serr := os.Stat(nativePath); serr != nil || st.Size() == 0 {
|
||||||
|
return "", fmt.Errorf("remotion finished but produced no output at %s", nativePath)
|
||||||
|
}
|
||||||
|
// Downscale to the quality tier (aspect-preserving). When ffmpeg is
|
||||||
|
// missing or the tier is unknown, ship the native render unchanged.
|
||||||
|
h := resolutionHeight(job.Resolution)
|
||||||
|
if h > 0 && ffmpegPath() != "" {
|
||||||
|
_ = onProgress(ctx, 96, "در حال بهینهسازی کیفیت…") // "Optimizing quality…"
|
||||||
|
mp4, terr := transcodeToMP4(ctx, nativePath, outputPath, h)
|
||||||
|
if terr != nil {
|
||||||
|
log.Printf("[remotion] tier transcode failed (%v) — shipping native render", terr)
|
||||||
|
_ = onProgress(ctx, 98, "اتمام رندر")
|
||||||
|
return nativePath, nil
|
||||||
|
}
|
||||||
|
_ = os.Remove(nativePath)
|
||||||
|
_ = onProgress(ctx, 98, "اتمام رندر")
|
||||||
|
return mp4, nil
|
||||||
|
}
|
||||||
|
_ = onProgress(ctx, 98, "اتمام رندر")
|
||||||
|
return nativePath, nil
|
||||||
|
case <-ticker.C:
|
||||||
|
pct, msg := remotionProgress(phase.Load(),
|
||||||
|
atomic.LoadInt64(&curFrame), atomic.LoadInt64(&totalFrames),
|
||||||
|
atomic.LoadInt64(&stitched), atomic.LoadInt64(&totalStitch))
|
||||||
|
_ = onProgress(ctx, pct, msg)
|
||||||
|
if onPreview != nil && time.Since(lastPreview) >= 8*time.Second {
|
||||||
|
lastPreview = time.Now()
|
||||||
|
if perr := onPreview(ctx, GeneratePreviewB64(pct, job.Quality, job.Resolution)); perr != nil {
|
||||||
|
log.Printf("[remotion] preview push error: %v", perr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
_ = cmd.Process.Kill()
|
||||||
|
return "", ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// remotionProgress maps the render phase + frame counts to a 4–96 percentage
|
||||||
|
// (leaving headroom for the orchestrator's upload step) plus a Persian message.
|
||||||
|
func remotionProgress(phase int32, cur, total, stch, stchTotal int64) (int, string) {
|
||||||
|
switch phase {
|
||||||
|
case 2: // stitching → 70..96
|
||||||
|
if stchTotal > 0 {
|
||||||
|
frac := float64(stch) / float64(stchTotal)
|
||||||
|
return 70 + int(frac*26), fmt.Sprintf("در حال ساخت ویدیو… %d از %d", stch, stchTotal)
|
||||||
|
}
|
||||||
|
return 70, "در حال ساخت ویدیو…"
|
||||||
|
case 1: // rendering frames → 8..70
|
||||||
|
if total > 0 {
|
||||||
|
frac := float64(cur) / float64(total)
|
||||||
|
return 8 + int(frac*62), fmt.Sprintf("در حال رندر… فریم %d از %d", cur, total)
|
||||||
|
}
|
||||||
|
return 8, "در حال رندر…"
|
||||||
|
default: // bundling
|
||||||
|
return 5, "در حال کامپایل قالب…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// remotionProjectDir resolves the repo's services/remotion directory relative to
|
||||||
|
// this test package (services/node-agent/internal/runner), or skips the test when
|
||||||
|
// it (or npx) is unavailable — keeps the test green on CI nodes without the
|
||||||
|
// Remotion project checked out.
|
||||||
|
func remotionProjectDir(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
if v := os.Getenv("REMOTION_PROJECT_DIR"); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
dir, err := filepath.Abs(filepath.Join("..", "..", "..", "remotion"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("abs: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "package.json")); err != nil {
|
||||||
|
t.Skipf("remotion project not found at %s (skipping)", dir)
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemotionProps(t *testing.T) {
|
||||||
|
job := &Job{Bindings: []Binding{
|
||||||
|
{Key: "logoText", Value: "HELLO"},
|
||||||
|
{Key: "accentColor", Value: "#22d3ee"},
|
||||||
|
{Key: "", Value: "ignored"}, // empty keys are dropped
|
||||||
|
}}
|
||||||
|
got, err := remotionProps(job)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("remotionProps: %v", err)
|
||||||
|
}
|
||||||
|
want := `{"accentColor":"#22d3ee","logoText":"HELLO"}`
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("props = %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemotionProgress(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
phase int32
|
||||||
|
cur, total, stch, stchTot int64
|
||||||
|
wantMin, wantMax int
|
||||||
|
}{
|
||||||
|
{0, 0, 0, 0, 0, 5, 5}, // bundling
|
||||||
|
{1, 90, 180, 0, 0, 30, 45}, // half the frames rendered
|
||||||
|
{2, 90, 180, 90, 180, 80, 90}, // half stitched
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
pct, _ := remotionProgress(c.phase, c.cur, c.total, c.stch, c.stchTot)
|
||||||
|
if pct < c.wantMin || pct > c.wantMax {
|
||||||
|
t.Errorf("phase %d: pct %d not in [%d,%d]", c.phase, pct, c.wantMin, c.wantMax)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunRemotion_EndToEnd renders a real composition through the engine and
|
||||||
|
// asserts an MP4 lands on disk. Slow (spawns Chrome) — run with `go test -run
|
||||||
|
// RunRemotion -timeout 6m`. Skipped automatically without the project or npx.
|
||||||
|
func TestRunRemotion_EndToEnd(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping end-to-end render in -short mode")
|
||||||
|
}
|
||||||
|
remDir := remotionProjectDir(t)
|
||||||
|
if _, err := exec.LookPath(npxCmd()); err != nil {
|
||||||
|
t.Skipf("%s not on PATH (skipping)", npxCmd())
|
||||||
|
}
|
||||||
|
|
||||||
|
out := filepath.Join(t.TempDir(), "engine-out.mp4")
|
||||||
|
job := &Job{
|
||||||
|
JobID: "test-remotion-e2e",
|
||||||
|
Engine: EngineRemotion,
|
||||||
|
CompName: "KineticQuote",
|
||||||
|
Quality: "free",
|
||||||
|
Resolution: "360p", // exercises the height tier mapping
|
||||||
|
Bindings: []Binding{
|
||||||
|
{Key: "quote", Value: "Two engines, one output."},
|
||||||
|
{Key: "author", Value: "Engine Test"},
|
||||||
|
{Key: "accentColor", Value: "#22d3ee"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastPct int
|
||||||
|
onProgress := func(_ context.Context, pct int, _ string) error { lastPct = pct; return nil }
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
got, err := RunRemotion(ctx, remDir, job, out, onProgress, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RunRemotion: %v", err)
|
||||||
|
}
|
||||||
|
st, err := os.Stat(got)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stat output: %v", err)
|
||||||
|
}
|
||||||
|
if st.Size() == 0 {
|
||||||
|
t.Fatal("output file is empty")
|
||||||
|
}
|
||||||
|
if lastPct < 90 {
|
||||||
|
t.Errorf("final progress only reached %d%%", lastPct)
|
||||||
|
}
|
||||||
|
t.Logf("rendered %s (%d bytes), final progress %d%%", got, st.Size(), lastPct)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRun_RemotionEngine exercises the real integration point the node-agent uses:
|
||||||
|
// runner.Run() dispatching on Job.Engine. With Engine=Remotion and an empty AE path
|
||||||
|
// (which would otherwise trigger the AE mock), it must route to the Remotion engine
|
||||||
|
// and produce a real MP4.
|
||||||
|
func TestRun_RemotionEngine(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping end-to-end render in -short mode")
|
||||||
|
}
|
||||||
|
remDir := remotionProjectDir(t)
|
||||||
|
if _, err := exec.LookPath(npxCmd()); err != nil {
|
||||||
|
t.Skipf("%s not on PATH (skipping)", npxCmd())
|
||||||
|
}
|
||||||
|
|
||||||
|
job := &Job{
|
||||||
|
JobID: "test-run-dispatch",
|
||||||
|
Engine: EngineRemotion,
|
||||||
|
RemotionDir: remDir,
|
||||||
|
CompName: "KineticQuote",
|
||||||
|
Quality: "free",
|
||||||
|
Resolution: "360p",
|
||||||
|
Bindings: []Binding{{Key: "author", Value: "Dispatch Test"}},
|
||||||
|
}
|
||||||
|
noop := func(context.Context, int, string) error { return nil }
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// aePath empty: an AE job would mock here; a Remotion job must still render for real.
|
||||||
|
got, err := Run(ctx, "", t.TempDir(), job, noop, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run (remotion engine): %v", err)
|
||||||
|
}
|
||||||
|
st, err := os.Stat(got)
|
||||||
|
if err != nil || st.Size() == 0 {
|
||||||
|
t.Fatalf("no output from Run: %v", err)
|
||||||
|
}
|
||||||
|
if string(mustRead(t, got)[:4]) == "mock" {
|
||||||
|
t.Fatal("Run produced the AE mock output instead of a real Remotion render")
|
||||||
|
}
|
||||||
|
t.Logf("Run dispatched to Remotion → %s (%d bytes)", got, st.Size())
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustRead(t *testing.T, path string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", path, err)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -46,6 +46,11 @@ type Job struct {
|
|||||||
FrameRate int
|
FrameRate int
|
||||||
HasMusic bool
|
HasMusic bool
|
||||||
HasVoiceover bool
|
HasVoiceover bool
|
||||||
|
// Engine selects the render engine: EngineAfterEffects (default, "" treated as
|
||||||
|
// AE for backwards-compat) or EngineRemotion (code-based React templates).
|
||||||
|
Engine string
|
||||||
|
// RemotionDir is the Remotion project root, used only when Engine == EngineRemotion.
|
||||||
|
RemotionDir string
|
||||||
// AEPFilePath is the local path to the downloaded .aep project file.
|
// AEPFilePath is the local path to the downloaded .aep project file.
|
||||||
// In a full implementation the agent downloads this from MinIO before calling Run.
|
// In a full implementation the agent downloads this from MinIO before calling Run.
|
||||||
AEPFilePath string
|
AEPFilePath string
|
||||||
@@ -75,6 +80,12 @@ func Run(ctx context.Context, aePath, workDir string, job *Job, onProgress Progr
|
|||||||
}
|
}
|
||||||
outputPath := filepath.Join(outputDir, "output.mp4")
|
outputPath := filepath.Join(outputDir, "output.mp4")
|
||||||
|
|
||||||
|
// Engine dispatch. Remotion is fully self-contained (Node + Chrome), so it
|
||||||
|
// never touches the AE / mock paths below.
|
||||||
|
if strings.EqualFold(job.Engine, EngineRemotion) {
|
||||||
|
return RunRemotion(ctx, job.RemotionDir, job, outputPath, onProgress, onPreview)
|
||||||
|
}
|
||||||
|
|
||||||
// Mock render when AE isn't installed (aePath empty) OR when this job has no
|
// Mock render when AE isn't installed (aePath empty) OR when this job has no
|
||||||
// template project to render (AEPFilePath empty — the template bundle wasn't
|
// template project to render (AEPFilePath empty — the template bundle wasn't
|
||||||
// uploaded/promoted yet). Mock drives progress+preview to completion so the job
|
// uploaded/promoted yet). Mock drives progress+preview to completion so the job
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
@@ -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",
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
“
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||