Files
flatrender/services/remotion/src/compositions/GlitterReveal.tsx
T
soroush.asadi 4f04f6bf75
CI/CD / CI · Web (tsc) (push) Successful in 1m21s
CI/CD / Deploy · full stack (push) Failing after 20s
feat(render+templates): Remotion engine, 16 branded templates (incl. 3D), seconds pricing, coming-soon
Render engine
- Add Remotion (code-based) as a 2nd render engine alongside After Effects.
  node-agent dispatches on Job.Engine; RunRemotion maps bindings -> --props,
  renders native then ffmpeg-scales to the quality tier (aspect-preserving).
- content.projects.render_engine + render_remotion_comp (migration 32);
  render-svc claim resolves engine and routes (skips .aep for Remotion).
- Admin TemplatesAdmin gains an engine picker + Remotion composition id field.

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 15:52:52 +03:30

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>
);
};