|
1 | 1 | import React from "react"; |
2 | 2 | import { |
3 | 3 | AbsoluteFill, |
4 | | - Easing, |
5 | 4 | Img, |
6 | 5 | interpolate, |
7 | | - spring, |
8 | 6 | useCurrentFrame, |
9 | 7 | useVideoConfig, |
10 | 8 | } 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"; |
26 | 10 |
|
27 | 11 | /** |
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) |
31 | 19 | */ |
| 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 | + |
32 | 40 | export const InfographicScene: React.FC<InfographicSceneProps> = ({ |
33 | 41 | narration, |
| 42 | + infographicUrls, |
34 | 43 | infographicUrl, |
35 | | - focusRegions, |
36 | 44 | sceneIndex, |
37 | 45 | durationInFrames, |
38 | 46 | isVertical = false, |
| 47 | + secondsPerImage, |
39 | 48 | wordTimestamps, |
40 | 49 | }) => { |
41 | 50 | const frame = useCurrentFrame(); |
42 | | - const { fps, width, height } = useVideoConfig(); |
| 51 | + const { fps } = useVideoConfig(); |
43 | 52 | const fonts = isVertical ? FONT_SIZES.portrait : FONT_SIZES.landscape; |
44 | 53 |
|
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 --- |
104 | 71 | const sceneOpacity = interpolate( |
105 | 72 | frame, |
106 | 73 | [0, 15], |
107 | 74 | [0, 1], |
108 | 75 | { extrapolateLeft: "clamp", extrapolateRight: "clamp" }, |
109 | 76 | ); |
110 | 77 |
|
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, |
118 | 120 | durationInFrames, |
119 | | - ], |
120 | | - [0, 1, 1, 0], |
121 | | - { extrapolateLeft: "clamp", extrapolateRight: "clamp" }, |
122 | | - ); |
| 121 | + ); |
123 | 122 |
|
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 | + }; |
153 | 130 |
|
154 | 131 | 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 | + }) |
170 | 170 | ) : ( |
171 | | - /* Fallback: gradient background */ |
| 171 | + /* Fallback: dark gradient when no images available */ |
172 | 172 | <AbsoluteFill |
173 | 173 | style={{ |
174 | | - background: `linear-gradient(${gradientAngle}deg, ${COLORS.gradientStart}, ${COLORS.backgroundDark}, ${COLORS.backgroundMedium})`, |
| 174 | + background: `linear-gradient(${(sceneIndex % 4) * 90}deg, #6D28D9, #0F0F23, #1A1A2E)`, |
175 | 175 | }} |
176 | 176 | /> |
177 | 177 | )} |
178 | 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 */} |
| 179 | + {/* Subtle vignette overlay for depth */} |
188 | 180 | <AbsoluteFill |
189 | 181 | 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", |
197 | 185 | }} |
198 | 186 | /> |
199 | 187 |
|
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 */} |
236 | 189 | <div |
237 | 190 | style={{ |
238 | 191 | position: "absolute", |
239 | 192 | bottom: isVertical ? 30 : 20, |
240 | 193 | right: isVertical ? 30 : 30, |
241 | 194 | fontSize: fonts.watermark, |
242 | | - color: "rgba(255, 255, 255, 0.35)", |
| 195 | + color: "rgba(255, 255, 255, 0.25)", |
243 | 196 | fontFamily: "monospace", |
244 | 197 | fontWeight: 600, |
245 | 198 | letterSpacing: 1, |
| 199 | + pointerEvents: "none", |
246 | 200 | }} |
247 | 201 | > |
248 | 202 | codingcat.dev |
|
0 commit comments