mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-nostr.git
synced 2026-01-31 11:13:18 +00:00
🎵 Add PortalAudioManager component for background music
- Add PortalAudioManager.tsx with background music fade in/out - 1 second fade-in at the beginning - 3 second fade-out at the end - Base volume at 0.25 (25%) - Integrate PortalAudioManager into PortalPresentation - Add PortalOutroScene to PortalPresentation (was using placeholder) - Add comprehensive tests for PortalAudioManager (13 tests) - Tests for volume at various frames - Tests for fade-in/fade-out behavior - Integration tests Scene-specific SFX remain in individual scene components for better timing accuracy and maintainability. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,8 @@ import { MeetupShowcaseScene } from "./scenes/portal/MeetupShowcaseScene";
|
|||||||
import { TopMeetupsScene } from "./scenes/portal/TopMeetupsScene";
|
import { TopMeetupsScene } from "./scenes/portal/TopMeetupsScene";
|
||||||
import { ActivityFeedScene } from "./scenes/portal/ActivityFeedScene";
|
import { ActivityFeedScene } from "./scenes/portal/ActivityFeedScene";
|
||||||
import { CallToActionScene } from "./scenes/portal/CallToActionScene";
|
import { CallToActionScene } from "./scenes/portal/CallToActionScene";
|
||||||
|
import { PortalOutroScene } from "./scenes/portal/PortalOutroScene";
|
||||||
|
import { PortalAudioManager } from "./components/PortalAudioManager";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PortalPresentation - Main composition for the Einundzwanzig Portal presentation video
|
* PortalPresentation - Main composition for the Einundzwanzig Portal presentation video
|
||||||
@@ -119,6 +121,9 @@ export const PortalPresentation: React.FC = () => {
|
|||||||
className="bg-gradient-to-br from-zinc-900 to-zinc-800"
|
className="bg-gradient-to-br from-zinc-900 to-zinc-800"
|
||||||
style={{ fontFamily: inconsolataFont }}
|
style={{ fontFamily: inconsolataFont }}
|
||||||
>
|
>
|
||||||
|
{/* Background Music with fade in/out */}
|
||||||
|
<PortalAudioManager />
|
||||||
|
|
||||||
{/* Wallpaper Background */}
|
{/* Wallpaper Background */}
|
||||||
<Img
|
<Img
|
||||||
src={staticFile("einundzwanzig-wallpaper.png")}
|
src={staticFile("einundzwanzig-wallpaper.png")}
|
||||||
@@ -203,7 +208,7 @@ export const PortalPresentation: React.FC = () => {
|
|||||||
durationInFrames={sceneFrames.outro.duration}
|
durationInFrames={sceneFrames.outro.duration}
|
||||||
premountFor={fps}
|
premountFor={fps}
|
||||||
>
|
>
|
||||||
<PlaceholderScene name="Outro" sceneNumber={9} />
|
<PortalOutroScene />
|
||||||
</Sequence>
|
</Sequence>
|
||||||
</AbsoluteFill>
|
</AbsoluteFill>
|
||||||
);
|
);
|
||||||
|
|||||||
233
videos/src/components/PortalAudioManager.test.tsx
Normal file
233
videos/src/components/PortalAudioManager.test.tsx
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, cleanup } from "@testing-library/react";
|
||||||
|
import { PortalAudioManager } from "./PortalAudioManager";
|
||||||
|
|
||||||
|
/* eslint-disable @remotion/warn-native-media-tag */
|
||||||
|
// Mock Remotion hooks with dynamic frame value
|
||||||
|
let mockCurrentFrame = 45;
|
||||||
|
|
||||||
|
vi.mock("remotion", () => ({
|
||||||
|
useCurrentFrame: vi.fn(() => mockCurrentFrame),
|
||||||
|
useVideoConfig: vi.fn(() => ({
|
||||||
|
fps: 30,
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
durationInFrames: 2700, // 90 seconds at 30fps
|
||||||
|
})),
|
||||||
|
interpolate: vi.fn((value, inputRange, outputRange, options) => {
|
||||||
|
const [inMin, inMax] = inputRange;
|
||||||
|
const [outMin, outMax] = outputRange;
|
||||||
|
let progress = (value - inMin) / (inMax - inMin);
|
||||||
|
if (options?.extrapolateLeft === "clamp") {
|
||||||
|
progress = Math.max(0, progress);
|
||||||
|
}
|
||||||
|
if (options?.extrapolateRight === "clamp") {
|
||||||
|
progress = Math.min(1, progress);
|
||||||
|
}
|
||||||
|
return outMin + progress * (outMax - outMin);
|
||||||
|
}),
|
||||||
|
staticFile: vi.fn((path: string) => `/static/${path}`),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock @remotion/media
|
||||||
|
vi.mock("@remotion/media", () => ({
|
||||||
|
Audio: vi.fn(({ src, volume, loop }) => (
|
||||||
|
<audio
|
||||||
|
data-testid="audio"
|
||||||
|
src={src}
|
||||||
|
data-volume={volume}
|
||||||
|
data-loop={loop ? "true" : "false"}
|
||||||
|
/>
|
||||||
|
)),
|
||||||
|
}));
|
||||||
|
/* eslint-enable @remotion/warn-native-media-tag */
|
||||||
|
|
||||||
|
describe("PortalAudioManager", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockCurrentFrame = 45; // Reset to mid-video frame
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders without errors", () => {
|
||||||
|
const { container } = render(<PortalAudioManager />);
|
||||||
|
expect(container).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the background music audio element", () => {
|
||||||
|
const { container } = render(<PortalAudioManager />);
|
||||||
|
const audioElements = container.querySelectorAll('[data-testid="audio"]');
|
||||||
|
expect(audioElements.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the correct background music file", () => {
|
||||||
|
const { container } = render(<PortalAudioManager />);
|
||||||
|
const audioElement = container.querySelector('[data-testid="audio"]');
|
||||||
|
expect(audioElement).toBeInTheDocument();
|
||||||
|
expect(audioElement?.getAttribute("src")).toBe(
|
||||||
|
"/static/music/background-music.mp3"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets the background music to loop", () => {
|
||||||
|
const { container } = render(<PortalAudioManager />);
|
||||||
|
const audioElement = container.querySelector('[data-testid="audio"]');
|
||||||
|
expect(audioElement).toBeInTheDocument();
|
||||||
|
expect(audioElement?.getAttribute("data-loop")).toBe("true");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PortalAudioManager volume behavior", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockCurrentFrame = 45;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders audio with volume attribute", () => {
|
||||||
|
const { container } = render(<PortalAudioManager />);
|
||||||
|
const audioElement = container.querySelector('[data-testid="audio"]');
|
||||||
|
expect(audioElement).toBeInTheDocument();
|
||||||
|
const volume = audioElement?.getAttribute("data-volume");
|
||||||
|
expect(volume).toBeDefined();
|
||||||
|
expect(parseFloat(volume as string)).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(parseFloat(volume as string)).toBeLessThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PortalAudioManager fade-in behavior", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts with zero volume at frame 0", () => {
|
||||||
|
mockCurrentFrame = 0;
|
||||||
|
|
||||||
|
const { container } = render(<PortalAudioManager />);
|
||||||
|
const audioElement = container.querySelector('[data-testid="audio"]');
|
||||||
|
expect(audioElement).toBeInTheDocument();
|
||||||
|
const volume = parseFloat(
|
||||||
|
audioElement?.getAttribute("data-volume") as string
|
||||||
|
);
|
||||||
|
// At frame 0, volume should be 0 (start of fade-in)
|
||||||
|
expect(volume).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has base volume after fade-in completes", () => {
|
||||||
|
// Frame 45 is after fade-in (1 second = 30 frames)
|
||||||
|
mockCurrentFrame = 45;
|
||||||
|
|
||||||
|
const { container } = render(<PortalAudioManager />);
|
||||||
|
const audioElement = container.querySelector('[data-testid="audio"]');
|
||||||
|
expect(audioElement).toBeInTheDocument();
|
||||||
|
const volume = parseFloat(
|
||||||
|
audioElement?.getAttribute("data-volume") as string
|
||||||
|
);
|
||||||
|
// After fade-in, volume should be at base level (0.25)
|
||||||
|
expect(volume).toBeCloseTo(0.25, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has partial volume during fade-in", () => {
|
||||||
|
// Frame 15 is midway through fade-in (0.5 seconds into 1 second fade)
|
||||||
|
mockCurrentFrame = 15;
|
||||||
|
|
||||||
|
const { container } = render(<PortalAudioManager />);
|
||||||
|
const audioElement = container.querySelector('[data-testid="audio"]');
|
||||||
|
expect(audioElement).toBeInTheDocument();
|
||||||
|
const volume = parseFloat(
|
||||||
|
audioElement?.getAttribute("data-volume") as string
|
||||||
|
);
|
||||||
|
// Midway through fade-in, volume should be around 0.125 (half of 0.25)
|
||||||
|
expect(volume).toBeCloseTo(0.125, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PortalAudioManager fade-out behavior", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maintains base volume before fade-out starts", () => {
|
||||||
|
// Frame 2600 is before fade-out (starts at 2700 - 90 = 2610)
|
||||||
|
mockCurrentFrame = 2600;
|
||||||
|
|
||||||
|
const { container } = render(<PortalAudioManager />);
|
||||||
|
const audioElement = container.querySelector('[data-testid="audio"]');
|
||||||
|
expect(audioElement).toBeInTheDocument();
|
||||||
|
const volume = parseFloat(
|
||||||
|
audioElement?.getAttribute("data-volume") as string
|
||||||
|
);
|
||||||
|
// Before fade-out, volume should be at base level (0.25)
|
||||||
|
expect(volume).toBeCloseTo(0.25, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has reduced volume during fade-out", () => {
|
||||||
|
// Frame 2655 is midway through fade-out (2610 + 45)
|
||||||
|
mockCurrentFrame = 2655;
|
||||||
|
|
||||||
|
const { container } = render(<PortalAudioManager />);
|
||||||
|
const audioElement = container.querySelector('[data-testid="audio"]');
|
||||||
|
expect(audioElement).toBeInTheDocument();
|
||||||
|
const volume = parseFloat(
|
||||||
|
audioElement?.getAttribute("data-volume") as string
|
||||||
|
);
|
||||||
|
// During fade-out, volume should be less than base
|
||||||
|
expect(volume).toBeLessThan(0.25);
|
||||||
|
expect(volume).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reaches zero volume at the final frame", () => {
|
||||||
|
mockCurrentFrame = 2700;
|
||||||
|
|
||||||
|
const { container } = render(<PortalAudioManager />);
|
||||||
|
const audioElement = container.querySelector('[data-testid="audio"]');
|
||||||
|
expect(audioElement).toBeInTheDocument();
|
||||||
|
const volume = parseFloat(
|
||||||
|
audioElement?.getAttribute("data-volume") as string
|
||||||
|
);
|
||||||
|
// At the last frame, volume should be 0
|
||||||
|
expect(volume).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PortalAudioManager integration", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockCurrentFrame = 45;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only renders one audio element for background music", () => {
|
||||||
|
const { container } = render(<PortalAudioManager />);
|
||||||
|
const audioElements = container.querySelectorAll('[data-testid="audio"]');
|
||||||
|
expect(audioElements.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render any sequence elements (SFX handled by individual scenes)", () => {
|
||||||
|
const { container } = render(<PortalAudioManager />);
|
||||||
|
const sequences = container.querySelectorAll('[data-testid="sequence"]');
|
||||||
|
expect(sequences.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
89
videos/src/components/PortalAudioManager.tsx
Normal file
89
videos/src/components/PortalAudioManager.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { Audio } from "@remotion/media";
|
||||||
|
import {
|
||||||
|
staticFile,
|
||||||
|
useVideoConfig,
|
||||||
|
useCurrentFrame,
|
||||||
|
interpolate,
|
||||||
|
} from "remotion";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PortalAudioManager Component
|
||||||
|
*
|
||||||
|
* Manages background music for the Portal Presentation video with:
|
||||||
|
* - 1 second fade-in at the beginning
|
||||||
|
* - 3 second fade-out at the end
|
||||||
|
*
|
||||||
|
* Scene-specific sound effects are handled within each scene component
|
||||||
|
* for better maintainability and timing accuracy.
|
||||||
|
*
|
||||||
|
* Scene Structure (90 seconds total @ 30fps = 2700 frames):
|
||||||
|
* 1. Logo Reveal (6s) - Frames 0-180
|
||||||
|
* 2. Portal Title (4s) - Frames 180-300
|
||||||
|
* 3. Dashboard Overview (12s) - Frames 300-660
|
||||||
|
* 4. Meine Meetups (12s) - Frames 660-1020
|
||||||
|
* 5. Top Länder (12s) - Frames 1020-1380
|
||||||
|
* 6. Top Meetups (10s) - Frames 1380-1680
|
||||||
|
* 7. Activity Feed (10s) - Frames 1680-1980
|
||||||
|
* 8. Call to Action (12s) - Frames 1980-2340
|
||||||
|
* 9. Outro (12s) - Frames 2340-2700
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BackgroundMusic - Handles the background music track with fade in/out
|
||||||
|
*/
|
||||||
|
const BackgroundMusic: React.FC = () => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps, durationInFrames } = useVideoConfig();
|
||||||
|
|
||||||
|
// Fade-in for 1 second
|
||||||
|
const fadeInDuration = 1 * fps;
|
||||||
|
// Fade-out for 3 seconds
|
||||||
|
const fadeOutDuration = 3 * fps;
|
||||||
|
const fadeOutStart = durationInFrames - fadeOutDuration;
|
||||||
|
|
||||||
|
// Base volume for background music
|
||||||
|
const baseVolume = 0.25;
|
||||||
|
|
||||||
|
// Calculate volume with fade-in and fade-out
|
||||||
|
let volume = baseVolume;
|
||||||
|
|
||||||
|
// Fade-in at the beginning
|
||||||
|
if (frame < fadeInDuration) {
|
||||||
|
volume = interpolate(frame, [0, fadeInDuration], [0, baseVolume], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Fade-out at the end
|
||||||
|
else if (frame >= fadeOutStart) {
|
||||||
|
volume = interpolate(
|
||||||
|
frame,
|
||||||
|
[fadeOutStart, durationInFrames],
|
||||||
|
[baseVolume, 0],
|
||||||
|
{
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Audio src={staticFile("music/background-music.mp3")} volume={volume} loop />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PortalAudioManager - Main audio manager component for the Portal Presentation
|
||||||
|
*
|
||||||
|
* This component handles the background music with proper fade in/out.
|
||||||
|
* Scene-specific sound effects are embedded within each scene component
|
||||||
|
* for better timing accuracy and maintainability.
|
||||||
|
*/
|
||||||
|
export const PortalAudioManager: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Background Music - plays throughout the entire video */}
|
||||||
|
<BackgroundMusic />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user