Files
OpenMontage/remotion-composer/src/CinematicRenderer.tsx
T
calesthio 5223eec21c Zero-key video formula: fix rendering bugs, add 8 demo compositions, update skills
Fix ComparisonCard and CalloutBox dark-theme passthrough in SceneRenderer.
Add 8 demo-props JSON files (3 fixed originals + 5 new compositions).
Update scene-director and compose-director skills with all 10 Remotion
scene types and zero-key rendering guidance. Document the proven
all-dark-background formula in remotion.md. Add cinematic renderer,
onboarding skill, prompt gallery, and demo render script.
2026-03-30 13:23:28 -07:00

366 lines
9.5 KiB
TypeScript

import React from "react";
import { loadFont } from "@remotion/google-fonts/SpaceGrotesk";
import {
AbsoluteFill,
Audio,
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 fadeInFrames = scene.fadeInFrames ?? 10;
const fadeOutFrames = scene.fadeOutFrames ?? 10;
const fadeOutStart = Math.max(fadeInFrames, durationInFrames - fadeOutFrames);
const fadeInOpacity =
fadeInFrames === 0
? 1
: interpolate(frame, [0, fadeInFrames], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const fadeOutOpacity =
fadeOutFrames === 0
? 1
: interpolate(frame, [fadeOutStart, durationInFrames], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const opacity = Math.min(fadeInOpacity, fadeOutOpacity);
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>
);
};
const Soundtrack: React.FC<{
src: string;
volume: number;
trimBeforeSeconds?: number;
trimAfterSeconds?: number;
fadeInSeconds: number;
fadeOutSeconds: number;
}> = ({
src,
volume,
trimBeforeSeconds,
trimAfterSeconds,
fadeInSeconds,
fadeOutSeconds,
}) => {
const frame = useCurrentFrame();
const { durationInFrames, fps } = useVideoConfig();
const fadeInFrames = Math.max(1, Math.round(fadeInSeconds * fps));
const fadeOutFrames = Math.max(1, Math.round(fadeOutSeconds * fps));
const trimBefore =
trimBeforeSeconds !== undefined
? Math.round(trimBeforeSeconds * fps)
: undefined;
const trimAfter =
trimAfterSeconds !== undefined
? Math.round(trimAfterSeconds * fps)
: undefined;
const fadeIn = interpolate(frame, [0, fadeInFrames], [0, volume], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const fadeOut = interpolate(
frame,
[durationInFrames - fadeOutFrames, durationInFrames],
[volume, 0],
{
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
},
);
return (
<Audio
src={src}
trimBefore={trimBefore}
trimAfter={trimAfter}
volume={() => Math.min(fadeIn, fadeOut)}
/>
);
};
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,
soundtrack,
}) => {
return (
<AbsoluteFill style={{ backgroundColor: "#000000" }}>
{soundtrack ? (
<Soundtrack
src={soundtrack.src}
volume={soundtrack.volume ?? 0.45}
trimBeforeSeconds={soundtrack.trimBeforeSeconds}
trimAfterSeconds={soundtrack.trimAfterSeconds}
fadeInSeconds={soundtrack.fadeInSeconds ?? 1.5}
fadeOutSeconds={soundtrack.fadeOutSeconds ?? 2}
/>
) : null}
{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>
);
};