🎬 Fine-tune all transition timing with centralized configuration

Implement Milestone 13 requirement: Timing fine-tuning for all transitions.

Changes:
- Create centralized timing configuration (src/config/timing.ts) with:
  - SPRING_CONFIGS: Unified spring presets (SMOOTH, SNAPPY, BOUNCY, etc.)
  - STAGGER_DELAYS: Consistent stagger timing for cards, lists, activities
  - TIMING: Scene-specific delay constants (intro, CTA, outro)
  - GLOW_CONFIG: Glow effect parameters (intensity, frequency, scale)
  - Helper functions: secondsToFrames(), getStaggeredDelay()

- Fine-tune all 8 portal scenes:
  - Reduced perspective rotations (30° → 25° / 20° → 18°) for smoother entrances
  - Increased initial scales (0.8 → 0.85-0.92) for subtler animations
  - Reduced Y translations (30-40px → 18-25px) for less jarring motion
  - Standardized glow frequencies using centralized config
  - Consistent spring configurations across all scenes

- Add comprehensive tests (src/config/timing.test.ts):
  - 38 tests covering all timing constants
  - Helper function tests
  - Timing consistency validation
  - Scene duration verification (total = 90s)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
HolgerHatGarKeineNode
2026-01-24 14:24:33 +01:00
parent d29c54cf56
commit 6f132e98b4
10 changed files with 867 additions and 259 deletions

View File

