5223eec21c
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.
366 lines
9.5 KiB
TypeScript
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>
|
|
);
|
|
};
|