Skip to content

Commit 9ebb258

Browse files
authored
feat: InfographicScene — 6th Remotion scene type
InfographicScene component (252 lines) with Ken Burns zoom/pan on Gemini-generated infographic PNGs. SceneRouter auto-routes scenes with infographicUrl. 6th scene type active.\n\nBuild verified: tsc clean, webpack 69s, Node 22.
2 parents 15f15c6 + b0c61b5 commit 9ebb258

File tree

4 files changed

+284
-1
lines changed

4 files changed

+284
-1
lines changed
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import React from "react";
2+
import {
3+
AbsoluteFill,
4+
Easing,
5+
Img,
6+
interpolate,
7+
spring,
8+
useCurrentFrame,
9+
useVideoConfig,
10+
} from "remotion";
11+
import type { InfographicSceneProps } from "../types";
12+
import {
13+
ANIMATION,
14+
COLORS,
15+
FONT_SIZES,
16+
INFOGRAPHIC_COLORS,
17+
} from "../constants";
18+
19+
// Default focus regions: center, top-left, bottom-right, top-right
20+
const DEFAULT_FOCUS_REGIONS: Array<{ x: number; y: number; zoom: number }> = [
21+
{ x: 0.5, y: 0.5, zoom: 1.0 },
22+
{ x: 0.3, y: 0.35, zoom: 1.25 },
23+
{ x: 0.7, y: 0.65, zoom: 1.2 },
24+
{ x: 0.6, y: 0.3, zoom: 1.15 },
25+
];
26+
27+
/**
28+
* InfographicScene — displays a Gemini-generated infographic PNG with
29+
* Ken Burns zoom/pan animation. The scene divides its duration into
30+
* focus regions and smoothly transitions between them.
31+
*/
32+
export const InfographicScene: React.FC<InfographicSceneProps> = ({
33+
narration,
34+
infographicUrl,
35+
focusRegions,
36+
sceneIndex,
37+
durationInFrames,
38+
isVertical = false,
39+
wordTimestamps,
40+
}) => {
41+
const frame = useCurrentFrame();
42+
const { fps, width, height } = useVideoConfig();
43+
const fonts = isVertical ? FONT_SIZES.portrait : FONT_SIZES.landscape;
44+
45+
const regions = focusRegions && focusRegions.length > 0
46+
? focusRegions
47+
: DEFAULT_FOCUS_REGIONS;
48+
49+
const regionCount = regions.length;
50+
const framesPerRegion = durationInFrames / regionCount;
51+
52+
// --- Determine current region and interpolation progress ---
53+
const currentRegionIndex = Math.min(
54+
Math.floor(frame / framesPerRegion),
55+
regionCount - 1,
56+
);
57+
const nextRegionIndex = Math.min(currentRegionIndex + 1, regionCount - 1);
58+
const regionLocalFrame = frame - currentRegionIndex * framesPerRegion;
59+
60+
// Smooth progress within current region (0 → 1)
61+
const regionProgress = interpolate(
62+
regionLocalFrame,
63+
[0, framesPerRegion],
64+
[0, 1],
65+
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
66+
);
67+
68+
// Eased progress for smooth motion
69+
const easedProgress = Easing.inOut(Easing.quad)(regionProgress);
70+
71+
// --- Compute pan position (interpolate between current and next region) ---
72+
const currentRegion = regions[currentRegionIndex];
73+
const nextRegion = regions[nextRegionIndex];
74+
75+
// Pan X: convert normalized (0-1) to pixel offset
76+
// At zoom 1.0, offset is 0. At higher zoom, we pan to center the focus region.
77+
const panX =
78+
currentRegion.x + (nextRegion.x - currentRegion.x) * easedProgress;
79+
const panY =
80+
currentRegion.y + (nextRegion.y - currentRegion.y) * easedProgress;
81+
82+
// --- Compute zoom level ---
83+
const currentZoom = currentRegion.zoom;
84+
const nextZoom = nextRegion.zoom;
85+
const zoom = currentZoom + (nextZoom - currentZoom) * easedProgress;
86+
87+
// Add a subtle per-region zoom-in effect (1.0 → 1.1 within each region)
88+
const intraRegionZoom = interpolate(
89+
regionLocalFrame,
90+
[0, framesPerRegion],
91+
[1.0, 1.08],
92+
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
93+
);
94+
95+
const totalZoom = zoom * intraRegionZoom;
96+
97+
// Convert normalized pan to translate offsets
98+
// When panX=0.5, panY=0.5 → centered (no translate)
99+
// The translate moves the image so the focus point is centered
100+
const translateX = -(panX - 0.5) * width * (totalZoom - 1) * 0.8;
101+
const translateY = -(panY - 0.5) * height * (totalZoom - 1) * 0.8;
102+
103+
// --- Scene entrance animation ---
104+
const sceneOpacity = interpolate(
105+
frame,
106+
[0, 15],
107+
[0, 1],
108+
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
109+
);
110+
111+
// --- Text animation: fade in → stay → fade out ---
112+
const textOpacity = interpolate(
113+
frame,
114+
[
115+
0,
116+
ANIMATION.fadeIn,
117+
durationInFrames - ANIMATION.fadeOut,
118+
durationInFrames,
119+
],
120+
[0, 1, 1, 0],
121+
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
122+
);
123+
124+
// Subtle slide-up for text
125+
const textTranslateY = interpolate(
126+
frame,
127+
[0, ANIMATION.fadeIn],
128+
[30, 0],
129+
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
130+
);
131+
132+
// Spring entrance for the caption bar
133+
const captionSpring = spring({
134+
frame: frame - 5,
135+
fps,
136+
config: {
137+
damping: ANIMATION.springDamping,
138+
mass: ANIMATION.springMass,
139+
stiffness: ANIMATION.springStiffness,
140+
},
141+
});
142+
143+
// --- Vignette pulse: subtle glow that follows focus region transitions ---
144+
const vignetteOpacity = interpolate(
145+
regionLocalFrame,
146+
[0, framesPerRegion * 0.3, framesPerRegion * 0.7, framesPerRegion],
147+
[0.6, 0.3, 0.3, 0.6],
148+
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
149+
);
150+
151+
// Alternating gradient for fallback
152+
const gradientAngle = (sceneIndex % 4) * 90;
153+
154+
return (
155+
<AbsoluteFill style={{ opacity: sceneOpacity }}>
156+
{/* Layer 1: Infographic image with Ken Burns effect */}
157+
{infographicUrl ? (
158+
<AbsoluteFill style={{ overflow: "hidden" }}>
159+
<Img
160+
src={infographicUrl}
161+
style={{
162+
width: "100%",
163+
height: "100%",
164+
objectFit: "cover",
165+
transform: `scale(${totalZoom}) translate(${translateX / totalZoom}px, ${translateY / totalZoom}px)`,
166+
transformOrigin: "center center",
167+
}}
168+
/>
169+
</AbsoluteFill>
170+
) : (
171+
/* Fallback: gradient background */
172+
<AbsoluteFill
173+
style={{
174+
background: `linear-gradient(${gradientAngle}deg, ${COLORS.gradientStart}, ${COLORS.backgroundDark}, ${COLORS.backgroundMedium})`,
175+
}}
176+
/>
177+
)}
178+
179+
{/* Layer 2: Vignette overlay for depth */}
180+
<AbsoluteFill
181+
style={{
182+
background: `radial-gradient(ellipse at center, transparent 40%, ${INFOGRAPHIC_COLORS.vignette} 100%)`,
183+
opacity: vignetteOpacity,
184+
}}
185+
/>
186+
187+
{/* Layer 3: Focus glow overlay — subtle purple tint */}
188+
<AbsoluteFill
189+
style={{
190+
backgroundColor: INFOGRAPHIC_COLORS.focusGlow,
191+
opacity: interpolate(
192+
frame,
193+
[0, durationInFrames * 0.1, durationInFrames * 0.9, durationInFrames],
194+
[0, 1, 1, 0],
195+
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
196+
),
197+
}}
198+
/>
199+
200+
{/* Layer 4: Narration caption bar */}
201+
<AbsoluteFill
202+
style={{
203+
justifyContent: "flex-end",
204+
alignItems: "center",
205+
padding: isVertical ? "60px 24px" : "40px 80px",
206+
}}
207+
>
208+
<div
209+
style={{
210+
opacity: textOpacity * captionSpring,
211+
transform: `translateY(${textTranslateY * (1 - captionSpring)}px)`,
212+
backgroundColor: INFOGRAPHIC_COLORS.captionBg,
213+
borderRadius: 12,
214+
padding: isVertical ? "24px 20px" : "24px 40px",
215+
maxWidth: isVertical ? "95%" : "85%",
216+
borderLeft: `4px solid ${COLORS.primary}`,
217+
marginBottom: isVertical ? 80 : 40,
218+
}}
219+
>
220+
<div
221+
style={{
222+
fontSize: fonts.narration,
223+
color: COLORS.textWhite,
224+
fontFamily: "sans-serif",
225+
fontWeight: 500,
226+
lineHeight: 1.5,
227+
textAlign: isVertical ? "center" : "left",
228+
}}
229+
>
230+
{narration}
231+
</div>
232+
</div>
233+
</AbsoluteFill>
234+
235+
{/* Layer 5: CodingCat.dev watermark */}
236+
<div
237+
style={{
238+
position: "absolute",
239+
bottom: isVertical ? 30 : 20,
240+
right: isVertical ? 30 : 30,
241+
fontSize: fonts.watermark,
242+
color: "rgba(255, 255, 255, 0.35)",
243+
fontFamily: "monospace",
244+
fontWeight: 600,
245+
letterSpacing: 1,
246+
}}
247+
>
248+
codingcat.dev
249+
</div>
250+
</AbsoluteFill>
251+
);
252+
};

