🎬 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

@@ -0,0 +1,285 @@
import { describe, it, expect } from "vitest";
import {
SPRING_CONFIGS,
STAGGER_DELAYS,
TIMING,
GLOW_CONFIG,
SCENE_DURATIONS,
secondsToFrames,
getStaggeredDelay,
} from "./timing";
describe("Timing Configuration", () => {
describe("SPRING_CONFIGS", () => {
it("exports all required spring configurations", () => {
expect(SPRING_CONFIGS.SMOOTH).toBeDefined();
expect(SPRING_CONFIGS.SNAPPY).toBeDefined();
expect(SPRING_CONFIGS.BOUNCY).toBeDefined();
expect(SPRING_CONFIGS.PERSPECTIVE).toBeDefined();
expect(SPRING_CONFIGS.FEATURED).toBeDefined();
expect(SPRING_CONFIGS.LOGO).toBeDefined();
expect(SPRING_CONFIGS.BADGE).toBeDefined();
expect(SPRING_CONFIGS.BUTTON).toBeDefined();
expect(SPRING_CONFIGS.COUNTER).toBeDefined();
expect(SPRING_CONFIGS.ROW).toBeDefined();
});
it("SMOOTH has high damping for slow animations", () => {
expect(SPRING_CONFIGS.SMOOTH.damping).toBe(200);
});
it("SNAPPY has moderate damping and stiffness for responsive UI", () => {
expect(SPRING_CONFIGS.SNAPPY.damping).toBe(15);
expect(SPRING_CONFIGS.SNAPPY.stiffness).toBe(80);
});
it("BOUNCY has low damping for playful animations", () => {
expect(SPRING_CONFIGS.BOUNCY.damping).toBe(12);
});
it("PERSPECTIVE has appropriate values for 3D entrances", () => {
expect(SPRING_CONFIGS.PERSPECTIVE.damping).toBe(20);
expect(SPRING_CONFIGS.PERSPECTIVE.stiffness).toBe(60);
});
it("BADGE has high stiffness for quick bounces", () => {
expect(SPRING_CONFIGS.BADGE.damping).toBe(8);
expect(SPRING_CONFIGS.BADGE.stiffness).toBe(150);
});
});
describe("STAGGER_DELAYS", () => {
it("exports all required stagger delay values", () => {
expect(STAGGER_DELAYS.CARD).toBeDefined();
expect(STAGGER_DELAYS.LIST_ITEM).toBeDefined();
expect(STAGGER_DELAYS.COUNTRY).toBeDefined();
expect(STAGGER_DELAYS.MEETUP_RANK).toBeDefined();
expect(STAGGER_DELAYS.ACTIVITY).toBeDefined();
expect(STAGGER_DELAYS.SIDEBAR).toBeDefined();
expect(STAGGER_DELAYS.QUICK_STAT).toBeDefined();
});
it("CARD has optimal stagger delay for card animations", () => {
// 5 frames = ~167ms at 30fps, good for card cascade
expect(STAGGER_DELAYS.CARD).toBe(5);
});
it("ACTIVITY has longer stagger for feed items", () => {
// 20 frames = ~667ms at 30fps, appropriate for activity feed
expect(STAGGER_DELAYS.ACTIVITY).toBe(20);
});
it("SIDEBAR has shortest stagger for quick menu items", () => {
// 3 frames = ~100ms at 30fps, snappy sidebar reveal
expect(STAGGER_DELAYS.SIDEBAR).toBe(3);
});
it("stagger delays are ordered by intended speed", () => {
expect(STAGGER_DELAYS.SIDEBAR).toBeLessThan(STAGGER_DELAYS.CARD);
expect(STAGGER_DELAYS.CARD).toBeLessThan(STAGGER_DELAYS.LIST_ITEM);
expect(STAGGER_DELAYS.LIST_ITEM).toBeLessThan(STAGGER_DELAYS.ACTIVITY);
});
});
describe("TIMING", () => {
it("exports all required timing values", () => {
expect(TIMING.PERSPECTIVE_ENTRANCE).toBeDefined();
expect(TIMING.HEADER_DELAY).toBeDefined();
expect(TIMING.CONTENT_BASE_DELAY).toBeDefined();
expect(TIMING.LOGO_ENTRANCE_DELAY).toBeDefined();
expect(TIMING.TITLE_DELAY).toBeDefined();
expect(TIMING.SUBTITLE_DELAY).toBeDefined();
expect(TIMING.CHAR_FRAMES).toBeDefined();
expect(TIMING.CURSOR_BLINK_FRAMES).toBeDefined();
});
it("header appears after perspective entrance starts", () => {
expect(TIMING.HEADER_DELAY).toBeGreaterThan(TIMING.PERSPECTIVE_ENTRANCE);
});
it("content appears after header", () => {
expect(TIMING.CONTENT_BASE_DELAY).toBeGreaterThan(TIMING.HEADER_DELAY);
});
it("title appears after logo entrance", () => {
expect(TIMING.TITLE_DELAY).toBeGreaterThan(TIMING.LOGO_ENTRANCE_DELAY);
});
it("subtitle appears after title", () => {
expect(TIMING.SUBTITLE_DELAY).toBeGreaterThan(TIMING.TITLE_DELAY);
});
it("typing animation uses 2 frames per character", () => {
expect(TIMING.CHAR_FRAMES).toBe(2);
});
it("cursor blinks at 16 frame intervals", () => {
expect(TIMING.CURSOR_BLINK_FRAMES).toBe(16);
});
it("counter animation has appropriate duration", () => {
// 60 frames = 2 seconds at 30fps
expect(TIMING.COUNTER_DURATION).toBe(60);
});
it("CTA scene elements appear in correct sequence", () => {
expect(TIMING.CTA_OVERLAY_DELAY).toBeLessThan(TIMING.CTA_TITLE_DELAY);
expect(TIMING.CTA_TITLE_DELAY).toBeLessThan(TIMING.CTA_LOGO_DELAY);
expect(TIMING.CTA_LOGO_DELAY).toBeLessThan(TIMING.CTA_URL_DELAY);
expect(TIMING.CTA_URL_DELAY).toBeLessThan(TIMING.CTA_SUBTITLE_DELAY);
});
it("outro elements appear in correct sequence", () => {
expect(TIMING.OUTRO_LOGO_DELAY).toBeLessThan(TIMING.OUTRO_TEXT_DELAY);
expect(TIMING.OUTRO_TEXT_DELAY).toBeLessThan(TIMING.OUTRO_SUBTITLE_DELAY);
});
});
describe("GLOW_CONFIG", () => {
it("exports intensity ranges", () => {
expect(GLOW_CONFIG.INTENSITY.SUBTLE).toEqual([0.3, 0.5]);
expect(GLOW_CONFIG.INTENSITY.NORMAL).toEqual([0.4, 0.8]);
expect(GLOW_CONFIG.INTENSITY.STRONG).toEqual([0.5, 1.0]);
});
it("exports frequency values", () => {
expect(GLOW_CONFIG.FREQUENCY.SLOW).toBe(0.04);
expect(GLOW_CONFIG.FREQUENCY.NORMAL).toBe(0.06);
expect(GLOW_CONFIG.FREQUENCY.FAST).toBe(0.08);
expect(GLOW_CONFIG.FREQUENCY.PULSE).toBe(0.1);
});
it("exports scale ranges", () => {
expect(GLOW_CONFIG.SCALE.SUBTLE).toEqual([1.0, 1.1]);
expect(GLOW_CONFIG.SCALE.NORMAL).toEqual([1.0, 1.15]);
expect(GLOW_CONFIG.SCALE.STRONG).toEqual([1.0, 1.2]);
});
it("frequency values increase from slow to pulse", () => {
expect(GLOW_CONFIG.FREQUENCY.SLOW).toBeLessThan(
GLOW_CONFIG.FREQUENCY.NORMAL
);
expect(GLOW_CONFIG.FREQUENCY.NORMAL).toBeLessThan(
GLOW_CONFIG.FREQUENCY.FAST
);
expect(GLOW_CONFIG.FREQUENCY.FAST).toBeLessThan(
GLOW_CONFIG.FREQUENCY.PULSE
);
});
});
describe("SCENE_DURATIONS", () => {
it("exports all scene durations", () => {
expect(SCENE_DURATIONS.LOGO_REVEAL).toBe(6);
expect(SCENE_DURATIONS.PORTAL_TITLE).toBe(4);
expect(SCENE_DURATIONS.DASHBOARD_OVERVIEW).toBe(12);
expect(SCENE_DURATIONS.MEINE_MEETUPS).toBe(12);
expect(SCENE_DURATIONS.TOP_LAENDER).toBe(12);
expect(SCENE_DURATIONS.TOP_MEETUPS).toBe(10);
expect(SCENE_DURATIONS.ACTIVITY_FEED).toBe(10);
expect(SCENE_DURATIONS.CALL_TO_ACTION).toBe(12);
expect(SCENE_DURATIONS.OUTRO).toBe(12);
});
it("total duration equals 90 seconds", () => {
const totalDuration =
SCENE_DURATIONS.LOGO_REVEAL +
SCENE_DURATIONS.PORTAL_TITLE +
SCENE_DURATIONS.DASHBOARD_OVERVIEW +
SCENE_DURATIONS.MEINE_MEETUPS +
SCENE_DURATIONS.TOP_LAENDER +
SCENE_DURATIONS.TOP_MEETUPS +
SCENE_DURATIONS.ACTIVITY_FEED +
SCENE_DURATIONS.CALL_TO_ACTION +
SCENE_DURATIONS.OUTRO;
expect(totalDuration).toBe(90);
});
});
describe("secondsToFrames helper", () => {
it("converts seconds to frames at default 30fps", () => {
expect(secondsToFrames(1)).toBe(30);
expect(secondsToFrames(2)).toBe(60);
expect(secondsToFrames(0.5)).toBe(15);
});
it("converts seconds to frames at custom fps", () => {
expect(secondsToFrames(1, 60)).toBe(60);
expect(secondsToFrames(2, 24)).toBe(48);
});
it("returns integer values (floors decimal results)", () => {
expect(secondsToFrames(0.3)).toBe(9); // 0.3 * 30 = 9
expect(secondsToFrames(0.35, 30)).toBe(10); // 0.35 * 30 = 10.5 -> 10
});
it("handles zero correctly", () => {
expect(secondsToFrames(0)).toBe(0);
});
});
describe("getStaggeredDelay helper", () => {
it("calculates staggered delay for first item", () => {
expect(getStaggeredDelay(0, 30, 5)).toBe(30);
});
it("calculates staggered delay for subsequent items", () => {
expect(getStaggeredDelay(1, 30, 5)).toBe(35);
expect(getStaggeredDelay(2, 30, 5)).toBe(40);
expect(getStaggeredDelay(3, 30, 5)).toBe(45);
});
it("works with different base delays", () => {
expect(getStaggeredDelay(0, 0, 10)).toBe(0);
expect(getStaggeredDelay(1, 0, 10)).toBe(10);
expect(getStaggeredDelay(2, 60, 15)).toBe(90);
});
it("integrates with STAGGER_DELAYS constants", () => {
const baseDelay = secondsToFrames(1); // 30 frames
expect(getStaggeredDelay(0, baseDelay, STAGGER_DELAYS.CARD)).toBe(30);
expect(getStaggeredDelay(1, baseDelay, STAGGER_DELAYS.CARD)).toBe(35);
expect(getStaggeredDelay(2, baseDelay, STAGGER_DELAYS.CARD)).toBe(40);
});
});
});
describe("Timing Consistency", () => {
it("timing values are reasonable for 30fps video", () => {
const fps = 30;
// Entrance delays should be under 2 seconds
expect(secondsToFrames(TIMING.HEADER_DELAY, fps)).toBeLessThan(60);
expect(secondsToFrames(TIMING.CONTENT_BASE_DELAY, fps)).toBeLessThan(60);
// Intro scene delays should allow content to appear within scene duration
const introSceneFrames = SCENE_DURATIONS.LOGO_REVEAL * fps;
expect(secondsToFrames(TIMING.LOGO_ENTRANCE_DELAY, fps)).toBeLessThan(
introSceneFrames
);
expect(secondsToFrames(TIMING.TITLE_DELAY, fps)).toBeLessThan(
introSceneFrames
);
expect(secondsToFrames(TIMING.SUBTITLE_DELAY, fps)).toBeLessThan(
introSceneFrames
);
});
it("CTA timing fits within CTA scene duration", () => {
const fps = 30;
const ctaSceneFrames = SCENE_DURATIONS.CALL_TO_ACTION * fps;
expect(secondsToFrames(TIMING.CTA_SUBTITLE_DELAY, fps)).toBeLessThan(
ctaSceneFrames
);
});
it("outro timing fits within outro scene duration", () => {
const fps = 30;
const outroSceneFrames = SCENE_DURATIONS.OUTRO * fps;
expect(secondsToFrames(TIMING.OUTRO_SUBTITLE_DELAY, fps)).toBeLessThan(
outroSceneFrames - secondsToFrames(TIMING.OUTRO_FADE_DURATION, fps)
);
});
});

