feat(render+templates): Remotion engine, 16 branded templates (incl. 3D), seconds pricing, coming-soon
CI/CD / CI · Web (tsc) (push) Successful in 1m21s
CI/CD / Deploy · full stack (push) Failing after 20s

Render engine
- Add Remotion (code-based) as a 2nd render engine alongside After Effects.
  node-agent dispatches on Job.Engine; RunRemotion maps bindings -> --props,
  renders native then ffmpeg-scales to the quality tier (aspect-preserving).
- content.projects.render_engine + render_remotion_comp (migration 32);
  render-svc claim resolves engine and routes (skips .aep for Remotion).
- Admin TemplatesAdmin gains an engine picker + Remotion composition id field.

Template pack (services/remotion)
- 16 branded, Persian (Vazirmatn), color- and text-editable templates, each in
  3 aspects (16:9 / 1:1 / 9:16): LogoMotion, Opener, InstaPromo, YouTubeIntro,
  Slideshow, HappyBirthday, SalePromo, QuoteCard, EventInvite, Countdown,
  GlitterReveal (editable logo image), NowruzGreeting (animated characters),
  and 4 cinematic 3D templates via @remotion/three (Hero3D, Nowruz3D,
  Birthday3D, Promo3D) with reflections + bloom/DOF/vignette.
- scripts/seed_remotion_templates.py seeds containers/projects/scenes/colors.

Pricing
- Rewrite /pricing to the seconds-based model (charge = length x resolution),
  data-driven from /v1/plans, Toman, broker checkout.

Coming-soon
- Persian experimental-build overlay on all pages (launch date + countdown).

Fixes
- middleware matcher bypasses all static asset paths; catalog mapping passes
  cover image + preview video so real thumbnails render.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-21 15:52:52 +03:30
