Harden cinematic pipeline and add reusable renderer

This commit is contained in:
calesthio
2026-03-29 15:49:49 -07:00
parent 5f71d63ff4
commit ede8aac4d2
11 changed files with 658 additions and 23 deletions
+292
View File
@@ -0,0 +1,292 @@
import React from "react";
import { loadFont } from "@remotion/google-fonts/SpaceGrotesk";
import {
AbsoluteFill,
CalculateMetadataFunction,
OffthreadVideo,
Sequence,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import { CinematicRendererProps, CinematicTone, CinematicVideoScene } from "./cinematic/types";
const FPS = 30;
const { fontFamily } = loadFont("normal", {
weights: ["400", "500", "700"],
subsets: ["latin"],
});
const toneGradient = (tone: CinematicTone) => {
switch (tone) {
case "steel":
return "linear-gradient(180deg, rgba(6,12,18,0.18) 0%, rgba(2,4,8,0.48) 100%)";
case "void":
return "linear-gradient(180deg, rgba(2,4,8,0.14) 0%, rgba(0,0,0,0.56) 100%)";
case "neutral":
return "linear-gradient(180deg, rgba(10,10,12,0.16) 0%, rgba(0,0,0,0.42) 100%)";
case "cold":
default:
return "linear-gradient(180deg, rgba(8,16,24,0.18) 0%, rgba(2,4,8,0.42) 100%)";
}
};
const SceneVideo: React.FC<{ scene: CinematicVideoScene }> = ({ scene }) => {
const frame = useCurrentFrame();
const { durationInFrames, fps } = useVideoConfig();
const opacity = interpolate(
frame,
[0, 10, durationInFrames - 10, durationInFrames],
[0, 1, 1, 0],
{
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
},
);
const scale = interpolate(frame, [0, durationInFrames], [1.015, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const trimBefore =
scene.trimBeforeSeconds !== undefined
? Math.round(scene.trimBeforeSeconds * fps)
: undefined;
const trimAfter =
scene.trimAfterSeconds !== undefined
? Math.round(scene.trimAfterSeconds * fps)
: undefined;
return (
<AbsoluteFill style={{ backgroundColor: "#020407", opacity }}>
<OffthreadVideo
muted
src={scene.src}
trimBefore={trimBefore}
trimAfter={trimAfter}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
transform: `scale(${scale})`,
filter:
scene.filter ?? "contrast(1.06) saturate(0.88) brightness(0.92)",
}}
/>
<AbsoluteFill
style={{
background: toneGradient(scene.tone ?? "cold"),
mixBlendMode: "multiply",
}}
/>
<AbsoluteFill
style={{
background:
"radial-gradient(circle at center, transparent 52%, rgba(0,0,0,0.52) 100%)",
}}
/>
<AbsoluteFill
style={{
background:
"linear-gradient(180deg, rgba(255,255,255,0.02) 0%, transparent 8%, transparent 92%, rgba(255,255,255,0.02) 100%)",
opacity: 0.6,
}}
/>
</AbsoluteFill>
);
};
const SignalTexture: React.FC<{
accent: string;
intensity: number;
lineCount: number;
}> = ({ accent, intensity, lineCount }) => {
const frame = useCurrentFrame();
return (
<AbsoluteFill style={{ pointerEvents: "none" }}>
{new Array(lineCount).fill(true).map((_, index) => {
const pulse = Math.max(0, Math.sin(frame * 0.06 + index * 0.85));
const opacity = (0.025 + pulse * 0.07) * intensity;
const width = 18 + ((index * 37) % 56);
const top = 140 + index * 42;
const left = index % 2 === 0 ? 0 : 1920 - width;
return (
<div
key={index}
style={{
position: "absolute",
top,
left,
width,
height: 1,
background: accent,
boxShadow: `0 0 16px ${accent}`,
opacity,
}}
/>
);
})}
<div
style={{
position: "absolute",
inset: 0,
background:
"repeating-linear-gradient(180deg, rgba(255,255,255,0.028) 0px, rgba(255,255,255,0.028) 1px, transparent 2px, transparent 6px)",
opacity: 0.12 * intensity,
}}
/>
</AbsoluteFill>
);
};
const TitleCard: React.FC<{
text: string;
accent: string;
intensity: number;
titleFontSize: number;
titleWidth: number;
signalLineCount: number;
}> = ({
text,
accent,
intensity,
titleFontSize,
titleWidth,
signalLineCount,
}) => {
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
const reveal = spring({
fps,
frame,
config: { damping: 18, stiffness: 90 },
});
const exit = interpolate(
frame,
[durationInFrames - 12, durationInFrames],
[1, 0],
{
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
},
);
const y = interpolate(reveal, [0, 1], [18, 0]);
const letterSpacing = interpolate(reveal, [0, 1], [0.3, 0.18]);
const flareOpacity =
0.18 + Math.max(0, Math.sin(frame * 0.08)) * 0.14 * intensity;
return (
<AbsoluteFill
style={{
background:
"radial-gradient(circle at 50% 42%, rgba(16,28,40,0.9) 0%, rgba(3,5,8,1) 58%, rgba(0,0,0,1) 100%)",
justifyContent: "center",
alignItems: "center",
}}
>
<SignalTexture
accent={accent}
intensity={intensity}
lineCount={signalLineCount}
/>
<div
style={{
position: "absolute",
width: 880,
height: 2,
background: accent,
boxShadow: `0 0 28px ${accent}`,
opacity: flareOpacity,
transform: "translateY(-126px)",
}}
/>
<div
style={{
position: "absolute",
width: 880,
height: 2,
background: accent,
boxShadow: `0 0 28px ${accent}`,
opacity: flareOpacity * 0.7,
transform: "translateY(126px)",
}}
/>
<div
style={{
opacity: reveal * exit,
transform: `translateY(${y}px)`,
fontFamily,
fontWeight: 700,
fontSize: titleFontSize,
lineHeight: 1.06,
letterSpacing: `${letterSpacing}em`,
textAlign: "center",
color: "#f3f6fa",
textTransform: "uppercase",
width: titleWidth,
textShadow: "0 0 22px rgba(255,255,255,0.08)",
}}
>
{text}
</div>
</AbsoluteFill>
);
};
export const calculateCinematicMetadata: CalculateMetadataFunction<CinematicRendererProps> =
async ({ props }) => {
const totalSeconds =
props.scenes.length === 0
? 30
: Math.max(
...props.scenes.map((scene) => scene.startSeconds + scene.durationSeconds),
);
return {
durationInFrames: Math.max(1, Math.ceil(totalSeconds * FPS)),
fps: FPS,
width: 1920,
height: 1080,
};
};
export const CinematicRenderer: React.FC<CinematicRendererProps> = ({
scenes,
titleFontSize = 78,
titleWidth = 1320,
signalLineCount = 18,
}) => {
return (
<AbsoluteFill style={{ backgroundColor: "#000000" }}>
{scenes.map((scene) => (
<Sequence
key={scene.id}
from={Math.round(scene.startSeconds * FPS)}
durationInFrames={Math.round(scene.durationSeconds * FPS)}
>
{scene.kind === "video" ? (
<SceneVideo scene={scene} />
) : (
<TitleCard
text={scene.text}
accent={scene.accent ?? "#86d8ff"}
intensity={scene.intensity ?? 1}
titleFontSize={titleFontSize}
titleWidth={titleWidth}
signalLineCount={signalLineCount}
/>
)}
</Sequence>
))}
</AbsoluteFill>
);
};
+1
View File
@@ -93,6 +93,7 @@ interface AudioConfig {
}
export interface ExplainerProps {
[key: string]: unknown;
cuts: Cut[];
overlays?: Overlay[];
captions?: WordCaption[];
+19
View File
@@ -1,5 +1,9 @@
import { Composition, CalculateMetadataFunction } from "remotion";
import { Explainer, ExplainerProps } from "./Explainer";
import {
CinematicRenderer,
calculateCinematicMetadata,
} from "./CinematicRenderer";
const calculateMetadata: CalculateMetadataFunction<ExplainerProps> = async ({
props,
@@ -31,6 +35,21 @@ export const Root: React.FC = () => {
}}
calculateMetadata={calculateMetadata}
/>
<Composition
id="CinematicRenderer"
component={CinematicRenderer}
durationInFrames={30 * 30}
fps={30}
width={1920}
height={1080}
defaultProps={{
scenes: [],
titleFontSize: 78,
titleWidth: 1320,
signalLineCount: 18,
}}
calculateMetadata={calculateCinematicMetadata}
/>
</>
);
};
+33
View File
@@ -0,0 +1,33 @@
export type CinematicTone = "cold" | "steel" | "void" | "neutral";
export interface CinematicBaseScene {
id: string;
startSeconds: number;
durationSeconds: number;
}
export interface CinematicVideoScene extends CinematicBaseScene {
kind: "video";
src: string;
tone?: CinematicTone;
trimBeforeSeconds?: number;
trimAfterSeconds?: number;
filter?: string;
}
export interface CinematicTitleScene extends CinematicBaseScene {
kind: "title";
text: string;
accent?: string;
intensity?: number;
}
export type CinematicScene = CinematicVideoScene | CinematicTitleScene;
export interface CinematicRendererProps {
[key: string]: unknown;
scenes: CinematicScene[];
titleFontSize?: number;
titleWidth?: number;
signalLineCount?: number;
}