Skip to content

Commit d69587c

Browse files
authored
feat: infographic-driven Remotion with multi-image cycling, no text overlays
Task B: Infographic-driven Remotion rewrite\n\n- InfographicScene rewritten for multi-image cycling with crossfade transitions (4s per image, 0.5s crossfade)\n- Scene.tsx stripped of ALL text overlays — audio narration carries all words\n- SceneRouter updated: infographicUrls[] array routing with backward compat for single URL\n- Types updated with infographicUrls and imagePrompts on sceneDataSchema\n- Subtle Ken Burns zoom (1.0→1.05) per image, objectFit:contain for full infographic display\n- NO text on screen anywhere — pure visual infographic + audio
1 parent c027ffc commit d69587c

5 files changed

Lines changed: 205 additions & 289 deletions

File tree

remotion/components/InfographicScene.tsx

Lines changed: 145 additions & 191 deletions
Original file line numberDiff line numberDiff line change
@@ -1,248 +1,202 @@
11
import React from "react";
22
import {
33
AbsoluteFill,
4-
Easing,
54
Img,
65
interpolate,
7-
spring,
86
useCurrentFrame,
97
useVideoConfig,
108
} 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-
];
9+
import { FONT_SIZES } from "../constants";
2610

2711
/**
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.
12+
* InfographicScene — cycles through multiple infographic images with
13+
* crossfade transitions and subtle Ken Burns zoom. NO text overlays.
14+
* Audio narration carries all words.
15+
*
16+
* Accepts both:
17+
* - `infographicUrls: string[]` (new multi-image cycling)
18+
* - `infographicUrl: string` (legacy single URL, wrapped into array)
3119
*/
20+
21+
interface InfographicSceneProps {
22+
/** Narration text — kept for data pipeline but NOT rendered on screen */
23+
narration: string;
24+
/** Array of infographic image URLs for cycling */
25+
infographicUrls?: string[];
26+
/** Legacy single infographic URL (backward compat) */
27+
infographicUrl?: string;
28+
/** Scene index for visual variety */
29+
sceneIndex: number;
30+
/** Total duration of this scene in frames */
31+
durationInFrames: number;
32+
/** Portrait mode (9:16) */
33+
isVertical?: boolean;
34+
/** Seconds each image is displayed (default 4) */
35+
secondsPerImage?: number;
36+
/** Word timestamps — kept for interface compat but NOT used visually */
37+
wordTimestamps?: Array<{ text: string; startMs: number; endMs: number }>;
38+
}
39+
3240
export const InfographicScene: React.FC<InfographicSceneProps> = ({
3341
narration,
42+
infographicUrls,
3443
infographicUrl,
35-
focusRegions,
3644
sceneIndex,
3745
durationInFrames,
3846
isVertical = false,
47+
secondsPerImage,
3948
wordTimestamps,
4049
}) => {
4150
const frame = useCurrentFrame();
42-
const { fps, width, height } = useVideoConfig();
51+
const { fps } = useVideoConfig();
4352
const fonts = isVertical ? FONT_SIZES.portrait : FONT_SIZES.landscape;
4453

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 ---
54+
// --- Resolve image URLs: prefer array, fall back to single, then empty ---
55+
const images: string[] = (() => {
56+
if (infographicUrls && infographicUrls.length > 0) {
57+
return infographicUrls;
58+
}
59+
if (infographicUrl) {
60+
return [infographicUrl];
61+
}
62+
return [];
63+
})();
64+
65+
// --- Timing constants ---
66+
const secPerImage = secondsPerImage ?? 4;
67+
const framesPerImage = Math.round(secPerImage * fps);
68+
const crossfadeDuration = Math.round(fps * 0.5); // 15 frames at 30fps
69+
70+
// --- Scene entrance fade ---
10471
const sceneOpacity = interpolate(
10572
frame,
10673
[0, 15],
10774
[0, 1],
10875
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
10976
);
11077

111-
// --- Text animation: fade in → stay → fade out ---
112-
const textOpacity = interpolate(
113-
frame,
114-
[
115-
0,
116-
ANIMATION.fadeIn,
117-
durationInFrames - ANIMATION.fadeOut,
78+
// --- Compute opacity and scale for each image layer ---
79+
const getImageOpacity = (index: number): number => {
80+
const startFrame = index * framesPerImage;
81+
const endFrame = startFrame + framesPerImage;
82+
83+
// Single image: always fully visible
84+
if (images.length === 1) {
85+
return 1;
86+
}
87+
88+
// Last image: fade in, then stay visible until scene ends
89+
if (index === images.length - 1) {
90+
return interpolate(
91+
frame,
92+
[
93+
startFrame,
94+
startFrame + crossfadeDuration,
95+
],
96+
[0, 1],
97+
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
98+
);
99+
}
100+
101+
// Normal image: fade in → hold → fade out
102+
return interpolate(
103+
frame,
104+
[
105+
startFrame,
106+
startFrame + crossfadeDuration,
107+
endFrame - crossfadeDuration,
108+
endFrame,
109+
],
110+
[0, 1, 1, 0],
111+
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
112+
);
113+
};
114+
115+
// Ken Burns: subtle zoom from 1.0 → 1.05 over the image's display duration
116+
const getImageScale = (index: number): number => {
117+
const startFrame = index * framesPerImage;
118+
const endFrame = Math.min(
119+
startFrame + framesPerImage + crossfadeDuration,
118120
durationInFrames,
119-
],
120-
[0, 1, 1, 0],
121-
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
122-
);
121+
);
123122

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;
123+
return interpolate(
124+
frame,
125+
[startFrame, endFrame],
126+
[1.0, 1.05],
127+
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
128+
);
129+
};
153130

154131
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>
132+
<AbsoluteFill
133+
style={{
134+
backgroundColor: "#000000",
135+
opacity: sceneOpacity,
136+
}}
137+
>
138+
{/* Image layers — stacked with crossfade opacity */}
139+
{images.length > 0 ? (
140+
images.map((url, index) => {
141+
const opacity = getImageOpacity(index);
142+
const scale = getImageScale(index);
143+
144+
// Skip rendering images that are fully transparent
145+
if (opacity <= 0) {
146+
return null;
147+
}
148+
149+
return (
150+
<AbsoluteFill
151+
key={`infographic-${sceneIndex}-${index}`}
152+
style={{
153+
opacity,
154+
overflow: "hidden",
155+
}}
156+
>
157+
<Img
158+
src={url}
159+
style={{
160+
width: "100%",
161+
height: "100%",
162+
objectFit: "contain",
163+
transform: `scale(${scale})`,
164+
transformOrigin: "center center",
165+
}}
166+
/>
167+
</AbsoluteFill>
168+
);
169+
})
170170
) : (
171-
/* Fallback: gradient background */
171+
/* Fallback: dark gradient when no images available */
172172
<AbsoluteFill
173173
style={{
174-
background: `linear-gradient(${gradientAngle}deg, ${COLORS.gradientStart}, ${COLORS.backgroundDark}, ${COLORS.backgroundMedium})`,
174+
background: `linear-gradient(${(sceneIndex % 4) * 90}deg, #6D28D9, #0F0F23, #1A1A2E)`,
175175
}}
176176
/>
177177
)}
178178

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 */}
179+
{/* Subtle vignette overlay for depth */}
188180
<AbsoluteFill
189181
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-
),
182+
background:
183+
"radial-gradient(ellipse at center, transparent 50%, rgba(0, 0, 0, 0.3) 100%)",
184+
pointerEvents: "none",
197185
}}
198186
/>
199187

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 */}
188+
{/* Watermark — subtle, bottom-right */}
236189
<div
237190
style={{
238191
position: "absolute",
239192
bottom: isVertical ? 30 : 20,
240193
right: isVertical ? 30 : 30,
241194
fontSize: fonts.watermark,
242-
color: "rgba(255, 255, 255, 0.35)",
195+
color: "rgba(255, 255, 255, 0.25)",
243196
fontFamily: "monospace",
244197
fontWeight: 600,
245198
letterSpacing: 1,
199+
pointerEvents: "none",
246200
}}
247201
>
248202
codingcat.dev

0 commit comments

Comments
 (0)