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>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user