From 6f132e98b456f337d2a14eed98a05d8705e0bba0 Mon Sep 17 00:00:00 2001 From: HolgerHatGarKeineNode Date: Sat, 24 Jan 2026 14:24:33 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=AC=20Fine-tune=20all=20transition=20t?= =?UTF-8?q?iming=20with=20centralized=20configuration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- videos/src/config/timing.test.ts | 285 ++++++++++++++++++ videos/src/config/timing.ts | 252 ++++++++++++++++ .../src/scenes/portal/ActivityFeedScene.tsx | 58 ++-- .../src/scenes/portal/CallToActionScene.tsx | 91 +++--- .../scenes/portal/DashboardOverviewScene.tsx | 97 +++--- .../src/scenes/portal/MeetupShowcaseScene.tsx | 97 +++--- videos/src/scenes/portal/PortalIntroScene.tsx | 58 ++-- videos/src/scenes/portal/PortalOutroScene.tsx | 59 ++-- videos/src/scenes/portal/PortalTitleScene.tsx | 52 ++-- videos/src/scenes/portal/TopMeetupsScene.tsx | 77 ++--- 10 files changed, 867 insertions(+), 259 deletions(-) create mode 100644 videos/src/config/timing.test.ts create mode 100644 videos/src/config/timing.ts diff --git a/videos/src/config/timing.test.ts b/videos/src/config/timing.test.ts new file mode 100644 index 0000000..4c02d3d --- /dev/null +++ b/videos/src/config/timing.test.ts @@ -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) + ); + }); +}); diff --git a/videos/src/config/timing.ts b/videos/src/config/timing.ts new file mode 100644 index 0000000..51157fd --- /dev/null +++ b/videos/src/config/timing.ts @@ -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; +} diff --git a/videos/src/scenes/portal/ActivityFeedScene.tsx b/videos/src/scenes/portal/ActivityFeedScene.tsx index 6104593..7ac04cd 100644 --- a/videos/src/scenes/portal/ActivityFeedScene.tsx +++ b/videos/src/scenes/portal/ActivityFeedScene.tsx @@ -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) => ( {/* Audio: badge-appear for list items */} - + @@ -300,7 +306,7 @@ export const MeetupShowcaseScene: React.FC = () => { ))} @@ -369,15 +375,18 @@ const UpcomingMeetupItem: React.FC = ({ 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 (
= ({ 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"; diff --git a/videos/src/scenes/portal/PortalIntroScene.tsx b/videos/src/scenes/portal/PortalIntroScene.tsx index 847b489..5ebfba6 100644 --- a/videos/src/scenes/portal/PortalIntroScene.tsx +++ b/videos/src/scenes/portal/PortalIntroScene.tsx @@ -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 ( diff --git a/videos/src/scenes/portal/PortalOutroScene.tsx b/videos/src/scenes/portal/PortalOutroScene.tsx index 816f32f..75b9109 100644 --- a/videos/src/scenes/portal/PortalOutroScene.tsx +++ b/videos/src/scenes/portal/PortalOutroScene.tsx @@ -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], diff --git a/videos/src/scenes/portal/PortalTitleScene.tsx b/videos/src/scenes/portal/PortalTitleScene.tsx index 50eec6f..0d20b8d 100644 --- a/videos/src/scenes/portal/PortalTitleScene.tsx +++ b/videos/src/scenes/portal/PortalTitleScene.tsx @@ -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 ( { 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) => (