4f04f6bf75
Render engine - Add Remotion (code-based) as a 2nd render engine alongside After Effects. node-agent dispatches on Job.Engine; RunRemotion maps bindings -> --props, renders native then ffmpeg-scales to the quality tier (aspect-preserving). - content.projects.render_engine + render_remotion_comp (migration 32); render-svc claim resolves engine and routes (skips .aep for Remotion). - Admin TemplatesAdmin gains an engine picker + Remotion composition id field. Template pack (services/remotion) - 16 branded, Persian (Vazirmatn), color- and text-editable templates, each in 3 aspects (16:9 / 1:1 / 9:16): LogoMotion, Opener, InstaPromo, YouTubeIntro, Slideshow, HappyBirthday, SalePromo, QuoteCard, EventInvite, Countdown, GlitterReveal (editable logo image), NowruzGreeting (animated characters), and 4 cinematic 3D templates via @remotion/three (Hero3D, Nowruz3D, Birthday3D, Promo3D) with reflections + bloom/DOF/vignette. - scripts/seed_remotion_templates.py seeds containers/projects/scenes/colors. Pricing - Rewrite /pricing to the seconds-based model (charge = length x resolution), data-driven from /v1/plans, Toman, broker checkout. Coming-soon - Persian experimental-build overlay on all pages (launch date + countdown). Fixes - middleware matcher bypasses all static asset paths; catalog mapping passes cover image + preview video so real thumbnails render. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
197 lines
8.8 KiB
TypeScript
197 lines
8.8 KiB
TypeScript
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>
|
|
);
|
|
};
|