remotion/components/SceneRouter.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { CodeMorphScene } from "./CodeMorphScene";
66
import { DynamicListScene } from "./DynamicListScene";
77
import { ComparisonGridScene } from "./ComparisonGridScene";
88
import { IsometricMockupScene } from "./IsometricMockupScene";
9+
import { InfographicScene } from "./InfographicScene";
910

1011
interface SceneRouterProps {
1112
scene: SceneData;
@@ -57,11 +58,23 @@ export const SceneRouter: React.FC<SceneRouterProps> = ({
5758
}
5859
break;
5960

61+
case "infographic":
62+
if (scene.infographicUrl) {
63+
return <InfographicScene {...baseProps} infographicUrl={scene.infographicUrl} />;
64+
}
65+
break;
66+
6067
case "narration":
6168
default:
6269
break;
6370
}
6471

72+
// If scene has an infographic URL but no specific sceneType, prefer InfographicScene
73+
// This makes infographics the primary visual when available
74+
if (scene.infographicUrl) {
75+
return <InfographicScene {...baseProps} infographicUrl={scene.infographicUrl} />;
76+
}
77+
6578
// Fallback: use the existing Scene component
6679
return (
6780
<Scene

remotion/constants.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,13 @@ export const COMPARISON_COLORS = {
159159
/** Right column accent */
160160
rightAccent: "#F59E0B",
161161
} as const;
162+
163+
// --- Infographic Scene Constants ---
164+
export const INFOGRAPHIC_COLORS = {
165+
/** Glow overlay for active focus region */
166+
focusGlow: "rgba(109, 40, 217, 0.15)",
167+
/** Vignette edge color */
168+
vignette: "rgba(0, 0, 0, 0.4)",
169+
/** Caption bar background */
170+
captionBg: "rgba(0, 0, 0, 0.7)",
171+
} as const;

remotion/types.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { z } from "zod";
33
// --- Zod Schemas (used for Remotion input props validation) ---
44

55
// Scene type discriminator — Gemini picks this per scene
6-
export const SCENE_TYPES = ["narration", "code", "list", "comparison", "mockup"] as const;
6+
export const SCENE_TYPES = ["narration", "code", "list", "comparison", "mockup", "infographic"] as const;
77
export type SceneType = typeof SCENE_TYPES[number];
88

99
// Word-level timestamp from ElevenLabs
@@ -63,6 +63,8 @@ export const sceneDataSchema = z.object({
6363
audioUrl: z.string().url().optional(),
6464
// Per-scene audio duration in ms
6565
audioDurationMs: z.number().optional(),
66+
// Infographic image URL (Gemini Imagen 4 / Sanity CDN)
67+
infographicUrl: z.string().url().optional(),
6668
});
6769

6870
export const sponsorDataSchema = z.object({
@@ -167,3 +169,9 @@ export interface IsometricMockupSceneProps extends BaseSceneProps {
167169
deviceType: "browser" | "phone" | "terminal";
168170
screenContent: string;
169171
}
172+
173+
// InfographicScene props
174+
export interface InfographicSceneProps extends BaseSceneProps {
175+
infographicUrl: string;
176+
focusRegions?: Array<{ x: number; y: number; zoom: number }>; // 0-1 normalized coordinates
177+
}

0 commit comments

Comments
 (0)