Upgrade Remotion composition engine with cinematic enhancements

- Upgrade Remotion packages to 4.0.441, add transitions/captions/fonts/media
- Add spring-animated image scenes, stat reveals, section titles, hero cards
- Add TikTok-style word-by-word captions with highlight effect
- Add Google Fonts (Space Grotesk) and dynamic duration via calculateMetadata
- Fix Remotion false-positive: check node_modules/ in _remotion_available()
- Add project directory convention, music library, tool naming docs
- Add music transparency to proposal stage, subtitle pipeline to compose stage
- Add README showcase montage and Windows npm troubleshooting note
This commit is contained in:
calesthio
2026-03-29 12:06:06 -07:00
parent b97b5ab5b4
commit 2c16c6e547
17 changed files with 3842 additions and 123 deletions
+212 -104
View File
@@ -6,13 +6,35 @@ import {
Sequence,
interpolate,
spring,
staticFile,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import { loadFont } from "@remotion/google-fonts/SpaceGrotesk";
// Resolve asset path — use staticFile() for local paths, passthrough URLs
function resolveAsset(src: string): string {
if (src.startsWith("http://") || src.startsWith("https://") || src.startsWith("data:")) {
return src;
}
// Strip any file:// prefix
const clean = src.replace(/^file:\/\/\/?/, "");
return staticFile(clean);
}
import { TextCard } from "./components/TextCard";
import { StatCard } from "./components/StatCard";
import { CalloutBox } from "./components/CalloutBox";
import { ComparisonCard } from "./components/ComparisonCard";
import { CaptionOverlay, WordCaption } from "./components/CaptionOverlay";
import { SectionTitle } from "./components/SectionTitle";
import { StatReveal } from "./components/StatReveal";
import { HeroTitle } from "./components/HeroTitle";
// Load Space Grotesk font for cinematic typography
const { fontFamily } = loadFont("normal", {
weights: ["400", "700"],
subsets: ["latin"],
});
// ---------------------------------------------------------------------------
// Types — aligned with edit_decisions artifact schema
@@ -43,35 +65,42 @@ interface Cut {
transform?: {
animation?: string;
scale?: number;
position?: { x: number; y: number };
position?: string | { x: number; y: number };
};
}
interface Overlay {
type: "section_title" | "stat_reveal" | "hero_title";
in_seconds: number;
out_seconds: number;
text: string;
subtitle?: string;
accentColor?: string;
position?: string;
}
interface AudioLayer {
src: string;
volume?: number;
}
interface SfxCue {
src: string;
start_seconds: number;
volume?: number;
}
interface AudioConfig {
narration?: AudioLayer;
music?: AudioLayer;
sfx?: SfxCue[];
music?: AudioLayer & {
fadeInSeconds?: number;
fadeOutSeconds?: number;
};
}
interface ExplainerProps {
export interface ExplainerProps {
cuts: Cut[];
subtitles?: { enabled: boolean; src?: string };
overlays?: Overlay[];
captions?: WordCaption[];
audio?: AudioConfig;
}
// ---------------------------------------------------------------------------
// Image scene — spring / interpolate animations (replaces FFmpeg Ken Burns)
// Image Extensions
// ---------------------------------------------------------------------------
const IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".bmp", ".tiff", ".tif", ".webp"];
@@ -87,6 +116,24 @@ function isVideo(source: string): boolean {
return VIDEO_EXTENSIONS.some((ext) => lower.endsWith(ext));
}
// ---------------------------------------------------------------------------
// Cinematic vignette overlay
// ---------------------------------------------------------------------------
const Vignette: React.FC = () => (
<AbsoluteFill
style={{
background:
"radial-gradient(ellipse at center, transparent 50%, rgba(0,0,0,0.6) 100%)",
pointerEvents: "none",
}}
/>
);
// ---------------------------------------------------------------------------
// Enhanced Image Scene — spring physics, parallax, variety
// ---------------------------------------------------------------------------
const ImageScene: React.FC<{ src: string; animation?: string }> = ({
src,
animation,
@@ -94,102 +141,101 @@ const ImageScene: React.FC<{ src: string; animation?: string }> = ({
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
// Smooth fade-in on entrance
const fadeIn = spring({ frame, fps, config: { damping: 20 } });
// Smooth spring fade-in
const fadeIn = spring({ frame, fps, config: { damping: 18, stiffness: 80 } });
// Fade-out for crossfade effect
const fadeOutStart = durationInFrames - 8;
const fadeOut = interpolate(frame, [fadeOutStart, durationInFrames], [1, 0.3], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
let scale = 1;
let translateX = 0;
let translateY = 0;
const anim = animation || "zoom-in";
// Progress with easing — smoother than linear
const progress = interpolate(frame, [0, durationInFrames], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
if (anim === "zoom-in") {
scale = interpolate(frame, [0, durationInFrames], [1, 1.15], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
scale = 1 + progress * 0.18;
} else if (anim === "zoom-out") {
scale = interpolate(frame, [0, durationInFrames], [1.15, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
scale = 1.18 - progress * 0.18;
} else if (anim === "pan-left") {
translateX = interpolate(frame, [0, durationInFrames], [30, -30], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
translateX = interpolate(progress, [0, 1], [40, -40]);
scale = 1.15;
} else if (anim === "pan-right") {
translateX = interpolate(frame, [0, durationInFrames], [-30, 30], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
translateX = interpolate(progress, [0, 1], [-40, 40]);
scale = 1.15;
} else if (anim === "ken-burns") {
scale = interpolate(frame, [0, durationInFrames], [1, 1.2], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
translateX = interpolate(frame, [0, durationInFrames], [0, -20], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
translateY = interpolate(frame, [0, durationInFrames], [0, -10], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
} else if (anim === "ken-burns" || anim === "ken-burns-slow-zoom") {
// Cinematic Ken Burns: gentle zoom + diagonal drift
scale = 1 + progress * 0.22;
translateX = interpolate(progress, [0, 1], [0, -25]);
translateY = interpolate(progress, [0, 1], [0, -15]);
} else if (anim === "parallax") {
// Subtle parallax — foreground moves faster
translateY = interpolate(progress, [0, 1], [15, -15]);
scale = 1.1;
}
// "static" or "none" → no motion, just fade in
// "static" or "none" → just display
return (
<AbsoluteFill style={{ overflow: "hidden", backgroundColor: "#000" }}>
<AbsoluteFill style={{ overflow: "hidden", backgroundColor: "#0F172A" }}>
<Img
src={src}
src={resolveAsset(src)}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
opacity: fadeIn,
opacity: fadeIn * fadeOut,
transform: `scale(${scale}) translate(${translateX}px, ${translateY}px)`,
willChange: "transform, opacity",
}}
/>
<Vignette />
</AbsoluteFill>
);
};
// ---------------------------------------------------------------------------
// Video scene — OffthreadVideo for frame-accurate rendering
// Enhanced Video Scene
// ---------------------------------------------------------------------------
const VideoScene: React.FC<{ src: string; startFrom?: number }> = ({
src,
startFrom = 0,
}) => {
const { fps } = useVideoConfig();
return (
<AbsoluteFill>
<OffthreadVideo
src={src}
startFrom={Math.round(startFrom * fps)}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
</AbsoluteFill>
);
};
// ---------------------------------------------------------------------------
// Fade transition wrapper
// ---------------------------------------------------------------------------
const FadeIn: React.FC<{
children: React.ReactNode;
durationFrames?: number;
}> = ({ children, durationFrames = 10 }) => {
const frame = useCurrentFrame();
const opacity = interpolate(frame, [0, durationFrames], [0, 1], {
const { fps, durationInFrames } = useVideoConfig();
const fadeIn = spring({ frame, fps, config: { damping: 20 } });
const fadeOutStart = durationInFrames - 8;
const fadeOut = interpolate(frame, [fadeOutStart, durationInFrames], [1, 0.3], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return <AbsoluteFill style={{ opacity }}>{children}</AbsoluteFill>;
return (
<AbsoluteFill style={{ backgroundColor: "#0F172A" }}>
<OffthreadVideo
src={resolveAsset(src)}
startFrom={Math.round(startFrom * fps)}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
opacity: fadeIn * fadeOut,
}}
muted
/>
<Vignette />
</AbsoluteFill>
);
};
// ---------------------------------------------------------------------------
@@ -227,7 +273,6 @@ const SceneRenderer: React.FC<{ cut: Cut }> = ({ cut }) => {
);
}
// Auto-detect from source file extension
const animation = cut.animation || cut.transform?.animation;
if (isImage(cut.source)) {
@@ -238,59 +283,122 @@ const SceneRenderer: React.FC<{ cut: Cut }> = ({ cut }) => {
return <VideoScene src={cut.source} startFrom={cut.in_seconds} />;
}
// Fallback: treat as image
return <ImageScene src={cut.source} animation={animation} />;
};
// ---------------------------------------------------------------------------
// Overlay renderer
// ---------------------------------------------------------------------------
const OverlayRenderer: React.FC<{ overlay: Overlay }> = ({ overlay }) => {
if (overlay.type === "section_title") {
return (
<SectionTitle
title={overlay.text}
subtitle={overlay.subtitle}
accentColor={overlay.accentColor}
position={(overlay.position as any) || "top-left"}
/>
);
}
if (overlay.type === "stat_reveal") {
return (
<StatReveal
stat={overlay.text}
label={overlay.subtitle}
accentColor={overlay.accentColor}
position={(overlay.position as any) || "bottom-right"}
/>
);
}
if (overlay.type === "hero_title") {
return <HeroTitle title={overlay.text} subtitle={overlay.subtitle} />;
}
return null;
};
// ---------------------------------------------------------------------------
// Main composition
// ---------------------------------------------------------------------------
export const Explainer: React.FC<ExplainerProps> = ({ cuts, audio }) => {
const { fps } = useVideoConfig();
export const Explainer: React.FC<ExplainerProps> = ({
cuts,
overlays,
captions,
audio,
}) => {
const { fps, durationInFrames } = useVideoConfig();
return (
<AbsoluteFill style={{ backgroundColor: "#000" }}>
{/* Visual layers */}
<AbsoluteFill style={{ backgroundColor: "#0F172A", fontFamily }}>
{/* Layer 1: Visual scenes */}
{cuts.map((cut) => {
const from = Math.round(cut.in_seconds * fps);
const duration = Math.round(
(cut.out_seconds - cut.in_seconds) * fps
);
const scene = <SceneRenderer cut={cut} />;
const wrapped =
cut.transition_in === "fade" ? (
<FadeIn>{scene}</FadeIn>
) : (
scene
);
const duration = Math.round((cut.out_seconds - cut.in_seconds) * fps);
return (
<Sequence key={cut.id} from={from} durationInFrames={duration}>
<AbsoluteFill>{wrapped}</AbsoluteFill>
<SceneRenderer cut={cut} />
</Sequence>
);
})}
{/* Audio layers */}
{audio?.narration?.src && (
<Audio src={audio.narration.src} volume={audio.narration.volume ?? 1} />
)}
{audio?.music?.src && (
<Audio
src={audio.music.src}
volume={audio.music.volume ?? 0.06}
{/* Layer 2: Overlays (section titles, stat reveals, hero titles) */}
{overlays?.map((overlay, i) => {
const from = Math.round(overlay.in_seconds * fps);
const duration = Math.round(
(overlay.out_seconds - overlay.in_seconds) * fps
);
return (
<Sequence key={`overlay-${i}`} from={from} durationInFrames={duration}>
<OverlayRenderer overlay={overlay} />
</Sequence>
);
})}
{/* Layer 3: Captions (word-by-word highlight) */}
{captions && captions.length > 0 && (
<CaptionOverlay
words={captions}
wordsPerPage={6}
fontSize={42}
highlightColor="#22D3EE"
backgroundColor="rgba(15, 23, 42, 0.7)"
/>
)}
{/* Layer 4: Audio — narration */}
{audio?.narration?.src && (
<Audio src={resolveAsset(audio.narration.src)} volume={audio.narration.volume ?? 1} />
)}
{/* Layer 4: Audio — music with fade in/out */}
{audio?.music?.src && (
<Audio
src={resolveAsset(audio.music.src)}
volume={(f) => {
const baseVol = audio.music!.volume ?? 0.1;
const fadeInDur = (audio.music!.fadeInSeconds ?? 2) * fps;
const fadeOutDur = (audio.music!.fadeOutSeconds ?? 3) * fps;
const totalFrames = durationInFrames;
// Fade in
const fadeIn = interpolate(f, [0, fadeInDur], [0, baseVol], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
// Fade out
const fadeOut = interpolate(
f,
[totalFrames - fadeOutDur, totalFrames],
[baseVol, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
return Math.min(fadeIn, fadeOut);
}}
/>
)}
{audio?.sfx?.map((cue, i) => (
<Sequence
key={`sfx-${i}`}
from={Math.round(cue.start_seconds * fps)}
>
<Audio src={cue.src} volume={cue.volume ?? 0.5} />
</Sequence>
))}
</AbsoluteFill>
);
};
+18 -4
View File
@@ -1,5 +1,17 @@
import { Composition } from "remotion";
import { Explainer } from "./Explainer";
import { Composition, CalculateMetadataFunction } from "remotion";
import { Explainer, ExplainerProps } from "./Explainer";
const calculateMetadata: CalculateMetadataFunction<ExplainerProps> = async ({
props,
}) => {
const cuts = props.cuts || [];
if (cuts.length === 0) {
return { durationInFrames: 30 * 60 };
}
const lastEnd = Math.max(...cuts.map((c) => c.out_seconds || 0));
// Add 1 second padding for final fade
return { durationInFrames: Math.ceil((lastEnd + 1) * 30) };
};
export const Root: React.FC = () => {
return (
@@ -7,15 +19,17 @@ export const Root: React.FC = () => {
<Composition
id="Explainer"
component={Explainer}
durationInFrames={30 * 60} // default 60s at 30fps, overridden by props
durationInFrames={30 * 60}
fps={30}
width={1920}
height={1080}
defaultProps={{
cuts: [],
subtitles: { enabled: false },
overlays: [],
captions: [],
audio: {},
}}
calculateMetadata={calculateMetadata}
/>
</>
);
@@ -0,0 +1,157 @@
import {
AbsoluteFill,
Sequence,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
} from "remotion";
// Word-level caption for TikTok-style highlight display
export interface WordCaption {
word: string;
startMs: number;
endMs: number;
}
interface CaptionOverlayProps {
words: WordCaption[];
// How many words to show at once in a "page"
wordsPerPage?: number;
fontSize?: number;
color?: string;
highlightColor?: string;
backgroundColor?: string;
fontFamily?: string;
}
interface CaptionPage {
words: WordCaption[];
startMs: number;
endMs: number;
}
function buildPages(words: WordCaption[], wordsPerPage: number): CaptionPage[] {
const pages: CaptionPage[] = [];
for (let i = 0; i < words.length; i += wordsPerPage) {
const pageWords = words.slice(i, i + wordsPerPage);
if (pageWords.length === 0) continue;
pages.push({
words: pageWords,
startMs: pageWords[0].startMs,
endMs: pageWords[pageWords.length - 1].endMs,
});
}
return pages;
}
const PageRenderer: React.FC<{
page: CaptionPage;
fontSize: number;
color: string;
highlightColor: string;
backgroundColor: string;
fontFamily: string;
}> = ({ page, fontSize, color, highlightColor, backgroundColor, fontFamily }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const currentMs = page.startMs + (frame / fps) * 1000;
// Spring entrance
const entrance = spring({
frame,
fps,
config: { damping: 18, stiffness: 120 },
});
return (
<AbsoluteFill
style={{
justifyContent: "flex-end",
alignItems: "center",
paddingBottom: 80,
}}
>
<div
style={{
opacity: entrance,
transform: `translateY(${interpolate(entrance, [0, 1], [20, 0])}px)`,
backgroundColor,
borderRadius: 12,
padding: "14px 28px",
maxWidth: "80%",
textAlign: "center",
}}
>
<span
style={{
fontSize,
fontWeight: 700,
fontFamily,
lineHeight: 1.4,
whiteSpace: "pre-wrap",
}}
>
{page.words.map((w, i) => {
const isActive = w.startMs <= currentMs && w.endMs > currentMs;
const isPast = w.endMs <= currentMs;
return (
<span
key={`${w.startMs}-${i}`}
style={{
color: isActive ? highlightColor : isPast ? color : `${color}99`,
transition: "none", // CSS transitions forbidden in Remotion
textShadow: isActive
? `0 0 20px ${highlightColor}66, 0 2px 4px rgba(0,0,0,0.5)`
: "0 2px 4px rgba(0,0,0,0.5)",
}}
>
{w.word}
</span>
);
})}
</span>
</div>
</AbsoluteFill>
);
};
export const CaptionOverlay: React.FC<CaptionOverlayProps> = ({
words,
wordsPerPage = 6,
fontSize = 42,
color = "#F8FAFC",
highlightColor = "#22D3EE",
backgroundColor = "rgba(15, 23, 42, 0.75)",
fontFamily = "Space Grotesk, Inter, system-ui, sans-serif",
}) => {
const { fps } = useVideoConfig();
const pages = buildPages(words, wordsPerPage);
return (
<AbsoluteFill>
{pages.map((page, i) => {
const fromFrame = Math.round((page.startMs / 1000) * fps);
const nextStart = pages[i + 1]?.startMs ?? page.endMs + 500;
const duration = Math.max(
1,
Math.round(((nextStart - page.startMs) / 1000) * fps)
);
return (
<Sequence key={i} from={fromFrame} durationInFrames={duration}>
<PageRenderer
page={page}
fontSize={fontSize}
color={color}
highlightColor={highlightColor}
backgroundColor={backgroundColor}
fontFamily={fontFamily}
/>
</Sequence>
);
})}
</AbsoluteFill>
);
};
@@ -0,0 +1,113 @@
import {
AbsoluteFill,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
} from "remotion";
interface HeroTitleProps {
title: string;
subtitle?: string;
}
export const HeroTitle: React.FC<HeroTitleProps> = ({ title, subtitle }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Staggered letter-by-letter spring
const titleChars = title.split("");
return (
<AbsoluteFill
style={{
justifyContent: "center",
alignItems: "center",
background:
"radial-gradient(ellipse at center, rgba(15,23,42,0.85) 0%, rgba(15,23,42,0.95) 100%)",
}}
>
<div style={{ textAlign: "center", maxWidth: "85%" }}>
{/* Main title with per-character spring */}
<div
style={{
fontSize: 72,
fontWeight: 800,
fontFamily: "Space Grotesk, Inter, system-ui, sans-serif",
lineHeight: 1.2,
display: "flex",
justifyContent: "center",
flexWrap: "wrap",
gap: 0,
}}
>
{titleChars.map((char, i) => {
const delay = i * 1.2;
const charSpring = spring({
frame: frame - delay,
fps,
config: { damping: 12, stiffness: 150 },
});
return (
<span
key={i}
style={{
display: "inline-block",
opacity: charSpring,
transform: `translateY(${interpolate(charSpring, [0, 1], [30, 0])}px)`,
color: i < 8 ? "#22D3EE" : "#F8FAFC", // Accent first word
whiteSpace: char === " " ? "pre" : undefined,
minWidth: char === " " ? "0.3em" : undefined,
}}
>
{char}
</span>
);
})}
</div>
{/* Subtitle */}
{subtitle && (
<div
style={{
marginTop: 20,
opacity: spring({
frame: frame - titleChars.length * 1.2 - 5,
fps,
config: { damping: 20 },
}),
fontSize: 28,
fontWeight: 400,
color: "#A78BFA",
fontFamily: "Space Grotesk, Inter, system-ui, sans-serif",
letterSpacing: "0.1em",
textTransform: "uppercase",
}}
>
{subtitle}
</div>
)}
{/* Animated underline */}
<div
style={{
margin: "24px auto 0",
height: 3,
backgroundColor: "#22D3EE",
borderRadius: 2,
width: interpolate(
spring({
frame: frame - 15,
fps,
config: { damping: 15, stiffness: 60 },
}),
[0, 1],
[0, 400]
),
}}
/>
</div>
</AbsoluteFill>
);
};
@@ -0,0 +1,101 @@
import {
AbsoluteFill,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
} from "remotion";
interface SectionTitleProps {
title: string;
subtitle?: string;
accentColor?: string;
position?: "top-left" | "bottom-left" | "center";
}
export const SectionTitle: React.FC<SectionTitleProps> = ({
title,
subtitle,
accentColor = "#22D3EE",
position = "top-left",
}) => {
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
// Entrance spring
const slideIn = spring({
frame,
fps,
config: { damping: 15, stiffness: 80 },
});
// Exit fade
const exitStart = durationInFrames - 15;
const fadeOut = interpolate(frame, [exitStart, durationInFrames], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const opacity = Math.min(slideIn, fadeOut);
const positionStyles: React.CSSProperties =
position === "center"
? { justifyContent: "center", alignItems: "center" }
: position === "bottom-left"
? { justifyContent: "flex-end", alignItems: "flex-start", padding: 60 }
: { justifyContent: "flex-start", alignItems: "flex-start", padding: 60 };
return (
<AbsoluteFill style={positionStyles}>
<div
style={{
opacity,
transform: `translateX(${interpolate(slideIn, [0, 1], [-40, 0])}px)`,
}}
>
{/* Accent bar */}
<div
style={{
width: interpolate(slideIn, [0, 1], [0, 60]),
height: 4,
backgroundColor: accentColor,
marginBottom: 12,
borderRadius: 2,
}}
/>
<div
style={{
fontSize: 28,
fontWeight: 700,
color: "#F8FAFC",
fontFamily: "Space Grotesk, Inter, system-ui, sans-serif",
letterSpacing: "0.05em",
textTransform: "uppercase",
textShadow: "0 2px 8px rgba(0,0,0,0.6)",
}}
>
{title}
</div>
{subtitle && (
<div
style={{
fontSize: 18,
fontWeight: 400,
color: accentColor,
fontFamily: "Space Grotesk, Inter, system-ui, sans-serif",
marginTop: 4,
opacity: spring({
frame: frame - 8,
fps,
config: { damping: 20 },
}),
textShadow: "0 2px 8px rgba(0,0,0,0.6)",
}}
>
{subtitle}
</div>
)}
</div>
</AbsoluteFill>
);
};
@@ -0,0 +1,99 @@
import {
AbsoluteFill,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
} from "remotion";
interface StatRevealProps {
stat: string;
label?: string;
accentColor?: string;
position?: "center" | "bottom-right" | "right";
}
export const StatReveal: React.FC<StatRevealProps> = ({
stat,
label,
accentColor = "#A78BFA",
position = "bottom-right",
}) => {
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
// Bouncy entrance
const scale = spring({
frame,
fps,
config: { damping: 10, stiffness: 100, mass: 0.8 },
from: 0,
to: 1,
});
const glow = spring({
frame: frame - 5,
fps,
config: { damping: 20, stiffness: 60 },
});
// Exit
const exitStart = durationInFrames - 12;
const fadeOut = interpolate(frame, [exitStart, durationInFrames], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const opacity = Math.min(spring({ frame, fps, config: { damping: 20 } }), fadeOut);
const positionStyles: React.CSSProperties =
position === "center"
? { justifyContent: "center", alignItems: "center" }
: position === "right"
? { justifyContent: "center", alignItems: "flex-end", paddingRight: 80 }
: { justifyContent: "flex-end", alignItems: "flex-end", padding: 80 };
return (
<AbsoluteFill style={positionStyles}>
<div
style={{
opacity,
transform: `scale(${scale})`,
textAlign: position === "center" ? "center" : "right",
}}
>
<div
style={{
fontSize: 96,
fontWeight: 800,
color: accentColor,
fontFamily: "Space Grotesk, Inter, system-ui, sans-serif",
lineHeight: 1,
textShadow: `0 0 ${interpolate(glow, [0, 1], [0, 30])}px ${accentColor}66, 0 4px 12px rgba(0,0,0,0.5)`,
}}
>
{stat}
</div>
{label && (
<div
style={{
fontSize: 22,
fontWeight: 500,
color: "#F8FAFC",
fontFamily: "Space Grotesk, Inter, system-ui, sans-serif",
marginTop: 8,
opacity: spring({
frame: frame - 10,
fps,
config: { damping: 20 },
}),
textShadow: "0 2px 8px rgba(0,0,0,0.6)",
}}
>
{label}
</div>
)}
</div>
</AbsoluteFill>
);
};
@@ -4,3 +4,7 @@ export { ProgressBar } from "./ProgressBar";
export { CalloutBox } from "./CalloutBox";
export { ComparisonCard } from "./ComparisonCard";
export { BarChart, LineChart, PieChart, KPIGrid } from "./charts";
export { CaptionOverlay } from "./CaptionOverlay";
export { SectionTitle } from "./SectionTitle";
export { StatReveal } from "./StatReveal";
export { HeroTitle } from "./HeroTitle";