Files
flatrender/services/remotion/src/compositions/VerticalStory.tsx
T

230 lines
6.0 KiB
TypeScript
Raw Normal View History

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