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

195 lines
5.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 } 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>
);
};