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:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user