Skip to content

Commit 1c5a615

Browse files
author
Miriad
committed
feat: Phase D — ComparisonGridScene + IsometricMockupScene components
- ComparisonGridScene: self-drawing SVG grid lines, staggered row fly-in, timestamp-driven row highlighting, responsive stacked layout for portrait - IsometricMockupScene: CSS 3D perspective device frames (browser/phone/terminal), spring-animated tilt settling, typing animation for terminal mode - Wire both components into SceneRouter (all 5 scene types now active) - Zero new dependencies, no CSS transitions, backward compatible
1 parent 24855da commit 1c5a615

File tree

3 files changed

+1067
-6
lines changed

3 files changed

+1067
-6
lines changed
Lines changed: 397 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,397 @@
1+
import React from "react";
2+
import {
3+
AbsoluteFill,
4+
interpolate,
5+
spring,
6+
useCurrentFrame,
7+
useVideoConfig,
8+
} from "remotion";
9+
import type { ComparisonGridSceneProps } from "../types";
10+
import { ANIMATION, COLORS, COMPARISON_COLORS, FONT_SIZES } from "../constants";
11+
import { getActiveSegmentAtFrame } from "../../lib/utils/audio-timestamps";
12+
13+
export const ComparisonGridScene: React.FC<ComparisonGridSceneProps> = ({
14+
narration,
15+
sceneIndex,
16+
durationInFrames,
17+
isVertical = false,
18+
wordTimestamps,
19+
leftLabel,
20+
rightLabel,
21+
rows,
22+
}) => {
23+
const frame = useCurrentFrame();
24+
const { fps } = useVideoConfig();
25+
const fonts = isVertical ? FONT_SIZES.portrait : FONT_SIZES.landscape;
26+
27+
// Guard against empty data
28+
if (!rows || rows.length === 0) {
29+
return (
30+
<AbsoluteFill
31+
style={{
32+
background: `linear-gradient(135deg, ${COLORS.gradientStart}, ${COLORS.backgroundDark})`,
33+
}}
34+
/>
35+
);
36+
}
37+
38+
// --- Scene-level fade in/out ---
39+
const sceneOpacity = interpolate(
40+
frame,
41+
[0, 15, durationInFrames - ANIMATION.fadeOut, durationInFrames],
42+
[0, 1, 1, 0],
43+
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
44+
);
45+
46+
// --- Narration text fade ---
47+
const textOpacity = interpolate(
48+
frame,
49+
[
50+
0,
51+
ANIMATION.fadeIn,
52+
durationInFrames - ANIMATION.fadeOut,
53+
durationInFrames,
54+
],
55+
[0, 1, 1, 0],
56+
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
57+
);
58+
59+
// --- Active segment (focus/dimming) ---
60+
const activeSegment =
61+
wordTimestamps && wordTimestamps.length > 0
62+
? getActiveSegmentAtFrame(wordTimestamps, rows.length, frame, fps)
63+
: Math.min(
64+
Math.floor((frame / durationInFrames) * rows.length),
65+
rows.length - 1,
66+
);
67+
68+
// Alternating gradient direction
69+
const gradientAngle = (sceneIndex % 4) * 90;
70+
71+
// --- SVG Grid Line Drawing ---
72+
// Grid dimensions
73+
const gridWidth = isVertical ? 900 : 1400;
74+
const headerHeight = 70;
75+
const rowHeight = isVertical ? 120 : 70;
76+
const gridHeight = headerHeight + rows.length * rowHeight;
77+
78+
const drawEnd = Math.round(durationInFrames * 0.2);
79+
80+
// Vertical divider line (center) — only in landscape
81+
const verticalLineLength = gridHeight;
82+
const verticalDrawProgress = interpolate(
83+
frame,
84+
[0, drawEnd],
85+
[verticalLineLength, 0],
86+
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
87+
);
88+
89+
// Horizontal line lengths
90+
const horizontalLineLength = gridWidth;
91+
const horizontalDrawProgress = interpolate(
92+
frame,
93+
[0, drawEnd],
94+
[horizontalLineLength, 0],
95+
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
96+
);
97+
98+
// --- Header entrance spring ---
99+
const headerSpring = spring({
100+
frame,
101+
fps,
102+
config: {
103+
damping: ANIMATION.springDamping,
104+
mass: ANIMATION.springMass,
105+
stiffness: ANIMATION.springStiffness,
106+
},
107+
});
108+
109+
const headerTranslateY = interpolate(headerSpring, [0, 1], [30, 0], {
110+
extrapolateLeft: "clamp",
111+
extrapolateRight: "clamp",
112+
});
113+
114+
return (
115+
<AbsoluteFill style={{ opacity: sceneOpacity }}>
116+
{/* Layer 1: Dark gradient background */}
117+
<AbsoluteFill
118+
style={{
119+
background: `linear-gradient(${gradientAngle}deg, ${COLORS.gradientStart}, ${COLORS.backgroundDark}, ${COLORS.backgroundMedium})`,
120+
}}
121+
/>
122+
123+
{/* Layer 2: Comparison grid */}
124+
<AbsoluteFill
125+
style={{
126+
justifyContent: "center",
127+
alignItems: "center",
128+
padding: isVertical ? "80px 20px" : "60px 80px",
129+
}}
130+
>
131+
<div
132+
style={{
133+
position: "relative",
134+
maxWidth: isVertical ? "95%" : "80%",
135+
width: "100%",
136+
display: "flex",
137+
flexDirection: "column",
138+
gap: 0,
139+
}}
140+
>
141+
{/* SVG Grid Lines Overlay */}
142+
<svg
143+
style={{
144+
position: "absolute",
145+
top: 0,
146+
left: 0,
147+
width: "100%",
148+
height: "100%",
149+
pointerEvents: "none",
150+
zIndex: 1,
151+
}}
152+
viewBox={`0 0 ${gridWidth} ${gridHeight}`}
153+
preserveAspectRatio="none"
154+
>
155+
{/* Vertical divider (center) — landscape only */}
156+
{!isVertical && (
157+
<line
158+
x1={gridWidth / 2}
159+
y1={0}
160+
x2={gridWidth / 2}
161+
y2={gridHeight}
162+
stroke={COMPARISON_COLORS.gridLine}
163+
strokeWidth={2}
164+
strokeDasharray={verticalLineLength}
165+
strokeDashoffset={verticalDrawProgress}
166+
/>
167+
)}
168+
169+
{/* Horizontal separators between header and rows, and between rows */}
170+
{Array.from({ length: rows.length + 1 }).map((_, i) => {
171+
const y = headerHeight + i * rowHeight;
172+
return (
173+
<line
174+
key={`h-line-${i}`}
175+
x1={0}
176+
y1={y}
177+
x2={gridWidth}
178+
y2={y}
179+
stroke={COMPARISON_COLORS.gridLine}
180+
strokeWidth={2}
181+
strokeDasharray={horizontalLineLength}
182+
strokeDashoffset={horizontalDrawProgress}
183+
/>
184+
);
185+
})}
186+
</svg>
187+
188+
{/* Header Row */}
189+
<div
190+
style={{
191+
display: "flex",
192+
flexDirection: isVertical ? "column" : "row",
193+
opacity: headerSpring,
194+
transform: `translateY(${headerTranslateY}px)`,
195+
background: COMPARISON_COLORS.headerBg,
196+
backdropFilter: "blur(8px)",
197+
borderRadius: "12px 12px 0 0",
198+
overflow: "hidden",
199+
position: "relative",
200+
zIndex: 2,
201+
}}
202+
>
203+
{/* Left label */}
204+
<div
205+
style={{
206+
flex: 1,
207+
padding: isVertical ? "16px 20px" : "20px 24px",
208+
fontSize: fonts.comparisonHeader,
209+
fontFamily: "sans-serif",
210+
fontWeight: 700,
211+
color: COMPARISON_COLORS.leftAccent,
212+
textAlign: "center",
213+
borderBottom: isVertical
214+
? `2px solid ${COMPARISON_COLORS.gridLine}`
215+
: "none",
216+
}}
217+
>
218+
{leftLabel}
219+
</div>
220+
221+
{/* Right label */}
222+
<div
223+
style={{
224+
flex: 1,
225+
padding: isVertical ? "16px 20px" : "20px 24px",
226+
fontSize: fonts.comparisonHeader,
227+
fontFamily: "sans-serif",
228+
fontWeight: 700,
229+
color: COMPARISON_COLORS.rightAccent,
230+
textAlign: "center",
231+
}}
232+
>
233+
{rightLabel}
234+
</div>
235+
</div>
236+
237+
{/* Data Rows */}
238+
{rows.map((row, index) => {
239+
const staggerDelay = Math.round(durationInFrames * 0.05) + index * 4;
240+
241+
// Row entrance spring
242+
const rowSpring =
243+
frame >= staggerDelay
244+
? spring({
245+
frame: frame - staggerDelay,
246+
fps,
247+
config: {
248+
damping: ANIMATION.springDamping,
249+
mass: ANIMATION.springMass,
250+
stiffness: ANIMATION.springStiffness,
251+
},
252+
})
253+
: 0;
254+
255+
const hasEntered = frame >= staggerDelay;
256+
const isActive = hasEntered && index === activeSegment;
257+
258+
// Opacity: invisible before entrance, then active/inactive
259+
const rowOpacity = !hasEntered
260+
? 0
261+
: isActive
262+
? rowSpring
263+
: rowSpring * 0.5;
264+
265+
// Transform values from spring
266+
const translateY = interpolate(rowSpring, [0, 1], [30, 0], {
267+
extrapolateLeft: "clamp",
268+
extrapolateRight: "clamp",
269+
});
270+
271+
const scale = isActive
272+
? interpolate(rowSpring, [0, 1], [0.95, 1.02], {
273+
extrapolateLeft: "clamp",
274+
extrapolateRight: "clamp",
275+
})
276+
: interpolate(rowSpring, [0, 1], [0.95, 1.0], {
277+
extrapolateLeft: "clamp",
278+
extrapolateRight: "clamp",
279+
});
280+
281+
const rowBg = isActive
282+
? COMPARISON_COLORS.activeRow
283+
: "rgba(15, 15, 35, 0.6)";
284+
285+
return (
286+
<div
287+
key={index}
288+
style={{
289+
display: "flex",
290+
flexDirection: isVertical ? "column" : "row",
291+
opacity: rowOpacity,
292+
transform: `translateY(${translateY}px) scale(${scale})`,
293+
background: rowBg,
294+
backdropFilter: "blur(8px)",
295+
position: "relative",
296+
zIndex: 2,
297+
borderRadius:
298+
index === rows.length - 1 ? "0 0 12px 12px" : 0,
299+
boxShadow: isActive
300+
? "0 0 20px rgba(167, 139, 250, 0.2)"
301+
: "none",
302+
}}
303+
>
304+
{/* Left cell */}
305+
<div
306+
style={{
307+
flex: 1,
308+
padding: isVertical ? "14px 20px" : "18px 24px",
309+
fontSize: fonts.comparisonCell,
310+
fontFamily: "sans-serif",
311+
fontWeight: 500,
312+
color: COLORS.textWhite,
313+
textAlign: "center",
314+
lineHeight: 1.4,
315+
borderBottom: isVertical
316+
? `1px solid ${COMPARISON_COLORS.gridLine}`
317+
: "none",
318+
borderLeft: `3px solid ${COMPARISON_COLORS.leftAccent}`,
319+
}}
320+
>
321+
{row.left}
322+
</div>
323+
324+
{/* Right cell */}
325+
<div
326+
style={{
327+
flex: 1,
328+
padding: isVertical ? "14px 20px" : "18px 24px",
329+
fontSize: fonts.comparisonCell,
330+
fontFamily: "sans-serif",
331+
fontWeight: 500,
332+
color: COLORS.textWhite,
333+
textAlign: "center",
334+
lineHeight: 1.4,
335+
borderRight: `3px solid ${COMPARISON_COLORS.rightAccent}`,
336+
}}
337+
>
338+
{row.right}
339+
</div>
340+
</div>
341+
);
342+
})}
343+
</div>
344+
</AbsoluteFill>
345+
346+
{/* Layer 3: Narration text overlay (bottom) */}
347+
<AbsoluteFill
348+
style={{
349+
justifyContent: "flex-end",
350+
alignItems: "center",
351+
padding: isVertical ? "60px 40px" : "80px 120px",
352+
}}
353+
>
354+
<div
355+
style={{
356+
opacity: textOpacity,
357+
backgroundColor: "rgba(0, 0, 0, 0.6)",
358+
borderRadius: 16,
359+
padding: isVertical ? "28px 24px" : "32px 48px",
360+
maxWidth: isVertical ? "95%" : "80%",
361+
backdropFilter: "blur(8px)",
362+
borderLeft: `4px solid ${COLORS.primary}`,
363+
}}
364+
>
365+
<div
366+
style={{
367+
fontSize: fonts.narration,
368+
color: COLORS.textWhite,
369+
fontFamily: "sans-serif",
370+
fontWeight: 500,
371+
lineHeight: 1.5,
372+
textAlign: isVertical ? "center" : "left",
373+
}}
374+
>
375+
{narration}
376+
</div>
377+
</div>
378+
</AbsoluteFill>
379+
380+
{/* Layer 4: CodingCat.dev watermark */}
381+
<div
382+
style={{
383+
position: "absolute",
384+
bottom: isVertical ? 30 : 20,
385+
right: isVertical ? 30 : 30,
386+
fontSize: fonts.watermark,
387+
color: "rgba(255, 255, 255, 0.35)",
388+
fontFamily: "monospace",
389+
fontWeight: 600,
390+
letterSpacing: 1,
391+
}}
392+
>
393+
codingcat.dev
394+
</div>
395+
</AbsoluteFill>
396+
);
397+
};

0 commit comments

Comments
 (0)