@@ -10,12 +10,13 @@ import {
} from "remotion";
import { Audio } from "@remotion/media";
import { ActivityItem } from "../../components/ActivityItem";
// Spring configurations
const SNAPPY = { damping: 15, stiffness: 80 };
// Stagger delay between activity items (in frames)
const ACTIVITY_STAGGER_DELAY = 20;
import {
SPRING_CONFIGS,
STAGGER_DELAYS,
TIMING,
GLOW_CONFIG,
secondsToFrames,
} from "../../config/timing";
// Activity feed data from the screenshot
const ACTIVITY_FEED_DATA = [
@@ -58,43 +59,46 @@ export const ActivityFeedScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// 3D Perspective entrance animation (0-60 frames)
// 3D Perspective entrance animation using centralized config
const perspectiveSpring = spring({
frame,
fps,
config: { damping: 20, stiffness: 60 },
config: SPRING_CONFIGS.PERSPECTIVE,
});
const perspectiveX = interpolate(perspectiveSpring, [0, 1], [20, 0]);
const perspectiveScale = interpolate(perspectiveSpring, [0, 1], [0.9, 1]);
// Fine-tuned: Reduced initial rotation for smoother entrance
const perspectiveX = interpolate(perspectiveSpring, [0, 1], [18, 0]);
const perspectiveScale = interpolate(perspectiveSpring, [0, 1], [0.92, 1]);
const perspectiveOpacity = interpolate(perspectiveSpring, [0, 1], [0, 1]);
// Header entrance animation (delayed)
const headerDelay = Math.floor(0.3 * fps);
// Header entrance animation (delayed) - Fine-tuned timing
const headerDelay = secondsToFrames(0.35, fps);
const headerSpring = spring({
frame: frame - headerDelay,
fps,
config: SNAPPY,
config: SPRING_CONFIGS.SNAPPY,
});
const headerOpacity = interpolate(headerSpring, [0, 1], [0, 1]);
const headerY = interpolate(headerSpring, [0, 1], [-40, 0]);
// Fine-tuned: Reduced Y translation
const headerY = interpolate(headerSpring, [0, 1], [-35, 0]);
// Subtitle animation (slightly more delayed)
const subtitleDelay = Math.floor(0.5 * fps);
// Subtitle animation (slightly more delayed) - Fine-tuned timing
const subtitleDelay = secondsToFrames(0.55, fps);
const subtitleSpring = spring({
frame: frame - subtitleDelay,
fps,
config: SNAPPY,
config: SPRING_CONFIGS.SNAPPY,
});
const subtitleOpacity = interpolate(subtitleSpring, [0, 1], [0, 1]);
const subtitleY = interpolate(subtitleSpring, [0, 1], [20, 0]);
// Fine-tuned: Reduced Y translation
const subtitleY = interpolate(subtitleSpring, [0, 1], [18, 0]);
// Base delay for activity items
const activityBaseDelay = Math.floor(1 * fps);
// Base delay for activity items using centralized timing
const activityBaseDelay = secondsToFrames(TIMING.CONTENT_BASE_DELAY, fps);
// Subtle pulse for live indicator
// Subtle pulse for live indicator using centralized config
const pulseIntensity = interpolate(
Math.sin(frame * 0.1),
Math.sin(frame * GLOW_CONFIG.FREQUENCY.PULSE),
[-1, 1],
[0.5, 1]
);
@@ -105,7 +109,7 @@ export const ActivityFeedScene: React.FC = () => {
{ACTIVITY_FEED_DATA.map((_, index) => (
<Sequence
key={`audio-${index}`}
from={activityBaseDelay + index * ACTIVITY_STAGGER_DELAY}
from={activityBaseDelay + index * STAGGER_DELAYS.ACTIVITY}
durationInFrames={Math.floor(0.5 * fps)}
>
<Audio src={staticFile("sfx/button-click.mp3")} volume={0.4} />
@@ -190,7 +194,7 @@ export const ActivityFeedScene: React.FC = () => {
<ActivityItemWrapper
key={activity.eventName}
activity={activity}
delay={activityBaseDelay + index * ACTIVITY_STAGGER_DELAY}
delay={activityBaseDelay + index * STAGGER_DELAYS.ACTIVITY}
index={index}
/>
))}
@@ -235,7 +239,7 @@ const ActivityItemWrapper: React.FC<ActivityItemWrapperProps> = ({
// Each item that appears before this one causes a slight downward push
let pushDownOffset = 0;
for (let i = 0; i < index; i++) {
const prevItemDelay = Math.floor(1 * fps) + i * ACTIVITY_STAGGER_DELAY;
const prevItemDelay = secondsToFrames(TIMING.CONTENT_BASE_DELAY, fps) + i * STAGGER_DELAYS.ACTIVITY;
const prevItemSpring = spring({
frame: frame - prevItemDelay,
fps,
@@ -245,11 +249,11 @@ const ActivityItemWrapper: React.FC<ActivityItemWrapperProps> = ({
pushDownOffset += interpolate(prevItemSpring, [0, 1], [0, 0]);
}
// Container spring for the wrapper itself
// Container spring for the wrapper itself using centralized config
const containerSpring = spring({
frame: frame - delay,
fps,
config: { damping: 15, stiffness: 80 },
config: SPRING_CONFIGS.SNAPPY,
});
const containerOpacity = interpolate(containerSpring, [0, 1], [0, 1]);

View File

@@ -9,11 +9,12 @@ import {
Sequence,
} from "remotion";
import { Audio } from "@remotion/media";
// Spring configurations
const SMOOTH = { damping: 200 };
const SNAPPY = { damping: 15, stiffness: 80 };
const BOUNCY = { damping: 12 };
import {
SPRING_CONFIGS,
TIMING,
GLOW_CONFIG,
secondsToFrames,
} from "../../config/timing";
// URL to display
const PORTAL_URL = "portal.einundzwanzig.space";
@@ -34,88 +35,100 @@ export const CallToActionScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Background blur and zoom out animation (0-60 frames)
// Background blur and zoom out animation using centralized config
const blurSpring = spring({
frame,
fps,
config: SMOOTH,
config: SPRING_CONFIGS.SMOOTH,
});
const backgroundBlur = interpolate(blurSpring, [0, 1], [0, 8]);
const backgroundScale = interpolate(blurSpring, [0, 1], [1, 0.95]);
const backgroundOpacity = interpolate(blurSpring, [0, 1], [0.3, 0.15]);
// Fine-tuned: Adjusted blur and scale for smoother background transition
const backgroundBlur = interpolate(blurSpring, [0, 1], [0, 10]);
const backgroundScale = interpolate(blurSpring, [0, 1], [1, 0.96]);
const backgroundOpacity = interpolate(blurSpring, [0, 1], [0.3, 0.12]);
// Glassmorphism overlay entrance (delayed 15 frames)
const overlayDelay = 15;
// Glassmorphism overlay entrance using centralized timing
const overlayDelay = secondsToFrames(TIMING.CTA_OVERLAY_DELAY, fps);
const overlaySpring = spring({
frame: frame - overlayDelay,
fps,
config: SNAPPY,
config: SPRING_CONFIGS.SNAPPY,
});
const overlayOpacity = interpolate(overlaySpring, [0, 1], [0, 1]);
// Title entrance (delayed 30 frames)
const titleDelay = Math.floor(1 * fps);
// Title entrance using centralized timing
const titleDelay = secondsToFrames(TIMING.CTA_TITLE_DELAY, fps);
const titleSpring = spring({
frame: frame - titleDelay,
fps,
config: BOUNCY,
config: SPRING_CONFIGS.BOUNCY,
});
const titleOpacity = interpolate(titleSpring, [0, 1], [0, 1]);
const titleY = interpolate(titleSpring, [0, 1], [50, 0]);
const titleScale = interpolate(titleSpring, [0, 1], [0.8, 1]);
// Fine-tuned: Reduced Y translation for subtler entrance
const titleY = interpolate(titleSpring, [0, 1], [40, 0]);
// Fine-tuned: Increased initial scale for smoother transition
const titleScale = interpolate(titleSpring, [0, 1], [0.85, 1]);
// Logo entrance (delayed 45 frames)
const logoDelay = Math.floor(1.5 * fps);
// Logo entrance using centralized timing
const logoDelay = secondsToFrames(TIMING.CTA_LOGO_DELAY, fps);
const logoSpring = spring({
frame: frame - logoDelay,
fps,
config: BOUNCY,
config: SPRING_CONFIGS.BOUNCY,
});
const logoOpacity = interpolate(logoSpring, [0, 1], [0, 1]);
const logoScale = interpolate(logoSpring, [0, 1], [0.5, 1]);
// Fine-tuned: Increased initial scale for smoother transition
const logoScale = interpolate(logoSpring, [0, 1], [0.6, 1]);
// Logo glow pulse
// Logo glow pulse using centralized config
const glowIntensity = interpolate(
Math.sin((frame - logoDelay) * 0.08),
Math.sin((frame - logoDelay) * GLOW_CONFIG.FREQUENCY.FAST),
[-1, 1],
[0.4, 1]
GLOW_CONFIG.INTENSITY.STRONG
);
// URL typing animation (delayed 2.5 seconds)
const urlDelay = Math.floor(2.5 * fps);
// URL typing animation using centralized timing
const urlDelay = secondsToFrames(TIMING.CTA_URL_DELAY, fps);
const urlDuration = secondsToFrames(TIMING.CTA_URL_DURATION, fps);
const urlTypingProgress = Math.max(
0,
Math.min(1, (frame - urlDelay) / (1.5 * fps))
Math.min(1, (frame - urlDelay) / urlDuration)
);
const displayedUrlLength = Math.floor(urlTypingProgress * PORTAL_URL.length);
const displayedUrl = PORTAL_URL.slice(0, displayedUrlLength);
const showCursor = frame >= urlDelay && urlTypingProgress < 1;
// URL container entrance
// URL container entrance - Fine-tuned timing
const urlContainerDelay = secondsToFrames(2.3, fps);
const urlContainerSpring = spring({
frame: frame - Math.floor(2.3 * fps),
frame: frame - urlContainerDelay,
fps,
config: SNAPPY,
config: SPRING_CONFIGS.SNAPPY,
});
const urlContainerOpacity = interpolate(urlContainerSpring, [0, 1], [0, 1]);
const urlContainerY = interpolate(urlContainerSpring, [0, 1], [30, 0]);
// Fine-tuned: Reduced Y translation
const urlContainerY = interpolate(urlContainerSpring, [0, 1], [25, 0]);
// URL pulse after typing complete
const urlPulseActive = frame > urlDelay + 1.5 * fps;
// URL pulse after typing complete using centralized config
const urlPulseActive = frame > urlDelay + urlDuration;
const urlPulseIntensity = urlPulseActive
? interpolate(Math.sin((frame - urlDelay - 1.5 * fps) * 0.1), [-1, 1], [0.6, 1])
? interpolate(
Math.sin((frame - urlDelay - urlDuration) * GLOW_CONFIG.FREQUENCY.PULSE),
[-1, 1],
[0.6, 1]
)
: 0;
// Subtitle entrance
const subtitleDelay = Math.floor(3.5 * fps);
// Subtitle entrance using centralized timing
const subtitleDelay = secondsToFrames(TIMING.CTA_SUBTITLE_DELAY, fps);
const subtitleSpring = spring({
frame: frame - subtitleDelay,
fps,
config: SNAPPY,
config: SPRING_CONFIGS.SNAPPY,
});
const subtitleOpacity = interpolate(subtitleSpring, [0, 1], [0, 1]);
const subtitleY = interpolate(subtitleSpring, [0, 1], [20, 0]);
// Fine-tuned: Reduced Y translation
const subtitleY = interpolate(subtitleSpring, [0, 1], [18, 0]);
return (
<AbsoluteFill className="bg-zinc-900 overflow-hidden">

View File

@@ -13,12 +13,13 @@ import { DashboardSidebar, SidebarNavItem } from "../../components/DashboardSide
import { StatsCounter } from "../../components/StatsCounter";
import { SparklineChart } from "../../components/SparklineChart";
import { ActivityItem } from "../../components/ActivityItem";
// Spring configurations
const SNAPPY = { damping: 15, stiffness: 80 };
// Stagger delay between content cards
const CARD_STAGGER_DELAY = 5;
import {
SPRING_CONFIGS,
STAGGER_DELAYS,
TIMING,
GLOW_CONFIG,
secondsToFrames,
} from "../../config/timing";
// Navigation items for the sidebar
const NAV_ITEMS: SidebarNavItem[] = [
@@ -54,32 +55,33 @@ export const DashboardOverviewScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// 3D Perspective entrance animation (0-60 frames)
// 3D Perspective entrance animation using centralized config
// Fine-tuned: slightly reduced initial rotation for less dramatic entrance
const perspectiveSpring = spring({
frame,
fps,
config: { damping: 20, stiffness: 60 },
config: SPRING_CONFIGS.PERSPECTIVE,
});
const perspectiveX = interpolate(perspectiveSpring, [0, 1], [30, 0]);
const perspectiveScale = interpolate(perspectiveSpring, [0, 1], [0.85, 1]);
const perspectiveX = interpolate(perspectiveSpring, [0, 1], [25, 0]); // Reduced from 30
const perspectiveScale = interpolate(perspectiveSpring, [0, 1], [0.88, 1]); // Increased from 0.85
const perspectiveOpacity = interpolate(perspectiveSpring, [0, 1], [0, 1]);
// Header entrance animation (delayed)
const headerDelay = Math.floor(0.5 * fps);
// Header entrance animation (delayed) using centralized timing
const headerDelay = secondsToFrames(TIMING.HEADER_DELAY, fps);
const headerSpring = spring({
frame: frame - headerDelay,
fps,
config: SNAPPY,
config: SPRING_CONFIGS.SNAPPY,
});
const headerOpacity = interpolate(headerSpring, [0, 1], [0, 1]);
const headerY = interpolate(headerSpring, [0, 1], [-30, 0]);
// Subtle background glow pulse
// Subtle background glow pulse using centralized config
const glowIntensity = interpolate(
Math.sin(frame * 0.04),
Math.sin(frame * GLOW_CONFIG.FREQUENCY.SLOW),
[-1, 1],
[0.3, 0.5]
GLOW_CONFIG.INTENSITY.SUBTLE
);
// Sidebar dimensions
@@ -90,8 +92,8 @@ export const DashboardOverviewScene: React.FC = () => {
const cardWidth = 380;
const cardGap = 24;
// Card entrance delays (staggered)
const cardBaseDelay = Math.floor(1 * fps); // Start after 1 second
// Card entrance delays (staggered) using centralized timing
const cardBaseDelay = secondsToFrames(TIMING.CONTENT_BASE_DELAY, fps);
return (
<AbsoluteFill className="bg-zinc-900 overflow-hidden">
@@ -172,7 +174,7 @@ export const DashboardOverviewScene: React.FC = () => {
className="flex gap-6 mb-8"
style={{ gap: cardGap }}
>
{/* Meetups Card */}
{/* Meetups Card - Fine-tuned: increased stagger delay between cards for better visual flow */}
<StatsCard
title="Meetups"
delay={cardBaseDelay}
@@ -181,8 +183,8 @@ export const DashboardOverviewScene: React.FC = () => {
>
<StatsCounter
targetNumber={204}
delay={cardBaseDelay + 15}
duration={60}
delay={cardBaseDelay + TIMING.COUNTER_PRE_DELAY}
duration={TIMING.COUNTER_DURATION}
label="Aktive Gruppen"
fontSize={72}
color="#f7931a"
@@ -192,7 +194,7 @@ export const DashboardOverviewScene: React.FC = () => {
data={MEETUP_TREND_DATA}
width={cardWidth - 48}
height={60}
delay={cardBaseDelay + 30}
delay={cardBaseDelay + TIMING.SPARKLINE_PRE_DELAY}
showFill={true}
fillOpacity={0.15}
/>
@@ -202,14 +204,14 @@ export const DashboardOverviewScene: React.FC = () => {
{/* Users Card */}
<StatsCard
title="Benutzer"
delay={cardBaseDelay + CARD_STAGGER_DELAY}
delay={cardBaseDelay + STAGGER_DELAYS.CARD}
width={cardWidth}
glowIntensity={glowIntensity}
>
<StatsCounter
targetNumber={1247}
delay={cardBaseDelay + CARD_STAGGER_DELAY + 15}
duration={60}
delay={cardBaseDelay + STAGGER_DELAYS.CARD + TIMING.COUNTER_PRE_DELAY}
duration={TIMING.COUNTER_DURATION}
label="Registrierte Nutzer"
fontSize={72}
color="#f7931a"
@@ -219,7 +221,7 @@ export const DashboardOverviewScene: React.FC = () => {
data={USER_TREND_DATA}
width={cardWidth - 48}
height={60}
delay={cardBaseDelay + CARD_STAGGER_DELAY + 30}
delay={cardBaseDelay + STAGGER_DELAYS.CARD + TIMING.SPARKLINE_PRE_DELAY}
showFill={true}
fillOpacity={0.15}
/>
@@ -229,14 +231,14 @@ export const DashboardOverviewScene: React.FC = () => {
{/* Events Card */}
<StatsCard
title="Events"
delay={cardBaseDelay + CARD_STAGGER_DELAY * 2}
delay={cardBaseDelay + STAGGER_DELAYS.CARD * 2}
width={cardWidth}
glowIntensity={glowIntensity}
>
<StatsCounter
targetNumber={89}
delay={cardBaseDelay + CARD_STAGGER_DELAY * 2 + 15}
duration={60}
delay={cardBaseDelay + STAGGER_DELAYS.CARD * 2 + TIMING.COUNTER_PRE_DELAY}
duration={TIMING.COUNTER_DURATION}
label="Diese Woche"
fontSize={72}
color="#f7931a"
@@ -246,7 +248,7 @@ export const DashboardOverviewScene: React.FC = () => {
data={EVENT_TREND_DATA}
width={cardWidth - 48}
height={60}
delay={cardBaseDelay + CARD_STAGGER_DELAY * 2 + 30}
delay={cardBaseDelay + STAGGER_DELAYS.CARD * 2 + TIMING.SPARKLINE_PRE_DELAY}
showFill={true}
fillOpacity={0.15}
/>
@@ -258,13 +260,13 @@ export const DashboardOverviewScene: React.FC = () => {
<div className="flex gap-6" style={{ gap: cardGap }}>
{/* Activity Feed */}
<ActivitySection
delay={cardBaseDelay + CARD_STAGGER_DELAY * 3}
delay={cardBaseDelay + STAGGER_DELAYS.CARD * 3}
glowIntensity={glowIntensity}
/>
{/* Quick Stats */}
<QuickStatsSection
delay={cardBaseDelay + CARD_STAGGER_DELAY * 4}
delay={cardBaseDelay + STAGGER_DELAYS.CARD * 4}
glowIntensity={glowIntensity}
/>
</div>
@@ -306,15 +308,18 @@ const StatsCard: React.FC<StatsCardProps> = ({
const adjustedFrame = Math.max(0, frame - delay);
// Fine-tuned: Using centralized SNAPPY config for consistent card animations
const cardSpring = spring({
frame: adjustedFrame,
fps,
config: SNAPPY,
config: SPRING_CONFIGS.SNAPPY,
});
const cardScale = interpolate(cardSpring, [0, 1], [0.8, 1]);
// Fine-tuned: Slightly increased initial scale for smoother entrance
const cardScale = interpolate(cardSpring, [0, 1], [0.85, 1]);
const cardOpacity = interpolate(cardSpring, [0, 1], [0, 1]);
const cardY = interpolate(cardSpring, [0, 1], [30, 0]);
// Fine-tuned: Reduced Y translation for subtler entrance
const cardY = interpolate(cardSpring, [0, 1], [25, 0]);
return (
<div
@@ -349,14 +354,15 @@ const ActivitySection: React.FC<ActivitySectionProps> = ({
const adjustedFrame = Math.max(0, frame - delay);
// Fine-tuned: Using centralized SNAPPY config
const sectionSpring = spring({
frame: adjustedFrame,
fps,
config: SNAPPY,
config: SPRING_CONFIGS.SNAPPY,
});
const sectionOpacity = interpolate(sectionSpring, [0, 1], [0, 1]);
const sectionY = interpolate(sectionSpring, [0, 1], [30, 0]);
const sectionY = interpolate(sectionSpring, [0, 1], [25, 0]); // Fine-tuned: reduced Y
const activities = [
{ eventName: "EINUNDZWANZIG Kempten", timestamp: "vor 13 Stunden", badgeText: "Neuer Termin" },
@@ -381,7 +387,7 @@ const ActivitySection: React.FC<ActivitySectionProps> = ({
eventName={activity.eventName}
timestamp={activity.timestamp}
badgeText={activity.badgeText}
delay={delay + 10 + index * 8}
delay={delay + 10 + index * STAGGER_DELAYS.LIST_ITEM}
width={480}
/>
))}
@@ -407,14 +413,15 @@ const QuickStatsSection: React.FC<QuickStatsSectionProps> = ({
const adjustedFrame = Math.max(0, frame - delay);
// Fine-tuned: Using centralized SNAPPY config
const sectionSpring = spring({
frame: adjustedFrame,
fps,
config: SNAPPY,
config: SPRING_CONFIGS.SNAPPY,
});
const sectionOpacity = interpolate(sectionSpring, [0, 1], [0, 1]);
const sectionY = interpolate(sectionSpring, [0, 1], [30, 0]);
const sectionY = interpolate(sectionSpring, [0, 1], [25, 0]); // Fine-tuned: reduced Y
return (
<div
@@ -436,12 +443,12 @@ const QuickStatsSection: React.FC<QuickStatsSectionProps> = ({
<QuickStatRow
label="Neue diese Woche"
value={12}
delay={delay + 18}
delay={delay + 10 + STAGGER_DELAYS.QUICK_STAT}
/>
<QuickStatRow
label="Aktive Nutzer"
value={847}
delay={delay + 26}
delay={delay + 10 + STAGGER_DELAYS.QUICK_STAT * 2}
/>
</div>
</div>
@@ -463,14 +470,16 @@ const QuickStatRow: React.FC<QuickStatRowProps> = ({ label, value, delay }) => {
const adjustedFrame = Math.max(0, frame - delay);
// Fine-tuned: Using centralized ROW config
const rowSpring = spring({
frame: adjustedFrame,
fps,
config: { damping: 15, stiffness: 90 },
config: SPRING_CONFIGS.ROW,
});
const rowOpacity = interpolate(rowSpring, [0, 1], [0, 1]);
const rowX = interpolate(rowSpring, [0, 1], [-20, 0]);
// Fine-tuned: Reduced X translation for subtler entrance
const rowX = interpolate(rowSpring, [0, 1], [-15, 0]);
// Animated counter
const counterValue = interpolate(rowSpring, [0, 1], [0, value]);

View File

@@ -10,13 +10,13 @@ import {
} from "remotion";
import { Audio } from "@remotion/media";
import { MeetupCard } from "../../components/MeetupCard";
// Spring configurations
const SNAPPY = { damping: 15, stiffness: 80 };
const BOUNCY = { damping: 12 };
// Stagger delay between meetup list items
const LIST_ITEM_STAGGER = 8;
import {
SPRING_CONFIGS,
STAGGER_DELAYS,
TIMING,
GLOW_CONFIG,
secondsToFrames,
} from "../../config/timing";
// Upcoming meetup data
const UPCOMING_MEETUPS = [
@@ -61,77 +61,83 @@ export const MeetupShowcaseScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// 3D Perspective entrance animation (0-60 frames)
// 3D Perspective entrance animation using centralized config
const perspectiveSpring = spring({
frame,
fps,
config: { damping: 20, stiffness: 60 },
config: SPRING_CONFIGS.PERSPECTIVE,
});
const perspectiveX = interpolate(perspectiveSpring, [0, 1], [20, 0]);
const perspectiveScale = interpolate(perspectiveSpring, [0, 1], [0.9, 1]);
// Fine-tuned: Reduced initial rotation for smoother entrance
const perspectiveX = interpolate(perspectiveSpring, [0, 1], [18, 0]);
const perspectiveScale = interpolate(perspectiveSpring, [0, 1], [0.92, 1]);
const perspectiveOpacity = interpolate(perspectiveSpring, [0, 1], [0, 1]);
// Header entrance animation (delayed)
const headerDelay = Math.floor(0.3 * fps);
// Header entrance animation (delayed) - Fine-tuned timing
const headerDelay = secondsToFrames(0.35, fps);
const headerSpring = spring({
frame: frame - headerDelay,
fps,
config: SNAPPY,
config: SPRING_CONFIGS.SNAPPY,
});
const headerOpacity = interpolate(headerSpring, [0, 1], [0, 1]);
const headerY = interpolate(headerSpring, [0, 1], [-40, 0]);
// Fine-tuned: Reduced Y translation
const headerY = interpolate(headerSpring, [0, 1], [-35, 0]);
// Featured card entrance delay
const featuredCardDelay = Math.floor(0.8 * fps);
// Featured card entrance delay using centralized timing
const featuredCardDelay = secondsToFrames(TIMING.FEATURED_DELAY, fps);
// Featured card 3D shadow animation
// Featured card 3D shadow animation using centralized config
const featuredSpring = spring({
frame: frame - featuredCardDelay,
fps,
config: { damping: 18, stiffness: 70 },
config: SPRING_CONFIGS.FEATURED,
});
const featuredScale = interpolate(featuredSpring, [0, 1], [0.85, 1]);
// Fine-tuned: Smoother scale transition
const featuredScale = interpolate(featuredSpring, [0, 1], [0.88, 1]);
const featuredOpacity = interpolate(featuredSpring, [0, 1], [0, 1]);
const featuredRotateX = interpolate(featuredSpring, [0, 1], [15, 0]);
// Fine-tuned: Reduced initial rotation for less dramatic entrance
const featuredRotateX = interpolate(featuredSpring, [0, 1], [12, 0]);
const featuredShadowY = interpolate(featuredSpring, [0, 1], [20, 40]);
const featuredShadowBlur = interpolate(featuredSpring, [0, 1], [10, 60]);
// Date/time info animation (after featured card)
const dateDelay = featuredCardDelay + Math.floor(0.4 * fps);
// Date/time info animation (after featured card) - Fine-tuned timing
const dateDelay = featuredCardDelay + secondsToFrames(0.45, fps);
const dateSpring = spring({
frame: frame - dateDelay,
fps,
config: SNAPPY,
config: SPRING_CONFIGS.SNAPPY,
});
const dateOpacity = interpolate(dateSpring, [0, 1], [0, 1]);
const dateY = interpolate(dateSpring, [0, 1], [20, 0]);
const dateY = interpolate(dateSpring, [0, 1], [18, 0]);
// Upcoming list header delay
const listHeaderDelay = featuredCardDelay + Math.floor(1 * fps);
// Upcoming list header delay - Fine-tuned timing
const listHeaderDelay = featuredCardDelay + secondsToFrames(1.1, fps);
const listHeaderSpring = spring({
frame: frame - listHeaderDelay,
fps,
config: SNAPPY,
config: SPRING_CONFIGS.SNAPPY,
});
const listHeaderOpacity = interpolate(listHeaderSpring, [0, 1], [0, 1]);
const listHeaderX = interpolate(listHeaderSpring, [0, 1], [-30, 0]);
// Fine-tuned: Reduced X translation
const listHeaderX = interpolate(listHeaderSpring, [0, 1], [-25, 0]);
// Action buttons animation
const buttonsDelay = Math.floor(3.5 * fps);
// Action buttons animation - Fine-tuned timing
const buttonsDelay = secondsToFrames(3.2, fps);
const buttonsSpring = spring({
frame: frame - buttonsDelay,
fps,
config: BOUNCY,
config: SPRING_CONFIGS.BOUNCY,
});
const buttonsOpacity = interpolate(buttonsSpring, [0, 1], [0, 1]);
const buttonsY = interpolate(buttonsSpring, [0, 1], [30, 0]);
// Fine-tuned: Reduced Y translation
const buttonsY = interpolate(buttonsSpring, [0, 1], [25, 0]);
// Subtle glow pulse
// Subtle glow pulse using centralized config
const glowIntensity = interpolate(
Math.sin(frame * 0.05),
Math.sin(frame * GLOW_CONFIG.FREQUENCY.SLOW),
[-1, 1],
[0.3, 0.6]
[0.35, 0.6]
);
// Featured meetup data
@@ -151,7 +157,7 @@ export const MeetupShowcaseScene: React.FC = () => {
</Sequence>
{/* Audio: badge-appear for list items */}
<Sequence from={listHeaderDelay + LIST_ITEM_STAGGER} durationInFrames={Math.floor(0.5 * fps)}>
<Sequence from={listHeaderDelay + STAGGER_DELAYS.LIST_ITEM} durationInFrames={Math.floor(0.5 * fps)}>
<Audio src={staticFile("sfx/badge-appear.mp3")} volume={0.4} />
</Sequence>
@@ -300,7 +306,7 @@ export const MeetupShowcaseScene: React.FC = () => {
<UpcomingMeetupItem
key={meetup.name}
meetup={meetup}
delay={listHeaderDelay + (index + 1) * LIST_ITEM_STAGGER}
delay={listHeaderDelay + (index + 1) * STAGGER_DELAYS.LIST_ITEM}
glowIntensity={glowIntensity}
/>
))}
@@ -369,15 +375,18 @@ const UpcomingMeetupItem: React.FC<UpcomingMeetupItemProps> = ({
const adjustedFrame = Math.max(0, frame - delay);
// Fine-tuned: Using centralized SNAPPY config
const itemSpring = spring({
frame: adjustedFrame,
fps,
config: SNAPPY,
config: SPRING_CONFIGS.SNAPPY,
});
const itemOpacity = interpolate(itemSpring, [0, 1], [0, 1]);
const itemY = interpolate(itemSpring, [0, 1], [30, 0]);
const itemScale = interpolate(itemSpring, [0, 1], [0.9, 1]);
// Fine-tuned: Reduced Y translation for subtler entrance
const itemY = interpolate(itemSpring, [0, 1], [25, 0]);
// Fine-tuned: Increased initial scale for smoother transition
const itemScale = interpolate(itemSpring, [0, 1], [0.92, 1]);
return (
<div
@@ -445,13 +454,15 @@ const ActionButton: React.FC<ActionButtonProps> = ({
const adjustedFrame = Math.max(0, frame - delay);
// Fine-tuned: Using centralized BUTTON config
const buttonSpring = spring({
frame: adjustedFrame,
fps,
config: { damping: 12, stiffness: 100 },
config: SPRING_CONFIGS.BUTTON,
});
const buttonScale = interpolate(buttonSpring, [0, 1], [0.8, 1]);
// Fine-tuned: Increased initial scale for smoother transition
const buttonScale = interpolate(buttonSpring, [0, 1], [0.85, 1]);
const buttonOpacity = interpolate(buttonSpring, [0, 1], [0, 1]);
const baseClasses = "flex items-center gap-3 px-6 py-3 rounded-xl font-semibold text-base transition-all";

View File

@@ -11,9 +11,12 @@ import {
import { Audio } from "@remotion/media";
import { AnimatedLogo } from "../../components/AnimatedLogo";
import { BitcoinEffect } from "../../components/BitcoinEffect";
// Spring configurations from PRD
const SMOOTH = { damping: 200 };
import {
SPRING_CONFIGS,
TIMING,
GLOW_CONFIG,
secondsToFrames,
} from "../../config/timing";
/**
* PortalIntroScene - Scene 1: Logo Reveal (6 seconds / 180 frames @ 30fps)
@@ -29,59 +32,70 @@ export const PortalIntroScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Background zoom animation - starts zoomed in, zooms out
const backgroundZoom = interpolate(frame, [0, 3 * fps], [1.2, 1.0], {
// Calculate delays using centralized timing
const logoEntranceDelay = secondsToFrames(TIMING.LOGO_ENTRANCE_DELAY, fps);
const titleDelay = secondsToFrames(TIMING.TITLE_DELAY, fps);
const subtitleDelay = secondsToFrames(TIMING.SUBTITLE_DELAY, fps);
// Background zoom animation - starts zoomed in, zooms out over 3 seconds
// Fine-tuned: Extended zoom duration for more cinematic feel
const backgroundZoomDuration = secondsToFrames(3.5, fps);
const backgroundZoom = interpolate(frame, [0, backgroundZoomDuration], [1.15, 1.0], {
extrapolateRight: "clamp",
});
// Logo entrance spring animation - delayed slightly for dramatic effect
const logoEntranceDelay = Math.floor(0.5 * fps);
const logoSpring = spring({
frame: frame - logoEntranceDelay,
fps,
config: { damping: 15, stiffness: 80 },
config: SPRING_CONFIGS.LOGO,
});
const logoScale = interpolate(logoSpring, [0, 1], [0, 1]);
const logoOpacity = interpolate(logoSpring, [0, 0.5], [0, 1], {
extrapolateRight: "clamp",
});
// Outer glow pulse effect
// Outer glow pulse effect using centralized config
const glowIntensity = interpolate(
Math.sin((frame - logoEntranceDelay) * 0.08),
Math.sin((frame - logoEntranceDelay) * GLOW_CONFIG.FREQUENCY.FAST),
[-1, 1],
[0.4, 0.8]
GLOW_CONFIG.INTENSITY.NORMAL
);
const glowScale = interpolate(
Math.sin((frame - logoEntranceDelay) * 0.06),
Math.sin((frame - logoEntranceDelay) * GLOW_CONFIG.FREQUENCY.NORMAL),
[-1, 1],
[1.0, 1.15]
GLOW_CONFIG.SCALE.NORMAL
);
// Title text entrance - appears after logo
const titleDelay = Math.floor(2 * fps);
// Title text entrance - appears after logo with smooth spring
const titleSpring = spring({
frame: frame - titleDelay,
fps,
config: SMOOTH,
config: SPRING_CONFIGS.SMOOTH,
});
const titleOpacity = interpolate(titleSpring, [0, 1], [0, 1]);
const titleY = interpolate(titleSpring, [0, 1], [30, 0]);
// Subtitle entrance
const subtitleDelay = Math.floor(2.8 * fps);
// Subtitle entrance - follows title with smooth spring
const subtitleSpring = spring({
frame: frame - subtitleDelay,
fps,
config: SMOOTH,
config: SPRING_CONFIGS.SMOOTH,
});
const subtitleOpacity = interpolate(subtitleSpring, [0, 1], [0, 1]);
// Center position for logo (adjusts as text appears)
const contentY = interpolate(frame, [titleDelay, titleDelay + fps], [0, -60], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
// Fine-tuned: smoother transition over longer duration
const contentTransitionDuration = secondsToFrames(1.2, fps);
const contentY = interpolate(
frame,
[titleDelay, titleDelay + contentTransitionDuration],
[0, -60],
{
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
}
);
return (
<AbsoluteFill className="bg-zinc-900 overflow-hidden">

View File

@@ -10,10 +10,12 @@ import {
} from "remotion";
import { Audio } from "@remotion/media";
import { BitcoinEffect } from "../../components/BitcoinEffect";
// Spring configurations
const SMOOTH = { damping: 200 };
const SNAPPY = { damping: 15, stiffness: 80 };
import {
SPRING_CONFIGS,
TIMING,
GLOW_CONFIG,
secondsToFrames,
} from "../../config/timing";
/**
* PortalOutroScene - Scene 9: Outro (12 seconds / 360 frames @ 30fps)
@@ -32,58 +34,63 @@ export const PortalOutroScene: React.FC = () => {
const { fps } = useVideoConfig();
const durationInFrames = 12 * fps; // 360 frames
// Background fade-in from black (0-30 frames)
// Background fade-in from black using centralized config
const backgroundSpring = spring({
frame,
fps,
config: SMOOTH,
config: SPRING_CONFIGS.SMOOTH,
});
const backgroundOpacity = interpolate(backgroundSpring, [0, 1], [0, 0.3]);
// Fine-tuned: Slightly increased max opacity for better visibility
const backgroundOpacity = interpolate(backgroundSpring, [0, 1], [0, 0.35]);
// Logo entrance animation (delayed 1 second)
const logoDelay = Math.floor(1 * fps);
// Logo entrance animation using centralized timing
const logoDelay = secondsToFrames(TIMING.OUTRO_LOGO_DELAY, fps);
const logoSpring = spring({
frame: frame - logoDelay,
fps,
config: SNAPPY,
config: SPRING_CONFIGS.SNAPPY,
});
const logoOpacity = interpolate(logoSpring, [0, 1], [0, 1]);
const logoScale = interpolate(logoSpring, [0, 1], [0.8, 1]);
const logoY = interpolate(logoSpring, [0, 1], [30, 0]);
// Fine-tuned: Increased initial scale for smoother entrance
const logoScale = interpolate(logoSpring, [0, 1], [0.85, 1]);
// Fine-tuned: Reduced Y translation
const logoY = interpolate(logoSpring, [0, 1], [25, 0]);
// Logo glow pulse effect
// Logo glow pulse effect using centralized config
const glowIntensity = interpolate(
Math.sin((frame - logoDelay) * 0.06),
Math.sin((frame - logoDelay) * GLOW_CONFIG.FREQUENCY.NORMAL),
[-1, 1],
[0.4, 0.9]
GLOW_CONFIG.INTENSITY.NORMAL
);
const glowScale = interpolate(
Math.sin((frame - logoDelay) * 0.04),
Math.sin((frame - logoDelay) * GLOW_CONFIG.FREQUENCY.SLOW),
[-1, 1],
[1.0, 1.2]
GLOW_CONFIG.SCALE.STRONG
);
// Text entrance (delayed 2 seconds)
const textDelay = Math.floor(2 * fps);
// Text entrance using centralized timing
const textDelay = secondsToFrames(TIMING.OUTRO_TEXT_DELAY, fps);
const textSpring = spring({
frame: frame - textDelay,
fps,
config: SMOOTH,
config: SPRING_CONFIGS.SMOOTH,
});
const textOpacity = interpolate(textSpring, [0, 1], [0, 1]);
const textY = interpolate(textSpring, [0, 1], [20, 0]);
// Fine-tuned: Reduced Y translation
const textY = interpolate(textSpring, [0, 1], [18, 0]);
// Subtitle entrance (delayed 2.5 seconds)
const subtitleDelay = Math.floor(2.5 * fps);
// Subtitle entrance using centralized timing
const subtitleDelay = secondsToFrames(TIMING.OUTRO_SUBTITLE_DELAY, fps);
const subtitleSpring = spring({
frame: frame - subtitleDelay,
fps,
config: SMOOTH,
config: SPRING_CONFIGS.SMOOTH,
});
const subtitleOpacity = interpolate(subtitleSpring, [0, 1], [0, 1]);
// Final fade out in last 2 seconds (frames 300-360)
const fadeOutStart = durationInFrames - 2 * fps;
// Final fade out using centralized timing
const fadeOutDuration = secondsToFrames(TIMING.OUTRO_FADE_DURATION, fps);
const fadeOutStart = durationInFrames - fadeOutDuration;
const finalFadeOpacity = interpolate(
frame,
[fadeOutStart, durationInFrames],

View File

@@ -9,13 +9,12 @@ import {
Sequence,
} from "remotion";
import { Audio } from "@remotion/media";
// Spring configurations from PRD
const SMOOTH = { damping: 200 };
// Typing animation configuration
const CHAR_FRAMES = 2; // Frames per character
const CURSOR_BLINK_FRAMES = 16;
import {
SPRING_CONFIGS,
TIMING,
GLOW_CONFIG,
secondsToFrames,
} from "../../config/timing";
/**
* PortalTitleScene - Scene 2: Title Card (4 seconds / 120 frames @ 30fps)
@@ -34,48 +33,57 @@ export const PortalTitleScene: React.FC = () => {
const titleText = "EINUNDZWANZIG PORTAL";
const subtitleText = "Das Herzstück der deutschsprachigen Bitcoin-Community";
// Calculate typed characters for title
// Calculate typed characters for title using centralized timing
const typedTitleChars = Math.min(
titleText.length,
Math.floor(frame / CHAR_FRAMES)
Math.floor(frame / TIMING.CHAR_FRAMES)
);
const typedTitle = titleText.slice(0, typedTitleChars);
// Title typing complete frame
const titleCompleteFrame = titleText.length * CHAR_FRAMES;
const titleCompleteFrame = titleText.length * TIMING.CHAR_FRAMES;
// Cursor blink effect - only show while typing or shortly after
const showCursor = frame < titleCompleteFrame + fps; // Show cursor for 1 second after typing completes
// Fine-tuned: cursor stays visible longer for better visual continuity
const cursorVisibleDuration = secondsToFrames(1.2, fps);
const showCursor = frame < titleCompleteFrame + cursorVisibleDuration;
const cursorOpacity = showCursor
? interpolate(
frame % CURSOR_BLINK_FRAMES,
[0, CURSOR_BLINK_FRAMES / 2, CURSOR_BLINK_FRAMES],
frame % TIMING.CURSOR_BLINK_FRAMES,
[0, TIMING.CURSOR_BLINK_FRAMES / 2, TIMING.CURSOR_BLINK_FRAMES],
[1, 0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
)
: 0;
// Subtitle entrance - after title typing completes
const subtitleDelay = titleCompleteFrame + Math.floor(0.3 * fps);
// Subtitle entrance - after title typing completes with a pause
// Fine-tuned: slightly longer pause (0.4s instead of 0.3s) for better pacing
const subtitleDelay = titleCompleteFrame + secondsToFrames(0.4, fps);
const subtitleSpring = spring({
frame: frame - subtitleDelay,
fps,
config: SMOOTH,
config: SPRING_CONFIGS.SMOOTH,
});
const subtitleOpacity = interpolate(subtitleSpring, [0, 1], [0, 1]);
const subtitleY = interpolate(subtitleSpring, [0, 1], [20, 0]);
// Background glow that pulses subtly
// Background glow that pulses subtly using centralized config
const glowIntensity = interpolate(
Math.sin(frame * 0.05),
Math.sin(frame * GLOW_CONFIG.FREQUENCY.SLOW),
[-1, 1],
[0.3, 0.5]
GLOW_CONFIG.INTENSITY.SUBTLE
);
// Scene entrance fade from intro scene
const entranceFade = interpolate(frame, [0, Math.floor(0.3 * fps)], [0, 1], {
extrapolateRight: "clamp",
});
// Fine-tuned: faster entrance (0.25s) for snappier transition
const entranceFade = interpolate(
frame,
[0, secondsToFrames(0.25, fps)],
[0, 1],
{
extrapolateRight: "clamp",
}
);
return (
<AbsoluteFill

View File

@@ -10,13 +10,13 @@ import {
} from "remotion";
import { Audio } from "@remotion/media";
import { SparklineChart } from "../../components/SparklineChart";
// Spring configurations
const SNAPPY = { damping: 15, stiffness: 80 };
const BOUNCY = { damping: 12 };
// Stagger delay between meetup items (in frames)
const MEETUP_STAGGER_DELAY = 15;
import {
SPRING_CONFIGS,
STAGGER_DELAYS,
TIMING,
GLOW_CONFIG,
secondsToFrames,
} from "../../config/timing";
// Top meetups data with sparkline growth data
const TOP_MEETUPS_DATA = [
@@ -83,45 +83,48 @@ export const TopMeetupsScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// 3D Perspective entrance animation (0-60 frames)
// 3D Perspective entrance animation using centralized config
const perspectiveSpring = spring({
frame,
fps,
config: { damping: 20, stiffness: 60 },
config: SPRING_CONFIGS.PERSPECTIVE,
});
const perspectiveX = interpolate(perspectiveSpring, [0, 1], [20, 0]);
const perspectiveScale = interpolate(perspectiveSpring, [0, 1], [0.9, 1]);
// Fine-tuned: Reduced initial rotation for smoother entrance
const perspectiveX = interpolate(perspectiveSpring, [0, 1], [18, 0]);
const perspectiveScale = interpolate(perspectiveSpring, [0, 1], [0.92, 1]);
const perspectiveOpacity = interpolate(perspectiveSpring, [0, 1], [0, 1]);
// Header entrance animation (delayed)
const headerDelay = Math.floor(0.3 * fps);
// Header entrance animation (delayed) - Fine-tuned timing
const headerDelay = secondsToFrames(0.35, fps);
const headerSpring = spring({
frame: frame - headerDelay,
fps,
config: SNAPPY,
config: SPRING_CONFIGS.SNAPPY,
});
const headerOpacity = interpolate(headerSpring, [0, 1], [0, 1]);
const headerY = interpolate(headerSpring, [0, 1], [-40, 0]);
// Fine-tuned: Reduced Y translation
const headerY = interpolate(headerSpring, [0, 1], [-35, 0]);
// Subtitle animation (slightly more delayed)
const subtitleDelay = Math.floor(0.5 * fps);
// Subtitle animation (slightly more delayed) - Fine-tuned timing
const subtitleDelay = secondsToFrames(0.55, fps);
const subtitleSpring = spring({
frame: frame - subtitleDelay,
fps,
config: SNAPPY,
config: SPRING_CONFIGS.SNAPPY,
});
const subtitleOpacity = interpolate(subtitleSpring, [0, 1], [0, 1]);
const subtitleY = interpolate(subtitleSpring, [0, 1], [20, 0]);
// Fine-tuned: Reduced Y translation
const subtitleY = interpolate(subtitleSpring, [0, 1], [18, 0]);
// Base delay for meetup items
const meetupBaseDelay = Math.floor(1 * fps);
// Base delay for meetup items using centralized timing
const meetupBaseDelay = secondsToFrames(TIMING.CONTENT_BASE_DELAY, fps);
// Subtle glow pulse for leading meetup
// Subtle glow pulse for leading meetup using centralized config
const glowIntensity = interpolate(
Math.sin(frame * 0.05),
Math.sin(frame * GLOW_CONFIG.FREQUENCY.SLOW),
[-1, 1],
[0.4, 0.8]
GLOW_CONFIG.INTENSITY.NORMAL
);
return (
@@ -130,7 +133,7 @@ export const TopMeetupsScene: React.FC = () => {
{TOP_MEETUPS_DATA.map((_, index) => (
<Sequence
key={`audio-${index}`}
from={meetupBaseDelay + index * MEETUP_STAGGER_DELAY}
from={meetupBaseDelay + index * STAGGER_DELAYS.MEETUP_RANK}
durationInFrames={Math.floor(0.5 * fps)}
>
<Audio src={staticFile("sfx/checkmark-pop.mp3")} volume={0.4} />
@@ -199,7 +202,7 @@ export const TopMeetupsScene: React.FC = () => {
key={meetup.name}
meetup={meetup}
maxCount={MAX_USER_COUNT}
delay={meetupBaseDelay + index * MEETUP_STAGGER_DELAY}
delay={meetupBaseDelay + index * STAGGER_DELAYS.MEETUP_RANK}
glowIntensity={glowIntensity}
isLeading={index === 0}
/>
@@ -250,29 +253,31 @@ const MeetupRow: React.FC<MeetupRowProps> = ({
const adjustedFrame = Math.max(0, frame - delay);
// Row entrance spring
// Row entrance spring using centralized config
const rowSpring = spring({
frame: adjustedFrame,
fps,
config: SNAPPY,
config: SPRING_CONFIGS.SNAPPY,
});
const rowOpacity = interpolate(rowSpring, [0, 1], [0, 1]);
const rowX = interpolate(rowSpring, [0, 1], [-80, 0]);
// Fine-tuned: Reduced X translation for subtler entrance
const rowX = interpolate(rowSpring, [0, 1], [-60, 0]);
// Rank badge bounce
// Rank badge bounce using centralized config
// Fine-tuned: Slightly increased delay for better stagger effect
const rankSpring = spring({
frame: adjustedFrame - 5,
frame: adjustedFrame - 6,
fps,
config: BOUNCY,
config: SPRING_CONFIGS.BOUNCY,
});
const rankScale = interpolate(rankSpring, [0, 1], [0, 1]);
// User count animation
// User count animation using centralized config
const countSpring = spring({
frame: adjustedFrame,
fps,
config: { damping: 18, stiffness: 70 },
config: SPRING_CONFIGS.FEATURED,
durationInFrames: 45,
});
const displayCount = Math.round(countSpring * meetup.userCount);
@@ -280,8 +285,8 @@ const MeetupRow: React.FC<MeetupRowProps> = ({
// Progress bar animation
const barProgress = interpolate(rowSpring, [0, 1], [0, meetup.userCount / maxCount]);
// Sparkline delay (appears slightly after the bar)
const sparklineDelay = delay + 25;
// Sparkline delay (appears slightly after the bar) - Fine-tuned timing
const sparklineDelay = delay + 22;
// Dynamic glow for leading meetup
const leadingGlow = isLeading