Harden cinematic pipeline and add reusable renderer
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -93,6 +93,7 @@ interface AudioConfig {
|
||||
}
|
||||
|
||||
export interface ExplainerProps {
|
||||
[key: string]: unknown;
|
||||
cuts: Cut[];
|
||||
overlays?: Overlay[];
|
||||
captions?: WordCaption[];
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user