252
videos/src/config/timing.ts Normal file
View File

@@ -0,0 +1,252 @@
/**
* Centralized timing configuration for all Portal Presentation animations.
*
* This file provides a single source of truth for:
* - Spring configurations (animation feel)
* - Stagger delays (sequencing)
* - Transition durations (timing)
*
* Usage:
* import { SPRING_CONFIGS, STAGGER_DELAYS, TIMING } from '../config/timing';
*/
// ============================================================================
// SPRING CONFIGURATIONS
// ============================================================================
/**
* Spring animation configurations for different animation feels.
* These are optimized for 30fps and provide consistent motion across scenes.
*/
export const SPRING_CONFIGS = {
/**
* SMOOTH - Very slow, gentle animations
* Use for: fades, overlays, background transitions, outro elements
* Character: Elegant, cinematic, no bounce
*/
SMOOTH: { damping: 200 },
/**
* SNAPPY - Fast, responsive UI element entrances
* Use for: cards, headers, buttons, UI elements
* Character: Professional, quick, minimal overshoot
*/
SNAPPY: { damping: 15, stiffness: 80 },
/**
* BOUNCY - Energetic, playful animations
* Use for: titles, logos, badges, call-to-action elements
* Character: Lively, attention-grabbing, noticeable bounce
*/
BOUNCY: { damping: 12 },
/**
* PERSPECTIVE - 3D entrance animations
* Use for: scene entrances with rotateX perspective effect
* Character: Cinematic, immersive, theatrical
*/
PERSPECTIVE: { damping: 20, stiffness: 60 },
/**
* FEATURED - Featured card 3D shadows and highlights
* Use for: hero cards, featured elements, highlighted content
* Character: Premium, elevated, attention-worthy
*/
FEATURED: { damping: 18, stiffness: 70 },
/**
* LOGO - Logo entrance animations
* Use for: logo reveals, brand elements
* Character: Impactful, memorable, brand-aligned
*/
LOGO: { damping: 15, stiffness: 80 },
/**
* BADGE - Badge bounce animations
* Use for: notification badges, count indicators, tags
* Character: Playful, noticeable, draws attention
*/
BADGE: { damping: 8, stiffness: 150 },
/**
* BUTTON - Button scale animations
* Use for: CTA buttons, interactive elements
* Character: Responsive, tactile, inviting
*/
BUTTON: { damping: 12, stiffness: 100 },
/**
* COUNTER - Number counting animations
* Use for: stats counters, animated numbers
* Character: Smooth counting, professional
*/
COUNTER: { damping: 20, stiffness: 80, mass: 1 },
/**
* ROW - List row entrance animations
* Use for: list items, table rows
* Character: Quick, efficient, organized
*/
ROW: { damping: 15, stiffness: 90 },
} as const;
// ============================================================================
// STAGGER DELAYS (in frames @ 30fps)
// ============================================================================
/**
* Stagger delays for sequential animations.
* All values are in frames at 30fps (multiply by 1000/30 for ms).
*/
export const STAGGER_DELAYS = {
/** Cards in a grid/row (5 frames = ~167ms) */
CARD: 5,
/** List items in meetup lists (8 frames = ~267ms) */
LIST_ITEM: 8,
/** Country statistics bars (12 frames = ~400ms) */
COUNTRY: 12,
/** Top meetups ranking items (15 frames = ~500ms) */
MEETUP_RANK: 15,
/** Activity feed items (20 frames = ~667ms) */
ACTIVITY: 20,
/** Sidebar navigation items (3 frames = ~100ms) */
SIDEBAR: 3,
/** Quick stat rows (8 frames = ~267ms) */
QUICK_STAT: 8,
} as const;
// ============================================================================
// TIMING CONSTANTS (in seconds, convert to frames with * fps)
// ============================================================================
/**
* Timing constants for delays and durations.
* All values are in seconds - multiply by fps to get frames.
*/
export const TIMING = {
// Scene entrance delays
PERSPECTIVE_ENTRANCE: 0, // Start immediately with perspective
HEADER_DELAY: 0.5, // Headers appear after perspective settles
CONTENT_BASE_DELAY: 1.0, // Content starts after 1 second
FEATURED_DELAY: 0.8, // Featured items delay
// Logo/Brand timing
LOGO_ENTRANCE_DELAY: 0.5, // Logo reveal delay in intro
TITLE_DELAY: 2.0, // Title text appears after logo
SUBTITLE_DELAY: 2.8, // Subtitle follows title
// Typing animation
CHAR_FRAMES: 2, // Frames per character in typing
CURSOR_BLINK_FRAMES: 16, // Cursor blink cycle
// Counter animation
COUNTER_DURATION: 60, // Frames for counter to complete
COUNTER_PRE_DELAY: 15, // Frames before counter starts
// Sparkline drawing
SPARKLINE_DURATION: 45, // Frames to draw sparkline
SPARKLINE_PRE_DELAY: 30, // Frames before sparkline starts
// Call to Action scene
CTA_OVERLAY_DELAY: 0.5, // Glassmorphism overlay
CTA_TITLE_DELAY: 1.0, // Title entrance
CTA_LOGO_DELAY: 1.5, // Logo entrance
CTA_URL_DELAY: 2.5, // URL typing start
CTA_URL_DURATION: 1.5, // URL typing duration
CTA_SUBTITLE_DELAY: 3.5, // Final subtitle
// Outro scene
OUTRO_LOGO_DELAY: 1.0, // Logo entrance
OUTRO_TEXT_DELAY: 2.0, // Text entrance
OUTRO_SUBTITLE_DELAY: 2.5, // Subtitle entrance
OUTRO_FADE_DURATION: 2.0, // Final fade out duration
// Audio sync offsets
AUDIO_PRE_ROLL: 0.5, // Audio starts slightly before visual
} as const;
// ============================================================================
// GLOW EFFECT PARAMETERS
// ============================================================================
/**
* Glow effect parameters for pulsing animations.
*/
export const GLOW_CONFIG = {
/** Glow intensity range [min, max] */
INTENSITY: {
SUBTLE: [0.3, 0.5] as const,
NORMAL: [0.4, 0.8] as const,
STRONG: [0.5, 1.0] as const,
},
/** Glow pulse frequency (lower = slower) */
FREQUENCY: {
SLOW: 0.04,
NORMAL: 0.06,
FAST: 0.08,
PULSE: 0.1,
},
/** Glow scale range [min, max] */
SCALE: {
SUBTLE: [1.0, 1.1] as const,
NORMAL: [1.0, 1.15] as const,
STRONG: [1.0, 1.2] as const,
},
} as const;
// ============================================================================
// SCENE DURATIONS (in seconds)
// ============================================================================
/**
* Scene duration constants matching PortalPresentation.tsx.
* Total: 90 seconds = 2700 frames @ 30fps
*/
export const SCENE_DURATIONS = {
LOGO_REVEAL: 6,
PORTAL_TITLE: 4,
DASHBOARD_OVERVIEW: 12,
MEINE_MEETUPS: 12,
TOP_LAENDER: 12,
TOP_MEETUPS: 10,
ACTIVITY_FEED: 10,
CALL_TO_ACTION: 12,
OUTRO: 12,
} as const;
// ============================================================================
// TRANSITION EASING HELPERS
// ============================================================================
/**
* Helper to calculate frame-based delay from seconds.
* @param seconds - Time in seconds
* @param fps - Frames per second (default 30)
* @returns Number of frames
*/
export function secondsToFrames(seconds: number, fps: number = 30): number {
return Math.floor(seconds * fps);
}
/**
* Helper to calculate staggered delay for an item in a sequence.
* @param index - Item index in the sequence
* @param baseDelay - Base delay in frames
* @param staggerDelay - Delay between items in frames
* @returns Total delay in frames for this item
*/
export function getStaggeredDelay(
index: number,
baseDelay: number,
staggerDelay: number
): number {
return baseDelay + index * staggerDelay;
}

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