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.
This commit is contained in:
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import { loadFont } from "@remotion/google-fonts/SpaceGrotesk";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
Audio,
|
||||
CalculateMetadataFunction,
|
||||
OffthreadVideo,
|
||||
Sequence,
|
||||
@@ -36,16 +37,24 @@ const toneGradient = (tone: CinematicTone) => {
|
||||
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 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",
|
||||
@@ -242,6 +251,59 @@ const TitleCard: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
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 =
|
||||
@@ -264,9 +326,20 @@ export const CinematicRenderer: React.FC<CinematicRendererProps> = ({
|
||||
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}
|
||||
|
||||
@@ -25,6 +25,11 @@ import { TextCard } from "./components/TextCard";
|
||||
import { StatCard } from "./components/StatCard";
|
||||
import { CalloutBox } from "./components/CalloutBox";
|
||||
import { ComparisonCard } from "./components/ComparisonCard";
|
||||
import { BarChart } from "./components/charts/BarChart";
|
||||
import { LineChart } from "./components/charts/LineChart";
|
||||
import { PieChart } from "./components/charts/PieChart";
|
||||
import { KPIGrid } from "./components/charts/KPIGrid";
|
||||
import { ProgressBar } from "./components/ProgressBar";
|
||||
import { CaptionOverlay, WordCaption } from "./components/CaptionOverlay";
|
||||
import { SectionTitle } from "./components/SectionTitle";
|
||||
import { StatReveal } from "./components/StatReveal";
|
||||
@@ -58,6 +63,34 @@ interface Cut {
|
||||
rightLabel?: string;
|
||||
leftValue?: string;
|
||||
rightValue?: string;
|
||||
// Chart props
|
||||
chartData?: any[];
|
||||
chartSeries?: any[];
|
||||
chartColors?: string[];
|
||||
chartAnimation?: string;
|
||||
donut?: boolean;
|
||||
centerLabel?: string;
|
||||
centerValue?: string;
|
||||
showGrid?: boolean;
|
||||
showValues?: boolean;
|
||||
showLegend?: boolean;
|
||||
showMarkers?: boolean;
|
||||
xLabel?: string;
|
||||
yLabel?: string;
|
||||
columns?: 2 | 3 | 4;
|
||||
// Progress bar props
|
||||
progress?: number;
|
||||
progressLabel?: string;
|
||||
progressColor?: string;
|
||||
progressAnimation?: string;
|
||||
progressSegments?: any[];
|
||||
// Hero title props (when used as scene, not overlay)
|
||||
heroSubtitle?: string;
|
||||
// Styling overrides
|
||||
backgroundColor?: string;
|
||||
color?: string;
|
||||
accentColor?: string;
|
||||
fontSize?: number;
|
||||
// Animation & transitions
|
||||
animation?: string;
|
||||
transition_in?: string;
|
||||
@@ -246,14 +279,36 @@ const VideoScene: React.FC<{ src: string; startFrom?: number }> = ({
|
||||
const SceneRenderer: React.FC<{ cut: Cut }> = ({ cut }) => {
|
||||
// Explicit component types
|
||||
if (cut.type === "text_card" && cut.text) {
|
||||
return <TextCard text={cut.text} />;
|
||||
return (
|
||||
<TextCard
|
||||
text={cut.text}
|
||||
fontSize={cut.fontSize}
|
||||
color={cut.color}
|
||||
backgroundColor={cut.backgroundColor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (cut.type === "stat_card" && cut.stat) {
|
||||
return <StatCard stat={cut.stat} subtitle={cut.subtitle} />;
|
||||
return (
|
||||
<StatCard
|
||||
stat={cut.stat}
|
||||
subtitle={cut.subtitle}
|
||||
accentColor={cut.accentColor}
|
||||
backgroundColor={cut.backgroundColor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (cut.type === "callout" && cut.text) {
|
||||
return (
|
||||
<CalloutBox text={cut.text} type={cut.callout_type} title={cut.title} />
|
||||
<CalloutBox
|
||||
text={cut.text}
|
||||
type={cut.callout_type}
|
||||
title={cut.title}
|
||||
borderColor={cut.accentColor}
|
||||
backgroundColor={cut.backgroundColor}
|
||||
textColor={cut.color}
|
||||
containerBackgroundColor={cut.backgroundColor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (
|
||||
@@ -270,21 +325,128 @@ const SceneRenderer: React.FC<{ cut: Cut }> = ({ cut }) => {
|
||||
leftValue={cut.leftValue}
|
||||
rightValue={cut.rightValue}
|
||||
title={cut.title}
|
||||
backgroundColor={cut.backgroundColor}
|
||||
textColor={cut.color}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (cut.type === "hero_title" && cut.text) {
|
||||
return <HeroTitle title={cut.text} subtitle={cut.heroSubtitle || cut.subtitle} />;
|
||||
}
|
||||
|
||||
// --- Chart types ---
|
||||
if (cut.type === "bar_chart" && cut.chartData) {
|
||||
return (
|
||||
<BarChart
|
||||
data={cut.chartData}
|
||||
title={cut.title}
|
||||
colors={cut.chartColors}
|
||||
animationStyle={(cut.chartAnimation as any) || "grow-up"}
|
||||
showGrid={cut.showGrid}
|
||||
showValues={cut.showValues}
|
||||
backgroundColor={cut.backgroundColor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (cut.type === "line_chart" && cut.chartSeries) {
|
||||
return (
|
||||
<LineChart
|
||||
series={cut.chartSeries}
|
||||
title={cut.title}
|
||||
colors={cut.chartColors}
|
||||
animationStyle={(cut.chartAnimation as any) || "draw"}
|
||||
showGrid={cut.showGrid}
|
||||
showMarkers={cut.showMarkers}
|
||||
showLegend={cut.showLegend}
|
||||
xLabel={cut.xLabel}
|
||||
yLabel={cut.yLabel}
|
||||
backgroundColor={cut.backgroundColor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (cut.type === "pie_chart" && cut.chartData) {
|
||||
return (
|
||||
<PieChart
|
||||
data={cut.chartData}
|
||||
title={cut.title}
|
||||
colors={cut.chartColors}
|
||||
animationStyle={(cut.chartAnimation as any) || "expand"}
|
||||
donut={cut.donut}
|
||||
centerLabel={cut.centerLabel}
|
||||
centerValue={cut.centerValue}
|
||||
showLegend={cut.showLegend}
|
||||
backgroundColor={cut.backgroundColor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (cut.type === "kpi_grid" && cut.chartData) {
|
||||
return (
|
||||
<KPIGrid
|
||||
metrics={cut.chartData}
|
||||
title={cut.title}
|
||||
columns={cut.columns}
|
||||
colors={cut.chartColors}
|
||||
animationStyle={(cut.chartAnimation as any) || "count-up"}
|
||||
backgroundColor={cut.backgroundColor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (cut.type === "progress_bar" && cut.progress !== undefined) {
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
backgroundColor: cut.backgroundColor || "#FFFFFF",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "80px 120px",
|
||||
}}
|
||||
>
|
||||
{cut.title && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 120,
|
||||
fontSize: 48,
|
||||
fontWeight: 700,
|
||||
color: "#1F2937",
|
||||
textAlign: "center",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{cut.title}
|
||||
</div>
|
||||
)}
|
||||
<ProgressBar
|
||||
progress={cut.progress}
|
||||
label={cut.progressLabel}
|
||||
color={cut.progressColor || cut.accentColor}
|
||||
animationStyle={(cut.progressAnimation as any) || "fill"}
|
||||
segments={cut.progressSegments}
|
||||
backgroundColor={cut.backgroundColor}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Media types (image / video fallback) ---
|
||||
const animation = cut.animation || cut.transform?.animation;
|
||||
|
||||
if (isImage(cut.source)) {
|
||||
if (cut.source && isImage(cut.source)) {
|
||||
return <ImageScene src={cut.source} animation={animation} />;
|
||||
}
|
||||
|
||||
if (isVideo(cut.source)) {
|
||||
if (cut.source && isVideo(cut.source)) {
|
||||
return <VideoScene src={cut.source} startFrom={cut.in_seconds} />;
|
||||
}
|
||||
|
||||
return <ImageScene src={cut.source} animation={animation} />;
|
||||
// Final fallback — try as image if source exists, otherwise show text_card
|
||||
if (cut.source) {
|
||||
return <ImageScene src={cut.source} animation={animation} />;
|
||||
}
|
||||
|
||||
// No source, no type — render as text card with cut id as fallback
|
||||
return <TextCard text={cut.text || cut.id} />;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
CinematicRenderer,
|
||||
calculateCinematicMetadata,
|
||||
} from "./CinematicRenderer";
|
||||
import { signalFromTomorrowWithMusicFixture } from "./cinematic/fixtures";
|
||||
|
||||
const calculateMetadata: CalculateMetadataFunction<ExplainerProps> = async ({
|
||||
props,
|
||||
@@ -50,6 +51,16 @@ export const Root: React.FC = () => {
|
||||
}}
|
||||
calculateMetadata={calculateCinematicMetadata}
|
||||
/>
|
||||
<Composition
|
||||
id="SignalFromTomorrowWithMusic"
|
||||
component={CinematicRenderer}
|
||||
durationInFrames={30 * 30}
|
||||
fps={30}
|
||||
width={1920}
|
||||
height={1080}
|
||||
defaultProps={signalFromTomorrowWithMusicFixture}
|
||||
calculateMetadata={calculateCinematicMetadata}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { staticFile } from "remotion";
|
||||
import { CinematicRendererProps } from "./types";
|
||||
|
||||
export const signalFromTomorrowWithMusicFixture: CinematicRendererProps = {
|
||||
titleFontSize: 78,
|
||||
titleWidth: 1320,
|
||||
signalLineCount: 18,
|
||||
soundtrack: {
|
||||
src: staticFile(
|
||||
"music/signal-from-tomorrow/cinematic_time_hans_zimmer_style.mp3",
|
||||
),
|
||||
volume: 0.42,
|
||||
fadeInSeconds: 1.5,
|
||||
fadeOutSeconds: 2.5,
|
||||
},
|
||||
scenes: [
|
||||
{
|
||||
id: "sc1",
|
||||
kind: "video",
|
||||
startSeconds: 0,
|
||||
durationSeconds: 4,
|
||||
src: staticFile("video/signal-from-tomorrow/sample_observatory_veo31_ref.mp4"),
|
||||
tone: "cold",
|
||||
trimBeforeSeconds: 1,
|
||||
fadeInFrames: 0,
|
||||
},
|
||||
{
|
||||
id: "sc2",
|
||||
kind: "video",
|
||||
startSeconds: 4,
|
||||
durationSeconds: 4,
|
||||
src: staticFile(
|
||||
"video/signal-from-tomorrow/sc2_mission_control_veo31_ref_8s.mp4",
|
||||
),
|
||||
tone: "steel",
|
||||
},
|
||||
{
|
||||
id: "sc3",
|
||||
kind: "title",
|
||||
startSeconds: 8,
|
||||
durationSeconds: 3,
|
||||
text: "YESTERDAY, THEY LAUNCHED.",
|
||||
accent: "#89d7ff",
|
||||
intensity: 1,
|
||||
},
|
||||
{
|
||||
id: "sc4",
|
||||
kind: "video",
|
||||
startSeconds: 11,
|
||||
durationSeconds: 7,
|
||||
src: staticFile("video/signal-from-tomorrow/sc4_launch_departure_veo31_ref.mp4"),
|
||||
tone: "cold",
|
||||
},
|
||||
{
|
||||
id: "sc5",
|
||||
kind: "title",
|
||||
startSeconds: 18,
|
||||
durationSeconds: 3,
|
||||
text: "THE SIGNAL CAME FROM EARTH.",
|
||||
accent: "#a6e6ff",
|
||||
intensity: 1.15,
|
||||
},
|
||||
{
|
||||
id: "sc6",
|
||||
kind: "video",
|
||||
startSeconds: 21,
|
||||
durationSeconds: 6,
|
||||
src: staticFile(
|
||||
"video/signal-from-tomorrow/sc6_orbital_paradox_veo31_ref_8s.mp4",
|
||||
),
|
||||
tone: "void",
|
||||
},
|
||||
{
|
||||
id: "sc7",
|
||||
kind: "title",
|
||||
startSeconds: 27,
|
||||
durationSeconds: 3,
|
||||
text: "SIGNAL FROM TOMORROW",
|
||||
accent: "#d6f1ff",
|
||||
intensity: 0.9,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -13,6 +13,8 @@ export interface CinematicVideoScene extends CinematicBaseScene {
|
||||
trimBeforeSeconds?: number;
|
||||
trimAfterSeconds?: number;
|
||||
filter?: string;
|
||||
fadeInFrames?: number;
|
||||
fadeOutFrames?: number;
|
||||
}
|
||||
|
||||
export interface CinematicTitleScene extends CinematicBaseScene {
|
||||
@@ -24,10 +26,20 @@ export interface CinematicTitleScene extends CinematicBaseScene {
|
||||
|
||||
export type CinematicScene = CinematicVideoScene | CinematicTitleScene;
|
||||
|
||||
export interface CinematicSoundtrack {
|
||||
src: string;
|
||||
volume?: number;
|
||||
trimBeforeSeconds?: number;
|
||||
trimAfterSeconds?: number;
|
||||
fadeInSeconds?: number;
|
||||
fadeOutSeconds?: number;
|
||||
}
|
||||
|
||||
export interface CinematicRendererProps {
|
||||
[key: string]: unknown;
|
||||
scenes: CinematicScene[];
|
||||
titleFontSize?: number;
|
||||
titleWidth?: number;
|
||||
signalLineCount?: number;
|
||||
soundtrack?: CinematicSoundtrack;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user