195 lines
5.0 KiB
TypeScript
195 lines
5.0 KiB
TypeScript
|
|
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",
|
||
|
|
}),
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
“
|
||
|
|
</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>
|
||
|
|
);
|
||
|
|
};
|