parent b9b91397b0
commit 4f04f6bf75
137 changed files with 8942 additions and 135 deletions
+3319
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "flatrender-remotion",
"version": "0.1.0",
"private": true,
"description": "FlatRender code-based (Remotion) video template renderer",
"scripts": {
"dev": "remotion studio",
"render": "remotion render",
"still": "remotion still",
"upgrade": "remotion upgrade"
},
"dependencies": {
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.1.2",
"@react-three/postprocessing": "^3.0.4",
"@remotion/cli": "4.0.290",
"@remotion/three": "^4.0.290",
"@remotion/zod-types": "4.0.290",
"@types/three": "^0.171.0",
"postprocessing": "^6.39.1",
"react": "19.0.0",
"react-dom": "19.0.0",
"remotion": "4.0.290",
"three": "^0.171.0",
"zod": "3.22.3"
},
"devDependencies": {
"@types/react": "19.0.0",
"typescript": "5.5.4"
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+18
View File
@@ -0,0 +1,18 @@
import { Config } from "@remotion/cli/config";
Config.setVideoImageFormat("jpeg");
Config.setOverwriteOutput(true);
// Higher quality concurrency defaults for the logo-intro previews.
Config.setConcurrency(4);
// Remotion's bundled Chrome Headless Shell download is geo-blocked (403) from
// Iran, so point it at the locally-installed Chrome instead. Override with the
// REMOTION_BROWSER env var on machines where Chrome lives elsewhere.
Config.setBrowserExecutable(
process.env.REMOTION_BROWSER ??
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
);
// Required for WebGL / Three.js (@remotion/three) templates to render headless.
// "angle" works with the local Chrome; the node-agent inherits this from config.
Config.setChromiumOpenGlRenderer("angle");
+135
View File
@@ -0,0 +1,135 @@
import { Composition } from "remotion";
import { ASPECTS } from "./lib/aspect";
import { TEMPLATES } from "./templates";
import { Three3DTest } from "./compositions/Three3DTest";
import {
IlluminatedCircles,
illuminatedCirclesSchema,
} from "./compositions/IlluminatedCircles";
import {
KineticQuote,
kineticQuoteSchema,
} from "./compositions/KineticQuote";
import {
GradientPromo,
gradientPromoSchema,
} from "./compositions/GradientPromo";
import {
VerticalStory,
verticalStorySchema,
} from "./compositions/VerticalStory";
const FPS = 30;
export const RemotionRoot: React.FC = () => {
return (
<>
{/* Logo intro — 16:9 */}
<Composition
id="IlluminatedCircles"
component={IlluminatedCircles}
durationInFrames={FPS * 6}
fps={FPS}
width={1920}
height={1080}
schema={illuminatedCirclesSchema}
defaultProps={{
logoText: "FLATRENDER",
tagline: "MOTION MADE SIMPLE",
accentColor: "#3ba7ff",
secondaryColor: "#a855f7",
backgroundColor: "#04060f",
}}
/>
{/* Kinetic typography quote — 1:1 social */}
<Composition
id="KineticQuote"
component={KineticQuote}
durationInFrames={FPS * 7}
fps={FPS}
width={1080}
height={1080}
schema={kineticQuoteSchema}
defaultProps={{
quote: "Great motion design is felt long before it is noticed.",
author: "FlatRender Studio",
accentColor: "#22d3ee",
secondaryColor: "#6366f1",
backgroundColor: "#0a0a12",
}}
/>
{/* Marketing / sale promo — 16:9 */}
<Composition
id="GradientPromo"
component={GradientPromo}
durationInFrames={FPS * 6}
fps={FPS}
width={1920}
height={1080}
schema={gradientPromoSchema}
defaultProps={{
eyebrow: "Limited time offer",
headline: "Make videos that move people.",
subheadline:
"Customizable code-based templates, rendered in the cloud in minutes.",
ctaText: "Start free →",
badgeText: "50% OFF",
accentColor: "#fb7185",
secondaryColor: "#f59e0b",
backgroundColor: "#0c0a14",
}}
/>
{/* Vertical social story — 9:16 */}
<Composition
id="VerticalStory"
component={VerticalStory}
durationInFrames={FPS * 6}
fps={FPS}
width={1080}
height={1920}
schema={verticalStorySchema}
defaultProps={{
kicker: "New drop",
line1: "Your story.",
line2: "Your style.",
line3: "One tap.",
ctaText: "Swipe up",
accentColor: "#34d399",
secondaryColor: "#3b82f6",
backgroundColor: "#060b0a",
}}
/>
{/* 3D feasibility test */}
<Composition
id="Three3DTest"
component={Three3DTest}
durationInFrames={120}
fps={30}
width={1280}
height={720}
/>
{/* Branded templates — each registered in all three aspects. */}
{TEMPLATES.flatMap((tpl) =>
ASPECTS.map((a) => (
<Composition
key={`${tpl.id}-${a.id}`}
id={`${tpl.id}-${a.id}`}
component={tpl.component}
durationInFrames={Math.round(FPS * tpl.durationSec)}
fps={FPS}
width={a.width}
height={a.height}
schema={tpl.schema}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
defaultProps={tpl.defaultProps as any}
/>
))
)}
</>
);
};
@@ -0,0 +1,179 @@
import React from "react";
import {
AbsoluteFill,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import { ThreeCanvas } from "@remotion/three";
import * as THREE from "three";
import { z } from "zod";
import { colorSchema } from "../lib/branding";
import { FONT } from "../lib/fonts";
import { useLayout } from "../lib/aspect";
import { hexToRgba, mixHex, rand } from "../lib/anim";
import { StudioEnv, StudioFloor, StudioLights, StudioEffects, Confetti3D } from "../lib/three-kit";
export const birthday3DSchema = z.object({
greeting: z.string(),
name: z.string(),
message: z.string(),
...colorSchema,
});
type Props = z.infer<typeof birthday3DSchema>;
const Candle: React.FC<{ x: number; z: number; i: number; accent: string }> = ({ x, z, i, accent }) => {
const frame = useCurrentFrame();
const flick = 1 + Math.sin(frame / 4 + i) * 0.18;
return (
<group position={[x, 0.95, z]}>
<mesh castShadow>
<cylinderGeometry args={[0.04, 0.045, 0.4, 16]} />
<meshStandardMaterial color={i % 2 ? "#ffffff" : accent} roughness={0.5} />
</mesh>
<mesh position={[0, 0.28, 0]} scale={[1, flick, 1]}>
<coneGeometry args={[0.04, 0.15, 16]} />
<meshStandardMaterial color="#ffd27a" emissive="#ffae3b" emissiveIntensity={3} toneMapped={false} />
</mesh>
<pointLight position={[0, 0.34, 0]} intensity={1.6 * flick} color="#ffb14d" distance={2.5} />
</group>
);
};
const Cake: React.FC<{ accent: string; secondary: string }> = ({ accent, secondary }) => {
const cream = "#fbeede";
const frost = accent;
const candleN = 5;
return (
<group position={[0, -0.55, 0]}>
{/* plate */}
<mesh position={[0, 0.04, 0]} receiveShadow castShadow>
<cylinderGeometry args={[1.15, 1.15, 0.08, 48]} />
<meshStandardMaterial color="#e8e8ee" roughness={0.25} metalness={0.4} />
</mesh>
{/* tier 1 */}
<mesh position={[0, 0.34, 0]} castShadow>
<cylinderGeometry args={[0.92, 0.95, 0.52, 48]} />
<meshStandardMaterial color={cream} roughness={0.6} />
</mesh>
<mesh position={[0, 0.6, 0]}>
<torusGeometry args={[0.92, 0.07, 16, 48]} />
<meshStandardMaterial color={frost} roughness={0.45} />
</mesh>
{/* tier 2 */}
<mesh position={[0, 0.82, 0]} castShadow>
<cylinderGeometry args={[0.62, 0.66, 0.46, 48]} />
<meshStandardMaterial color={mixHex(cream, frost, 0.15)} roughness={0.6} />
</mesh>
<mesh position={[0, 1.05, 0]}>
<torusGeometry args={[0.62, 0.06, 16, 48]} />
<meshStandardMaterial color={secondary} roughness={0.45} />
</mesh>
{/* cherries */}
{Array.from({ length: 8 }).map((_, i) => (
<mesh key={i} position={[Math.cos((i / 8) * Math.PI * 2) * 0.62, 1.06, Math.sin((i / 8) * Math.PI * 2) * 0.62]}>
<sphereGeometry args={[0.05, 16, 16]} />
<meshStandardMaterial color="#e23b3b" roughness={0.3} />
</mesh>
))}
{/* candles */}
{Array.from({ length: candleN }).map((_, i) => {
const a = (i / candleN) * Math.PI * 2;
return <Candle key={i} i={i} x={Math.cos(a) * 0.32} z={Math.sin(a) * 0.32} accent={accent} />;
})}
</group>
);
};
const Balloon: React.FC<{ x: number; z: number; i: number; color: string }> = ({ x, z, i, color }) => {
const frame = useCurrentFrame();
const bob = Math.sin(frame / 30 + i) * 0.25;
const sway = Math.sin(frame / 40 + i * 2) * 0.1;
const baseY = 1.4 + (i % 3) * 0.5;
return (
<group position={[x + sway, baseY + bob, z]}>
<mesh castShadow>
<sphereGeometry args={[0.38, 24, 24]} />
<meshStandardMaterial color={color} roughness={0.25} metalness={0.05} emissive={color} emissiveIntensity={0.06} />
</mesh>
<mesh position={[0, -0.4, 0]}>
<coneGeometry args={[0.05, 0.1, 12]} />
<meshStandardMaterial color={color} roughness={0.3} />
</mesh>
<mesh position={[0, -1.0, 0]}>
<cylinderGeometry args={[0.005, 0.005, 1.1, 6]} />
<meshStandardMaterial color="#ffffff" opacity={0.5} transparent />
</mesh>
</group>
);
};
const Scene: React.FC<{ accent: string; secondary: string }> = ({ accent, secondary }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const enter = spring({ frame: frame - 8, fps, config: { damping: 14, stiffness: 60 } });
const orbit = Math.sin(frame / 110) * 0.2;
const balloonColors = [accent, secondary, "#fde047", "#34d399", "#60a5fa"];
return (
<group rotation={[0, orbit, 0]} scale={enter}>
<StudioLights accent={accent} secondary={secondary} />
<StudioEnv />
<StudioFloor color="#241d33" />
<Cake accent={accent} secondary={secondary} />
{[[-2.2, -0.5], [2.2, -0.6], [-1.7, -1.6], [1.8, -1.4], [0, -2.2]].map((p, i) => (
<Balloon key={i} i={i} x={p[0]} z={p[1]} color={balloonColors[i % balloonColors.length]} />
))}
<Confetti3D colors={[accent, secondary, "#fde047", "#34d399", "#ffffff"]} />
</group>
);
};
export const Birthday3D: React.FC<Props> = ({
greeting,
name,
message,
accentColor,
secondaryColor,
backgroundColor,
textColor,
}) => {
const frame = useCurrentFrame();
const { width, height, fps } = useVideoConfig();
const L = useLayout();
const gOp = interpolate(frame, [12, 30], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const namePop = spring({ frame: frame - 30, fps, config: { damping: 10, stiffness: 120 } });
const nameScale = interpolate(namePop, [0, 1], [0.4, 1]);
const msgOp = interpolate(frame, [150, 172], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
return (
<AbsoluteFill style={{ backgroundColor }}>
<AbsoluteFill style={{ background: `radial-gradient(circle at 50% 35%, ${hexToRgba(accentColor, 0.22)} 0%, ${hexToRgba(secondaryColor, 0.08)} 40%, ${backgroundColor} 74%)` }} />
<ThreeCanvas
width={width}
height={height}
camera={{ position: [0, 2.3, 5.7], fov: 50 }}
shadows
style={{ position: "absolute", inset: 0 }}
gl={{ toneMapping: THREE.ACESFilmicToneMapping, antialias: true }}
>
<Scene accent={accentColor} secondary={secondaryColor} />
<StudioEffects bloom={0.6} focus={0.014} bokeh={3} vignette={0.55} />
</ThreeCanvas>
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl", alignItems: "center", justifyContent: "flex-start", paddingTop: height * 0.05 }}>
<div style={{ opacity: gOp, fontWeight: 700, fontSize: L.vmin(44), color: textColor, textShadow: `0 ${L.vmin(2)}px ${L.vmin(6)}px ${hexToRgba("#1a0a14", 0.7)}` }}>
{greeting}
</div>
<div style={{ transform: `scale(${nameScale})`, margin: `${L.vmin(6)}px 0`, fontWeight: 900, fontSize: L.vmin(100), lineHeight: 1.05, backgroundImage: `linear-gradient(120deg, ${accentColor}, ${secondaryColor})`, WebkitBackgroundClip: "text", backgroundClip: "text", WebkitTextFillColor: "transparent", filter: `drop-shadow(0 ${L.vmin(3)}px ${L.vmin(10)}px ${hexToRgba("#1a0a14", 0.6)})` }}>
{name}
</div>
<div style={{ opacity: msgOp, fontWeight: 600, fontSize: L.vmin(28), color: hexToRgba(textColor, 0.92), textShadow: `0 ${L.vmin(2)}px ${L.vmin(6)}px ${hexToRgba("#1a0a14", 0.7)}` }}>
{message}
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};
@@ -0,0 +1,84 @@
import React from "react";
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
import { z } from "zod";
import { colorSchema } from "../lib/branding";
import { FONT } from "../lib/fonts";
import { useLayout } from "../lib/aspect";
import { BrandBackground, useReveal } from "../lib/kit";
import { hexToRgba } from "../lib/anim";
export const countdownSchema = z.object({
title: z.string(),
// coerce so a string binding ("5") from the studio still validates as a number
startNumber: z.coerce.number().int().min(1).max(9),
goText: z.string(),
subtitle: z.string(),
...colorSchema,
});
type Props = z.infer<typeof countdownSchema>;
export const Countdown: React.FC<Props> = ({
title,
startNumber,
goText,
subtitle,
accentColor,
secondaryColor,
backgroundColor,
textColor,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const L = useLayout();
const titleR = useReveal(6, { from: 24 });
// Count down one number per second after a short intro.
const introF = Math.round(fps * 1.2);
const elapsed = Math.max(0, frame - introF);
const sec = Math.floor(elapsed / fps);
const current = startNumber - sec; // >0 → number, <=0 → GO
const localInSec = (elapsed % fps) / fps;
// Each tick pops in and fades/scales out.
const pop = spring({ frame: (elapsed % fps), fps, config: { damping: 12, stiffness: 130, mass: 0.7 } });
const scaleIn = interpolate(pop, [0, 1], [0.4, 1]);
const scaleOut = interpolate(localInSec, [0.7, 1], [1, 1.4], { extrapolateLeft: "clamp" });
const fadeOut = interpolate(localInSec, [0.75, 1], [1, 0], { extrapolateLeft: "clamp" });
const isGo = current <= 0;
const ringProgress = 1 - localInSec;
const ringR = L.vmin(220);
const circ = 2 * Math.PI * ringR;
const sub = useReveal(introF + 4, { from: 24 });
return (
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl" }}>
<BrandBackground bg={backgroundColor} accent={accentColor} secondary={secondaryColor} particles={18} />
<div style={{ position: "absolute", top: L.vmin(120), left: 0, right: 0, textAlign: "center", opacity: titleR.opacity, transform: `translateY(${titleR.y}px)`, fontWeight: 800, fontSize: L.vmin(44), color: hexToRgba(textColor, 0.9) }}>
{title}
</div>
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
{/* Progress ring */}
{!isGo && (
<svg width={ringR * 2.4} height={ringR * 2.4} viewBox={`${-ringR * 1.2} ${-ringR * 1.2} ${ringR * 2.4} ${ringR * 2.4}`} style={{ position: "absolute" }}>
<circle cx={0} cy={0} r={ringR} fill="none" stroke={hexToRgba(textColor, 0.12)} strokeWidth={L.vmin(6)} />
<circle cx={0} cy={0} r={ringR} fill="none" stroke={accentColor} strokeWidth={L.vmin(6)} strokeLinecap="round" strokeDasharray={`${circ * ringProgress} ${circ}`} transform="rotate(-90)" style={{ filter: `drop-shadow(0 0 ${L.vmin(8)}px ${accentColor})` }} />
</svg>
)}
<div style={{ transform: `scale(${isGo ? scaleIn : scaleIn * scaleOut})`, opacity: isGo ? 1 : fadeOut, fontWeight: 900, fontSize: isGo ? L.vmin(150) : L.vmin(260), lineHeight: 1, backgroundImage: `linear-gradient(135deg, ${accentColor}, ${secondaryColor})`, WebkitBackgroundClip: "text", backgroundClip: "text", WebkitTextFillColor: "transparent", filter: `drop-shadow(0 0 ${L.vmin(30)}px ${hexToRgba(accentColor, 0.6)})` }}>
{isGo ? goText : current}
</div>
</AbsoluteFill>
<div style={{ position: "absolute", bottom: L.vmin(140), left: 0, right: 0, textAlign: "center", opacity: sub.opacity, transform: `translateY(${sub.y}px)`, fontWeight: 500, fontSize: L.vmin(30), color: hexToRgba(textColor, 0.78) }}>
{subtitle}
</div>
</AbsoluteFill>
);
};
@@ -0,0 +1,78 @@
import React from "react";
import { AbsoluteFill, interpolate, useCurrentFrame, Easing } from "remotion";
import { z } from "zod";
import { colorSchema } from "../lib/branding";
import { FONT } from "../lib/fonts";
import { useLayout } from "../lib/aspect";
import { BrandBackground, useReveal } from "../lib/kit";
import { hexToRgba } from "../lib/anim";
export const eventInviteSchema = z.object({
kicker: z.string(),
eventTitle: z.string(),
date: z.string(),
location: z.string(),
cta: z.string(),
...colorSchema,
});
type Props = z.infer<typeof eventInviteSchema>;
export const EventInvite: React.FC<Props> = ({
kicker,
eventTitle,
date,
location,
cta,
accentColor,
secondaryColor,
backgroundColor,
textColor,
}) => {
const frame = useCurrentFrame();
const L = useLayout();
const kick = useReveal(8, { from: 22 });
const title = useReveal(22, { from: 44 });
const meta = useReveal(44, { from: 26 });
const ctaR = useReveal(64, { from: 22, damping: 12 });
// Elegant double border that draws in.
const borderInset = interpolate(frame, [0, 30], [L.vmin(40), L.vmin(70)], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.cubic) });
const borderOp = interpolate(frame, [0, 24], [0, 1], { extrapolateRight: "clamp" });
return (
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl" }}>
<BrandBackground bg={backgroundColor} accent={accentColor} secondary={secondaryColor} particles={12} nebula />
{/* Ornamental frame */}
<div style={{ position: "absolute", inset: borderInset, border: `${L.vmin(2)}px solid ${hexToRgba(accentColor, 0.5)}`, borderRadius: L.vmin(10), opacity: borderOp }} />
<div style={{ position: "absolute", inset: borderInset + L.vmin(10), border: `${L.vmin(1)}px solid ${hexToRgba(secondaryColor, 0.35)}`, borderRadius: L.vmin(8), opacity: borderOp }} />
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", flexDirection: "column", padding: L.vmin(110) }}>
<div style={{ opacity: kick.opacity, transform: `translateY(${kick.y}px)`, fontWeight: 600, fontSize: L.vmin(26), letterSpacing: L.vmin(8), color: accentColor, marginBottom: L.vmin(22) }}>
{kicker}
</div>
<div style={{ opacity: title.opacity, transform: `translateY(${title.y}px)`, fontWeight: 900, fontSize: L.vmin(92), lineHeight: 1.1, color: textColor, textAlign: "center", maxWidth: L.vmin(880), textShadow: `0 ${L.vmin(6)}px ${L.vmin(36)}px ${hexToRgba(accentColor, 0.4)}` }}>
{eventTitle}
</div>
<div style={{ marginTop: L.vmin(40), opacity: meta.opacity, transform: `translateY(${meta.y}px)`, display: "flex", gap: L.vmin(40), flexWrap: "wrap", justifyContent: "center" }}>
<Meta L={L} icon="📅" label={date} color={textColor} accent={accentColor} />
<Meta L={L} icon="📍" label={location} color={textColor} accent={accentColor} />
</div>
<div style={{ marginTop: L.vmin(52), opacity: ctaR.opacity, transform: `scale(${ctaR.scale})`, padding: `${L.vmin(20)}px ${L.vmin(56)}px`, borderRadius: 999, background: `linear-gradient(135deg, ${accentColor}, ${secondaryColor})`, boxShadow: `0 0 ${L.vmin(40)}px ${hexToRgba(accentColor, 0.55)}`, fontWeight: 800, fontSize: L.vmin(32), color: "#fff" }}>
{cta}
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};
const Meta: React.FC<{ L: ReturnType<typeof useLayout>; icon: string; label: string; color: string; accent: string }> = ({ L, icon, label, color, accent }) => (
<div style={{ display: "flex", alignItems: "center", gap: L.vmin(12), padding: `${L.vmin(12)}px ${L.vmin(24)}px`, borderRadius: 999, background: hexToRgba(accent, 0.1), border: `${L.vmin(1.5)}px solid ${hexToRgba(accent, 0.3)}`, fontWeight: 600, fontSize: L.vmin(28), color }}>
<span style={{ fontSize: L.vmin(30) }}>{icon}</span>
{label}
</div>
);
@@ -0,0 +1,196 @@
import React from "react";
import {
AbsoluteFill,
Img,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
Easing,
} from "remotion";
import { z } from "zod";
import { colorSchema } from "../lib/branding";
import { FONT } from "../lib/fonts";
import { useLayout } from "../lib/aspect";
import { hexToRgba, mixHex, rand } from "../lib/anim";
export const glitterRevealSchema = z.object({
brandText: z.string(),
tagline: z.string(),
/** Optional logo image URL. When empty the FlatRender brand mark is used. */
logoUrl: z.string(),
...colorSchema,
});
type Props = z.infer<typeof glitterRevealSchema>;
// ── Default FlatRender brand mark (used when the user hasn't uploaded a logo) ──
const DefaultLogo: React.FC<{ size: number }> = ({ size }) => (
<svg width={size} height={size} viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" rx="12" fill="#2563EB" />
<rect x="16" y="13" width="3.6" height="22" rx="1.8" fill="white" />
<rect x="16" y="13" width="16" height="3.6" rx="1.8" fill="white" />
<rect x="16" y="22.2" width="11" height="3.6" rx="1.8" fill="white" fillOpacity="0.75" />
<path d="M30 29L35.5 32L30 35Z" fill="white" fillOpacity="0.9" />
</svg>
);
// Deterministic glitter field — each particle flies in from the edge, gathers at
// the logo, then disperses into an ambient orbit (the classic glitter-dust reveal).
const GLITTER = Array.from({ length: 150 }).map((_, i) => ({
i,
angleIn: rand(i) * Math.PI * 2,
distIn: 520 + rand(i + 7) * 460,
// gather target: a tight cluster over the logo
tx: (rand(i + 11) - 0.5) * 360,
ty: (rand(i + 19) - 0.5) * 240,
// ambient orbit it settles into
ambAngle: rand(i + 23) * Math.PI * 2,
ambR: 230 + rand(i + 29) * 320,
size: 1.6 + rand(i + 3) * 4.5,
delay: (i % 18) * 0.9,
speed: 0.4 + rand(i + 5) * 1.2,
}));
const Glitter: React.FC<{ accent: string; secondary: string; gold: string }> = ({
accent,
secondary,
gold,
}) => {
const frame = useCurrentFrame();
const { width, height } = useVideoConfig();
const L = useLayout();
const cx = width / 2;
const cy = height / 2;
return (
<AbsoluteFill>
<svg width={width} height={height} style={{ overflow: "visible" }}>
{GLITTER.map((p) => {
const conv = interpolate(frame, [p.delay, p.delay + 34], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
const disp = interpolate(frame, [46, 86], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.inOut(Easing.quad),
});
// start (far out) → gather cluster → ambient orbit
const sx = cx + Math.cos(p.angleIn) * L.vmin(p.distIn);
const sy = cy + Math.sin(p.angleIn) * L.vmin(p.distIn);
const gx = cx + L.vmin(p.tx);
const gy = cy + L.vmin(p.ty);
const ax = cx + Math.cos(p.ambAngle + frame * 0.004 * p.speed) * L.vmin(p.ambR);
const ay = cy + Math.sin(p.ambAngle + frame * 0.004 * p.speed) * L.vmin(p.ambR);
const tgtX = gx + (ax - gx) * disp;
const tgtY = gy + (ay - gy) * disp;
const x = sx + (tgtX - sx) * conv;
const y = sy + (tgtY - sy) * conv;
const twinkle = 0.3 + 0.7 * Math.abs(Math.sin((frame + p.i * 13) / (6 + (p.i % 5))));
const appear = interpolate(frame, [p.delay, p.delay + 10], [0, 1], { extrapolateRight: "clamp" });
const c = p.i % 4 === 0 ? gold : p.i % 3 === 0 ? secondary : accent;
const r = L.vmin(p.size) * (0.7 + conv * 0.5);
return (
<circle
key={p.i}
cx={x}
cy={y}
r={r}
fill={c}
opacity={twinkle * appear}
style={{ filter: `drop-shadow(0 0 ${r * 2.6}px ${c})` }}
/>
);
})}
</svg>
</AbsoluteFill>
);
};
export const GlitterReveal: React.FC<Props> = ({
brandText,
tagline,
logoUrl,
accentColor,
secondaryColor,
backgroundColor,
textColor,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const L = useLayout();
const gold = "#fcd34d";
// Logo reveal (the glitter gathers ~frame 44, then the logo emerges).
const logoSpring = spring({ frame: frame - 42, fps, config: { damping: 13, stiffness: 95, mass: 0.9 } });
const logoScale = interpolate(logoSpring, [0, 1], [0.55, 1]);
const logoOpacity = interpolate(frame, [42, 60], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
// Bright convergence flash.
const flash = interpolate(frame, [40, 47, 60], [0, 0.85, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
// Core glow that breathes behind the logo.
const glow = interpolate(frame, [44, 70], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const breathe = 1 + 0.05 * Math.sin(frame / 16);
// Shine sweep across the logo at reveal.
const sweepX = interpolate(frame, [58, 88], [-L.vmin(360), L.vmin(360)], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.inOut(Easing.cubic) });
const sweepOp = interpolate(frame, [58, 66, 82, 90], [0, 0.9, 0.9, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
// Text.
const brandY = interpolate(frame, [70, 92], [L.vmin(70), 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.cubic) });
const brandOpacity = interpolate(frame, [70, 90], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const tagOpacity = interpolate(frame, [92, 112], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const tagSpacing = interpolate(frame, [92, 120], [L.vmin(14), L.vmin(6)], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const logoSize = L.vmin(240);
const hasLogo = Boolean(logoUrl && logoUrl.trim().length > 0);
return (
<AbsoluteFill style={{ backgroundColor, fontFamily: FONT, direction: "rtl" }}>
{/* Deep radial backdrop */}
<AbsoluteFill style={{ background: `radial-gradient(circle at 50% 45%, ${hexToRgba(accentColor, 0.16)} 0%, ${hexToRgba(secondaryColor, 0.06)} 32%, ${backgroundColor} 66%)` }} />
{/* Core glow */}
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
<div style={{ width: logoSize * 2.2 * glow * breathe, height: logoSize * 2.2 * glow * breathe, borderRadius: "50%", background: `radial-gradient(circle, ${hexToRgba(accentColor, 0.5)} 0%, ${hexToRgba(gold, 0.18)} 35%, transparent 70%)`, filter: `blur(${L.vmin(10)}px)` }} />
</AbsoluteFill>
<Glitter accent={accentColor} secondary={secondaryColor} gold={gold} />
{/* Logo */}
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
<div style={{ transform: `scale(${logoScale})`, opacity: logoOpacity, filter: `drop-shadow(0 0 ${L.vmin(24)}px ${hexToRgba(accentColor, 0.7)})`, display: "flex", alignItems: "center", justifyContent: "center", width: logoSize, height: logoSize }}>
{hasLogo ? (
<Img src={logoUrl} style={{ maxWidth: logoSize, maxHeight: logoSize, objectFit: "contain" }} />
) : (
<DefaultLogo size={logoSize} />
)}
</div>
</AbsoluteFill>
{/* Convergence flash */}
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", pointerEvents: "none" }}>
<div style={{ width: logoSize * 2.4, height: logoSize * 2.4, borderRadius: "50%", background: `radial-gradient(circle, ${hexToRgba("#ffffff", flash)} 0%, ${hexToRgba(gold, flash * 0.6)} 25%, transparent 60%)`, mixBlendMode: "screen" }} />
</AbsoluteFill>
{/* Shine sweep */}
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", overflow: "hidden" }}>
<div style={{ position: "absolute", width: L.vmin(140), height: logoSize * 1.4, transform: `translateX(${sweepX}px) rotate(18deg)`, background: `linear-gradient(90deg, transparent, ${hexToRgba(mixHex(textColor, gold, 0.4), 0.95)}, transparent)`, filter: `blur(${L.vmin(18)}px)`, opacity: sweepOp, mixBlendMode: "screen" }} />
</AbsoluteFill>
{/* Brand text + tagline */}
<AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "center", flexDirection: "column", paddingBottom: L.vmin(130) }}>
<div style={{ transform: `translateY(${brandY}px)`, opacity: brandOpacity, fontWeight: 900, fontSize: L.vmin(82), color: textColor, textAlign: "center", textShadow: `0 0 ${L.vmin(16)}px ${hexToRgba(accentColor, 0.7)}` }}>
{brandText}
</div>
<div style={{ marginTop: L.vmin(18), opacity: tagOpacity, fontWeight: 500, fontSize: L.vmin(26), letterSpacing: tagSpacing, color: hexToRgba(textColor, 0.8), textAlign: "center" }}>
{tagline}
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};
@@ -0,0 +1,245 @@
import React from "react";
import {
AbsoluteFill,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
Easing,
} from "remotion";
import { zColor } from "@remotion/zod-types";
import { z } from "zod";
import { hexToRgba, mixHex, rand } from "../lib/anim";
export const gradientPromoSchema = z.object({
eyebrow: z.string(),
headline: z.string(),
subheadline: z.string(),
ctaText: z.string(),
badgeText: z.string(),
accentColor: zColor(),
secondaryColor: zColor(),
backgroundColor: zColor(),
});
type Props = z.infer<typeof gradientPromoSchema>;
// ── Drifting mesh-gradient blobs ─────────────────────────────────────────────
const BLOBS = [
{ baseX: 0.2, baseY: 0.3, r: 520, useAccent: true },
{ baseX: 0.78, baseY: 0.28, r: 460, useAccent: false },
{ baseX: 0.62, baseY: 0.8, r: 580, useAccent: true },
{ baseX: 0.12, baseY: 0.82, r: 420, useAccent: false },
];
const MeshBackground: React.FC<{
bg: string;
accent: string;
secondary: string;
}> = ({ bg, accent, secondary }) => {
const frame = useCurrentFrame();
const { width, height } = useVideoConfig();
return (
<AbsoluteFill style={{ backgroundColor: bg, overflow: "hidden" }}>
{BLOBS.map((b, i) => {
const dx = Math.sin(frame / (50 + i * 12) + rand(i) * 6) * 70;
const dy = Math.cos(frame / (60 + i * 9) + rand(i + 4) * 6) * 60;
const color = b.useAccent ? accent : secondary;
return (
<div
key={i}
style={{
position: "absolute",
left: b.baseX * width - b.r / 2 + dx,
top: b.baseY * height - b.r / 2 + dy,
width: b.r,
height: b.r,
borderRadius: "50%",
background: `radial-gradient(circle, ${hexToRgba(
color,
0.5
)} 0%, transparent 68%)`,
filter: "blur(40px)",
}}
/>
);
})}
{/* Subtle grain/vignette to ground the gradients */}
<AbsoluteFill
style={{ boxShadow: "inset 0 0 600px 180px rgba(0,0,0,0.55)" }}
/>
</AbsoluteFill>
);
};
// ── Spinning offer badge in the corner ───────────────────────────────────────
const Badge: React.FC<{ text: string; accent: string; secondary: string }> = ({
text,
accent,
secondary,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const pop = spring({
frame: frame - 26,
fps,
config: { damping: 11, mass: 0.6, stiffness: 140 },
});
const scale = interpolate(pop, [0, 1], [0, 1]);
const wobble = Math.sin(frame / 16) * 6;
return (
<div
style={{
position: "absolute",
top: 90,
right: 130,
width: 190,
height: 190,
transform: `scale(${scale}) rotate(${wobble - 12}deg)`,
borderRadius: "50%",
background: `linear-gradient(135deg, ${accent}, ${secondary})`,
display: "flex",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
boxShadow: `0 0 50px ${hexToRgba(accent, 0.6)}`,
fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif",
fontWeight: 800,
fontSize: 30,
lineHeight: 1.1,
letterSpacing: 1,
color: "#fff",
padding: 18,
}}
>
{text}
</div>
);
};
export const GradientPromo: React.FC<Props> = ({
eyebrow,
headline,
subheadline,
ctaText,
badgeText,
accentColor,
secondaryColor,
backgroundColor,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const reveal = (delay: number) =>
spring({ frame: frame - delay, fps, config: { damping: 18, stiffness: 90 } });
const eyebrowOp = interpolate(reveal(6), [0, 1], [0, 1]);
const eyebrowX = interpolate(reveal(6), [0, 1], [-40, 0]);
const headSpring = reveal(14);
const headY = interpolate(headSpring, [0, 1], [60, 0]);
const headOp = interpolate(headSpring, [0, 1], [0, 1]);
const subOp = interpolate(reveal(28), [0, 1], [0, 1]);
const subY = interpolate(reveal(28), [0, 1], [30, 0]);
const ctaSpring = reveal(40);
const ctaScale = interpolate(ctaSpring, [0, 1], [0.7, 1]);
const ctaOp = interpolate(ctaSpring, [0, 1], [0, 1]);
const ctaGlow = 0.4 + 0.3 * Math.sin(frame / 12);
return (
<AbsoluteFill>
<MeshBackground
bg={backgroundColor}
accent={accentColor}
secondary={secondaryColor}
/>
<Badge text={badgeText} accent={accentColor} secondary={secondaryColor} />
<AbsoluteFill
style={{
justifyContent: "center",
flexDirection: "column",
paddingLeft: 150,
paddingRight: 150,
}}
>
<div
style={{
transform: `translateX(${eyebrowX}px)`,
opacity: eyebrowOp,
fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif",
fontWeight: 700,
fontSize: 26,
letterSpacing: 8,
textTransform: "uppercase",
color: mixHex(accentColor, secondaryColor, 0.5),
marginBottom: 24,
}}
>
{eyebrow}
</div>
<div
style={{
transform: `translateY(${headY}px)`,
opacity: headOp,
fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif",
fontWeight: 800,
fontSize: 110,
lineHeight: 1.02,
letterSpacing: -2,
color: "#fff",
maxWidth: 1100,
textShadow: `0 6px 40px ${hexToRgba(accentColor, 0.4)}`,
}}
>
{headline}
</div>
<div
style={{
transform: `translateY(${subY}px)`,
opacity: subOp,
marginTop: 30,
fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif",
fontWeight: 400,
fontSize: 32,
lineHeight: 1.4,
color: hexToRgba("#ffffff", 0.75),
maxWidth: 820,
}}
>
{subheadline}
</div>
<div
style={{
marginTop: 56,
transform: `scale(${ctaScale})`,
transformOrigin: "left center",
opacity: ctaOp,
alignSelf: "flex-start",
padding: "22px 56px",
borderRadius: 999,
background: `linear-gradient(135deg, ${accentColor}, ${secondaryColor})`,
boxShadow: `0 0 ${30 + ctaGlow * 40}px ${hexToRgba(
accentColor,
ctaGlow
)}`,
fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif",
fontWeight: 700,
fontSize: 30,
letterSpacing: 1,
color: "#fff",
}}
>
{ctaText}
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};
@@ -0,0 +1,71 @@
import React from "react";
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
import { z } from "zod";
import { colorSchema } from "../lib/branding";
import { FONT } from "../lib/fonts";
import { useLayout } from "../lib/aspect";
import { BrandBackground, useReveal } from "../lib/kit";
import { hexToRgba, rand } from "../lib/anim";
export const happyBirthdaySchema = z.object({
greeting: z.string(),
name: z.string(),
message: z.string(),
...colorSchema,
});
type Props = z.infer<typeof happyBirthdaySchema>;
const CONFETTI = Array.from({ length: 60 });
export const HappyBirthday: React.FC<Props> = ({
greeting,
name,
message,
accentColor,
secondaryColor,
backgroundColor,
textColor,
}) => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
const L = useLayout();
const greet = useReveal(8, { from: 30 });
const namePop = spring({ frame: frame - 26, fps, config: { damping: 10, stiffness: 120, mass: 0.8 } });
const nameScale = interpolate(namePop, [0, 1], [0.3, 1]);
const msg = useReveal(56, { from: 24 });
const colors = [accentColor, secondaryColor, "#fde047", "#fb7185", "#34d399"];
return (
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl" }}>
<BrandBackground bg={backgroundColor} accent={accentColor} secondary={secondaryColor} particles={0} />
{/* Confetti rain */}
<AbsoluteFill>
{CONFETTI.map((_, i) => {
const startDelay = (i % 12) * 2;
const t = Math.max(0, frame - startDelay);
const x = rand(i) * width + Math.sin((frame + i * 20) / 18) * L.vmin(30);
const y = ((rand(i + 5) * height) + t * (2 + rand(i) * 3) * L.unit) % (height + 40) - 20;
const sz = L.vmin(8 + (i % 4) * 4);
const rot = (frame + i * 30) * (i % 2 ? 4 : -4);
return <div key={i} style={{ position: "absolute", left: x, top: y, width: sz, height: sz * 0.6, background: colors[i % colors.length], transform: `rotate(${rot}deg)`, opacity: 0.9, borderRadius: i % 3 === 0 ? "50%" : 2 }} />;
})}
</AbsoluteFill>
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", flexDirection: "column", padding: L.vmin(70) }}>
<div style={{ fontSize: L.vmin(70), marginBottom: L.vmin(10) }}>🎂</div>
<div style={{ opacity: greet.opacity, transform: `translateY(${greet.y}px)`, fontWeight: 700, fontSize: L.vmin(48), color: hexToRgba(textColor, 0.9), textAlign: "center" }}>
{greeting}
</div>
<div style={{ transform: `scale(${nameScale})`, margin: `${L.vmin(14)}px 0`, fontWeight: 900, fontSize: L.vmin(120), lineHeight: 1.05, textAlign: "center", color: textColor, backgroundImage: `linear-gradient(120deg, ${accentColor}, ${secondaryColor})`, WebkitBackgroundClip: "text", backgroundClip: "text", WebkitTextFillColor: "transparent", filter: `drop-shadow(0 ${L.vmin(6)}px ${L.vmin(30)}px ${hexToRgba(accentColor, 0.5)})` }}>
{name}
</div>
<div style={{ opacity: msg.opacity, transform: `translateY(${msg.y}px)`, fontWeight: 500, fontSize: L.vmin(32), color: hexToRgba(textColor, 0.82), textAlign: "center", maxWidth: L.vmin(820) }}>
{message}
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};
@@ -0,0 +1,160 @@
import React, { useMemo } from "react";
import {
AbsoluteFill,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
Easing,
} from "remotion";
import { ThreeCanvas } from "@remotion/three";
import * as THREE from "three";
import { z } from "zod";
import { colorSchema } from "../lib/branding";
import { FONT } from "../lib/fonts";
import { useLayout } from "../lib/aspect";
import { hexToRgba, mixHex, rand } from "../lib/anim";
export const hero3DSchema = z.object({
brandText: z.string(),
tagline: z.string(),
...colorSchema,
});
type Props = z.infer<typeof hero3DSchema>;
const SHAPES = ["icosa", "octa", "dodeca", "box", "torus"] as const;
// One floating polyhedron, drifting + self-rotating (animated off the timeline,
// not R3F's render loop, so renders stay deterministic).
const FloatingShape: React.FC<{ i: number; accent: string; secondary: string }> = ({ i, accent, secondary }) => {
const frame = useCurrentFrame();
const kind = SHAPES[i % SHAPES.length];
const ang = rand(i) * Math.PI * 2;
const radius = 2.6 + rand(i + 5) * 2.4;
const depth = -1 - rand(i + 9) * 4;
const x = Math.cos(ang + frame * 0.004 * (0.5 + rand(i) * 0.6)) * radius;
const y = Math.sin(ang * 1.7 + frame * 0.006) * (1.4 + rand(i + 3) * 1.4);
const s = 0.18 + rand(i + 7) * 0.35;
const col = i % 2 === 0 ? accent : secondary;
const appear = interpolate(frame, [8 + i * 2, 36 + i * 2], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
return (
<mesh position={[x, y, depth]} rotation={[frame * 0.02 * (1 + rand(i)), frame * 0.025, 0]} scale={s * appear}>
{kind === "icosa" && <icosahedronGeometry args={[1, 0]} />}
{kind === "octa" && <octahedronGeometry args={[1, 0]} />}
{kind === "dodeca" && <dodecahedronGeometry args={[1, 0]} />}
{kind === "box" && <boxGeometry args={[1.4, 1.4, 1.4]} />}
{kind === "torus" && <torusGeometry args={[0.9, 0.32, 16, 32]} />}
<meshStandardMaterial color={col} metalness={0.5} roughness={0.25} flatShading emissive={col} emissiveIntensity={0.12} />
</mesh>
);
};
const Bokeh: React.FC<{ accent: string; secondary: string }> = ({ accent, secondary }) => {
const frame = useCurrentFrame();
return (
<group>
{Array.from({ length: 16 }).map((_, i) => {
const x = (rand(i) - 0.5) * 12;
const y = (rand(i + 11) - 0.5) * 7;
const z = -6 - rand(i + 4) * 5;
const tw = 0.3 + 0.5 * Math.abs(Math.sin((frame + i * 20) / 25));
const col = i % 3 === 0 ? secondary : accent;
return (
<mesh key={i} position={[x, y, z]} scale={0.25 + rand(i + 2) * 0.5}>
<sphereGeometry args={[1, 12, 12]} />
<meshBasicMaterial color={col} transparent opacity={tw * 0.5} />
</mesh>
);
})}
</group>
);
};
const Scene: React.FC<{ accent: string; secondary: string }> = ({ accent, secondary }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const pop = spring({ frame: frame - 6, fps, config: { damping: 12, stiffness: 80, mass: 1 } });
const heroScale = interpolate(pop, [0, 1], [0, 1.35]);
const heroSpin = frame * 0.02;
const heroColor = useMemo(() => mixHex(accent, secondary, 0.25), [accent, secondary]);
return (
<group rotation={[0, Math.sin(frame / 90) * 0.25, 0]}>
<ambientLight intensity={0.45} />
<directionalLight position={[4, 6, 6]} intensity={2.2} color="#ffffff" />
<pointLight position={[-5, -1, 4]} intensity={45} color={secondary} />
<pointLight position={[5, 2, 2]} intensity={35} color={accent} />
<Bokeh accent={accent} secondary={secondary} />
{Array.from({ length: 10 }).map((_, i) => (
<FloatingShape key={i} i={i} accent={accent} secondary={secondary} />
))}
{/* Hero faceted gem */}
<mesh rotation={[heroSpin * 0.6, heroSpin, heroSpin * 0.2]} scale={heroScale}>
<icosahedronGeometry args={[1, 0]} />
<meshStandardMaterial
color={heroColor}
metalness={0.55}
roughness={0.14}
flatShading
emissive={accent}
emissiveIntensity={0.18}
/>
</mesh>
{/* Inner glow core */}
<mesh scale={heroScale * 0.55}>
<sphereGeometry args={[1, 24, 24]} />
<meshBasicMaterial color={mixHex(accent, "#ffffff", 0.4)} transparent opacity={0.5} />
</mesh>
</group>
);
};
export const Hero3D: React.FC<Props> = ({
brandText,
tagline,
accentColor,
secondaryColor,
backgroundColor,
textColor,
}) => {
const frame = useCurrentFrame();
const { width, height } = useVideoConfig();
const L = useLayout();
const brandY = interpolate(frame, [70, 92], [L.vmin(60), 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.cubic) });
const brandOp = interpolate(frame, [70, 90], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const tagOp = interpolate(frame, [92, 114], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const tagSpacing = interpolate(frame, [92, 122], [L.vmin(14), L.vmin(6)], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
return (
<AbsoluteFill style={{ backgroundColor }}>
{/* gradient vignette behind the 3D */}
<AbsoluteFill style={{ background: `radial-gradient(circle at 50% 42%, ${hexToRgba(accentColor, 0.18)} 0%, ${hexToRgba(secondaryColor, 0.05)} 35%, ${backgroundColor} 70%)` }} />
<ThreeCanvas
width={width}
height={height}
camera={{ position: [0, 0, 7], fov: 55 }}
style={{ position: "absolute", inset: 0 }}
gl={{ toneMapping: THREE.ACESFilmicToneMapping, antialias: true }}
>
<Scene accent={accentColor} secondary={secondaryColor} />
</ThreeCanvas>
{/* 2D text overlay (crisp Persian via Vazirmatn) */}
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl", alignItems: "center", justifyContent: "flex-end", paddingBottom: L.vmin(170) }}>
<div style={{ transform: `translateY(${brandY}px)`, opacity: brandOp, fontWeight: 900, fontSize: L.vmin(92), color: textColor, textShadow: `0 0 ${L.vmin(24)}px ${hexToRgba(accentColor, 0.7)}` }}>
{brandText}
</div>
<div style={{ marginTop: L.vmin(18), opacity: tagOp, fontWeight: 500, fontSize: L.vmin(28), letterSpacing: tagSpacing, color: hexToRgba(textColor, 0.82) }}>
{tagline}
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};
@@ -0,0 +1,388 @@
import React from "react";
import {
AbsoluteFill,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
Easing,
} from "remotion";
import { zColor } from "@remotion/zod-types";
import { z } from "zod";
export const illuminatedCirclesSchema = z.object({
logoText: z.string(),
tagline: z.string(),
accentColor: zColor(),
secondaryColor: zColor(),
backgroundColor: zColor(),
});
type Props = z.infer<typeof illuminatedCirclesSchema>;
// ── Small helpers ────────────────────────────────────────────────────────────
/** Mix two hex colors by t (0..1). Cheap linear blend, good enough for glows. */
function mixHex(a: string, b: string, t: number): string {
const pa = a.replace("#", "");
const pb = b.replace("#", "");
const ai = parseInt(pa, 16);
const bi = parseInt(pb, 16);
const ar = (ai >> 16) & 255;
const ag = (ai >> 8) & 255;
const ab = ai & 255;
const br = (bi >> 16) & 255;
const bg = (bi >> 8) & 255;
const bb = bi & 255;
const r = Math.round(ar + (br - ar) * t);
const g = Math.round(ag + (bg - ag) * t);
const bl = Math.round(ab + (bb - ab) * t);
return `rgb(${r}, ${g}, ${bl})`;
}
function hexToRgba(hex: string, alpha: number): string {
const p = hex.replace("#", "");
const i = parseInt(p, 16);
const r = (i >> 16) & 255;
const g = (i >> 8) & 255;
const b = i & 255;
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
// ── Background: deep radial gradient + drifting nebula + vignette ─────────────
const Background: React.FC<{ bg: string; accent: string; secondary: string }> = ({
bg,
accent,
secondary,
}) => {
const frame = useCurrentFrame();
const drift = Math.sin(frame / 60) * 40;
return (
<AbsoluteFill style={{ backgroundColor: bg }}>
<AbsoluteFill
style={{
background: `radial-gradient(circle at 50% 46%, ${hexToRgba(
accent,
0.18
)} 0%, ${hexToRgba(secondary, 0.06)} 28%, ${bg} 62%)`,
}}
/>
<AbsoluteFill
style={{
background: `radial-gradient(circle at ${50 + drift / 20}% 70%, ${hexToRgba(
secondary,
0.1
)} 0%, transparent 45%)`,
}}
/>
{/* Vignette */}
<AbsoluteFill
style={{
boxShadow: "inset 0 0 600px 200px rgba(0,0,0,0.85)",
}}
/>
</AbsoluteFill>
);
};
// ── Concentric illuminated rings ─────────────────────────────────────────────
const RING_DEFS = [
{ r: 150, speed: 0.5, dash: "2 14", width: 2, op: 0.9 },
{ r: 230, speed: -0.32, dash: "1 22", width: 1.5, op: 0.7 },
{ r: 320, speed: 0.22, dash: "3 28", width: 2.5, op: 0.85 },
{ r: 420, speed: -0.16, dash: "1 40", width: 1.5, op: 0.55 },
{ r: 520, speed: 0.12, dash: "2 60", width: 1.5, op: 0.4 },
];
const Rings: React.FC<{ accent: string; secondary: string }> = ({
accent,
secondary,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const entrance = spring({
frame,
fps,
config: { damping: 14, mass: 0.9, stiffness: 90 },
});
const scale = interpolate(entrance, [0, 1], [0.55, 1]);
const groupOpacity = interpolate(frame, [0, 28], [0, 1], {
extrapolateRight: "clamp",
});
return (
<AbsoluteFill
style={{
justifyContent: "center",
alignItems: "center",
opacity: groupOpacity,
transform: `scale(${scale})`,
}}
>
<svg
width={1200}
height={1200}
viewBox="-600 -600 1200 1200"
style={{ overflow: "visible" }}
>
<defs>
<linearGradient id="ringGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor={accent} />
<stop offset="55%" stopColor={mixHex(accent, secondary, 0.5)} />
<stop offset="100%" stopColor={secondary} />
</linearGradient>
</defs>
{RING_DEFS.map((ring, i) => {
const rot = frame * ring.speed;
// Each ring reveals its dash over the first ~30 frames.
const circ = 2 * Math.PI * ring.r;
const draw = interpolate(frame, [4 + i * 4, 34 + i * 4], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
return (
<g key={i} transform={`rotate(${rot})`}>
<circle
cx={0}
cy={0}
r={ring.r}
fill="none"
stroke="url(#ringGrad)"
strokeWidth={ring.width}
strokeDasharray={`${circ * draw} ${circ}`}
strokeLinecap="round"
opacity={ring.op}
style={{
filter: `drop-shadow(0 0 6px ${hexToRgba(accent, 0.9)})`,
}}
/>
</g>
);
})}
</svg>
</AbsoluteFill>
);
};
// ── Orbiting illuminated particles ───────────────────────────────────────────
const PARTICLES = Array.from({ length: 28 }).map((_, i) => {
// Deterministic pseudo-random placement (no Math.random — keeps renders stable).
const a = (i * 137.508 * Math.PI) / 180; // golden angle
const ringRadius = 150 + ((i * 53) % 380);
const size = 2 + ((i * 17) % 5);
const speed = 0.15 + ((i % 5) * 0.06) * (i % 2 === 0 ? 1 : -1);
const phase = (i * 41) % 360;
return { a, ringRadius, size, speed, phase, idx: i };
});
const Particles: React.FC<{ accent: string; secondary: string }> = ({
accent,
secondary,
}) => {
const frame = useCurrentFrame();
const appear = interpolate(frame, [18, 50], [0, 1], {
extrapolateRight: "clamp",
});
return (
<AbsoluteFill
style={{ justifyContent: "center", alignItems: "center", opacity: appear }}
>
<svg width={1400} height={1400} viewBox="-700 -700 1400 1400">
{PARTICLES.map((p) => {
const ang = p.a + (frame * p.speed * Math.PI) / 180;
const x = Math.cos(ang) * p.ringRadius;
const y = Math.sin(ang) * p.ringRadius;
const twinkle =
0.4 + 0.6 * Math.abs(Math.sin((frame + p.phase) / 9));
const color = p.idx % 3 === 0 ? secondary : accent;
return (
<circle
key={p.idx}
cx={x}
cy={y}
r={p.size}
fill={color}
opacity={twinkle}
style={{ filter: `drop-shadow(0 0 ${p.size * 2.5}px ${color})` }}
/>
);
})}
</svg>
</AbsoluteFill>
);
};
// ── Central core glow that pulses behind the logo ────────────────────────────
const CoreGlow: React.FC<{ accent: string; secondary: string }> = ({
accent,
secondary,
}) => {
const frame = useCurrentFrame();
const grow = interpolate(frame, [30, 70], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
const breathe = 1 + 0.06 * Math.sin(frame / 14);
const size = 460 * grow * breathe;
return (
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
<div
style={{
width: size,
height: size,
borderRadius: "50%",
background: `radial-gradient(circle, ${hexToRgba(
accent,
0.55
)} 0%, ${hexToRgba(secondary, 0.25)} 35%, transparent 70%)`,
filter: "blur(8px)",
}}
/>
</AbsoluteFill>
);
};
// ── Sweeping light flare across the logo at reveal ───────────────────────────
const LightSweep: React.FC = () => {
const frame = useCurrentFrame();
const x = interpolate(frame, [62, 92], [-900, 900], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.inOut(Easing.cubic),
});
const op = interpolate(frame, [62, 70, 88, 96], [0, 0.85, 0.85, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
<div
style={{
position: "absolute",
width: 220,
height: 420,
transform: `translateX(${x}px) rotate(18deg)`,
background:
"linear-gradient(90deg, transparent, rgba(255,255,255,0.9), transparent)",
filter: "blur(26px)",
opacity: op,
mixBlendMode: "screen",
}}
/>
</AbsoluteFill>
);
};
// ── Logo + tagline reveal ────────────────────────────────────────────────────
const LogoReveal: React.FC<{ logoText: string; tagline: string; accent: string }> = ({
logoText,
tagline,
accent,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const logoSpring = spring({
frame: frame - 55,
fps,
config: { damping: 16, mass: 1, stiffness: 80 },
});
const logoScale = interpolate(logoSpring, [0, 1], [1.25, 1]);
const logoOpacity = interpolate(frame, [55, 78], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const blur = interpolate(frame, [55, 84], [26, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
const tagOpacity = interpolate(frame, [92, 116], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const tagSpacing = interpolate(frame, [92, 130], [22, 10], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
return (
<AbsoluteFill
style={{
justifyContent: "center",
alignItems: "center",
flexDirection: "column",
}}
>
<div
style={{
transform: `scale(${logoScale})`,
opacity: logoOpacity,
filter: `blur(${blur}px)`,
fontFamily:
"'Helvetica Neue', Helvetica, Arial, sans-serif",
fontWeight: 800,
fontSize: 116,
letterSpacing: 8,
color: "#ffffff",
textShadow: `0 0 18px ${hexToRgba(accent, 0.9)}, 0 0 48px ${hexToRgba(
accent,
0.6
)}`,
}}
>
{logoText}
</div>
<div
style={{
marginTop: 26,
opacity: tagOpacity,
fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif",
fontWeight: 400,
fontSize: 26,
letterSpacing: tagSpacing,
color: hexToRgba("#ffffff", 0.82),
textTransform: "uppercase",
}}
>
{tagline}
</div>
</AbsoluteFill>
);
};
// ── Composition root ─────────────────────────────────────────────────────────
export const IlluminatedCircles: React.FC<Props> = ({
logoText,
tagline,
accentColor,
secondaryColor,
backgroundColor,
}) => {
return (
<AbsoluteFill>
<Background
bg={backgroundColor}
accent={accentColor}
secondary={secondaryColor}
/>
<CoreGlow accent={accentColor} secondary={secondaryColor} />
<Rings accent={accentColor} secondary={secondaryColor} />
<Particles accent={accentColor} secondary={secondaryColor} />
<LogoReveal logoText={logoText} tagline={tagline} accent={accentColor} />
<LightSweep />
</AbsoluteFill>
);
};
@@ -0,0 +1,67 @@
import React from "react";
import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion";
import { z } from "zod";
import { colorSchema } from "../lib/branding";
import { FONT } from "../lib/fonts";
import { useLayout } from "../lib/aspect";
import { BrandBackground, useReveal } from "../lib/kit";
import { hexToRgba } from "../lib/anim";
export const instaPromoSchema = z.object({
handle: z.string(),
headline: z.string(),
subtext: z.string(),
cta: z.string(),
...colorSchema,
});
type Props = z.infer<typeof instaPromoSchema>;
export const InstaPromo: React.FC<Props> = ({
handle,
headline,
subtext,
cta,
accentColor,
secondaryColor,
backgroundColor,
textColor,
}) => {
const frame = useCurrentFrame();
const L = useLayout();
const card = useReveal(8, { from: 60, damping: 14 });
const head = useReveal(26, { from: 36 });
const ctaR = useReveal(52, { from: 24, damping: 12 });
const heart = interpolate(frame % 60, [0, 15, 30], [1, 1.25, 1]);
return (
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl" }}>
<BrandBackground bg={backgroundColor} accent={accentColor} secondary={secondaryColor} particles={18} />
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", flexDirection: "column" }}>
{/* Profile chip */}
<div style={{ opacity: card.opacity, transform: `scale(${card.scale})`, display: "flex", alignItems: "center", gap: L.vmin(16), padding: `${L.vmin(14)}px ${L.vmin(26)}px`, borderRadius: 999, background: hexToRgba(textColor, 0.06), border: `${L.vmin(1.5)}px solid ${hexToRgba(textColor, 0.15)}` }}>
<div style={{ width: L.vmin(56), height: L.vmin(56), borderRadius: "50%", background: `conic-gradient(from ${frame * 2}deg, ${accentColor}, ${secondaryColor}, ${accentColor})`, display: "flex", alignItems: "center", justifyContent: "center" }}>
<div style={{ width: L.vmin(44), height: L.vmin(44), borderRadius: "50%", background: backgroundColor, display: "flex", alignItems: "center", justifyContent: "center", fontSize: L.vmin(24) }}>📸</div>
</div>
<span style={{ fontWeight: 800, fontSize: L.vmin(30), color: textColor, direction: "ltr" }}>{handle}</span>
</div>
<div style={{ marginTop: L.vmin(48), opacity: head.opacity, transform: `translateY(${head.y}px)`, fontWeight: 900, fontSize: L.vmin(78), lineHeight: 1.1, color: textColor, textAlign: "center", maxWidth: L.vmin(820), textShadow: `0 ${L.vmin(6)}px ${L.vmin(36)}px ${hexToRgba(accentColor, 0.4)}` }}>
{headline}
</div>
<div style={{ marginTop: L.vmin(22), opacity: head.opacity, fontWeight: 500, fontSize: L.vmin(30), color: hexToRgba(textColor, 0.78), textAlign: "center", maxWidth: L.vmin(720) }}>
{subtext}
</div>
{/* Floating reactions */}
<div style={{ position: "absolute", top: `calc(50% - ${L.vmin(220)}px)`, right: `calc(50% - ${L.vmin(360)}px)`, fontSize: L.vmin(48), transform: `scale(${heart})` }}></div>
<div style={{ marginTop: L.vmin(54), opacity: ctaR.opacity, transform: `scale(${ctaR.scale})`, padding: `${L.vmin(20)}px ${L.vmin(56)}px`, borderRadius: 999, background: `linear-gradient(135deg, ${accentColor}, ${secondaryColor})`, boxShadow: `0 0 ${L.vmin(40)}px ${hexToRgba(accentColor, 0.6)}`, fontWeight: 800, fontSize: L.vmin(32), color: "#fff" }}>
{cta}
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};
@@ -0,0 +1,194 @@
import React from "react";
import {
AbsoluteFill,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
Easing,
} from "remotion";
import { zColor } from "@remotion/zod-types";
import { z } from "zod";
import { hexToRgba, mixHex } from "../lib/anim";
export const kineticQuoteSchema = z.object({
quote: z.string(),
author: z.string(),
accentColor: zColor(),
secondaryColor: zColor(),
backgroundColor: zColor(),
});
type Props = z.infer<typeof kineticQuoteSchema>;
// ── Slowly rotating gradient sheen behind the text ───────────────────────────
const SheenBackground: React.FC<{
bg: string;
accent: string;
secondary: string;
}> = ({ bg, accent, secondary }) => {
const frame = useCurrentFrame();
const angle = (frame * 0.4) % 360;
return (
<AbsoluteFill style={{ backgroundColor: bg }}>
<AbsoluteFill
style={{
background: `linear-gradient(${angle}deg, ${hexToRgba(
accent,
0.16
)}, transparent 55%, ${hexToRgba(secondary, 0.14)})`,
}}
/>
{/* Soft top glow */}
<AbsoluteFill
style={{
background: `radial-gradient(circle at 50% 18%, ${hexToRgba(
accent,
0.22
)} 0%, transparent 50%)`,
}}
/>
<AbsoluteFill
style={{ boxShadow: "inset 0 0 500px 160px rgba(0,0,0,0.7)" }}
/>
</AbsoluteFill>
);
};
// ── Word-by-word reveal of the quote ─────────────────────────────────────────
const Quote: React.FC<{ quote: string; accent: string; secondary: string }> = ({
quote,
accent,
secondary,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const words = quote.split(/\s+/).filter(Boolean);
return (
<div
style={{
display: "flex",
flexWrap: "wrap",
justifyContent: "center",
maxWidth: 880,
gap: "0 18px",
fontFamily: "'Georgia', 'Times New Roman', serif",
fontWeight: 600,
fontSize: 64,
lineHeight: 1.28,
color: "#fff",
textAlign: "center",
}}
>
{words.map((w, i) => {
const start = 12 + i * 4;
const s = spring({
frame: frame - start,
fps,
config: { damping: 18, mass: 0.7, stiffness: 110 },
});
const y = interpolate(s, [0, 1], [28, 0]);
const op = interpolate(s, [0, 1], [0, 1]);
return (
<span
key={i}
style={{
display: "inline-block",
transform: `translateY(${y}px)`,
opacity: op,
color: i % 5 === 2 ? mixHex(accent, secondary, 0.4) : "#fff",
}}
>
{w}
</span>
);
})}
</div>
);
};
export const KineticQuote: React.FC<Props> = ({
quote,
author,
accentColor,
secondaryColor,
backgroundColor,
}) => {
const frame = useCurrentFrame();
const words = quote.split(/\s+/).filter(Boolean);
// The decorative rule + author appear once the quote has finished landing.
const tail = 12 + words.length * 4 + 8;
const ruleW = interpolate(frame, [tail, tail + 18], [0, 120], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
const authorOp = interpolate(frame, [tail + 10, tail + 30], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<AbsoluteFill>
<SheenBackground
bg={backgroundColor}
accent={accentColor}
secondary={secondaryColor}
/>
<AbsoluteFill
style={{
justifyContent: "center",
alignItems: "center",
flexDirection: "column",
padding: 80,
}}
>
{/* Opening quotation mark */}
<div
style={{
fontFamily: "'Georgia', serif",
fontSize: 160,
lineHeight: 0.4,
marginBottom: 36,
color: hexToRgba(accentColor, 0.85),
opacity: interpolate(frame, [0, 14], [0, 1], {
extrapolateRight: "clamp",
}),
}}
>
&ldquo;
</div>
<Quote quote={quote} accent={accentColor} secondary={secondaryColor} />
<div
style={{
width: ruleW,
height: 3,
marginTop: 48,
borderRadius: 2,
background: `linear-gradient(90deg, ${accentColor}, ${secondaryColor})`,
}}
/>
<div
style={{
marginTop: 22,
opacity: authorOp,
fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif",
fontWeight: 500,
fontSize: 28,
letterSpacing: 4,
textTransform: "uppercase",
color: hexToRgba("#ffffff", 0.78),
}}
>
{author}
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};
@@ -0,0 +1,154 @@
import React from "react";
import {
AbsoluteFill,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
Easing,
} from "remotion";
import { z } from "zod";
import { colorSchema } from "../lib/branding";
import { FONT } from "../lib/fonts";
import { useLayout } from "../lib/aspect";
import { hexToRgba, mixHex, rand } from "../lib/anim";
export const logoMotionSchema = z.object({
brandText: z.string(),
tagline: z.string(),
...colorSchema,
});
type Props = z.infer<typeof logoMotionSchema>;
export const LogoMotion: React.FC<Props> = ({
brandText,
tagline,
accentColor,
secondaryColor,
backgroundColor,
textColor,
}) => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
const L = useLayout();
// Background: radial brand glow + drifting nebula.
const drift = Math.sin(frame / 50) * 30;
// Logo entrance.
const logoSpring = spring({ frame, fps, config: { damping: 14, stiffness: 90, mass: 0.9 } });
const ringScale = interpolate(logoSpring, [0, 1], [0.4, 1]);
const ringOpacity = interpolate(frame, [0, 22], [0, 1], { extrapolateRight: "clamp" });
const wordSpring = spring({ frame: frame - 22, fps, config: { damping: 16, stiffness: 80 } });
const wordScale = interpolate(wordSpring, [0, 1], [1.18, 1]);
const wordOpacity = interpolate(frame, [22, 42], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const wordBlur = interpolate(frame, [22, 46], [16, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.cubic) });
const tagOpacity = interpolate(frame, [50, 72], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const tagSpacing = interpolate(frame, [50, 80], [L.vmin(14), L.vmin(6)], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.cubic) });
// Light sweep across the wordmark at reveal.
const sweepX = interpolate(frame, [44, 74], [-L.vmin(700), L.vmin(700)], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.inOut(Easing.cubic) });
const sweepOp = interpolate(frame, [44, 52, 70, 78], [0, 0.8, 0.8, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const ringR = L.vmin(190);
return (
<AbsoluteFill style={{ backgroundColor, fontFamily: FONT, direction: "rtl" }}>
{/* Brand glow */}
<AbsoluteFill
style={{
background: `radial-gradient(circle at 50% 46%, ${hexToRgba(accentColor, 0.22)} 0%, ${hexToRgba(secondaryColor, 0.08)} 30%, ${backgroundColor} 64%)`,
}}
/>
<AbsoluteFill
style={{
background: `radial-gradient(circle at ${50 + drift / 18}% 72%, ${hexToRgba(secondaryColor, 0.12)} 0%, transparent 45%)`,
}}
/>
{/* Orbiting sparks */}
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
<svg width={width} height={height} style={{ overflow: "visible" }}>
{Array.from({ length: 26 }).map((_, i) => {
const ang = (i * 137.5 * Math.PI) / 180 + (frame * (0.1 + (i % 4) * 0.04) * Math.PI) / 180;
const rr = L.vmin(150) + ((i * 47) % L.vmin(360));
const cx = width / 2 + Math.cos(ang) * rr;
const cy = height / 2 + Math.sin(ang) * rr;
const tw = 0.3 + 0.6 * Math.abs(Math.sin((frame + i * 18) / 10));
const appear = interpolate(frame, [16, 44], [0, 1], { extrapolateRight: "clamp" });
const c = i % 3 === 0 ? secondaryColor : accentColor;
const s = L.vmin(2 + (i % 4));
return <circle key={i} cx={cx} cy={cy} r={s} fill={c} opacity={tw * appear} style={{ filter: `drop-shadow(0 0 ${s * 2.5}px ${c})` }} />;
})}
</svg>
</AbsoluteFill>
{/* Concentric brand ring */}
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", opacity: ringOpacity, transform: `scale(${ringScale})` }}>
<svg width={ringR * 3} height={ringR * 3} viewBox={`${-ringR * 1.5} ${-ringR * 1.5} ${ringR * 3} ${ringR * 3}`} style={{ overflow: "visible" }}>
<defs>
<linearGradient id="lm-grad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor={accentColor} />
<stop offset="100%" stopColor={secondaryColor} />
</linearGradient>
</defs>
{[ringR, ringR * 0.74].map((r, i) => {
const circ = 2 * Math.PI * r;
const draw = interpolate(frame, [4 + i * 5, 30 + i * 5], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.cubic) });
return (
<circle key={i} cx={0} cy={0} r={r} fill="none" stroke="url(#lm-grad)" strokeWidth={L.vmin(2.5 - i)} strokeDasharray={`${circ * draw} ${circ}`} strokeLinecap="round" transform={`rotate(${frame * (i ? -0.4 : 0.3)})`} style={{ filter: `drop-shadow(0 0 ${L.vmin(6)}px ${hexToRgba(accentColor, 0.8)})` }} />
);
})}
</svg>
</AbsoluteFill>
{/* Wordmark + tagline */}
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", flexDirection: "column" }}>
<div
style={{
transform: `scale(${wordScale})`,
opacity: wordOpacity,
filter: `blur(${wordBlur}px)`,
fontWeight: 900,
fontSize: L.vmin(108),
color: textColor,
textShadow: `0 0 ${L.vmin(16)}px ${hexToRgba(accentColor, 0.9)}, 0 0 ${L.vmin(42)}px ${hexToRgba(accentColor, 0.55)}`,
lineHeight: 1.1,
}}
>
{brandText}
</div>
<div
style={{
marginTop: L.vmin(22),
opacity: tagOpacity,
fontWeight: 500,
fontSize: L.vmin(28),
letterSpacing: tagSpacing,
color: hexToRgba(textColor, 0.82),
}}
>
{tagline}
</div>
</AbsoluteFill>
{/* Light sweep */}
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", overflow: "hidden" }}>
<div
style={{
position: "absolute",
width: L.vmin(180),
height: L.vmin(420),
transform: `translateX(${sweepX}px) rotate(18deg)`,
background: `linear-gradient(90deg, transparent, ${mixHex(textColor, accentColor, 0.2)}, transparent)`,
filter: `blur(${L.vmin(24)}px)`,
opacity: sweepOp,
mixBlendMode: "screen",
}}
/>
</AbsoluteFill>
</AbsoluteFill>
);
};
@@ -0,0 +1,335 @@
import React from "react";
import {
AbsoluteFill,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
Easing,
} from "remotion";
import { ThreeCanvas } from "@remotion/three";
import { Environment, Lightformer, MeshReflectorMaterial, RoundedBox } from "@react-three/drei";
import { EffectComposer, Bloom, DepthOfField, Vignette } from "@react-three/postprocessing";
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, rand } from "../lib/anim";
export const nowruz3DSchema = z.object({
greeting: z.string(),
subtitle: z.string(),
message: z.string(),
...colorSchema,
});
type Props = z.infer<typeof nowruz3DSchema>;
const GOLD = "#f5c542";
const RED = "#e23b3b";
const SKIN = "#f0b486";
const GREEN = "#4fb84f";
// ── Stylized 3D Haji Firuz (primitive-built, clay-render look) ────────────────
const HajiFiruz3D: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const enter = spring({ frame: frame - 20, fps, config: { damping: 14, stiffness: 60 } });
const bob = Math.abs(Math.sin(frame / 7)) * 0.12 * enter;
const sway = Math.sin(frame / 7) * 0.08 * enter;
const armSwing = Math.sin(frame / 3.2) * 0.5;
const y = -0.5 + bob;
return (
<group position={[0, y, 0]} rotation={[0, sway, 0]} scale={enter}>
{/* tunic (tapered) */}
<mesh position={[0, 0.55, 0]} castShadow>
<cylinderGeometry args={[0.32, 0.6, 1.1, 32]} />
<meshStandardMaterial color={RED} roughness={0.55} metalness={0.05} />
</mesh>
{/* gold hem + sash */}
<mesh position={[0, 0.05, 0]}>
<cylinderGeometry args={[0.6, 0.62, 0.12, 32]} />
<meshStandardMaterial color={GOLD} roughness={0.25} metalness={0.85} />
</mesh>
<mesh position={[0, 0.55, 0.0]}>
<torusGeometry args={[0.42, 0.05, 12, 32]} />
<meshStandardMaterial color={GOLD} roughness={0.25} metalness={0.85} />
</mesh>
{/* buttons */}
{[0.75, 0.6, 0.45].map((by, i) => (
<mesh key={i} position={[0, by, 0.46 - i * 0.02]}>
<sphereGeometry args={[0.04, 16, 16]} />
<meshStandardMaterial color={GOLD} metalness={0.9} roughness={0.2} />
</mesh>
))}
{/* head */}
<mesh position={[0, 1.32, 0]} castShadow>
<sphereGeometry args={[0.33, 32, 32]} />
<meshStandardMaterial color={SKIN} roughness={0.6} />
</mesh>
{/* eyes */}
{[-0.12, 0.12].map((ex, i) => (
<mesh key={i} position={[ex, 1.36, 0.3]}>
<sphereGeometry args={[0.045, 16, 16]} />
<meshStandardMaterial color="#2a2030" roughness={0.4} />
</mesh>
))}
{/* smile */}
<mesh position={[0, 1.24, 0.3]} rotation={[0, 0, 0]}>
<torusGeometry args={[0.1, 0.018, 12, 24, Math.PI]} />
<meshStandardMaterial color="#7a3a30" roughness={0.5} />
</mesh>
{/* hat (cone) + band + tip */}
<mesh position={[0, 1.95, 0]} castShadow>
<coneGeometry args={[0.34, 0.8, 32]} />
<meshStandardMaterial color={RED} roughness={0.5} metalness={0.05} />
</mesh>
<mesh position={[0, 1.62, 0]}>
<cylinderGeometry args={[0.35, 0.35, 0.1, 32]} />
<meshStandardMaterial color={GOLD} roughness={0.25} metalness={0.85} />
</mesh>
<mesh position={[0, 2.36, 0]}>
<sphereGeometry args={[0.07, 16, 16]} />
<meshStandardMaterial color={GOLD} metalness={0.9} roughness={0.2} />
</mesh>
{/* right arm (down, swings) */}
<group position={[0.45, 0.95, 0]} rotation={[0, 0, -0.5 + armSwing * 0.3]}>
<mesh position={[0, -0.3, 0]} castShadow>
<capsuleGeometry args={[0.1, 0.5, 8, 16]} />
<meshStandardMaterial color={RED} roughness={0.55} />
</mesh>
<mesh position={[0, -0.62, 0]}>
<sphereGeometry args={[0.12, 16, 16]} />
<meshStandardMaterial color={SKIN} roughness={0.6} />
</mesh>
</group>
{/* left arm raised with tambourine */}
<group position={[-0.45, 1.0, 0.05]} rotation={[0, 0, 0.9 + armSwing * 0.4]}>
<mesh position={[0, 0.28, 0]} castShadow>
<capsuleGeometry args={[0.1, 0.5, 8, 16]} />
<meshStandardMaterial color={RED} roughness={0.55} />
</mesh>
<group position={[0, 0.6, 0]} rotation={[Math.PI / 2, 0, armSwing]}>
<mesh>
<torusGeometry args={[0.26, 0.05, 16, 32]} />
<meshStandardMaterial color={GOLD} metalness={0.85} roughness={0.25} />
</mesh>
<mesh>
<circleGeometry args={[0.24, 32]} />
<meshStandardMaterial color="#fff3d6" roughness={0.4} side={THREE.DoubleSide} transparent opacity={0.85} />
</mesh>
{Array.from({ length: 8 }).map((_, i) => (
<mesh key={i} position={[Math.cos((i / 8) * Math.PI * 2) * 0.26, Math.sin((i / 8) * Math.PI * 2) * 0.26, 0]}>
<sphereGeometry args={[0.035, 12, 12]} />
<meshStandardMaterial color={GOLD} metalness={0.9} roughness={0.2} />
</mesh>
))}
</group>
</group>
{/* legs */}
{[-0.16, 0.16].map((lx, i) => (
<mesh key={i} position={[lx, -0.2, 0]} castShadow>
<capsuleGeometry args={[0.1, 0.3, 8, 16]} />
<meshStandardMaterial color="#2a2f45" roughness={0.6} />
</mesh>
))}
</group>
);
};
// ── Haft-Sin props ───────────────────────────────────────────────────────────
const Candle: React.FC<{ x: number; z: number }> = ({ x, z }) => {
const frame = useCurrentFrame();
const flick = 1 + Math.sin(frame / 4) * 0.12;
return (
<group position={[x, -0.5, z]}>
<mesh castShadow>
<cylinderGeometry args={[0.09, 0.1, 0.5, 24]} />
<meshStandardMaterial color="#fbf0d8" roughness={0.6} />
</mesh>
<mesh position={[0, 0.34, 0]} scale={[1, flick, 1]}>
<coneGeometry args={[0.05, 0.18, 16]} />
<meshStandardMaterial color="#ffd27a" emissive="#ffae3b" emissiveIntensity={4} toneMapped={false} />
</mesh>
<pointLight position={[0, 0.4, 0]} intensity={2.2 * flick} color="#ffb14d" distance={3} />
</group>
);
};
const Egg: React.FC<{ x: number; z: number; color: string }> = ({ x, z, color }) => (
<mesh position={[x, -0.42, z]} scale={[1, 1.3, 1]} castShadow>
<sphereGeometry args={[0.14, 24, 24]} />
<meshStandardMaterial color={color} roughness={0.35} metalness={0.1} />
</mesh>
);
const FishBowl3D: React.FC<{ x: number; z: number }> = ({ x, z }) => {
const frame = useCurrentFrame();
const fishX = Math.sin(frame / 20) * 0.12;
return (
<group position={[x, -0.25, z]}>
{/* water */}
<mesh position={[0, -0.02, 0]}>
<sphereGeometry args={[0.27, 32, 32]} />
<meshStandardMaterial color="#5bc8f0" roughness={0.1} metalness={0.2} transparent opacity={0.55} />
</mesh>
{/* fish */}
<mesh position={[fishX, -0.02, 0]} scale={[0.13, 0.08, 0.05]}>
<sphereGeometry args={[1, 16, 16]} />
<meshStandardMaterial color="#ff5a2c" roughness={0.4} emissive="#ff4a1a" emissiveIntensity={0.2} />
</mesh>
{/* glass */}
<mesh>
<sphereGeometry args={[0.32, 32, 32]} />
<meshPhysicalMaterial color="#ffffff" roughness={0.05} metalness={0} transmission={0.0} transparent opacity={0.18} ior={1.4} />
</mesh>
</group>
);
};
const Sabzeh3D: React.FC<{ x: number; z: number }> = ({ x, z }) => {
const frame = useCurrentFrame();
return (
<group position={[x, -0.5, z]}>
<mesh position={[0, 0.05, 0]} castShadow>
<cylinderGeometry args={[0.26, 0.22, 0.18, 24]} />
<meshStandardMaterial color="#caa06a" roughness={0.7} />
</mesh>
{Array.from({ length: 30 }).map((_, i) => {
const a = rand(i) * Math.PI * 2;
const r = rand(i + 5) * 0.22;
const sway = Math.sin(frame / 18 + i) * 0.08;
return (
<mesh key={i} position={[Math.cos(a) * r, 0.28, Math.sin(a) * r]} rotation={[sway, 0, sway]}>
<coneGeometry args={[0.012, 0.3 + rand(i + 2) * 0.2, 5]} />
<meshStandardMaterial color={i % 2 ? GREEN : "#3da53d"} roughness={0.7} />
</mesh>
);
})}
</group>
);
};
const Petals3D: React.FC = () => {
const frame = useCurrentFrame();
return (
<group>
{Array.from({ length: 30 }).map((_, i) => {
const x = (rand(i) - 0.5) * 9;
const z = (rand(i + 3) - 0.5) * 4 - 1;
const fall = 4 - ((frame * (0.01 + rand(i) * 0.02) + rand(i + 7) * 6) % 7);
const rot = frame * 0.03 * (1 + rand(i));
return (
<mesh key={i} position={[x + Math.sin(frame / 30 + i) * 0.4, fall, z]} rotation={[rot, rot * 0.7, rot * 0.3]}>
<circleGeometry args={[0.06 + rand(i + 1) * 0.05, 8]} />
<meshStandardMaterial color={["#ffd1e8", "#ffc1dd", "#fff0f6"][i % 3]} side={THREE.DoubleSide} roughness={0.6} />
</mesh>
);
})}
</group>
);
};
// ── Scene ────────────────────────────────────────────────────────────────────
const Scene: React.FC = () => {
const frame = useCurrentFrame();
const orbit = Math.sin(frame / 110) * 0.22;
return (
<group rotation={[0, orbit, 0]}>
<ambientLight intensity={0.5} />
<directionalLight position={[4, 8, 4]} intensity={2.4} color="#fff3e0" castShadow shadow-mapSize={[1024, 1024]} />
<pointLight position={[-4, 2, 3]} intensity={20} color="#ff8a5c" />
<pointLight position={[4, 1, -2]} intensity={16} color={GOLD} />
<Environment resolution={256}>
<Lightformer intensity={2} position={[0, 4, -3]} scale={[10, 5, 1]} color="#fff0d8" />
<Lightformer intensity={1.2} position={[-4, 2, 2]} scale={[4, 6, 1]} color="#ffb98a" />
<Lightformer intensity={1.2} position={[4, 2, 2]} scale={[4, 6, 1]} color="#9ad8e8" />
</Environment>
{/* reflective floor */}
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.62, 0]} receiveShadow>
<planeGeometry args={[40, 40]} />
<MeshReflectorMaterial
blur={[300, 80]}
resolution={1024}
mixBlur={1}
mixStrength={45}
roughness={0.85}
depthScale={1}
minDepthThreshold={0.4}
maxDepthThreshold={1.2}
color="#2a2236"
metalness={0.5}
/>
</mesh>
<HajiFiruz3D />
<FishBowl3D x={1.5} z={0.3} />
<Sabzeh3D x={-1.5} z={0.2} />
<Candle x={0.9} z={0.9} />
<Candle x={-0.9} z={0.9} />
<Egg x={0.5} z={1.1} color={RED} />
<Egg x={-0.4} z={1.15} color="#5bc8f0" />
<Egg x={1.0} z={-0.4} color={GOLD} />
<Petals3D />
</group>
);
};
export const Nowruz3D: React.FC<Props> = ({
greeting,
subtitle,
message,
accentColor,
secondaryColor,
backgroundColor,
textColor,
}) => {
const frame = useCurrentFrame();
const { width, height } = useVideoConfig();
const L = useLayout();
const gSpring = spring({ frame: frame - 120, fps: 30, config: { damping: 13, stiffness: 90 } });
const gScale = interpolate(gSpring, [0, 1], [0.6, 1]);
const gOp = interpolate(frame, [120, 140], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const subOp = interpolate(frame, [142, 162], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const msgOp = interpolate(frame, [156, 176], [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)} 38%, ${backgroundColor} 72%)` }} />
<ThreeCanvas
width={width}
height={height}
camera={{ position: [0, 2.0, 5.0], fov: 50 }}
shadows
style={{ position: "absolute", inset: 0 }}
gl={{ toneMapping: THREE.ACESFilmicToneMapping, antialias: true }}
>
<Scene />
<EffectComposer>
<Bloom intensity={0.75} luminanceThreshold={0.62} luminanceSmoothing={0.3} mipmapBlur />
<DepthOfField focusDistance={0.013} focalLength={0.045} bokehScale={3} />
<Vignette eskil={false} offset={0.32} darkness={0.55} />
</EffectComposer>
</ThreeCanvas>
{/* Greeting overlay */}
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl", alignItems: "center", justifyContent: "flex-start", paddingTop: height * 0.05 }}>
<div style={{ transform: `scale(${gScale})`, opacity: gOp, fontWeight: 900, fontSize: L.vmin(96), color: textColor, textShadow: `0 ${L.vmin(3)}px ${L.vmin(6)}px ${hexToRgba("#1a0a00", 0.7)}, 0 0 ${L.vmin(30)}px ${hexToRgba(accentColor, 0.7)}` }}>
{greeting}
</div>
<div style={{ marginTop: L.vmin(12), opacity: subOp, fontWeight: 700, fontSize: L.vmin(32), color: textColor, textShadow: `0 ${L.vmin(2)}px ${L.vmin(6)}px ${hexToRgba("#1a0a00", 0.8)}` }}>
{subtitle}
</div>
<div style={{ marginTop: L.vmin(8), opacity: msgOp, fontWeight: 600, fontSize: L.vmin(26), color: hexToRgba(textColor, 0.9) }}>
{message}
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};
@@ -0,0 +1,307 @@
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, type Layout } from "../lib/aspect";
import { hexToRgba, mixHex, rand } from "../lib/anim";
export const nowruzGreetingSchema = z.object({
greeting: z.string(),
subtitle: z.string(),
message: z.string(),
...colorSchema,
});
type Props = z.infer<typeof nowruzGreetingSchema>;
// Fixed scene palette (the colour props tint the sky / gold / accent / text).
const GREEN = "#5cc85c";
const GREEN_D = "#3da53d";
const SKIN = "#f0b486";
const SKIN_D = "#d99a68";
// ── Sun with rotating rays ───────────────────────────────────────────────────
const Sun: React.FC<{ L: Layout; gold: string; intro: number }> = ({ L, gold, intro }) => {
const frame = useCurrentFrame();
const cx = L.width * 0.84;
const cy = L.height * 0.16;
const r = L.vmin(70);
return (
<g transform={`translate(${cx} ${cy}) scale(${intro})`} opacity={intro}>
<g transform={`rotate(${frame * 0.5})`}>
{Array.from({ length: 14 }).map((_, i) => (
<rect key={i} x={-L.vmin(4)} y={-(r + L.vmin(46))} width={L.vmin(8)} height={L.vmin(28)} rx={L.vmin(4)}
fill={gold} opacity={0.8} transform={`rotate(${(360 / 14) * i})`} />
))}
</g>
<circle r={r + L.vmin(14)} fill={hexToRgba(gold, 0.25)} />
<circle r={r} fill={gold} />
<circle r={r} fill={hexToRgba("#ffffff", 0.18)} />
</g>
);
};
// ── Drifting blossom petals ──────────────────────────────────────────────────
const Petals: React.FC<{ L: Layout }> = ({ L }) => {
const frame = useCurrentFrame();
const COLORS = ["#ffd1e8", "#ffe3f1", "#ffc1dd", "#fff0f6"];
return (
<g>
{Array.from({ length: 26 }).map((_, i) => {
const x = rand(i) * L.width + Math.sin((frame + i * 30) / 30) * L.vmin(40);
const fall = ((rand(i + 9) * L.height) + frame * (1 + rand(i) * 2) * L.unit) % (L.height + 40) - 20;
const s = L.vmin(7 + rand(i + 3) * 8);
const rot = (frame + i * 40) * (i % 2 ? 2 : -2);
const appear = interpolate(frame, [0, 25], [0, 1], { extrapolateRight: "clamp" });
return (
<g key={i} transform={`translate(${x} ${fall}) rotate(${rot})`} opacity={0.85 * appear}>
<path d={`M0 ${-s} C ${s * 0.7} ${-s * 0.5} ${s * 0.7} ${s * 0.5} 0 ${s} C ${-s * 0.7} ${s * 0.5} ${-s * 0.7} ${-s * 0.5} 0 ${-s} Z`}
fill={COLORS[i % COLORS.length]} />
</g>
);
})}
</g>
);
};
// ── Sabzeh (growing grass) + a tulip ─────────────────────────────────────────
const Sabzeh: React.FC<{ L: Layout; x: number; groundY: number; delay: number; scale?: number }> = ({ L, x, groundY, delay, scale = 1 }) => {
const frame = useCurrentFrame();
const grow = spring({ frame: frame - delay, fps: 30, config: { damping: 12, stiffness: 80 } });
const h = L.vmin(70) * scale;
return (
<g transform={`translate(${x} ${groundY})`}>
{Array.from({ length: 7 }).map((_, i) => {
const lean = (i - 3) * 7 + Math.sin((frame + i * 20) / 22) * 5;
const bh = h * (0.7 + (i % 3) * 0.15) * grow;
return (
<path key={i} d={`M0 0 Q ${lean * 0.6} ${-bh * 0.6} ${lean} ${-bh}`}
stroke={i % 2 ? GREEN : GREEN_D} strokeWidth={L.vmin(4) * scale} strokeLinecap="round" fill="none" />
);
})}
</g>
);
};
const Tulip: React.FC<{ L: Layout; x: number; groundY: number; delay: number; color: string; scale?: number }> = ({ L, x, groundY, delay, color, scale = 1 }) => {
const frame = useCurrentFrame();
const grow = spring({ frame: frame - delay, fps: 30, config: { damping: 11, stiffness: 90 } });
const stem = L.vmin(90) * scale * grow;
const sway = Math.sin((frame + x) / 26) * 4;
const bw = L.vmin(34) * scale * grow;
return (
<g transform={`translate(${x} ${groundY}) rotate(${sway})`}>
<path d={`M0 0 Q ${L.vmin(8)} ${-stem * 0.5} 0 ${-stem}`} stroke={GREEN_D} strokeWidth={L.vmin(6) * scale} fill="none" strokeLinecap="round" />
<path d={`M0 ${-stem * 0.55} Q ${L.vmin(36) * scale} ${-stem * 0.5} ${L.vmin(20) * scale} ${-stem * 0.8}`} fill={GREEN} />
<g transform={`translate(0 ${-stem})`}>
<path d={`M${-bw / 2} 0 Q ${-bw / 2} ${-bw * 1.2} 0 ${-bw * 1.3} Q ${bw / 2} ${-bw * 1.2} ${bw / 2} 0 Q 0 ${bw * 0.3} ${-bw / 2} 0 Z`} fill={color} />
<path d={`M0 ${-bw * 1.3} Q ${-bw * 0.28} ${-bw * 0.6} 0 0`} fill={mixHex(color, "#ffffff", 0.25)} />
</g>
</g>
);
};
// ── Goldfish bowl ────────────────────────────────────────────────────────────
const FishBowl: React.FC<{ L: Layout; x: number; groundY: number; intro: number }> = ({ L, x, groundY, intro }) => {
const frame = useCurrentFrame();
const R = L.vmin(78);
const swim = Math.sin(frame / 18) * R * 0.4;
const dir = Math.cos(frame / 18) >= 0 ? 1 : -1;
const tail = Math.sin(frame / 5) * 14;
return (
<g transform={`translate(${x} ${groundY - R}) scale(${intro})`} opacity={intro}>
{/* water */}
<clipPath id="bowlclip"><circle r={R} /></clipPath>
<circle r={R} fill={hexToRgba("#9fe3ff", 0.55)} />
<g clipPath="url(#bowlclip)">
<rect x={-R} y={-R * 0.45} width={R * 2} height={R * 1.5} fill={hexToRgba("#4db8e8", 0.55)} />
{/* bubbles */}
{[0, 1, 2].map((i) => {
const by = (R - ((frame * (1 + i) * L.unit + i * 30) % (R * 1.4)));
return <circle key={i} cx={(-R * 0.5) + i * R * 0.4} cy={by} r={L.vmin(4 + i)} fill={hexToRgba("#ffffff", 0.6)} />;
})}
{/* fish */}
<g transform={`translate(${swim} ${L.vmin(6)}) scale(${dir} 1)`}>
<g transform={`rotate(${tail})`}>
<path d={`M${-L.vmin(20)} 0 L ${-L.vmin(40)} ${-L.vmin(16)} L ${-L.vmin(40)} ${L.vmin(16)} Z`} fill="#ff5a3c" />
</g>
<ellipse rx={L.vmin(26)} ry={L.vmin(17)} fill="#ff6b4a" />
<circle cx={L.vmin(16)} cy={-L.vmin(4)} r={L.vmin(3.5)} fill="#1c2330" />
</g>
</g>
{/* glass */}
<circle r={R} fill="none" stroke={hexToRgba("#ffffff", 0.7)} strokeWidth={L.vmin(5)} />
<path d={`M ${-R * 0.5} ${-R * 0.6} A ${R} ${R} 0 0 1 ${R * 0.2} ${-R * 0.85}`} stroke={hexToRgba("#ffffff", 0.6)} strokeWidth={L.vmin(4)} fill="none" strokeLinecap="round" />
</g>
);
};
// ── Butterfly ────────────────────────────────────────────────────────────────
const Butterfly: React.FC<{ L: Layout; i: number; color: string }> = ({ L, i, color }) => {
const frame = useCurrentFrame();
const t = frame + i * 25;
const x = interpolate((t * (0.8 + (i % 3) * 0.3)) % (L.width + 200), [0, L.width + 200], [-100, L.width + 100]);
const y = L.height * (0.3 + (i % 3) * 0.12) + Math.sin(t / 14) * L.vmin(50);
const flap = Math.abs(Math.sin(frame / 3.5));
const s = L.vmin(20);
const appear = interpolate(frame, [90, 110], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
return (
<g transform={`translate(${x} ${y})`} opacity={appear}>
<g transform={`scale(${0.4 + flap * 0.6} 1)`}>
<ellipse cx={-s * 0.5} cy={-s * 0.4} rx={s * 0.5} ry={s * 0.4} fill={color} />
<ellipse cx={s * 0.5} cy={-s * 0.4} rx={s * 0.5} ry={s * 0.4} fill={color} />
<ellipse cx={-s * 0.4} cy={s * 0.4} rx={s * 0.4} ry={s * 0.35} fill={mixHex(color, "#ffffff", 0.2)} />
<ellipse cx={s * 0.4} cy={s * 0.4} rx={s * 0.4} ry={s * 0.35} fill={mixHex(color, "#ffffff", 0.2)} />
</g>
<rect x={-s * 0.06} y={-s * 0.5} width={s * 0.12} height={s} rx={s * 0.06} fill="#3a2a2a" />
</g>
);
};
// ── Haji Firuz (modern stylized) ─────────────────────────────────────────────
const HajiFiruz: React.FC<{ L: Layout; x: number; groundY: number; red: string; gold: string }> = ({ L, x, groundY, red, gold }) => {
const frame = useCurrentFrame();
const inF = frame - 60;
// Hop in from the left with a couple of bounces, then dance in place.
const entr = spring({ frame: inF, fps: 30, config: { damping: 12, stiffness: 70 } });
const startX = -L.width * 0.4;
const px = startX + (x - startX) * entr;
const danceY = inF > 0 ? -Math.abs(Math.sin(frame / 7)) * L.vmin(22) : 0;
const sway = inF > 0 ? Math.sin(frame / 7) * 4 : 0;
// Tambourine shake.
const tam = Math.sin(frame / 3.2) * 18;
const scale = L.vmin(3.2); // unit -> px; character drawn ~ 110 units tall
if (frame < 60 && entr === 0) return null;
return (
<g transform={`translate(${px} ${groundY + danceY}) scale(${scale}) rotate(${sway})`}>
{/* shadow */}
<ellipse cx={0} cy={2} rx={26} ry={5} fill={hexToRgba("#000000", 0.12)} transform={`scale(${1 / 1} 1)`} />
{/* legs (bouncing) */}
<g>
<rect x={-12} y={-30} width={9} height={32} rx={4} fill="#2a2f45" transform={`rotate(${Math.sin(frame / 7) * 8} -8 -28)`} />
<rect x={3} y={-30} width={9} height={32} rx={4} fill="#2a2f45" transform={`rotate(${-Math.sin(frame / 7) * 8} 8 -28)`} />
<ellipse cx={-8} cy={2} rx={8} ry={4} fill="#1c2030" />
<ellipse cx={8} cy={2} rx={8} ry={4} fill="#1c2030" />
</g>
{/* body (red tunic with gold trim) */}
<path d="M-20 -78 Q 0 -86 20 -78 L 16 -28 Q 0 -22 -16 -28 Z" fill={red} />
<path d="M-18 -34 Q 0 -28 18 -34 L 16 -28 Q 0 -22 -16 -28 Z" fill={gold} />
<circle cx={0} cy={-62} r={3.2} fill={gold} />
<circle cx={0} cy={-50} r={3.2} fill={gold} />
{/* left arm raised holding tambourine */}
<g transform={`rotate(${-20 + tam * 0.4} -16 -74)`}>
<rect x={-30} y={-78} width={9} height={26} rx={4.5} fill={red} transform="rotate(-35 -16 -74)" />
{/* tambourine */}
<g transform={`translate(-34 -92) rotate(${tam})`}>
<circle r={15} fill="none" stroke={gold} strokeWidth={4} />
<circle r={15} fill={hexToRgba("#fff3d6", 0.5)} />
{Array.from({ length: 8 }).map((_, i) => (
<circle key={i} r={2.4} fill={gold} transform={`rotate(${i * 45}) translate(0 -15)`} />
))}
</g>
</g>
{/* right arm */}
<g transform={`rotate(${25 - tam * 0.3} 16 -74)`}>
<rect x={18} y={-78} width={9} height={28} rx={4.5} fill={red} transform="rotate(20 16 -74)" />
<circle cx={30} cy={-52} r={5} fill={SKIN} />
</g>
{/* head */}
<circle cx={0} cy={-94} r={15} fill={SKIN} />
<path d="M-15 -94 a 15 15 0 0 1 30 0 Z" fill={SKIN_D} opacity={0.25} />
{/* face — friendly stylized */}
<circle cx={-6} cy={-96} r={2} fill="#2a2030" />
<circle cx={6} cy={-96} r={2} fill="#2a2030" />
<path d="M-6 -88 Q 0 -83 6 -88" stroke="#7a3a30" strokeWidth={2} fill="none" strokeLinecap="round" />
<circle cx={-10} cy={-90} r={2.6} fill={hexToRgba("#ff9a8a", 0.6)} />
<circle cx={10} cy={-90} r={2.6} fill={hexToRgba("#ff9a8a", 0.6)} />
{/* conical hat */}
<path d="M-15 -106 L 0 -150 L 15 -106 Z" fill={red} />
<rect x={-16} y={-110} width={32} height={7} rx={3} fill={gold} />
<circle cx={0} cy={-150} r={4.5} fill={gold} />
</g>
);
};
// ── Composition ──────────────────────────────────────────────────────────────
export const NowruzGreeting: React.FC<Props> = ({
greeting,
subtitle,
message,
accentColor,
secondaryColor,
backgroundColor,
textColor,
}) => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
const L = useLayout();
const gold = accentColor;
const red = secondaryColor;
const sky = backgroundColor;
const groundY = height * 0.84;
const intro = interpolate(frame, [0, 24], [0, 1], { extrapolateRight: "clamp" });
const fishIntro = spring({ frame: frame - 95, fps, config: { damping: 12, stiffness: 90 } });
// Greeting reveal.
const gSpring = spring({ frame: frame - 150, fps, config: { damping: 12, stiffness: 90, mass: 0.8 } });
const gScale = interpolate(gSpring, [0, 1], [0.5, 1]);
const gOpacity = interpolate(frame, [150, 168], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const subOpacity = interpolate(frame, [172, 192], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const msgOpacity = interpolate(frame, [186, 206], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
return (
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl", backgroundColor: sky }}>
{/* Sky gradient */}
<AbsoluteFill style={{ background: `linear-gradient(180deg, ${mixHex(sky, "#ffffff", 0.25)} 0%, ${sky} 45%, ${mixHex(sky, "#ffffff", 0.4)} 100%)` }} />
<svg width={width} height={height} style={{ position: "absolute", inset: 0 }}>
<Sun L={L} gold={gold} intro={intro} />
<Petals L={L} />
{/* Ground */}
<path d={`M0 ${groundY} Q ${width / 2} ${groundY - L.vmin(40)} ${width} ${groundY} L ${width} ${height} L 0 ${height} Z`} fill={GREEN} opacity={intro} />
<path d={`M0 ${groundY + L.vmin(20)} Q ${width / 2} ${groundY - L.vmin(15)} ${width} ${groundY + L.vmin(20)} L ${width} ${height} L 0 ${height} Z`} fill={GREEN_D} opacity={intro} />
{/* Plants along the ground */}
<Sabzeh L={L} x={width * 0.12} groundY={groundY} delay={30} />
<Tulip L={L} x={width * 0.2} groundY={groundY} delay={42} color={red} />
<Tulip L={L} x={width * 0.55} groundY={groundY} delay={48} color="#ff8fab" scale={0.9} />
<Sabzeh L={L} x={width * 0.62} groundY={groundY} delay={36} scale={0.85} />
<Tulip L={L} x={width * 0.9} groundY={groundY} delay={54} color={gold} scale={0.85} />
<Sabzeh L={L} x={width * 0.86} groundY={groundY} delay={40} scale={0.7} />
<FishBowl L={L} x={width * 0.72} groundY={groundY} intro={Math.max(0, Math.min(1, fishIntro))} />
{[0, 1, 2].map((i) => (
<Butterfly key={i} L={L} i={i} color={[red, gold, "#a855f7"][i]} />
))}
<HajiFiruz L={L} x={width * 0.34} groundY={groundY} red={red} gold={gold} />
</svg>
{/* Greeting */}
<AbsoluteFill style={{ alignItems: "center", justifyContent: "flex-start", paddingTop: height * 0.16 }}>
<div style={{ transform: `scale(${gScale})`, opacity: gOpacity, fontWeight: 900, fontSize: L.vmin(96), color: textColor, textShadow: `0 ${L.vmin(4)}px ${L.vmin(2)}px ${hexToRgba("#7a4a00", 0.25)}, 0 0 ${L.vmin(20)}px ${hexToRgba(gold, 0.6)}` }}>
{greeting}
</div>
<div style={{ marginTop: L.vmin(14), opacity: subOpacity, fontWeight: 700, fontSize: L.vmin(34), color: mixHex(textColor, gold, 0.3), textShadow: `0 ${L.vmin(2)}px ${L.vmin(2)}px ${hexToRgba("#7a4a00", 0.2)}` }}>
{subtitle}
</div>
<div style={{ marginTop: L.vmin(10), opacity: msgOpacity, fontWeight: 600, fontSize: L.vmin(26), color: hexToRgba(textColor, 0.92) }}>
{message}
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};
@@ -0,0 +1,87 @@
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 openerSchema = z.object({
kicker: z.string(),
title: z.string(),
subtitle: z.string(),
...colorSchema,
});
type Props = z.infer<typeof openerSchema>;
export const Opener: React.FC<Props> = ({
kicker,
title,
subtitle,
accentColor,
secondaryColor,
backgroundColor,
textColor,
}) => {
const frame = useCurrentFrame();
const L = useLayout();
const kick = useReveal(8, { from: 24 });
const sub = useReveal(40, { from: 30 });
// Title wipes up behind a clipping mask.
const titleY = interpolate(frame, [18, 44], [L.vmin(140), 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
const lineW = interpolate(frame, [30, 60], [0, L.vmin(260)], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
return (
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl" }}>
<BrandBackground bg={backgroundColor} accent={accentColor} secondary={secondaryColor} particles={0} />
{/* Two framing bars that draw in from the sides */}
{[0, 1].map((i) => {
const w = interpolate(frame, [4, 26], [0, L.vmin(620)], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.cubic) });
return (
<div
key={i}
style={{
position: "absolute",
left: "50%",
transform: "translateX(-50%)",
top: i === 0 ? `calc(50% - ${L.vmin(150)}px)` : `calc(50% + ${L.vmin(150)}px)`,
width: w,
height: L.vmin(3),
background: `linear-gradient(90deg, transparent, ${accentColor}, ${secondaryColor}, transparent)`,
}}
/>
);
})}
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", flexDirection: "column" }}>
<div style={{ opacity: kick.opacity, transform: `translateY(${kick.y}px)`, fontWeight: 700, fontSize: L.vmin(26), letterSpacing: L.vmin(8), color: accentColor, marginBottom: L.vmin(18) }}>
{kicker}
</div>
<div style={{ overflow: "hidden", padding: `${L.vmin(6)}px 0` }}>
<div style={{ transform: `translateY(${titleY}px)`, fontWeight: 900, fontSize: L.vmin(96), lineHeight: 1.05, color: textColor, textAlign: "center", textShadow: `0 ${L.vmin(8)}px ${L.vmin(40)}px ${hexToRgba(accentColor, 0.4)}` }}>
{title}
</div>
</div>
<div style={{ width: lineW, height: L.vmin(4), borderRadius: 4, margin: `${L.vmin(24)}px 0`, background: `linear-gradient(90deg, ${accentColor}, ${secondaryColor})` }} />
<div style={{ opacity: sub.opacity, transform: `translateY(${sub.y}px)`, fontWeight: 500, fontSize: L.vmin(28), color: hexToRgba(textColor, 0.8), textAlign: "center", maxWidth: L.vmin(760) }}>
{subtitle}
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};
@@ -0,0 +1,158 @@
import React from "react";
import {
AbsoluteFill,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import { ThreeCanvas } from "@remotion/three";
import { RoundedBox } from "@react-three/drei";
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, rand } from "../lib/anim";
import { StudioEnv, StudioFloor, StudioLights, StudioEffects, Confetti3D } from "../lib/three-kit";
export const promo3DSchema = z.object({
badge: z.string(),
headline: z.string(),
subtext: z.string(),
cta: z.string(),
...colorSchema,
});
type Props = z.infer<typeof promo3DSchema>;
const Gift: React.FC<{ size: number; color: string; ribbon: string }> = ({ size, color, ribbon }) => (
<group>
<RoundedBox args={[size, size, size]} radius={size * 0.07} smoothness={4} castShadow>
<meshStandardMaterial color={color} roughness={0.35} metalness={0.15} />
</RoundedBox>
{/* crossing ribbons */}
<mesh>
<boxGeometry args={[size * 1.04, size * 1.04, size * 0.2]} />
<meshStandardMaterial color={ribbon} roughness={0.3} metalness={0.4} />
</mesh>
<mesh>
<boxGeometry args={[size * 0.2, size * 1.04, size * 1.04]} />
<meshStandardMaterial color={ribbon} roughness={0.3} metalness={0.4} />
</mesh>
{/* bow */}
<group position={[0, size * 0.5, 0]}>
{[-1, 1].map((s) => (
<mesh key={s} position={[s * size * 0.16, size * 0.06, 0]} rotation={[0, 0, s * 0.5]} scale={[1, 0.6, 0.5]}>
<sphereGeometry args={[size * 0.16, 16, 16]} />
<meshStandardMaterial color={ribbon} roughness={0.3} metalness={0.4} />
</mesh>
))}
<mesh position={[0, size * 0.06, 0]}>
<sphereGeometry args={[size * 0.08, 16, 16]} />
<meshStandardMaterial color={ribbon} roughness={0.3} metalness={0.4} />
</mesh>
</group>
</group>
);
const FloatingGift: React.FC<{ i: number; accent: string; secondary: string }> = ({ i, accent, secondary }) => {
const frame = useCurrentFrame();
const ang = rand(i) * Math.PI * 2;
const radius = 2.4 + rand(i + 5) * 2.0;
const depth = -1 - rand(i + 9) * 3.5;
const x = Math.cos(ang + frame * 0.004 * (0.5 + rand(i) * 0.5)) * radius;
const y = -0.1 + Math.sin(ang * 1.4 + frame / 40) * (1.0 + rand(i + 3) * 1.0);
const size = 0.4 + rand(i + 7) * 0.4;
const colA = i % 2 === 0 ? accent : secondary;
const colB = i % 2 === 0 ? "#fde047" : "#ffffff";
const appear = interpolate(frame, [6 + i * 2, 32 + i * 2], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
return (
<group position={[x, y, depth]} rotation={[frame * 0.01 * (1 + rand(i)), frame * 0.015, 0]} scale={size * appear}>
<Gift size={1} color={colA} ribbon={colB} />
</group>
);
};
const Scene: React.FC<{ accent: string; secondary: string }> = ({ accent, secondary }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const pop = spring({ frame: frame - 10, fps, config: { damping: 11, stiffness: 80 } });
const heroScale = interpolate(pop, [0, 1], [0, 1.3]);
const heroBob = Math.sin(frame / 22) * 0.12;
const heroSpin = Math.sin(frame / 60) * 0.5;
const orbit = Math.sin(frame / 120) * 0.18;
return (
<group rotation={[0, orbit, 0]}>
<StudioLights accent={accent} secondary={secondary} />
<StudioEnv />
<StudioFloor color="#1f1a2e" />
{/* hero gift */}
<group position={[0, -0.15 + heroBob, 0]} rotation={[0, heroSpin, 0]} scale={heroScale}>
<Gift size={1.25} color={accent} ribbon={"#fde047"} />
</group>
{Array.from({ length: 9 }).map((_, i) => (
<FloatingGift key={i} i={i} accent={accent} secondary={secondary} />
))}
<Confetti3D colors={[accent, secondary, "#fde047", "#ffffff"]} count={40} />
</group>
);
};
export const Promo3D: React.FC<Props> = ({
badge,
headline,
subtext,
cta,
accentColor,
secondaryColor,
backgroundColor,
textColor,
}) => {
const frame = useCurrentFrame();
const { width, height, fps } = useVideoConfig();
const L = useLayout();
const badgePop = spring({ frame: frame - 40, fps, config: { damping: 9, stiffness: 130 } });
const badgeScale = interpolate(badgePop, [0, 1], [0, 1]);
const badgeWobble = Math.sin(frame / 14) * 5;
const headOp = interpolate(frame, [70, 90], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const headY = interpolate(frame, [70, 92], [L.vmin(40), 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const subOp = interpolate(frame, [92, 112], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const ctaPop = spring({ frame: frame - 116, fps, config: { damping: 11, stiffness: 120 } });
const ctaScale = interpolate(ctaPop, [0, 1], [0.6, 1]);
const ctaGlow = 0.4 + 0.3 * Math.sin(frame / 10);
return (
<AbsoluteFill style={{ backgroundColor }}>
<AbsoluteFill style={{ background: `radial-gradient(circle at 50% 40%, ${hexToRgba(accentColor, 0.2)} 0%, ${hexToRgba(secondaryColor, 0.07)} 40%, ${backgroundColor} 74%)` }} />
<ThreeCanvas
width={width}
height={height}
camera={{ position: [0, 1.7, 6.6], fov: 52 }}
shadows
style={{ position: "absolute", inset: 0 }}
gl={{ toneMapping: THREE.ACESFilmicToneMapping, antialias: true }}
>
<Scene accent={accentColor} secondary={secondaryColor} />
<StudioEffects bloom={0.7} focus={0.015} bokeh={3.5} vignette={0.55} />
</ThreeCanvas>
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl", alignItems: "center", justifyContent: "flex-start", paddingTop: height * 0.06 }}>
{/* discount badge */}
<div style={{ width: L.vmin(170), height: L.vmin(170), transform: `scale(${badgeScale}) rotate(${badgeWobble - 8}deg)`, borderRadius: "50%", background: `linear-gradient(135deg, ${accentColor}, ${secondaryColor})`, display: "flex", alignItems: "center", justifyContent: "center", textAlign: "center", boxShadow: `0 0 ${L.vmin(40)}px ${hexToRgba(accentColor, 0.7)}`, fontWeight: 900, fontSize: L.vmin(34), color: "#fff", padding: L.vmin(16) }}>
{badge}
</div>
<div style={{ marginTop: L.vmin(26), transform: `translateY(${headY}px)`, opacity: headOp, fontWeight: 900, fontSize: L.vmin(72), color: textColor, textShadow: `0 ${L.vmin(2)}px ${L.vmin(8)}px ${hexToRgba("#0a0614", 0.7)}` }}>
{headline}
</div>
<div style={{ marginTop: L.vmin(12), opacity: subOp, fontWeight: 500, fontSize: L.vmin(30), color: hexToRgba(textColor, 0.85), textShadow: `0 ${L.vmin(2)}px ${L.vmin(6)}px ${hexToRgba("#0a0614", 0.7)}` }}>
{subtext}
</div>
<div style={{ marginTop: L.vmin(28), transform: `scale(${ctaScale})`, opacity: ctaPop, padding: `${L.vmin(20)}px ${L.vmin(54)}px`, borderRadius: 999, background: textColor, boxShadow: `0 0 ${L.vmin(20 + ctaGlow * 36)}px ${hexToRgba(accentColor, ctaGlow)}`, fontWeight: 900, fontSize: L.vmin(32), color: backgroundColor }}>
{cta}
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};
@@ -0,0 +1,63 @@
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, mixHex } from "../lib/anim";
export const quoteCardSchema = z.object({
quote: z.string(),
author: z.string(),
...colorSchema,
});
type Props = z.infer<typeof quoteCardSchema>;
export const QuoteCard: React.FC<Props> = ({
quote,
author,
accentColor,
secondaryColor,
backgroundColor,
textColor,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const L = useLayout();
const words = quote.split(/\s+/).filter(Boolean);
const markOp = interpolate(frame, [0, 14], [0, 1], { extrapolateRight: "clamp" });
const tail = 14 + words.length * 3 + 8;
const ruleW = interpolate(frame, [tail, tail + 18], [0, L.vmin(120)], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const auth = useReveal(tail + 8, { from: 20 });
return (
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl" }}>
<BrandBackground bg={backgroundColor} accent={accentColor} secondary={secondaryColor} particles={10} />
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", flexDirection: "column", padding: L.vmin(90) }}>
<div style={{ fontFamily: "Georgia, serif", fontSize: L.vmin(150), lineHeight: 0.5, color: hexToRgba(accentColor, 0.85), opacity: markOp, marginBottom: L.vmin(30) }}>
&rdquo;
</div>
<div style={{ display: "flex", flexWrap: "wrap", justifyContent: "center", maxWidth: L.vmin(900), gap: `0 ${L.vmin(14)}px`, fontWeight: 700, fontSize: L.vmin(58), lineHeight: 1.4, color: textColor, textAlign: "center" }}>
{words.map((w, i) => {
const s = spring({ frame: frame - (12 + i * 3), fps, config: { damping: 18, mass: 0.6, stiffness: 110 } });
return (
<span key={i} style={{ display: "inline-block", transform: `translateY(${interpolate(s, [0, 1], [L.vmin(22), 0])}px)`, opacity: interpolate(s, [0, 1], [0, 1]), color: i % 5 === 2 ? mixHex(accentColor, secondaryColor, 0.4) : textColor }}>
{w}
</span>
);
})}
</div>
<div style={{ width: ruleW, height: L.vmin(3), marginTop: L.vmin(40), borderRadius: 2, background: `linear-gradient(90deg, ${accentColor}, ${secondaryColor})` }} />
<div style={{ marginTop: L.vmin(20), opacity: auth.opacity, transform: `translateY(${auth.y}px)`, fontWeight: 600, fontSize: L.vmin(30), color: hexToRgba(textColor, 0.78) }}>
{author}
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};
@@ -0,0 +1,67 @@
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 salePromoSchema = z.object({
badge: z.string(),
headline: z.string(),
subtext: z.string(),
cta: z.string(),
...colorSchema,
});
type Props = z.infer<typeof salePromoSchema>;
export const SalePromo: React.FC<Props> = ({
badge,
headline,
subtext,
cta,
accentColor,
secondaryColor,
backgroundColor,
textColor,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const L = useLayout();
const badgePop = spring({ frame: frame - 6, fps, config: { damping: 9, stiffness: 140, mass: 0.6 } });
const badgeScale = interpolate(badgePop, [0, 1], [0, 1]);
const badgeWobble = Math.sin(frame / 14) * 6;
const head = useReveal(22, { from: 50 });
const sub = useReveal(40, { from: 28 });
const ctaR = useReveal(56, { from: 24, damping: 11 });
const ctaGlow = 0.4 + 0.3 * Math.sin(frame / 10);
return (
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl" }}>
<BrandBackground bg={backgroundColor} accent={accentColor} secondary={secondaryColor} particles={16} />
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", flexDirection: "column", padding: L.vmin(70) }}>
{/* Discount badge */}
<div style={{ width: L.vmin(200), height: L.vmin(200), transform: `scale(${badgeScale}) rotate(${badgeWobble - 10}deg)`, borderRadius: "50%", background: `linear-gradient(135deg, ${accentColor}, ${secondaryColor})`, display: "flex", alignItems: "center", justifyContent: "center", textAlign: "center", boxShadow: `0 0 ${L.vmin(50)}px ${hexToRgba(accentColor, 0.6)}`, fontWeight: 900, fontSize: L.vmin(40), color: "#fff", padding: L.vmin(18) }}>
{badge}
</div>
<div style={{ marginTop: L.vmin(46), opacity: head.opacity, transform: `translateY(${head.y}px)`, fontWeight: 900, fontSize: L.vmin(92), lineHeight: 1.08, color: textColor, textAlign: "center", maxWidth: L.vmin(900), textShadow: `0 ${L.vmin(6)}px ${L.vmin(40)}px ${hexToRgba(accentColor, 0.4)}` }}>
{headline}
</div>
<div style={{ marginTop: L.vmin(20), opacity: sub.opacity, transform: `translateY(${sub.y}px)`, fontWeight: 500, fontSize: L.vmin(32), color: hexToRgba(textColor, 0.8), textAlign: "center", maxWidth: L.vmin(760) }}>
{subtext}
</div>
<div style={{ marginTop: L.vmin(52), opacity: ctaR.opacity, transform: `scale(${ctaR.scale})`, padding: `${L.vmin(22)}px ${L.vmin(60)}px`, borderRadius: 999, background: textColor, boxShadow: `0 0 ${L.vmin(20 + ctaGlow * 40)}px ${hexToRgba(accentColor, ctaGlow)}`, fontWeight: 900, fontSize: L.vmin(34), color: backgroundColor }}>
{cta}
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};
@@ -0,0 +1,81 @@
import React from "react";
import { AbsoluteFill, interpolate, 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 } from "../lib/kit";
import { hexToRgba, mixHex } from "../lib/anim";
export const slideshowSchema = z.object({
title: z.string(),
slide1: z.string(),
slide2: z.string(),
slide3: z.string(),
...colorSchema,
});
type Props = z.infer<typeof slideshowSchema>;
export const Slideshow: React.FC<Props> = ({
title,
slide1,
slide2,
slide3,
accentColor,
secondaryColor,
backgroundColor,
textColor,
}) => {
const frame = useCurrentFrame();
const { durationInFrames } = useVideoConfig();
const L = useLayout();
const slides = [slide1, slide2, slide3];
const per = durationInFrames / (slides.length + 0.5); // leave a beat for the title
const titleEnd = per * 0.5;
const titleOp = interpolate(frame, [4, 18, titleEnd - 8, titleEnd], [0, 1, 1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const titleScale = interpolate(frame, [4, titleEnd], [0.9, 1.05], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
return (
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl" }}>
<BrandBackground bg={backgroundColor} accent={accentColor} secondary={secondaryColor} particles={20} />
{/* Title card */}
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", opacity: titleOp }}>
<div style={{ transform: `scale(${titleScale})`, fontWeight: 900, fontSize: L.vmin(92), color: textColor, textAlign: "center", textShadow: `0 ${L.vmin(6)}px ${L.vmin(40)}px ${hexToRgba(accentColor, 0.5)}` }}>
{title}
</div>
</AbsoluteFill>
{/* Slides */}
{slides.map((s, i) => {
const start = titleEnd + i * per;
const local = frame - start;
if (local < -10 || local > per + 10) return null;
const op = interpolate(local, [0, 14, per - 14, per], [0, 1, 1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const x = interpolate(local, [0, 18], [L.vmin(60), 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
return (
<AbsoluteFill key={i} style={{ justifyContent: "center", alignItems: "center", flexDirection: "column", opacity: op, padding: L.vmin(80) }}>
<div style={{ fontWeight: 800, fontSize: L.vmin(40), color: mixHex(accentColor, secondaryColor, 0.4), marginBottom: L.vmin(24) }}>
{String(i + 1).padStart(2, "0")}
</div>
<div style={{ transform: `translateX(${x}px)`, fontWeight: 800, fontSize: L.vmin(64), lineHeight: 1.25, color: textColor, textAlign: "center", maxWidth: L.vmin(880) }}>
{s}
</div>
</AbsoluteFill>
);
})}
{/* Progress dots */}
<div style={{ position: "absolute", bottom: L.vmin(70), left: 0, right: 0, display: "flex", justifyContent: "center", gap: L.vmin(14) }}>
{slides.map((_, i) => {
const start = titleEnd + i * per;
const active = frame >= start && frame < start + per;
return <div key={i} style={{ width: active ? L.vmin(46) : L.vmin(14), height: L.vmin(14), borderRadius: 999, transition: "all .3s", background: active ? `linear-gradient(90deg, ${accentColor}, ${secondaryColor})` : hexToRgba(textColor, 0.25) }} />;
})}
</div>
</AbsoluteFill>
);
};
@@ -0,0 +1,30 @@
import React from "react";
import { ThreeCanvas } from "@remotion/three";
import { useCurrentFrame, useVideoConfig } from "remotion";
const Spinning: React.FC = () => {
const frame = useCurrentFrame();
return (
<mesh rotation={[frame * 0.03, frame * 0.04, 0]}>
<torusKnotGeometry args={[1, 0.35, 128, 32]} />
<meshStandardMaterial color="#3ba7ff" metalness={0.65} roughness={0.2} />
</mesh>
);
};
export const Three3DTest: React.FC = () => {
const { width, height } = useVideoConfig();
return (
<ThreeCanvas
width={width}
height={height}
camera={{ position: [0, 0, 5], fov: 50 }}
style={{ backgroundColor: "#05040e" }}
>
<ambientLight intensity={0.5} />
<pointLight position={[5, 5, 5]} intensity={60} color="#ffffff" />
<pointLight position={[-5, -2, 3]} intensity={30} color="#a855f7" />
<Spinning />
</ThreeCanvas>
);
};
@@ -0,0 +1,229 @@
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 verticalStorySchema = z.object({
kicker: z.string(),
line1: z.string(),
line2: z.string(),
line3: z.string(),
ctaText: z.string(),
accentColor: zColor(),
secondaryColor: zColor(),
backgroundColor: zColor(),
});
type Props = z.infer<typeof verticalStorySchema>;
// ── Diagonal animated gradient + floating dust ───────────────────────────────
const StoryBackground: React.FC<{
bg: string;
accent: string;
secondary: string;
}> = ({ bg, accent, secondary }) => {
const frame = useCurrentFrame();
const { width, height } = useVideoConfig();
const shift = interpolate(frame, [0, 180], [0, 60]);
return (
<AbsoluteFill style={{ backgroundColor: bg, overflow: "hidden" }}>
<AbsoluteFill
style={{
background: `linear-gradient(160deg, ${hexToRgba(
accent,
0.32
)} 0%, ${bg} 45%, ${hexToRgba(secondary, 0.3)} 100%)`,
transform: `translateY(${-shift}px)`,
}}
/>
<AbsoluteFill
style={{
background: `radial-gradient(circle at 50% 30%, ${hexToRgba(
accent,
0.25
)} 0%, transparent 45%)`,
}}
/>
{Array.from({ length: 22 }).map((_, i) => {
const x = rand(i) * width;
const baseY = rand(i + 9) * height;
const y = (baseY - frame * (0.6 + rand(i) * 1.2)) % height;
const size = 2 + rand(i + 3) * 5;
const tw = 0.2 + 0.6 * Math.abs(Math.sin((frame + i * 20) / 16));
return (
<div
key={i}
style={{
position: "absolute",
left: x,
top: (y + height) % height,
width: size,
height: size,
borderRadius: "50%",
background: i % 2 ? secondary : accent,
opacity: tw,
filter: `blur(0.5px) drop-shadow(0 0 ${size * 2}px ${
i % 2 ? secondary : accent
})`,
}}
/>
);
})}
<AbsoluteFill
style={{ boxShadow: "inset 0 0 400px 120px rgba(0,0,0,0.6)" }}
/>
</AbsoluteFill>
);
};
const StoryLine: React.FC<{
text: string;
delay: number;
highlight?: string;
}> = ({ text, delay, highlight }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const s = spring({
frame: frame - delay,
fps,
config: { damping: 16, mass: 0.8, stiffness: 100 },
});
const y = interpolate(s, [0, 1], [70, 0]);
const op = interpolate(s, [0, 1], [0, 1]);
return (
<div
style={{
transform: `translateY(${y}px)`,
opacity: op,
fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif",
fontWeight: 800,
fontSize: 104,
lineHeight: 1.04,
letterSpacing: -2,
color: highlight ?? "#fff",
textShadow: highlight
? `0 0 40px ${hexToRgba(highlight, 0.6)}`
: "0 4px 24px rgba(0,0,0,0.4)",
}}
>
{text}
</div>
);
};
export const VerticalStory: React.FC<Props> = ({
kicker,
line1,
line2,
line3,
ctaText,
accentColor,
secondaryColor,
backgroundColor,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const kickerOp = interpolate(frame, [4, 20], [0, 1], {
extrapolateRight: "clamp",
});
const ctaSpring = spring({
frame: frame - 64,
fps,
config: { damping: 12, stiffness: 120 },
});
const ctaScale = interpolate(ctaSpring, [0, 1], [0.6, 1]);
const ctaOp = interpolate(ctaSpring, [0, 1], [0, 1]);
const arrowBounce = Math.sin(frame / 8) * 8;
return (
<AbsoluteFill>
<StoryBackground
bg={backgroundColor}
accent={accentColor}
secondary={secondaryColor}
/>
<AbsoluteFill
style={{
flexDirection: "column",
justifyContent: "center",
padding: 90,
}}
>
<div
style={{
opacity: kickerOp,
display: "inline-block",
alignSelf: "flex-start",
padding: "10px 24px",
marginBottom: 40,
borderRadius: 999,
border: `2px solid ${hexToRgba(accentColor, 0.7)}`,
background: hexToRgba(accentColor, 0.12),
fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif",
fontWeight: 700,
fontSize: 26,
letterSpacing: 6,
textTransform: "uppercase",
color: "#fff",
}}
>
{kicker}
</div>
<StoryLine text={line1} delay={14} />
<StoryLine
text={line2}
delay={26}
highlight={mixHex(accentColor, secondaryColor, 0.35)}
/>
<StoryLine text={line3} delay={38} />
</AbsoluteFill>
{/* Swipe-up CTA pinned near the bottom */}
<div
style={{
position: "absolute",
bottom: 150,
left: 0,
right: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
opacity: ctaOp,
transform: `scale(${ctaScale})`,
}}
>
<div style={{ transform: `translateY(${-arrowBounce}px)`, fontSize: 56 }}>
</div>
<div
style={{
marginTop: -6,
padding: "20px 60px",
borderRadius: 999,
background: `linear-gradient(135deg, ${accentColor}, ${secondaryColor})`,
boxShadow: `0 0 50px ${hexToRgba(accentColor, 0.6)}`,
fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif",
fontWeight: 800,
fontSize: 34,
letterSpacing: 1,
color: "#fff",
}}
>
{ctaText}
</div>
</div>
</AbsoluteFill>
);
};
@@ -0,0 +1,69 @@
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 youTubeIntroSchema = z.object({
channelName: z.string(),
subtitle: z.string(),
cta: z.string(),
...colorSchema,
});
type Props = z.infer<typeof youTubeIntroSchema>;
export const YouTubeIntro: React.FC<Props> = ({
channelName,
subtitle,
cta,
accentColor,
secondaryColor,
backgroundColor,
textColor,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const L = useLayout();
const playPop = spring({ frame, fps, config: { damping: 11, stiffness: 130, mass: 0.7 } });
const playScale = interpolate(playPop, [0, 1], [0, 1]);
const ripple = (frame % 45) / 45;
const name = useReveal(28, { from: 40 });
const sub = useReveal(44, { from: 26 });
const bell = useReveal(60, { from: 22, damping: 11 });
const bellWiggle = Math.sin(frame / 5) * (frame > 60 && frame < 90 ? 10 : 0);
return (
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl" }}>
<BrandBackground bg={backgroundColor} accent={accentColor} secondary={secondaryColor} particles={14} />
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", flexDirection: "column" }}>
{/* Play button with ripple */}
<div style={{ position: "relative", width: L.vmin(170), height: L.vmin(170), display: "flex", alignItems: "center", justifyContent: "center", transform: `scale(${playScale})` }}>
<div style={{ position: "absolute", width: L.vmin(170) * (1 + ripple), height: L.vmin(170) * (1 + ripple), borderRadius: "50%", border: `${L.vmin(3)}px solid ${hexToRgba(accentColor, 1 - ripple)}` }} />
<div style={{ width: L.vmin(150), height: L.vmin(150), borderRadius: "50%", background: `linear-gradient(135deg, ${accentColor}, ${secondaryColor})`, display: "flex", alignItems: "center", justifyContent: "center", boxShadow: `0 0 ${L.vmin(50)}px ${hexToRgba(accentColor, 0.6)}` }}>
<div style={{ width: 0, height: 0, marginInlineStart: L.vmin(10), borderTop: `${L.vmin(34)}px solid transparent`, borderBottom: `${L.vmin(34)}px solid transparent`, borderInlineEnd: `${L.vmin(56)}px solid #fff` }} />
</div>
</div>
<div style={{ marginTop: L.vmin(46), opacity: name.opacity, transform: `translateY(${name.y}px)`, fontWeight: 900, fontSize: L.vmin(86), color: textColor, textAlign: "center", textShadow: `0 ${L.vmin(6)}px ${L.vmin(36)}px ${hexToRgba(accentColor, 0.4)}` }}>
{channelName}
</div>
<div style={{ marginTop: L.vmin(16), opacity: sub.opacity, transform: `translateY(${sub.y}px)`, fontWeight: 500, fontSize: L.vmin(30), color: hexToRgba(textColor, 0.78), textAlign: "center" }}>
{subtitle}
</div>
{/* Subscribe pill */}
<div style={{ marginTop: L.vmin(50), opacity: bell.opacity, transform: `scale(${bell.scale})`, display: "flex", alignItems: "center", gap: L.vmin(14), padding: `${L.vmin(18)}px ${L.vmin(46)}px`, borderRadius: 999, background: "#ff0033", boxShadow: `0 0 ${L.vmin(36)}px ${hexToRgba("#ff0033", 0.5)}`, fontWeight: 800, fontSize: L.vmin(32), color: "#fff" }}>
<span style={{ display: "inline-block", transform: `rotate(${bellWiggle}deg)`, fontSize: L.vmin(34) }}>🔔</span>
{cta}
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};
+4
View File
@@ -0,0 +1,4 @@
import { registerRoot } from "remotion";
import { RemotionRoot } from "./Root";
registerRoot(RemotionRoot);
+30
View File
@@ -0,0 +1,30 @@
// Shared color + animation helpers for FlatRender code-based templates.
/** Parse a #rrggbb hex string into [r,g,b] (0..255). */
export function hexRgb(hex: string): [number, number, number] {
const p = hex.replace("#", "");
const i = parseInt(p.length === 3 ? p.replace(/(.)/g, "$1$1") : p, 16);
return [(i >> 16) & 255, (i >> 8) & 255, i & 255];
}
/** Mix two hex colors by t (0..1) → rgb() string. Cheap linear blend. */
export function mixHex(a: string, b: string, t: number): string {
const [ar, ag, ab] = hexRgb(a);
const [br, bg, bb] = hexRgb(b);
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})`;
}
/** #rrggbb + alpha → rgba() string. */
export function hexToRgba(hex: string, alpha: number): string {
const [r, g, b] = hexRgb(hex);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
/** Stable per-index pseudo-random in [0,1) — no Math.random, renders stay deterministic. */
export function rand(seed: number): number {
const x = Math.sin(seed * 127.1 + 311.7) * 43758.5453;
return x - Math.floor(x);
}
+53
View File
@@ -0,0 +1,53 @@
import { useVideoConfig } from "remotion";
export type AspectKind = "wide" | "square" | "tall";
export interface Layout {
kind: AspectKind;
width: number;
height: number;
isWide: boolean;
isSquare: boolean;
isTall: boolean;
/** Smaller side / 1000 — a resolution-independent sizing unit. */
unit: number;
/** Convenience scalers relative to the design baseline (1080 short side). */
vmin: (n: number) => number;
}
/** Classify a width×height into one of the three supported aspects. */
export function classify(width: number, height: number): AspectKind {
const r = width / height;
if (r > 1.2) return "wide";
if (r < 0.85) return "tall";
return "square";
}
/**
* Aspect-aware layout tokens. Templates read this to adapt one design to 16:9,
* 1:1 and 9:16 — sizing off the shorter side so type and shapes stay balanced
* in every frame.
*/
export function useLayout(): Layout {
const { width, height } = useVideoConfig();
const kind = classify(width, height);
const short = Math.min(width, height);
const unit = short / 1000;
return {
kind,
width,
height,
isWide: kind === "wide",
isSquare: kind === "square",
isTall: kind === "tall",
unit,
vmin: (n: number) => (n * short) / 1080,
};
}
/** The three aspect presets every template is registered in. */
export const ASPECTS: { id: string; width: number; height: number; label: string }[] = [
{ id: "16x9", width: 1920, height: 1080, label: "16:9" },
{ id: "1x1", width: 1080, height: 1080, label: "1:1" },
{ id: "9x16", width: 1080, height: 1920, label: "9:16" },
];
+46
View File
@@ -0,0 +1,46 @@
import { zColor } from "@remotion/zod-types";
import { z } from "zod";
/**
* FlatRender brand palette + shared template helpers. Every template exposes the
* same colour props (accent / secondary / background / text) so the studio can
* render one consistent "colour change" control set across all of them.
*/
export const BRAND = {
blue: "#3ba7ff",
purple: "#a855f7",
cyan: "#22d3ee",
pink: "#fb7185",
amber: "#f59e0b",
green: "#34d399",
ink: "#04060f",
white: "#ffffff",
} as const;
/** The shared colour schema every template extends. */
export const colorSchema = {
accentColor: zColor(),
secondaryColor: zColor(),
backgroundColor: zColor(),
textColor: zColor(),
};
/** Default brand colours used by most templates' defaultProps. */
export const defaultColors = {
accentColor: BRAND.blue,
secondaryColor: BRAND.purple,
backgroundColor: BRAND.ink,
textColor: BRAND.white,
};
export type ColorProps = {
accentColor: string;
secondaryColor: string;
backgroundColor: string;
textColor: string;
};
/** The FlatRender wordmark, in Persian. */
export const BRAND_NAME_FA = "فلت‌رندر";
export const zHex = (hex: string) => hex as z.infer<ReturnType<typeof zColor>>;
+35
View File
@@ -0,0 +1,35 @@
import { continueRender, delayRender, staticFile } from "remotion";
/** The Persian/Latin font used across all FlatRender templates. */
export const FONT = "Vazirmatn";
const WEIGHTS = [400, 600, 700, 800, 900];
let started = false;
/**
* Loads the local Vazirmatn weights (Persian RTL + Latin) and blocks rendering
* until they're ready, so text never renders in a fallback font. Idempotent —
* runs once when this module is first imported.
*/
function loadVazirmatn() {
if (started || typeof document === "undefined") return;
started = true;
for (const w of WEIGHTS) {
const handle = delayRender(`vazirmatn-${w}`);
const face = new FontFace(
FONT,
`url(${staticFile(`fonts/vazirmatn-${w}.woff2`)}) format('woff2')`,
{ weight: String(w) }
);
face
.load()
.then((loaded) => {
document.fonts.add(loaded);
continueRender(handle);
})
.catch(() => continueRender(handle));
}
}
loadVazirmatn();
+77
View File
@@ -0,0 +1,77 @@
import React from "react";
import {
AbsoluteFill,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import { hexToRgba, rand } from "./anim";
import { useLayout } from "./aspect";
/**
* Shared FlatRender template kit: a branded animated background and a couple of
* reveal helpers, so each template focuses on its own content + copy.
*/
export const BrandBackground: React.FC<{
bg: string;
accent: string;
secondary: string;
/** number of floating particles (0 = none) */
particles?: number;
/** add a soft moving second glow */
nebula?: boolean;
}> = ({ bg, accent, secondary, particles = 0, nebula = true }) => {
const frame = useCurrentFrame();
const { width, height } = useVideoConfig();
const L = useLayout();
const drift = Math.sin(frame / 55) * 30;
return (
<AbsoluteFill style={{ backgroundColor: bg }}>
<AbsoluteFill
style={{
background: `radial-gradient(circle at 50% 42%, ${hexToRgba(accent, 0.2)} 0%, ${hexToRgba(secondary, 0.07)} 32%, ${bg} 66%)`,
}}
/>
{nebula && (
<AbsoluteFill
style={{
background: `radial-gradient(circle at ${50 + drift / 16}% 74%, ${hexToRgba(secondary, 0.13)} 0%, transparent 46%)`,
}}
/>
)}
{particles > 0 && (
<AbsoluteFill>
<svg width={width} height={height}>
{Array.from({ length: particles }).map((_, i) => {
const x = rand(i) * width;
const baseY = rand(i + 7) * height;
const y = (baseY - frame * (0.5 + rand(i) * 1.1) * L.unit + height) % height;
const s = L.vmin(2 + (rand(i + 3) * 4));
const tw = 0.25 + 0.6 * Math.abs(Math.sin((frame + i * 17) / 11));
const c = i % 3 === 0 ? secondary : accent;
return (
<circle key={i} cx={x} cy={y} r={s} fill={c} opacity={tw} style={{ filter: `drop-shadow(0 0 ${s * 2}px ${c})` }} />
);
})}
</svg>
</AbsoluteFill>
)}
<AbsoluteFill style={{ boxShadow: `inset 0 0 ${L.vmin(500)}px ${L.vmin(160)}px rgba(0,0,0,0.6)` }} />
</AbsoluteFill>
);
};
/** Spring-based reveal: returns opacity + translateY(px) + scale for a delay. */
export function useReveal(delay: number, opts?: { from?: number; damping?: number }) {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const L = useLayout();
const s = spring({ frame: frame - delay, fps, config: { damping: opts?.damping ?? 16, stiffness: 90, mass: 0.85 } });
return {
opacity: interpolate(s, [0, 1], [0, 1]),
y: interpolate(s, [0, 1], [L.vmin(opts?.from ?? 40), 0]),
scale: interpolate(s, [0, 1], [0.9, 1]),
};
}
+83
View File
@@ -0,0 +1,83 @@
import React from "react";
import { useCurrentFrame } from "remotion";
import { Environment, Lightformer, MeshReflectorMaterial } from "@react-three/drei";
import { EffectComposer, Bloom, DepthOfField, Vignette } from "@react-three/postprocessing";
import { rand } from "./anim";
/**
* Shared max-quality 3D building blocks so every 3D template gets the same
* studio look: offline environment reflections (Lightformers — no HDR needed),
* a reflective floor, a 3-point + colour-rim light rig, and a post-processing
* stack (bloom + depth-of-field + vignette). All verified to render headless
* via ANGLE.
*/
export const StudioEnv: React.FC = () => (
<Environment resolution={256}>
<Lightformer intensity={2} position={[0, 4, -3]} scale={[10, 5, 1]} color="#fff4e6" />
<Lightformer intensity={1.2} position={[-4, 2, 2]} scale={[4, 6, 1]} color="#ffd0a8" />
<Lightformer intensity={1.2} position={[4, 2, 2]} scale={[4, 6, 1]} color="#a8d8ff" />
</Environment>
);
export const StudioLights: React.FC<{ accent: string; secondary: string }> = ({ accent, secondary }) => (
<>
<ambientLight intensity={0.5} />
<directionalLight position={[4, 8, 4]} intensity={2.4} color="#fff3e8" castShadow shadow-mapSize={[1024, 1024]} />
<pointLight position={[-4, 2, 3]} intensity={24} color={secondary} />
<pointLight position={[4, 1, -2]} intensity={18} color={accent} />
</>
);
export const StudioFloor: React.FC<{ color?: string; y?: number }> = ({ color = "#241d33", y = -0.62 }) => (
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, y, 0]} receiveShadow>
<planeGeometry args={[40, 40]} />
<MeshReflectorMaterial
blur={[300, 80]}
resolution={1024}
mixBlur={1}
mixStrength={40}
roughness={0.85}
depthScale={1}
minDepthThreshold={0.4}
maxDepthThreshold={1.2}
color={color}
metalness={0.5}
/>
</mesh>
);
export const StudioEffects: React.FC<{ bloom?: number; focus?: number; bokeh?: number; vignette?: number }> = ({
bloom = 0.75,
focus = 0.013,
bokeh = 3,
vignette = 0.55,
}) => (
<EffectComposer>
<Bloom intensity={bloom} luminanceThreshold={0.62} luminanceSmoothing={0.3} mipmapBlur />
<DepthOfField focusDistance={focus} focalLength={0.045} bokehScale={bokeh} />
<Vignette eskil={false} offset={0.32} darkness={vignette} />
</EffectComposer>
);
/** Falling 3D confetti planes. */
export const Confetti3D: React.FC<{ colors: string[]; count?: number; top?: number }> = ({ colors, count = 36, top = 4.5 }) => {
const frame = useCurrentFrame();
return (
<group>
{Array.from({ length: count }).map((_, i) => {
const x = (rand(i) - 0.5) * 9;
const z = (rand(i + 3) - 0.5) * 4 - 0.5;
const span = 9;
const y = top - ((frame * (0.03 + rand(i) * 0.04) + rand(i + 7) * span) % span);
const rot = frame * 0.06 * (1 + rand(i));
return (
<mesh key={i} position={[x + Math.sin(frame / 28 + i) * 0.5, y, z]} rotation={[rot, rot * 0.7, rot * 0.4]}>
<planeGeometry args={[0.12, 0.07]} />
<meshStandardMaterial color={colors[i % colors.length]} side={2} roughness={0.5} metalness={0.1} emissiveIntensity={0.1} />
</mesh>
);
})}
</group>
);
};
+226
View File
@@ -0,0 +1,226 @@
/**
* Registry of FlatRender branded templates. Each entry is rendered into the
* three supported aspects (16:9 / 1:1 / 9:16) by Root.tsx, producing composition
* ids like "LogoMotion-16x9". Every template uses Persian text presets + the
* shared colour props so the studio can offer one consistent edit experience.
*/
import React from "react";
import type { AnyZodObject } from "zod";
import { BRAND } from "./lib/branding";
import { LogoMotion, logoMotionSchema } from "./compositions/LogoMotion";
import { Opener, openerSchema } from "./compositions/Opener";
import { InstaPromo, instaPromoSchema } from "./compositions/InstaPromo";
import { YouTubeIntro, youTubeIntroSchema } from "./compositions/YouTubeIntro";
import { Slideshow, slideshowSchema } from "./compositions/Slideshow";
import { HappyBirthday, happyBirthdaySchema } from "./compositions/HappyBirthday";
import { SalePromo, salePromoSchema } from "./compositions/SalePromo";
import { QuoteCard, quoteCardSchema } from "./compositions/QuoteCard";
import { EventInvite, eventInviteSchema } from "./compositions/EventInvite";
import { Countdown, countdownSchema } from "./compositions/Countdown";
import { GlitterReveal, glitterRevealSchema } from "./compositions/GlitterReveal";
import { NowruzGreeting, nowruzGreetingSchema } from "./compositions/NowruzGreeting";
import { Hero3D, hero3DSchema } from "./compositions/Hero3D";
import { Nowruz3D, nowruz3DSchema } from "./compositions/Nowruz3D";
import { Birthday3D, birthday3DSchema } from "./compositions/Birthday3D";
import { Promo3D, promo3DSchema } from "./compositions/Promo3D";
export interface TemplateDef {
/** Base id; the registered composition ids are `${id}-${aspect}`. */
id: string;
/** Persian display name (used when seeding the site catalog). */
name: string;
/** Short Persian description for the catalog. */
description: string;
component: React.FC<any>; // eslint-disable-line @typescript-eslint/no-explicit-any
schema: AnyZodObject;
durationSec: number;
defaultProps: Record<string, unknown>;
}
const c = (accent: string, secondary: string, bg: string) => ({
accentColor: accent,
secondaryColor: secondary,
backgroundColor: bg,
textColor: BRAND.white,
});
export const TEMPLATES: TemplateDef[] = [
{
id: "LogoMotion",
name: "موشن لوگو",
description: "نمایش حرفه‌ای لوگو و نام برند با درخشش و حرکت",
component: LogoMotion,
schema: logoMotionSchema,
durationSec: 5,
defaultProps: { brandText: "فلت‌رندر", tagline: "موشن، ساده و حرفه‌ای", ...c(BRAND.blue, BRAND.purple, "#04060f") },
},
{
id: "Opener",
name: "تیتراژ آغازین",
description: "شروع سینمایی برای ویدیو با عنوان و زیرعنوان",
component: Opener,
schema: openerSchema,
durationSec: 5,
defaultProps: { kicker: "تقدیم می‌کند", title: "یک شروع تازه", subtitle: "داستان شما از همین‌جا آغاز می‌شود", ...c(BRAND.cyan, "#6366f1", "#0a0a12") },
},
{
id: "InstaPromo",
name: "تبلیغ پیج اینستاگرام",
description: "معرفی و تبلیغ صفحهٔ اینستاگرام با دعوت به فالو",
component: InstaPromo,
schema: instaPromoSchema,
durationSec: 5,
defaultProps: { handle: "@flatrender", headline: "پیج ما را دنبال کنید", subtext: "هر روز محتوای تازه و الهام‌بخش", cta: "فالو کنید", ...c(BRAND.pink, BRAND.amber, "#140a12") },
},
{
id: "YouTubeIntro",
name: "اینترو کانال یوتیوب",
description: "اینترو حرفه‌ای کانال یوتیوب با دکمهٔ سابسکرایب",
component: YouTubeIntro,
schema: youTubeIntroSchema,
durationSec: 5,
defaultProps: { channelName: "کانال فلت‌رندر", subtitle: "آموزش، ترفند و انگیزه", cta: "سابسکرایب کنید", ...c("#ff4d4d", BRAND.purple, "#0c0810") },
},
{
id: "Slideshow",
name: "اسلایدشو",
description: "نمایش پشت‌سرهم چند پیام یا ویژگی به‌صورت اسلاید",
component: Slideshow,
schema: slideshowSchema,
durationSec: 9,
defaultProps: { title: "چرا فلت‌رندر؟", slide1: "ساخت ویدیو در چند دقیقه", slide2: "بدون نیاز به دانش فنی", slide3: "خروجی با کیفیت حرفه‌ای", ...c(BRAND.green, "#3b82f6", "#060b0a") },
},
{
id: "HappyBirthday",
name: "تولدت مبارک",
description: "کارت تبریک تولد با کاغذرنگی و نام شخص",
component: HappyBirthday,
schema: happyBirthdaySchema,
durationSec: 6,
defaultProps: { greeting: "تولدت مبارک", name: "سارا", message: "بهترین‌ها را برایت آرزومندیم 🎉", ...c(BRAND.pink, "#fde047", "#140a18") },
},
{
id: "SalePromo",
name: "فروش ویژه",
description: "بنر تبلیغاتی فروش و تخفیف با دعوت به خرید",
component: SalePromo,
schema: salePromoSchema,
durationSec: 5,
defaultProps: { badge: "۵۰٪ تخفیف", headline: "فروش ویژهٔ پایان فصل", subtext: "فقط تا پایان همین هفته", cta: "همین حالا خرید کنید", ...c(BRAND.amber, BRAND.pink, "#120a08") },
},
{
id: "QuoteCard",
name: "کارت نقل‌قول",
description: "نمایش جملهٔ انگیزشی یا نقل‌قول با نام گوینده",
component: QuoteCard,
schema: quoteCardSchema,
durationSec: 6,
defaultProps: { quote: "موفقیت، مجموع تلاش‌های کوچکِ هر روز است.", author: "فلت‌رندر", ...c(BRAND.cyan, "#6366f1", "#0a0a12") },
},
{
id: "EventInvite",
name: "دعوت‌نامهٔ رویداد",
description: "دعوت‌نامهٔ شیک برای رویداد با تاریخ و مکان",
component: EventInvite,
schema: eventInviteSchema,
durationSec: 6,
defaultProps: { kicker: "دعوت‌نامه", eventTitle: "همایش سالانهٔ نوآوری", date: "۱۵ مهر ۱۴۰۳", location: "تهران، سالن همایش‌ها", cta: "ثبت‌نام کنید", ...c(BRAND.purple, BRAND.blue, "#0a0814") },
},
{
id: "Countdown",
name: "شمارش معکوس",
description: "شمارش معکوس هیجان‌انگیز برای شروع یک رویداد",
component: Countdown,
schema: countdownSchema,
durationSec: 8,
defaultProps: { title: "شروع رویداد تا", startNumber: 5, goText: "شروع!", subtitle: "آماده‌اید؟", ...c(BRAND.blue, BRAND.cyan, "#04060f") },
},
{
id: "GlitterReveal",
name: "نمایش لوگو با غبار درخشان",
description: "نمایش جادویی لوگو با ذرات درخشان؛ لوگو و متن قابل ویرایش",
component: GlitterReveal,
schema: glitterRevealSchema,
durationSec: 6,
defaultProps: { brandText: "فلت‌رندر", tagline: "موشن، ساده و حرفه‌ای", logoUrl: "", ...c(BRAND.blue, BRAND.purple, "#05040e") },
},
{
id: "NowruzGreeting",
name: "تبریک نوروز",
description: "صحنهٔ بهاری نوروز با شخصیت‌های متحرک؛ حاجی‌فیروز، ماهی قرمز و سبزه",
component: NowruzGreeting,
schema: nowruzGreetingSchema,
durationSec: 7.5,
defaultProps: {
greeting: "نوروز مبارک",
subtitle: "سال نو پیروز و شادمان",
message: "۱۴۰۶",
accentColor: "#f5b942",
secondaryColor: "#e23b3b",
backgroundColor: "#1fb6b0",
textColor: "#fdf6e3",
},
},
{
id: "Hero3D",
name: "نمایش سه‌بعدی برند",
description: "نمایش حرفه‌ای و سه‌بعدی لوگو و برند با نورپردازی و جلوه‌های واقعی",
component: Hero3D,
schema: hero3DSchema,
durationSec: 6,
defaultProps: { brandText: "فلت‌رندر", tagline: "موشن، ساده و حرفه‌ای", ...c(BRAND.blue, BRAND.purple, "#04060f") },
},
{
id: "Nowruz3D",
name: "تبریک نوروز سه‌بعدی",
description: "صحنهٔ سه‌بعدی نوروز با حاجی‌فیروز، سفرهٔ هفت‌سین و نورپردازی سینمایی",
component: Nowruz3D,
schema: nowruz3DSchema,
durationSec: 7,
defaultProps: {
greeting: "نوروز مبارک",
subtitle: "سال نو پیروز و شادمان",
message: "۱۴۰۶",
accentColor: "#f5c542",
secondaryColor: "#e23b3b",
backgroundColor: "#1a1228",
textColor: "#fdf6e3",
},
},
{
id: "Birthday3D",
name: "تولد سه‌بعدی",
description: "صحنهٔ سه‌بعدی تولد با کیک و شمع‌های روشن، بادکنک و کاغذرنگی",
component: Birthday3D,
schema: birthday3DSchema,
durationSec: 6,
defaultProps: {
greeting: "تولدت مبارک",
name: "سارا",
message: "بهترین‌ها را برایت آرزومندیم 🎉",
accentColor: "#fb7185",
secondaryColor: "#a855f7",
backgroundColor: "#1a1226",
textColor: "#fdf6e3",
},
},
{
id: "Promo3D",
name: "فروش ویژه سه‌بعدی",
description: "تبلیغ سه‌بعدی فروش و تخفیف با جعبه‌های هدیه و نورپردازی سینمایی",
component: Promo3D,
schema: promo3DSchema,
durationSec: 6,
defaultProps: {
badge: "۵۰٪ تخفیف",
headline: "فروش ویژهٔ پایان فصل",
subtext: "فقط تا پایان همین هفته",
cta: "همین حالا خرید کنید",
accentColor: "#f59e0b",
secondaryColor: "#fb7185",
backgroundColor: "#140e1f",
textColor: "#ffffff",
},
},
];
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["react"]
},
"include": ["src"]
}