🎨 Enhance Outro Scene with cinematic improvements and extend duration

- 🕰️ Increase PortalOutroScene duration to 30 seconds for cinematic effect
- ✏️ Add LogoMatrix3DMobile component for a dynamic 3D logo display
- 🔄 Adjust animations, glow, and opacity for smoother transitions
- 🎵 Replace outdated audio timings, add precise frame-based sync for "logo-whoosh" and "final-chime"
- 💡 Update text and subtitle styles: larger fonts and adjusted colors for better readability
- 🔧 Update tests and configs for new scene duration and animations
This commit is contained in:
HolgerHatGarKeineNode
2026-01-24 21:08:33 +01:00
parent 0bf80d3989
commit 070cfb0cb2
38 changed files with 6235 additions and 1401 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -10,7 +10,7 @@ vi.mock("remotion", () => ({
fps: 30, fps: 30,
width: 1920, width: 1920,
height: 1080, height: 1080,
durationInFrames: 2700, durationInFrames: 3240,
})), })),
interpolate: vi.fn((value, inputRange, outputRange, options) => { interpolate: vi.fn((value, inputRange, outputRange, options) => {
const [inMin, inMax] = inputRange; const [inMin, inMax] = inputRange;
@@ -246,16 +246,16 @@ describe("PortalPresentation", () => {
expect(scene).toBeInTheDocument(); expect(scene).toBeInTheDocument();
}); });
it("renders sequences with correct durations totaling 90 seconds", () => { it("renders sequences with correct durations totaling 108 seconds", () => {
const { container } = render(<PortalPresentation />); const { container } = render(<PortalPresentation />);
const sequences = container.querySelectorAll('[data-testid="sequence"]'); const sequences = container.querySelectorAll('[data-testid="sequence"]');
const durations = Array.from(sequences).map((seq) => const durations = Array.from(sequences).map((seq) =>
parseInt(seq.getAttribute("data-duration") || "0", 10) parseInt(seq.getAttribute("data-duration") || "0", 10)
); );
// 90 seconds * 30fps = 2700 frames total // 108 seconds * 30fps = 3240 frames total
const totalDuration = durations.reduce((sum, d) => sum + d, 0); const totalDuration = durations.reduce((sum, d) => sum + d, 0);
expect(totalDuration).toBe(2700); expect(totalDuration).toBe(3240);
}); });
it("renders Scene 1 (Logo Reveal) with 6 second duration (180 frames)", () => { it("renders Scene 1 (Logo Reveal) with 6 second duration (180 frames)", () => {
@@ -266,11 +266,11 @@ describe("PortalPresentation", () => {
expect(firstSequence?.getAttribute("data-from")).toBe("0"); expect(firstSequence?.getAttribute("data-from")).toBe("0");
}); });
it("renders Scene 9 (Outro) with 12 second duration (360 frames)", () => { it("renders Scene 9 (Outro) with 30 second duration (900 frames)", () => {
const { container } = render(<PortalPresentation />); const { container } = render(<PortalPresentation />);
const sequences = container.querySelectorAll('[data-testid="sequence"]'); const sequences = container.querySelectorAll('[data-testid="sequence"]');
const lastSequence = sequences[sequences.length - 1]; const lastSequence = sequences[sequences.length - 1];
expect(lastSequence?.getAttribute("data-duration")).toBe("360"); expect(lastSequence?.getAttribute("data-duration")).toBe("900");
}); });
it("applies Inconsolata font family to the composition", () => { it("applies Inconsolata font family to the composition", () => {

View File

@@ -14,7 +14,7 @@ import { PortalAudioManager } from "./components/PortalAudioManager";
/** /**
* PortalPresentation - Main composition for the Einundzwanzig Portal presentation video * PortalPresentation - Main composition for the Einundzwanzig Portal presentation video
* *
* Scene Structure (90 seconds total @ 30fps = 2700 frames): * Scene Structure (108 seconds total @ 30fps = 3240 frames):
* 1. Logo Reveal (6s) - Frames 0-180 * 1. Logo Reveal (6s) - Frames 0-180
* 2. Portal Title (4s) - Frames 180-300 * 2. Portal Title (4s) - Frames 180-300
* 3. Dashboard Overview (12s) - Frames 300-660 * 3. Dashboard Overview (12s) - Frames 300-660
@@ -23,7 +23,7 @@ import { PortalAudioManager } from "./components/PortalAudioManager";
* 6. Top Meetups (10s) - Frames 1380-1680 * 6. Top Meetups (10s) - Frames 1380-1680
* 7. Activity Feed (10s) - Frames 1680-1980 * 7. Activity Feed (10s) - Frames 1680-1980
* 8. Call to Action (12s) - Frames 1980-2340 * 8. Call to Action (12s) - Frames 1980-2340
* 9. Outro (12s) - Frames 2340-2700 * 9. Outro - Cinematic Logo Matrix (30s) - Frames 2340-3240
*/ */
export const PortalPresentation: React.FC = () => { export const PortalPresentation: React.FC = () => {
const { fps } = useVideoConfig(); const { fps } = useVideoConfig();
@@ -38,7 +38,7 @@ export const PortalPresentation: React.FC = () => {
topMeetups: 10, topMeetups: 10,
activityFeed: 10, activityFeed: 10,
callToAction: 12, callToAction: 12,
outro: 12, outro: 30, // Extended for cinematic logo matrix animation
}; };
// Calculate frame positions for each scene // Calculate frame positions for each scene

View File

@@ -10,7 +10,7 @@ vi.mock("remotion", () => ({
fps: 30, fps: 30,
width: 1080, width: 1080,
height: 1920, height: 1920,
durationInFrames: 2700, durationInFrames: 3240,
})), })),
interpolate: vi.fn((value, inputRange, outputRange, options) => { interpolate: vi.fn((value, inputRange, outputRange, options) => {
const [inMin, inMax] = inputRange; const [inMin, inMax] = inputRange;
@@ -246,16 +246,16 @@ describe("PortalPresentationMobile", () => {
expect(scene).toBeInTheDocument(); expect(scene).toBeInTheDocument();
}); });
it("renders sequences with correct durations totaling 90 seconds", () => { it("renders sequences with correct durations totaling 108 seconds", () => {
const { container } = render(<PortalPresentationMobile />); const { container } = render(<PortalPresentationMobile />);
const sequences = container.querySelectorAll('[data-testid="sequence"]'); const sequences = container.querySelectorAll('[data-testid="sequence"]');
const durations = Array.from(sequences).map((seq) => const durations = Array.from(sequences).map((seq) =>
parseInt(seq.getAttribute("data-duration") || "0", 10) parseInt(seq.getAttribute("data-duration") || "0", 10)
); );
// 90 seconds * 30fps = 2700 frames total // 108 seconds * 30fps = 3240 frames total
const totalDuration = durations.reduce((sum, d) => sum + d, 0); const totalDuration = durations.reduce((sum, d) => sum + d, 0);
expect(totalDuration).toBe(2700); expect(totalDuration).toBe(3240);
}); });
it("renders Scene 1 (Logo Reveal) with 6 second duration (180 frames)", () => { it("renders Scene 1 (Logo Reveal) with 6 second duration (180 frames)", () => {
@@ -266,11 +266,11 @@ describe("PortalPresentationMobile", () => {
expect(firstSequence?.getAttribute("data-from")).toBe("0"); expect(firstSequence?.getAttribute("data-from")).toBe("0");
}); });
it("renders Scene 9 (Outro) with 12 second duration (360 frames)", () => { it("renders Scene 9 (Outro) with 30 second duration (900 frames)", () => {
const { container } = render(<PortalPresentationMobile />); const { container } = render(<PortalPresentationMobile />);
const sequences = container.querySelectorAll('[data-testid="sequence"]'); const sequences = container.querySelectorAll('[data-testid="sequence"]');
const lastSequence = sequences[sequences.length - 1]; const lastSequence = sequences[sequences.length - 1];
expect(lastSequence?.getAttribute("data-duration")).toBe("360"); expect(lastSequence?.getAttribute("data-duration")).toBe("900");
}); });
it("applies Inconsolata font family to the composition", () => { it("applies Inconsolata font family to the composition", () => {

View File

@@ -16,7 +16,7 @@ import { PortalAudioManager } from "./components/PortalAudioManager";
* *
* Resolution: 1080x1920 (9:16 portrait) * Resolution: 1080x1920 (9:16 portrait)
* *
* Scene Structure (90 seconds total @ 30fps = 2700 frames): * Scene Structure (108 seconds total @ 30fps = 3240 frames):
* 1. Logo Reveal (6s) - Frames 0-180 * 1. Logo Reveal (6s) - Frames 0-180
* 2. Portal Title (4s) - Frames 180-300 * 2. Portal Title (4s) - Frames 180-300
* 3. Dashboard Overview (12s) - Frames 300-660 * 3. Dashboard Overview (12s) - Frames 300-660
@@ -25,7 +25,7 @@ import { PortalAudioManager } from "./components/PortalAudioManager";
* 6. Top Meetups (10s) - Frames 1380-1680 * 6. Top Meetups (10s) - Frames 1380-1680
* 7. Activity Feed (10s) - Frames 1680-1980 * 7. Activity Feed (10s) - Frames 1680-1980
* 8. Call to Action (12s) - Frames 1980-2340 * 8. Call to Action (12s) - Frames 1980-2340
* 9. Outro (12s) - Frames 2340-2700 * 9. Outro - Cinematic Logo Matrix (30s) - Frames 2340-3240
*/ */
export const PortalPresentationMobile: React.FC = () => { export const PortalPresentationMobile: React.FC = () => {
const { fps } = useVideoConfig(); const { fps } = useVideoConfig();
@@ -40,7 +40,7 @@ export const PortalPresentationMobile: React.FC = () => {
topMeetups: 10, topMeetups: 10,
activityFeed: 10, activityFeed: 10,
callToAction: 12, callToAction: 12,
outro: 12, outro: 30, // Extended for cinematic logo matrix animation
}; };
// Calculate frame positions for each scene // Calculate frame positions for each scene

View File

@@ -63,7 +63,7 @@ describe("RemotionRoot", () => {
expect(composition?.getAttribute("data-width")).toBe("1920"); expect(composition?.getAttribute("data-width")).toBe("1920");
expect(composition?.getAttribute("data-height")).toBe("1080"); expect(composition?.getAttribute("data-height")).toBe("1080");
expect(composition?.getAttribute("data-fps")).toBe("30"); expect(composition?.getAttribute("data-fps")).toBe("30");
expect(composition?.getAttribute("data-duration")).toBe("2700"); // 90 * 30 expect(composition?.getAttribute("data-duration")).toBe("3240"); // 108 * 30
}); });
it("renders PortalPresentationMobile composition in Portal folder", () => { it("renders PortalPresentationMobile composition in Portal folder", () => {
@@ -76,7 +76,7 @@ describe("RemotionRoot", () => {
expect(composition?.getAttribute("data-width")).toBe("1080"); expect(composition?.getAttribute("data-width")).toBe("1080");
expect(composition?.getAttribute("data-height")).toBe("1920"); expect(composition?.getAttribute("data-height")).toBe("1920");
expect(composition?.getAttribute("data-fps")).toBe("30"); expect(composition?.getAttribute("data-fps")).toBe("30");
expect(composition?.getAttribute("data-duration")).toBe("2700"); // 90 * 30 expect(composition?.getAttribute("data-duration")).toBe("3240"); // 108 * 30
}); });
it("renders NIP-05-Tutorial folder", () => { it("renders NIP-05-Tutorial folder", () => {

View File

@@ -39,7 +39,7 @@ export const RemotionRoot: React.FC = () => {
<Composition <Composition
id="PortalPresentation" id="PortalPresentation"
component={PortalPresentation} component={PortalPresentation}
durationInFrames={90 * 30} durationInFrames={108 * 30}
fps={30} fps={30}
width={1920} width={1920}
height={1080} height={1080}
@@ -47,7 +47,7 @@ export const RemotionRoot: React.FC = () => {
<Composition <Composition
id="PortalPresentationMobile" id="PortalPresentationMobile"
component={PortalPresentationMobile} component={PortalPresentationMobile}
durationInFrames={90 * 30} durationInFrames={108 * 30}
fps={30} fps={30}
width={1080} width={1080}
height={1920} height={1920}

View File

@@ -133,7 +133,6 @@ describe("DashboardSidebar", () => {
const navItemsWithIndent: SidebarNavItem[] = [ const navItemsWithIndent: SidebarNavItem[] = [
{ label: "Settings", icon: "settings" }, { label: "Settings", icon: "settings" },
{ label: "Language", icon: "language", indentLevel: 1 }, { label: "Language", icon: "language", indentLevel: 1 },
{ label: "Interface", icon: "interface", indentLevel: 1 },
]; ];
const { container } = render( const { container } = render(
@@ -145,7 +144,6 @@ describe("DashboardSidebar", () => {
expect(container.textContent).toContain("Settings"); expect(container.textContent).toContain("Settings");
expect(container.textContent).toContain("Language"); expect(container.textContent).toContain("Language");
expect(container.textContent).toContain("Interface");
}); });
it("has flex column layout", () => { it("has flex column layout", () => {
@@ -271,8 +269,6 @@ describe("DashboardSidebar", () => {
{ label: "Events", icon: "events" }, { label: "Events", icon: "events" },
{ label: "Settings", icon: "settings" }, { label: "Settings", icon: "settings" },
{ label: "Language", icon: "language" }, { label: "Language", icon: "language" },
{ label: "Interface", icon: "interface" },
{ label: "Provider", icon: "provider" },
]; ];
const { container } = render( const { container } = render(

View File

@@ -0,0 +1,619 @@
import {
useCurrentFrame,
useVideoConfig,
interpolate,
spring,
Img,
staticFile,
Easing,
} from "remotion";
import { useMemo } from "react";
// Seeded random for consistent renders
function seededRandom(seed: number): () => number {
return () => {
seed = (seed * 9301 + 49297) % 233280;
return seed / 233280;
};
}
// All meetup logos from public/logos
const MEETUP_LOGOS = [
"21BanskaBystrica.svg",
"21BissendorfEst798421.jpg",
"21BitcoinMeetupZypern.jpg",
"21ElsenburgKaubAmRhein.png",
"21Giessen.jpg",
"21Levice.svg",
"21MeetupPaphosZypern.jpg",
"21Neumarkt.jpeg",
"21ZitadelleUckermark.jpg",
"32EinezwanzgSeeland.jpg",
"Aschaffenburg.png",
"AshevilleBitcoiners.jpg",
"BadenBitcoinClub.png",
"BGBTCMeetUp.png",
"BielefelderBitcoiner.jpg",
"Bitcoin21.jpeg",
"BitcoinAlps.jpg",
"BitcoinAustria.jpg",
"BitcoinBeachLubeckTravemunde.png",
"BitcoinDresden.jpeg",
"BitcoinersBulgaria.png",
"BitcoinMagdeburg.png",
"BitcoinMeetUpChiemseeChiemgau.jpg",
"BitcoinMeetupEinundzwanzigPotsdam.jpg",
"BitcoinMeetupHalleSaale.jpg",
"BitcoinMeetupHarz.jpg",
"BitcoinMeetupJever.png",
"BitcoinMeetupSchwerinEinundzwanzig.jpg",
"BitcoinMeetupZurich.jpg",
"BitcoinMunchen.jpg",
"BitcoinOnlyMeetupInBulgaria.jpg",
"BitcoinTalkNetwork.png",
"BitcoinTenerifePuertoDeLaCruz.png",
"BitcoinWalkHamburg.jpg",
"BitcoinWalkKoln.jpeg",
"BitcoinWalkWurzburg.jpg",
"BitcoinWallis.jpeg",
"BitcoinWesterwald.jpg",
"Bocholt21.jpeg",
"BTCSchwyz.png",
"BTCStammtischOberfranken.jpg",
"Bussen.png",
"CharlestonBitcoinMeetup.jpg",
"DwadziesciaJedenKrakow.png",
"DwadziesciaJedenPoznan.png",
"DwadziesciaJedenWarszawa.png",
"DwadziesciaJedenWroclaw.png",
"DwadzisciaJedynKatowice.png",
"EenanzwanzegLetzebuerg.png",
"EinundzwanigWandernChiemgauBerchtesgarden.jpg",
"Einundzwanzig3LanderEck.png",
"EinundzwanzigAachen.jpg",
"EinundzwanzigAlsfeld.jpg",
"EinundzwanzigAmbergSulzbach.png",
"EinundzwanzigAmstetten.png",
"EinundzwanzigAnsbach.png",
"EinundzwanzigAusserfern.jpg",
"EinundzwanzigAutobahnA9.png",
"EinundzwanzigBadIburg.jpg",
"EinundzwanzigBadKissingen.png",
"EinundzwanzigBasel.png",
"EinundzwanzigBeckum.png",
"EinundzwanzigBensheim.png",
"EinundzwanzigBerlin.jpg",
"EinundzwanzigBerlinNord.png",
"EinundzwanzigBielBienne.jpg",
"EinundzwanzigBitburg.jpg",
"EINUNDZWANZIGBochum.jpg",
"EinundzwanzigBonn.jpg",
"EinundzwanzigBraunau.jpeg",
"EinundzwanzigBraunschweig.jpg",
"EinundzwanzigBremen.jpg",
"EinundzwanzigBremerhaven.png",
"EinundzwanzigBruhl.png",
"EinundzwanzigCelleResidenzstadtMeetup.jpg",
"EinundzwanzigCham.png",
"EinundzwanzigDarmstadt.png",
"EinundzwanzigDetmoldLippe.png",
"EinundzwanzigDingolfingLandau.jpg",
"EinundzwanzigDortmund.jpg",
"EinundzwanzigElbeElster.PNG",
"EinundzwanzigEllwangen.jpg",
"EinundzwanzigErding.png",
"EinundzwanzigErfurt.png",
"EinundzwanzigEschenbach.jpg",
"EinundzwanzigEssen.jpg",
"EinundzwanzigFehmarn.jpg",
"EinundzwanzigFranken.jpg",
"EinundzwanzigFrankfurtAmMain.png",
"EinundzwanzigFreising.PNG",
"EinundzwanzigFriedrichshafen.png",
"EinundzwanzigFulda.png",
"EinundzwanzigGarmischPartenkirchen.png",
"EinundzwanzigGastein.png",
"EinundzwanzigGelnhausen.jpg",
"EINUNDZWANZIGGelsenkirchen.png",
"EinundzwanzigGiessen.jpg",
"EinundzwanzigGrenzland.jpg",
"EinundzwanzigGrunstadt.jpg",
"EinundzwanzigGummersbach.png",
"EinundzwanzigHamburg.jpg",
"EinundzwanzigHameln.png",
"EinundzwanzigHannover.png",
"EinundzwanzigHeilbronn.jpg",
"EinundzwanzigHennef.jpg",
"EinundzwanzigHildesheim.jpg",
"EinundzwanzigHochschwarzwald.png",
"EinundzwanzigHuckelhoven.jpg",
"EinundzwanzigIngolstadt.png",
"EinundzwanzigJena.png",
"EinundzwanzigKasselBitcoin.jpg",
"EinundzwanzigKempten.jpg",
"EinundzwanzigKiel.jpg",
"EinundzwanzigKirchdorfOO.jpg",
"EinundzwanzigKoblenz.jpg",
"EinundzwanzigKonstanz.jpg",
"EinundzwanzigLandau.jpg",
"EinundzwanzigLandshut.png",
"EinundzwanzigLangen.png",
"EinundzwanzigLechrain.png",
"EINUNDZWANZIGLEIPZIG.jpg",
"EinundzwanzigLimburg.jpg",
"EinundzwanzigLingen.jpg",
"EinundzwanzigLinz.jpg",
"EinundzwanzigLubeck.jpg",
"EinundzwanzigLudwigsburg.jpg",
"EinundzwanzigLuzern.jpg",
"EinundzwanzigMainz.jpg",
"EinundzwanzigMannheim.jpg",
"EinundzwanzigMarburg.jpg",
"EinundzwanzigMeetupAltotting.png",
"EinundzwanzigMeetupDusseldorf.jpg",
"EinundzwanzigMeetupMuhldorfAmInn.png",
"EinundzwanzigMeetupPfaffikonSZ.png",
"EINUNDZWANZIGMeetupWieselburg.jpg",
"EinundzwanzigMemmingen.jpg",
"EinundzwanzigMoers.jpg",
"EinundzwanzigMonchengladbach.jpg",
"EINUNDZWANZIGMunchen.jpg",
"EinundzwanzigMunster.png",
"EinundzwanzigNeubrandenburg.jpeg",
"EinundzwanzigNiederrhein.jpg",
"EinundzwanzigNordburgenland.jpg",
"EinundzwanzigNorderstedt.jpg",
"EinundzwanzigNordhausen.png",
"EinundzwanzigOberland.jpg",
"EinundzwanzigOdenwald.jpg",
"EinundzwanzigOldenburg.jpg",
"EinundzwanzigOrtenaukreisOffenburg.jpg",
"EinundzwanzigOstBrandenburg.png",
"EinundzwanzigOstBrandenburgAltlandsberg.jpg",
"EinundzwanzigOstschweiz.jpg",
"EinundzwanzigOsttirol.jpg",
"EinundzwanzigOWL.jpeg",
"EinundzwanzigPeine.png",
"EinundzwanzigPfalz.jpg",
"EinundzwanzigPfarrkirchenRottalInn.jpg",
"EinundzwanzigPforzheim.jpg",
"EinundzwanzigRegensburg.png",
"EinundzwanzigRemstal.jpg",
"EinundzwanzigReutlingen.png",
"EinundzwanzigRheinhessen.png",
"EinundzwanzigRheinischBergischerKreis.png",
"EinundzwanzigRinteln.png",
"EinundzwanzigRohrbach.png",
"EinundzwanzigRostock.jpg",
"EinundzwanzigRothenburgObDerTauber.jpg",
"EinundzwanzigRothSchwabachWeissenburg.jpeg",
"EinundzwanzigRottweil.jpg",
"EinundzwanzigRugen.png",
"EinundzwanzigSaarbrucken.png",
"EinundzwanzigSaarland.jpg",
"EinundzwanzigSaarlouis.jpg",
"EinundzwanzigSalzburg.jpg",
"EinundzwanzigSauerland.jpeg",
"EinundzwanzigSchafstall.jpg",
"EinundzwanzigScharding.jpg",
"EinundzwanzigSchwarzwaldBaar.jpg",
"EinundzwanzigSchweden.png",
"EinundzwanzigSchweinfurt.png",
"EinundzwanzigSeelze.png",
"EinundzwanzigSigmaringen.JPG",
"EinundzwanzigSolingen.jpg",
"EinundzwanzigSpeyer.png",
"EinundzwanzigSpreewald.jpg",
"EinundzwanzigStarnbergBitcoinMeetup.jpg",
"EinundzwanzigStormarn.png",
"EinundzwanzigStrohgau.png",
"EinundzwanzigStuttgart.jpg",
"EinundzwanzigStyria.jpg",
"EinundzwanzigSudniedersachsen.png",
"EinundzwanzigSudtirol.jpg",
"EinundzwanzigThurgau.jpg",
"EinundzwanzigTirol.png",
"EinundzwanzigTrier.jpg",
"EinundzwanzigUelzen.png",
"EinundzwanzigUlm.jpg",
"EinundzwanzigUndLibertarePassau.png",
"EinundzwanzigVillingenSchwenningen.png",
"EinundzwanzigVorarlberg.jpg",
"EinundzwanzigVulkaneifel.jpg",
"EinundzwanzigWaldenrath.jpg",
"EinundzwanzigWeiden.jpg",
"EinundzwanzigWestmunsterland.jpg",
"EinundzwanzigWetterau.png",
"EinundzwanzigWien.png",
"EinundzwanzigWiesbaden.png",
"EinundzwanzigWinterthurBITCOINWINTI.png",
"EinundzwanzigZollernAlbKreisBalingen.png",
"Flensburg.png",
"HerBitcoinLaPalma.jpg",
"KalmarBitcoinMeetUp.png",
"KirchheimTeck.jpg",
"MagicCityBitcoin.png",
"MicroMeetUpPragerPlatz.png",
"MittwochMountainMeetup.jpg",
"MKEinundzwanzig.jpg",
"Munchberg.jpg",
"NostrMeetup.jpg",
"RabbitBitcoinClubMagdeburg.png",
"ReichenbachAnDerFils.jpg",
"SatoshisCoffeeshop.jpg",
"Sylt.jpg",
"TjugoettStockholm.jpg",
"TWENTYONEUSA.png",
"VINTEEUMFunchal.jpg",
"Wurzburg.png",
"Zeitz.png",
"ZollernalbBalingen.png",
];
// Extract readable meetup name from filename
function extractMeetupName(filename: string): string {
const nameWithoutExt = filename.replace(/\.(svg|jpg|jpeg|png|PNG|JPG|jfif|JPG)$/i, "");
// Insert spaces before capital letters and clean up
return nameWithoutExt
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
.replace(/^(Einundzwanzig|EINUNDZWANZIG|21|Bitcoin)/i, "")
.replace(/Meetup/gi, "")
.replace(/^[\s-]+|[\s-]+$/g, "")
.trim() || nameWithoutExt;
}
interface LogoPosition {
x: number;
y: number;
z: number;
rotateX: number;
rotateY: number;
rotateZ: number;
scale: number;
logo: string;
name: string;
delay: number;
}
interface SpotlightMeetup {
logo: string;
name: string;
startFrame: number;
duration: number;
}
export const LogoMatrix3D: React.FC = () => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
// Animation phases (in seconds)
const PHASE = {
MATRIX_FORM: { start: 0, end: 4 },
CAMERA_FLIGHT: { start: 4, end: 16 },
SPOTLIGHTS: { start: 16, end: 26 },
CONVERGENCE: { start: 26, end: 30 },
};
// Generate deterministic logo positions
const logoPositions = useMemo<LogoPosition[]>(() => {
const random = seededRandom(21);
const positions: LogoPosition[] = [];
const gridSize = 15; // 15x15 grid = 225 logos max
const spacing = 200;
const halfGrid = (gridSize - 1) / 2;
MEETUP_LOGOS.forEach((logo, index) => {
const gridX = index % gridSize;
const gridY = Math.floor(index / gridSize);
positions.push({
x: (gridX - halfGrid) * spacing + (random() - 0.5) * 60,
y: (gridY - halfGrid) * spacing + (random() - 0.5) * 60,
z: (random() - 0.5) * 800 - 400,
rotateX: (random() - 0.5) * 30,
rotateY: (random() - 0.5) * 30,
rotateZ: (random() - 0.5) * 15,
scale: 0.6 + random() * 0.4,
logo,
name: extractMeetupName(logo),
delay: random() * 30,
});
});
return positions;
}, []);
// Select random meetups for spotlight reveals
const spotlightMeetups = useMemo<SpotlightMeetup[]>(() => {
const random = seededRandom(42);
const shuffled = [...logoPositions].sort(() => random() - 0.5);
const selected = shuffled.slice(0, 8);
const spotlightDuration = 2.5 * fps; // 2.5 seconds each
const startFrame = PHASE.SPOTLIGHTS.start * fps;
return selected.map((pos, index) => ({
logo: pos.logo,
name: pos.name,
startFrame: startFrame + index * Math.floor(spotlightDuration * 0.4),
duration: spotlightDuration,
}));
}, [fps, logoPositions]);
// Camera movement through the matrix
// Note: Keyframes must be strictly monotonically increasing
const cameraZ = interpolate(
frame,
[
0, // Start
PHASE.MATRIX_FORM.end * fps, // 120 - Matrix formed
PHASE.CAMERA_FLIGHT.end * fps, // 480 - Flight done
PHASE.CONVERGENCE.start * fps, // 780 - Start convergence
PHASE.CONVERGENCE.end * fps, // 900 - End
],
[2000, 1200, -600, -600, 800],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const cameraX = interpolate(
frame,
[
PHASE.CAMERA_FLIGHT.start * fps,
PHASE.CAMERA_FLIGHT.start * fps + 3 * fps,
PHASE.CAMERA_FLIGHT.start * fps + 6 * fps,
PHASE.CAMERA_FLIGHT.start * fps + 9 * fps,
PHASE.CAMERA_FLIGHT.end * fps,
],
[0, 300, -200, 150, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const cameraY = interpolate(
frame,
[
PHASE.CAMERA_FLIGHT.start * fps,
PHASE.CAMERA_FLIGHT.start * fps + 4 * fps,
PHASE.CAMERA_FLIGHT.start * fps + 8 * fps,
PHASE.CAMERA_FLIGHT.end * fps,
],
[0, -200, 250, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const cameraRotateX = interpolate(
frame,
[
0, // Start
PHASE.MATRIX_FORM.end * fps, // 120
PHASE.CAMERA_FLIGHT.end * fps, // 480
],
[15, 5, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// Matrix formation progress
const formationProgress = spring({
frame,
fps,
config: { damping: 80, stiffness: 30 },
});
// Convergence animation
const convergenceProgress = interpolate(
frame,
[PHASE.CONVERGENCE.start * fps, PHASE.CONVERGENCE.end * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.inOut(Easing.cubic) }
);
// Check if a spotlight is active
const getActiveSpotlight = () => {
return spotlightMeetups.find(
(s) => frame >= s.startFrame && frame < s.startFrame + s.duration
);
};
const activeSpotlight = getActiveSpotlight();
// Spotlight animation
const spotlightSpring = activeSpotlight
? spring({
frame: frame - activeSpotlight.startFrame,
fps,
config: { damping: 12, stiffness: 80 },
})
: 0;
const spotlightScale = interpolate(spotlightSpring, [0, 0.5, 1], [1, 2.5, 2.2]);
const spotlightOpacity = interpolate(
frame - (activeSpotlight?.startFrame || 0),
[0, 15, (activeSpotlight?.duration || 60) - 15, activeSpotlight?.duration || 60],
[0, 1, 1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// Global matrix glow intensity
const glowPulse = interpolate(
Math.sin(frame * 0.05),
[-1, 1],
[0.3, 0.7]
);
return (
<div
className="absolute inset-0 overflow-hidden"
style={{
perspective: 2000,
perspectiveOrigin: "50% 50%",
}}
>
{/* 3D Scene Container */}
<div
className="absolute"
style={{
width: "100%",
height: "100%",
transformStyle: "preserve-3d",
transform: `
translateZ(${cameraZ}px)
translateX(${cameraX}px)
translateY(${cameraY}px)
rotateX(${cameraRotateX}deg)
`,
}}
>
{/* Logo Grid */}
{logoPositions.map((pos, index) => {
const isSpotlit = activeSpotlight?.logo === pos.logo;
const entryDelay = pos.delay;
// Individual logo entrance
const logoEntrance = spring({
frame: frame - entryDelay,
fps,
config: { damping: 20, stiffness: 50 },
});
// Floating animation
const floatY = Math.sin((frame + index * 17) * 0.03) * 15;
const floatRotate = Math.sin((frame + index * 23) * 0.02) * 5;
// Calculate final position (convergence to center)
const finalX = interpolate(convergenceProgress, [0, 1], [pos.x, 0]);
const finalY = interpolate(convergenceProgress, [0, 1], [pos.y, 0]);
const finalZ = interpolate(convergenceProgress, [0, 1], [pos.z, 0]);
const finalScale = interpolate(
convergenceProgress,
[0, 1],
[pos.scale, 0.1]
);
const finalOpacity = interpolate(convergenceProgress, [0, 0.7, 1], [1, 1, 0]);
// Distance from camera for depth fog
const distance = Math.sqrt(finalX ** 2 + finalY ** 2 + (finalZ - cameraZ) ** 2);
const fogOpacity = interpolate(distance, [0, 1500, 3000], [1, 0.6, 0.2], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<div
key={pos.logo}
className="absolute"
style={{
left: width / 2,
top: height / 2,
transformStyle: "preserve-3d",
transform: `
translate3d(${finalX}px, ${finalY + floatY}px, ${finalZ}px)
rotateX(${pos.rotateX + floatRotate}deg)
rotateY(${pos.rotateY}deg)
rotateZ(${pos.rotateZ}deg)
scale(${finalScale * logoEntrance * formationProgress})
`,
opacity: fogOpacity * logoEntrance * finalOpacity,
zIndex: isSpotlit ? 1000 : Math.round(1000 - pos.z),
}}
>
{/* Logo Card */}
<div
className="relative"
style={{
width: 120,
height: 120,
marginLeft: -60,
marginTop: -60,
borderRadius: 12,
background: "rgba(24, 24, 27, 0.9)",
boxShadow: `
0 0 ${20 * glowPulse}px rgba(247, 147, 26, ${0.3 * glowPulse}),
0 4px 20px rgba(0, 0, 0, 0.5)
`,
border: "1px solid rgba(247, 147, 26, 0.3)",
overflow: "hidden",
transform: isSpotlit ? `scale(${spotlightScale})` : "scale(1)",
transition: "transform 0.1s",
}}
>
<Img
src={staticFile(`logos/${pos.logo}`)}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
{/* Glow overlay */}
<div
className="absolute inset-0"
style={{
background: `radial-gradient(circle, rgba(247, 147, 26, ${0.15 * glowPulse}) 0%, transparent 70%)`,
}}
/>
</div>
</div>
);
})}
</div>
{/* Spotlight Meetup Name Display */}
{activeSpotlight && (
<div
className="absolute inset-x-0 bottom-32 flex flex-col items-center justify-center"
style={{ opacity: spotlightOpacity }}
>
<div
className="px-8 py-4 rounded-2xl"
style={{
background: "rgba(24, 24, 27, 0.95)",
boxShadow: "0 0 60px rgba(247, 147, 26, 0.4), 0 10px 40px rgba(0, 0, 0, 0.5)",
border: "2px solid rgba(247, 147, 26, 0.6)",
transform: `scale(${spotlightSpring})`,
}}
>
<h2
className="text-4xl font-bold text-white text-center"
style={{
textShadow: "0 0 30px rgba(247, 147, 26, 0.8)",
}}
>
{activeSpotlight.name}
</h2>
</div>
</div>
)}
{/* Ambient particles */}
{Array.from({ length: 30 }).map((_, i) => {
const random = seededRandom(i + 100);
const particleX = random() * width;
const particleY = (random() * height + frame * (0.5 + random() * 1)) % (height + 100) - 50;
const particleSize = 2 + random() * 4;
const particleOpacity = 0.3 + random() * 0.4;
return (
<div
key={`particle-${i}`}
className="absolute rounded-full"
style={{
left: particleX,
top: particleY,
width: particleSize,
height: particleSize,
background: `rgba(247, 147, 26, ${particleOpacity})`,
boxShadow: `0 0 ${particleSize * 3}px rgba(247, 147, 26, ${particleOpacity * 0.5})`,
}}
/>
);
})}
{/* Vignette */}
<div
className="absolute inset-0 pointer-events-none"
style={{
boxShadow: "inset 0 0 200px 80px rgba(0, 0, 0, 0.8)",
}}
/>
</div>
);
};

View File

@@ -0,0 +1,623 @@
import {
useCurrentFrame,
useVideoConfig,
interpolate,
spring,
Img,
staticFile,
Easing,
} from "remotion";
import { useMemo } from "react";
// Seeded random for consistent renders
function seededRandom(seed: number): () => number {
return () => {
seed = (seed * 9301 + 49297) % 233280;
return seed / 233280;
};
}
// All meetup logos from public/logos (same as desktop)
const MEETUP_LOGOS = [
"21BanskaBystrica.svg",
"21BissendorfEst798421.jpg",
"21BitcoinMeetupZypern.jpg",
"21ElsenburgKaubAmRhein.png",
"21Giessen.jpg",
"21Levice.svg",
"21MeetupPaphosZypern.jpg",
"21Neumarkt.jpeg",
"21ZitadelleUckermark.jpg",
"32EinezwanzgSeeland.jpg",
"Aschaffenburg.png",
"AshevilleBitcoiners.jpg",
"BadenBitcoinClub.png",
"BGBTCMeetUp.png",
"BielefelderBitcoiner.jpg",
"Bitcoin21.jpeg",
"BitcoinAlps.jpg",
"BitcoinAustria.jpg",
"BitcoinBeachLubeckTravemunde.png",
"BitcoinDresden.jpeg",
"BitcoinersBulgaria.png",
"BitcoinMagdeburg.png",
"BitcoinMeetUpChiemseeChiemgau.jpg",
"BitcoinMeetupEinundzwanzigPotsdam.jpg",
"BitcoinMeetupHalleSaale.jpg",
"BitcoinMeetupHarz.jpg",
"BitcoinMeetupJever.png",
"BitcoinMeetupSchwerinEinundzwanzig.jpg",
"BitcoinMeetupZurich.jpg",
"BitcoinMunchen.jpg",
"BitcoinOnlyMeetupInBulgaria.jpg",
"BitcoinTalkNetwork.png",
"BitcoinTenerifePuertoDeLaCruz.png",
"BitcoinWalkHamburg.jpg",
"BitcoinWalkKoln.jpeg",
"BitcoinWalkWurzburg.jpg",
"BitcoinWallis.jpeg",
"BitcoinWesterwald.jpg",
"Bocholt21.jpeg",
"BTCSchwyz.png",
"BTCStammtischOberfranken.jpg",
"Bussen.png",
"CharlestonBitcoinMeetup.jpg",
"DwadziesciaJedenKrakow.png",
"DwadziesciaJedenPoznan.png",
"DwadziesciaJedenWarszawa.png",
"DwadziesciaJedenWroclaw.png",
"DwadzisciaJedynKatowice.png",
"EenanzwanzegLetzebuerg.png",
"EinundzwanigWandernChiemgauBerchtesgarden.jpg",
"Einundzwanzig3LanderEck.png",
"EinundzwanzigAachen.jpg",
"EinundzwanzigAlsfeld.jpg",
"EinundzwanzigAmbergSulzbach.png",
"EinundzwanzigAmstetten.png",
"EinundzwanzigAnsbach.png",
"EinundzwanzigAusserfern.jpg",
"EinundzwanzigAutobahnA9.png",
"EinundzwanzigBadIburg.jpg",
"EinundzwanzigBadKissingen.png",
"EinundzwanzigBasel.png",
"EinundzwanzigBeckum.png",
"EinundzwanzigBensheim.png",
"EinundzwanzigBerlin.jpg",
"EinundzwanzigBerlinNord.png",
"EinundzwanzigBielBienne.jpg",
"EinundzwanzigBitburg.jpg",
"EINUNDZWANZIGBochum.jpg",
"EinundzwanzigBonn.jpg",
"EinundzwanzigBraunau.jpeg",
"EinundzwanzigBraunschweig.jpg",
"EinundzwanzigBremen.jpg",
"EinundzwanzigBremerhaven.png",
"EinundzwanzigBruhl.png",
"EinundzwanzigCelleResidenzstadtMeetup.jpg",
"EinundzwanzigCham.png",
"EinundzwanzigDarmstadt.png",
"EinundzwanzigDetmoldLippe.png",
"EinundzwanzigDingolfingLandau.jpg",
"EinundzwanzigDortmund.jpg",
"EinundzwanzigElbeElster.PNG",
"EinundzwanzigEllwangen.jpg",
"EinundzwanzigErding.png",
"EinundzwanzigErfurt.png",
"EinundzwanzigEschenbach.jpg",
"EinundzwanzigEssen.jpg",
"EinundzwanzigFehmarn.jpg",
"EinundzwanzigFranken.jpg",
"EinundzwanzigFrankfurtAmMain.png",
"EinundzwanzigFreising.PNG",
"EinundzwanzigFriedrichshafen.png",
"EinundzwanzigFulda.png",
"EinundzwanzigGarmischPartenkirchen.png",
"EinundzwanzigGastein.png",
"EinundzwanzigGelnhausen.jpg",
"EINUNDZWANZIGGelsenkirchen.png",
"EinundzwanzigGiessen.jpg",
"EinundzwanzigGrenzland.jpg",
"EinundzwanzigGrunstadt.jpg",
"EinundzwanzigGummersbach.png",
"EinundzwanzigHamburg.jpg",
"EinundzwanzigHameln.png",
"EinundzwanzigHannover.png",
"EinundzwanzigHeilbronn.jpg",
"EinundzwanzigHennef.jpg",
"EinundzwanzigHildesheim.jpg",
"EinundzwanzigHochschwarzwald.png",
"EinundzwanzigHuckelhoven.jpg",
"EinundzwanzigIngolstadt.png",
"EinundzwanzigJena.png",
"EinundzwanzigKasselBitcoin.jpg",
"EinundzwanzigKempten.jpg",
"EinundzwanzigKiel.jpg",
"EinundzwanzigKirchdorfOO.jpg",
"EinundzwanzigKoblenz.jpg",
"EinundzwanzigKonstanz.jpg",
"EinundzwanzigLandau.jpg",
"EinundzwanzigLandshut.png",
"EinundzwanzigLangen.png",
"EinundzwanzigLechrain.png",
"EINUNDZWANZIGLEIPZIG.jpg",
"EinundzwanzigLimburg.jpg",
"EinundzwanzigLingen.jpg",
"EinundzwanzigLinz.jpg",
"EinundzwanzigLubeck.jpg",
"EinundzwanzigLudwigsburg.jpg",
"EinundzwanzigLuzern.jpg",
"EinundzwanzigMainz.jpg",
"EinundzwanzigMannheim.jpg",
"EinundzwanzigMarburg.jpg",
"EinundzwanzigMeetupAltotting.png",
"EinundzwanzigMeetupDusseldorf.jpg",
"EinundzwanzigMeetupMuhldorfAmInn.png",
"EinundzwanzigMeetupPfaffikonSZ.png",
"EINUNDZWANZIGMeetupWieselburg.jpg",
"EinundzwanzigMemmingen.jpg",
"EinundzwanzigMoers.jpg",
"EinundzwanzigMonchengladbach.jpg",
"EINUNDZWANZIGMunchen.jpg",
"EinundzwanzigMunster.png",
"EinundzwanzigNeubrandenburg.jpeg",
"EinundzwanzigNiederrhein.jpg",
"EinundzwanzigNordburgenland.jpg",
"EinundzwanzigNorderstedt.jpg",
"EinundzwanzigNordhausen.png",
"EinundzwanzigOberland.jpg",
"EinundzwanzigOdenwald.jpg",
"EinundzwanzigOldenburg.jpg",
"EinundzwanzigOrtenaukreisOffenburg.jpg",
"EinundzwanzigOstBrandenburg.png",
"EinundzwanzigOstBrandenburgAltlandsberg.jpg",
"EinundzwanzigOstschweiz.jpg",
"EinundzwanzigOsttirol.jpg",
"EinundzwanzigOWL.jpeg",
"EinundzwanzigPeine.png",
"EinundzwanzigPfalz.jpg",
"EinundzwanzigPfarrkirchenRottalInn.jpg",
"EinundzwanzigPforzheim.jpg",
"EinundzwanzigRegensburg.png",
"EinundzwanzigRemstal.jpg",
"EinundzwanzigReutlingen.png",
"EinundzwanzigRheinhessen.png",
"EinundzwanzigRheinischBergischerKreis.png",
"EinundzwanzigRinteln.png",
"EinundzwanzigRohrbach.png",
"EinundzwanzigRostock.jpg",
"EinundzwanzigRothenburgObDerTauber.jpg",
"EinundzwanzigRothSchwabachWeissenburg.jpeg",
"EinundzwanzigRottweil.jpg",
"EinundzwanzigRugen.png",
"EinundzwanzigSaarbrucken.png",
"EinundzwanzigSaarland.jpg",
"EinundzwanzigSaarlouis.jpg",
"EinundzwanzigSalzburg.jpg",
"EinundzwanzigSauerland.jpeg",
"EinundzwanzigSchafstall.jpg",
"EinundzwanzigScharding.jpg",
"EinundzwanzigSchwarzwaldBaar.jpg",
"EinundzwanzigSchweden.png",
"EinundzwanzigSchweinfurt.png",
"EinundzwanzigSeelze.png",
"EinundzwanzigSigmaringen.JPG",
"EinundzwanzigSolingen.jpg",
"EinundzwanzigSpeyer.png",
"EinundzwanzigSpreewald.jpg",
"EinundzwanzigStarnbergBitcoinMeetup.jpg",
"EinundzwanzigStormarn.png",
"EinundzwanzigStrohgau.png",
"EinundzwanzigStuttgart.jpg",
"EinundzwanzigStyria.jpg",
"EinundzwanzigSudniedersachsen.png",
"EinundzwanzigSudtirol.jpg",
"EinundzwanzigThurgau.jpg",
"EinundzwanzigTirol.png",
"EinundzwanzigTrier.jpg",
"EinundzwanzigUelzen.png",
"EinundzwanzigUlm.jpg",
"EinundzwanzigUndLibertarePassau.png",
"EinundzwanzigVillingenSchwenningen.png",
"EinundzwanzigVorarlberg.jpg",
"EinundzwanzigVulkaneifel.jpg",
"EinundzwanzigWaldenrath.jpg",
"EinundzwanzigWeiden.jpg",
"EinundzwanzigWestmunsterland.jpg",
"EinundzwanzigWetterau.png",
"EinundzwanzigWien.png",
"EinundzwanzigWiesbaden.png",
"EinundzwanzigWinterthurBITCOINWINTI.png",
"EinundzwanzigZollernAlbKreisBalingen.png",
"Flensburg.png",
"HerBitcoinLaPalma.jpg",
"KalmarBitcoinMeetUp.png",
"KirchheimTeck.jpg",
"MagicCityBitcoin.png",
"MicroMeetUpPragerPlatz.png",
"MittwochMountainMeetup.jpg",
"MKEinundzwanzig.jpg",
"Munchberg.jpg",
"NostrMeetup.jpg",
"RabbitBitcoinClubMagdeburg.png",
"ReichenbachAnDerFils.jpg",
"SatoshisCoffeeshop.jpg",
"Sylt.jpg",
"TjugoettStockholm.jpg",
"TWENTYONEUSA.png",
"VINTEEUMFunchal.jpg",
"Wurzburg.png",
"Zeitz.png",
"ZollernalbBalingen.png",
];
// Extract readable meetup name from filename
function extractMeetupName(filename: string): string {
const nameWithoutExt = filename.replace(/\.(svg|jpg|jpeg|png|PNG|JPG|jfif|JPG)$/i, "");
return nameWithoutExt
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
.replace(/^(Einundzwanzig|EINUNDZWANZIG|21|Bitcoin)/i, "")
.replace(/Meetup/gi, "")
.replace(/^[\s-]+|[\s-]+$/g, "")
.trim() || nameWithoutExt;
}
interface LogoPosition {
x: number;
y: number;
z: number;
rotateX: number;
rotateY: number;
rotateZ: number;
scale: number;
logo: string;
name: string;
delay: number;
}
interface SpotlightMeetup {
logo: string;
name: string;
startFrame: number;
duration: number;
}
/**
* Mobile-optimized 3D Logo Matrix
* - Portrait layout (1080x1920)
* - Smaller logo cards
* - Adjusted camera movements for vertical format
*/
export const LogoMatrix3DMobile: React.FC = () => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
// Animation phases (in seconds)
const PHASE = {
MATRIX_FORM: { start: 0, end: 4 },
CAMERA_FLIGHT: { start: 4, end: 16 },
SPOTLIGHTS: { start: 16, end: 26 },
CONVERGENCE: { start: 26, end: 30 },
};
// Generate deterministic logo positions - optimized for portrait
const logoPositions = useMemo<LogoPosition[]>(() => {
const random = seededRandom(21);
const positions: LogoPosition[] = [];
const gridCols = 8;
const spacingX = 140;
const spacingY = 180;
const halfCols = (gridCols - 1) / 2;
MEETUP_LOGOS.forEach((logo, index) => {
const gridX = index % gridCols;
const gridY = Math.floor(index / gridCols);
positions.push({
x: (gridX - halfCols) * spacingX + (random() - 0.5) * 40,
y: (gridY - 7) * spacingY + (random() - 0.5) * 50,
z: (random() - 0.5) * 600 - 300,
rotateX: (random() - 0.5) * 25,
rotateY: (random() - 0.5) * 25,
rotateZ: (random() - 0.5) * 12,
scale: 0.5 + random() * 0.35,
logo,
name: extractMeetupName(logo),
delay: random() * 25,
});
});
return positions;
}, []);
// Select random meetups for spotlight reveals
const spotlightMeetups = useMemo<SpotlightMeetup[]>(() => {
const random = seededRandom(42);
const shuffled = [...logoPositions].sort(() => random() - 0.5);
const selected = shuffled.slice(0, 6);
const spotlightDuration = 2.5 * fps;
const startFrame = PHASE.SPOTLIGHTS.start * fps;
return selected.map((pos, index) => ({
logo: pos.logo,
name: pos.name,
startFrame: startFrame + index * Math.floor(spotlightDuration * 0.4),
duration: spotlightDuration,
}));
}, [fps, logoPositions]);
// Camera movement - vertical sweep for portrait
// Note: Keyframes must be strictly monotonically increasing
const cameraZ = interpolate(
frame,
[
0, // Start
PHASE.MATRIX_FORM.end * fps, // 120 - Matrix formed
PHASE.CAMERA_FLIGHT.end * fps, // 480 - Flight done
PHASE.CONVERGENCE.start * fps, // 780 - Start convergence
PHASE.CONVERGENCE.end * fps, // 900 - End
],
[1800, 1000, -400, -400, 600],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const cameraY = interpolate(
frame,
[
PHASE.CAMERA_FLIGHT.start * fps,
PHASE.CAMERA_FLIGHT.start * fps + 4 * fps,
PHASE.CAMERA_FLIGHT.start * fps + 8 * fps,
PHASE.CAMERA_FLIGHT.end * fps,
],
[0, -400, 350, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const cameraX = interpolate(
frame,
[
PHASE.CAMERA_FLIGHT.start * fps,
PHASE.CAMERA_FLIGHT.start * fps + 6 * fps,
PHASE.CAMERA_FLIGHT.end * fps,
],
[0, 150, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const cameraRotateX = interpolate(
frame,
[
0, // Start
PHASE.MATRIX_FORM.end * fps, // 120
PHASE.CAMERA_FLIGHT.end * fps, // 480
],
[20, 8, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// Matrix formation progress
const formationProgress = spring({
frame,
fps,
config: { damping: 80, stiffness: 30 },
});
// Convergence animation
const convergenceProgress = interpolate(
frame,
[PHASE.CONVERGENCE.start * fps, PHASE.CONVERGENCE.end * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.inOut(Easing.cubic) }
);
// Check if a spotlight is active
const getActiveSpotlight = () => {
return spotlightMeetups.find(
(s) => frame >= s.startFrame && frame < s.startFrame + s.duration
);
};
const activeSpotlight = getActiveSpotlight();
// Spotlight animation
const spotlightSpring = activeSpotlight
? spring({
frame: frame - activeSpotlight.startFrame,
fps,
config: { damping: 12, stiffness: 80 },
})
: 0;
const spotlightScale = interpolate(spotlightSpring, [0, 0.5, 1], [1, 2.2, 2]);
const spotlightOpacity = interpolate(
frame - (activeSpotlight?.startFrame || 0),
[0, 15, (activeSpotlight?.duration || 60) - 15, activeSpotlight?.duration || 60],
[0, 1, 1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// Global matrix glow intensity
const glowPulse = interpolate(
Math.sin(frame * 0.05),
[-1, 1],
[0.3, 0.7]
);
return (
<div
className="absolute inset-0 overflow-hidden"
style={{
perspective: 1500,
perspectiveOrigin: "50% 50%",
}}
>
{/* 3D Scene Container */}
<div
className="absolute"
style={{
width: "100%",
height: "100%",
transformStyle: "preserve-3d",
transform: `
translateZ(${cameraZ}px)
translateX(${cameraX}px)
translateY(${cameraY}px)
rotateX(${cameraRotateX}deg)
`,
}}
>
{/* Logo Grid */}
{logoPositions.map((pos, index) => {
const isSpotlit = activeSpotlight?.logo === pos.logo;
const entryDelay = pos.delay;
// Individual logo entrance
const logoEntrance = spring({
frame: frame - entryDelay,
fps,
config: { damping: 20, stiffness: 50 },
});
// Floating animation
const floatY = Math.sin((frame + index * 17) * 0.03) * 12;
const floatRotate = Math.sin((frame + index * 23) * 0.02) * 4;
// Calculate final position (convergence to center)
const finalX = interpolate(convergenceProgress, [0, 1], [pos.x, 0]);
const finalY = interpolate(convergenceProgress, [0, 1], [pos.y, 0]);
const finalZ = interpolate(convergenceProgress, [0, 1], [pos.z, 0]);
const finalScale = interpolate(
convergenceProgress,
[0, 1],
[pos.scale, 0.08]
);
const finalOpacity = interpolate(convergenceProgress, [0, 0.7, 1], [1, 1, 0]);
// Distance from camera for depth fog
const distance = Math.sqrt(finalX ** 2 + finalY ** 2 + (finalZ - cameraZ) ** 2);
const fogOpacity = interpolate(distance, [0, 1200, 2500], [1, 0.6, 0.2], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<div
key={pos.logo}
className="absolute"
style={{
left: width / 2,
top: height / 2,
transformStyle: "preserve-3d",
transform: `
translate3d(${finalX}px, ${finalY + floatY}px, ${finalZ}px)
rotateX(${pos.rotateX + floatRotate}deg)
rotateY(${pos.rotateY}deg)
rotateZ(${pos.rotateZ}deg)
scale(${finalScale * logoEntrance * formationProgress})
`,
opacity: fogOpacity * logoEntrance * finalOpacity,
zIndex: isSpotlit ? 1000 : Math.round(1000 - pos.z),
}}
>
{/* Logo Card - smaller for mobile */}
<div
className="relative"
style={{
width: 90,
height: 90,
marginLeft: -45,
marginTop: -45,
borderRadius: 10,
background: "rgba(24, 24, 27, 0.9)",
boxShadow: `
0 0 ${16 * glowPulse}px rgba(247, 147, 26, ${0.3 * glowPulse}),
0 3px 15px rgba(0, 0, 0, 0.5)
`,
border: "1px solid rgba(247, 147, 26, 0.3)",
overflow: "hidden",
transform: isSpotlit ? `scale(${spotlightScale})` : "scale(1)",
transition: "transform 0.1s",
}}
>
<Img
src={staticFile(`logos/${pos.logo}`)}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
{/* Glow overlay */}
<div
className="absolute inset-0"
style={{
background: `radial-gradient(circle, rgba(247, 147, 26, ${0.15 * glowPulse}) 0%, transparent 70%)`,
}}
/>
</div>
</div>
);
})}
</div>
{/* Spotlight Meetup Name Display - positioned for mobile */}
{activeSpotlight && (
<div
className="absolute inset-x-0 bottom-48 flex flex-col items-center justify-center px-6"
style={{ opacity: spotlightOpacity }}
>
<div
className="px-6 py-3 rounded-xl"
style={{
background: "rgba(24, 24, 27, 0.95)",
boxShadow: "0 0 50px rgba(247, 147, 26, 0.4), 0 8px 30px rgba(0, 0, 0, 0.5)",
border: "2px solid rgba(247, 147, 26, 0.6)",
transform: `scale(${spotlightSpring})`,
}}
>
<h2
className="text-2xl font-bold text-white text-center"
style={{
textShadow: "0 0 25px rgba(247, 147, 26, 0.8)",
}}
>
{activeSpotlight.name}
</h2>
</div>
</div>
)}
{/* Ambient particles - fewer for mobile */}
{Array.from({ length: 20 }).map((_, i) => {
const random = seededRandom(i + 100);
const particleX = random() * width;
const particleY = (random() * height + frame * (0.5 + random() * 1)) % (height + 100) - 50;
const particleSize = 2 + random() * 3;
const particleOpacity = 0.3 + random() * 0.4;
return (
<div
key={`particle-${i}`}
className="absolute rounded-full"
style={{
left: particleX,
top: particleY,
width: particleSize,
height: particleSize,
background: `rgba(247, 147, 26, ${particleOpacity})`,
boxShadow: `0 0 ${particleSize * 3}px rgba(247, 147, 26, ${particleOpacity * 0.5})`,
}}
/>
);
})}
{/* Vignette */}
<div
className="absolute inset-0 pointer-events-none"
style={{
boxShadow: "inset 0 0 200px 80px rgba(0, 0, 0, 0.8)",
}}
/>
</div>
);
};

View File

@@ -12,7 +12,7 @@ vi.mock("remotion", () => ({
fps: 30, fps: 30,
width: 1920, width: 1920,
height: 1080, height: 1080,
durationInFrames: 2700, // 90 seconds at 30fps durationInFrames: 3240, // 108 seconds at 30fps
})), })),
interpolate: vi.fn((value, inputRange, outputRange, options) => { interpolate: vi.fn((value, inputRange, outputRange, options) => {
const [inMin, inMax] = inputRange; const [inMin, inMax] = inputRange;
@@ -166,7 +166,7 @@ describe("PortalAudioManager fade-out behavior", () => {
}); });
it("maintains base volume before fade-out starts", () => { it("maintains base volume before fade-out starts", () => {
// Frame 2600 is before fade-out (starts at 2700 - 90 = 2610) // Frame 2600 is before fade-out (starts at 3240 - 90 = 3150)
mockCurrentFrame = 2600; mockCurrentFrame = 2600;
const { container } = render(<PortalAudioManager />); const { container } = render(<PortalAudioManager />);
@@ -180,8 +180,8 @@ describe("PortalAudioManager fade-out behavior", () => {
}); });
it("has reduced volume during fade-out", () => { it("has reduced volume during fade-out", () => {
// Frame 2655 is midway through fade-out (2610 + 45) // Frame 3195 is midway through fade-out (3150 + 45)
mockCurrentFrame = 2655; mockCurrentFrame = 3195;
const { container } = render(<PortalAudioManager />); const { container } = render(<PortalAudioManager />);
const audioElement = container.querySelector('[data-testid="audio"]'); const audioElement = container.querySelector('[data-testid="audio"]');
@@ -195,7 +195,7 @@ describe("PortalAudioManager fade-out behavior", () => {
}); });
it("reaches zero volume at the final frame", () => { it("reaches zero volume at the final frame", () => {
mockCurrentFrame = 2700; mockCurrentFrame = 3240;
const { container } = render(<PortalAudioManager />); const { container } = render(<PortalAudioManager />);
const audioElement = container.querySelector('[data-testid="audio"]'); const audioElement = container.querySelector('[data-testid="audio"]');

View File

@@ -33,11 +33,11 @@ describe("Audio Sync Configuration Constants", () => {
}); });
it("defines correct total duration in frames", () => { it("defines correct total duration in frames", () => {
expect(TOTAL_DURATION_FRAMES).toBe(2700); expect(TOTAL_DURATION_FRAMES).toBe(3240);
}); });
it("defines correct total duration in seconds", () => { it("defines correct total duration in seconds", () => {
expect(TOTAL_DURATION_SECONDS).toBe(90); expect(TOTAL_DURATION_SECONDS).toBe(108);
}); });
it("duration frames equals seconds times FPS", () => { it("duration frames equals seconds times FPS", () => {
@@ -408,7 +408,7 @@ describe("calculateBackgroundMusicVolume", () => {
describe("fade-out phase", () => { describe("fade-out phase", () => {
it("starts fade-out at correct frame", () => { it("starts fade-out at correct frame", () => {
const fadeOutStart = TOTAL_DURATION_FRAMES - 90; // 2610 const fadeOutStart = TOTAL_DURATION_FRAMES - 90; // 3150
const volumeJustBefore = calculateBackgroundMusicVolume(fadeOutStart - 1); const volumeJustBefore = calculateBackgroundMusicVolume(fadeOutStart - 1);
const volumeAtStart = calculateBackgroundMusicVolume(fadeOutStart); const volumeAtStart = calculateBackgroundMusicVolume(fadeOutStart);
const volumeAfterStart = calculateBackgroundMusicVolume(fadeOutStart + 1); const volumeAfterStart = calculateBackgroundMusicVolume(fadeOutStart + 1);
@@ -419,14 +419,14 @@ describe("calculateBackgroundMusicVolume", () => {
}); });
it("returns half volume midway through fade-out", () => { it("returns half volume midway through fade-out", () => {
const fadeOutStart = 2610; const fadeOutStart = 3150; // 3240 - 90
const midPoint = fadeOutStart + 45; // 45 frames into 90-frame fade const midPoint = fadeOutStart + 45; // 45 frames into 90-frame fade
const volume = calculateBackgroundMusicVolume(midPoint); const volume = calculateBackgroundMusicVolume(midPoint);
expect(volume).toBeCloseTo(0.125, 4); expect(volume).toBeCloseTo(0.125, 4);
}); });
it("returns 0 at final frame", () => { it("returns 0 at final frame", () => {
const volume = calculateBackgroundMusicVolume(2700); const volume = calculateBackgroundMusicVolume(3240);
expect(volume).toBe(0); expect(volume).toBe(0);
}); });
}); });

View File

@@ -72,10 +72,10 @@ export interface SceneAudioConfig {
export const STANDARD_FPS = 30; export const STANDARD_FPS = 30;
/** Total composition duration in frames */ /** Total composition duration in frames */
export const TOTAL_DURATION_FRAMES = 2700; export const TOTAL_DURATION_FRAMES = 3240;
/** Total composition duration in seconds */ /** Total composition duration in seconds */
export const TOTAL_DURATION_SECONDS = 90; export const TOTAL_DURATION_SECONDS = 108;
// ============================================================================ // ============================================================================
// BACKGROUND MUSIC CONFIGURATION // BACKGROUND MUSIC CONFIGURATION

View File

@@ -178,10 +178,10 @@ describe("Timing Configuration", () => {
expect(SCENE_DURATIONS.TOP_MEETUPS).toBe(10); expect(SCENE_DURATIONS.TOP_MEETUPS).toBe(10);
expect(SCENE_DURATIONS.ACTIVITY_FEED).toBe(10); expect(SCENE_DURATIONS.ACTIVITY_FEED).toBe(10);
expect(SCENE_DURATIONS.CALL_TO_ACTION).toBe(12); expect(SCENE_DURATIONS.CALL_TO_ACTION).toBe(12);
expect(SCENE_DURATIONS.OUTRO).toBe(12); expect(SCENE_DURATIONS.OUTRO).toBe(30);
}); });
it("total duration equals 90 seconds", () => { it("total duration equals 108 seconds", () => {
const totalDuration = const totalDuration =
SCENE_DURATIONS.LOGO_REVEAL + SCENE_DURATIONS.LOGO_REVEAL +
SCENE_DURATIONS.PORTAL_TITLE + SCENE_DURATIONS.PORTAL_TITLE +
@@ -192,7 +192,7 @@ describe("Timing Configuration", () => {
SCENE_DURATIONS.ACTIVITY_FEED + SCENE_DURATIONS.ACTIVITY_FEED +
SCENE_DURATIONS.CALL_TO_ACTION + SCENE_DURATIONS.CALL_TO_ACTION +
SCENE_DURATIONS.OUTRO; SCENE_DURATIONS.OUTRO;
expect(totalDuration).toBe(90); expect(totalDuration).toBe(108);
}); });
}); });

View File

@@ -208,7 +208,7 @@ export const GLOW_CONFIG = {
/** /**
* Scene duration constants matching PortalPresentation.tsx. * Scene duration constants matching PortalPresentation.tsx.
* Total: 90 seconds = 2700 frames @ 30fps * Total: 108 seconds = 3240 frames @ 30fps
*/ */
export const SCENE_DURATIONS = { export const SCENE_DURATIONS = {
LOGO_REVEAL: 6, LOGO_REVEAL: 6,
@@ -219,7 +219,7 @@ export const SCENE_DURATIONS = {
TOP_MEETUPS: 10, TOP_MEETUPS: 10,
ACTIVITY_FEED: 10, ACTIVITY_FEED: 10,
CALL_TO_ACTION: 12, CALL_TO_ACTION: 12,
OUTRO: 12, OUTRO: 30, // Extended for cinematic logo matrix animation
} as const; } as const;
// ============================================================================ // ============================================================================

View File

@@ -103,23 +103,7 @@ describe("CallToActionScene", () => {
it("renders the subtitle text", () => { it("renders the subtitle text", () => {
const { container } = render(<CallToActionScene />); const { container } = render(<CallToActionScene />);
expect(container.textContent).toContain("Die deutschsprachige Bitcoin-Community wartet auf dich"); expect(container.textContent).toContain("Die Bitcoin-Community wartet auf dich");
});
it("renders audio sequences for sound effects", () => {
const { container } = render(<CallToActionScene />);
const sequences = container.querySelectorAll('[data-testid="sequence"]');
// success-fanfare, typing, url-emphasis, logo-reveal = 4 sequences
expect(sequences.length).toBe(4);
});
it("includes success-fanfare audio", () => {
const { container } = render(<CallToActionScene />);
const audioElements = container.querySelectorAll('[data-testid="audio"]');
const fanfareAudio = Array.from(audioElements).find((audio) =>
audio.getAttribute("src")?.includes("success-fanfare.mp3")
);
expect(fanfareAudio).toBeInTheDocument();
}); });
it("includes typing audio for URL animation", () => { it("includes typing audio for URL animation", () => {

View File

@@ -29,7 +29,6 @@ const PORTAL_URL = "portal.einundzwanzig.space";
* 4. URL types out: `portal.einundzwanzig.space` * 4. URL types out: `portal.einundzwanzig.space`
* 5. URL pulses with orange glow * 5. URL pulses with orange glow
* 6. EINUNDZWANZIG Logo appears center with glow * 6. EINUNDZWANZIG Logo appears center with glow
* 7. Audio: success-fanfare
*/ */
export const CallToActionScene: React.FC = () => { export const CallToActionScene: React.FC = () => {
const frame = useCurrentFrame(); const frame = useCurrentFrame();
@@ -132,10 +131,6 @@ export const CallToActionScene: React.FC = () => {
return ( return (
<AbsoluteFill className="bg-zinc-900 overflow-hidden"> <AbsoluteFill className="bg-zinc-900 overflow-hidden">
{/* Audio: success-fanfare */}
<Sequence from={titleDelay} durationInFrames={Math.floor(4 * fps)}>
<Audio src={staticFile("sfx/success-fanfare.mp3")} volume={0.6} />
</Sequence>
{/* Audio: typing for URL */} {/* Audio: typing for URL */}
<Sequence from={urlDelay} durationInFrames={Math.floor(1.5 * fps)}> <Sequence from={urlDelay} durationInFrames={Math.floor(1.5 * fps)}>
@@ -297,7 +292,7 @@ export const CallToActionScene: React.FC = () => {
transform: `translateY(${subtitleY}px)`, transform: `translateY(${subtitleY}px)`,
}} }}
> >
Die deutschsprachige Bitcoin-Community wartet auf dich Die Bitcoin-Community wartet auf dich
</p> </p>
</div> </div>
</div> </div>

View File

@@ -129,7 +129,7 @@ describe("CountryStatsScene", () => {
it("renders the subtitle text", () => { it("renders the subtitle text", () => {
const { container } = render(<CountryStatsScene />); const { container } = render(<CountryStatsScene />);
expect(container.textContent).toContain("Die deutschsprachige Bitcoin-Community wächst überall"); expect(container.textContent).toContain("Die Bitcoin-Community wächst überall");
}); });
it("renders all six countries", () => { it("renders all six countries", () => {

View File

@@ -195,7 +195,7 @@ export const CountryStatsScene: React.FC = () => {
transform: `translateY(${subtitleY}px)`, transform: `translateY(${subtitleY}px)`,
}} }}
> >
Die deutschsprachige Bitcoin-Community wächst überall Die Bitcoin-Community wächst überall
</p> </p>
</div> </div>

View File

@@ -20,6 +20,7 @@ vi.mock("remotion", () => ({
return outMin + progress * (outMax - outMin); return outMin + progress * (outMax - outMin);
}), }),
spring: vi.fn(() => 1), spring: vi.fn(() => 1),
random: vi.fn((seed: string) => 0.5),
AbsoluteFill: vi.fn(({ children, className, style }) => ( AbsoluteFill: vi.fn(({ children, className, style }) => (
<div data-testid="absolute-fill" className={className} style={style}> <div data-testid="absolute-fill" className={className} style={style}>
{children} {children}
@@ -64,25 +65,9 @@ vi.mock("../../components/DashboardSidebar", () => ({
)), )),
})); }));
// Mock StatsCounter component
vi.mock("../../components/StatsCounter", () => ({
StatsCounter: vi.fn(({ targetNumber, delay, label, fontSize, color }) => (
<div
data-testid="stats-counter"
data-target={targetNumber}
data-delay={delay}
data-label={label}
data-font-size={fontSize}
data-color={color}
>
{targetNumber}
</div>
)),
}));
// Mock SparklineChart component // Mock SparklineChart component
vi.mock("../../components/SparklineChart", () => ({ vi.mock("../../components/SparklineChart", () => ({
SparklineChart: vi.fn(({ data, width, height, delay, showFill }) => ( SparklineChart: vi.fn(({ data, width, height, delay, showFill, strokeColor }) => (
<div <div
data-testid="sparkline-chart" data-testid="sparkline-chart"
data-points={data.length} data-points={data.length}
@@ -90,6 +75,7 @@ vi.mock("../../components/SparklineChart", () => ({
data-height={height} data-height={height}
data-delay={delay} data-delay={delay}
data-show-fill={showFill} data-show-fill={showFill}
data-stroke-color={strokeColor}
> >
SparklineChart SparklineChart
</div> </div>
@@ -131,7 +117,6 @@ describe("DashboardOverviewScene", () => {
const { container } = render(<DashboardOverviewScene />); const { container } = render(<DashboardOverviewScene />);
const absoluteFill = container.querySelector('[data-testid="absolute-fill"]'); const absoluteFill = container.querySelector('[data-testid="absolute-fill"]');
expect(absoluteFill).toBeInTheDocument(); expect(absoluteFill).toBeInTheDocument();
expect(absoluteFill).toHaveClass("bg-zinc-900");
expect(absoluteFill).toHaveClass("overflow-hidden"); expect(absoluteFill).toHaveClass("overflow-hidden");
}); });
@@ -148,95 +133,91 @@ describe("DashboardOverviewScene", () => {
const { container } = render(<DashboardOverviewScene />); const { container } = render(<DashboardOverviewScene />);
const sidebar = container.querySelector('[data-testid="dashboard-sidebar"]'); const sidebar = container.querySelector('[data-testid="dashboard-sidebar"]');
expect(sidebar).toBeInTheDocument(); expect(sidebar).toBeInTheDocument();
expect(sidebar).toHaveAttribute("data-width", "280"); expect(sidebar).toHaveAttribute("data-width", "220");
expect(sidebar).toHaveAttribute("data-height", "1080");
expect(sidebar).toHaveAttribute("data-delay", "0"); expect(sidebar).toHaveAttribute("data-delay", "0");
// Should have navigation items // Should have navigation items
expect(parseInt(sidebar?.getAttribute("data-nav-items") || "0")).toBeGreaterThan(0); expect(parseInt(sidebar?.getAttribute("data-nav-items") || "0")).toBeGreaterThan(0);
}); });
it("renders the Dashboard header", () => { it("renders the 'Meine nächsten Meetup Termine' section", () => {
const { container } = render(<DashboardOverviewScene />); const { container } = render(<DashboardOverviewScene />);
const header = container.querySelector("h1"); const sectionHeaders = container.querySelectorAll("h3");
expect(header).toBeInTheDocument(); const terminHeader = Array.from(sectionHeaders).find(
expect(header).toHaveTextContent("Dashboard"); (h3) => h3.textContent === "Meine nächsten Meetup Termine"
expect(header).toHaveClass("text-5xl");
expect(header).toHaveClass("font-bold");
expect(header).toHaveClass("text-white");
});
it("renders the welcome subtitle", () => {
const { container } = render(<DashboardOverviewScene />);
const subtitle = container.querySelector("p");
expect(subtitle).toBeInTheDocument();
expect(subtitle).toHaveTextContent("Willkommen im Einundzwanzig Portal");
expect(subtitle).toHaveClass("text-zinc-400");
});
it("renders three StatsCounter components", () => {
const { container } = render(<DashboardOverviewScene />);
const statsCounters = container.querySelectorAll('[data-testid="stats-counter"]');
expect(statsCounters.length).toBe(3);
});
it("renders StatsCounter for Meetups with target 204", () => {
const { container } = render(<DashboardOverviewScene />);
const statsCounters = container.querySelectorAll('[data-testid="stats-counter"]');
const meetupsCounter = Array.from(statsCounters).find(
(counter) => counter.getAttribute("data-target") === "204"
); );
expect(meetupsCounter).toBeInTheDocument(); expect(terminHeader).toBeInTheDocument();
expect(meetupsCounter).toHaveAttribute("data-label", "Aktive Gruppen");
}); });
it("renders StatsCounter for Users with target 1247", () => { it("renders the upcoming Kempten meetup", () => {
const { container } = render(<DashboardOverviewScene />); const { container } = render(<DashboardOverviewScene />);
const statsCounters = container.querySelectorAll('[data-testid="stats-counter"]'); expect(container.textContent).toContain("Einundzwanzig Kempten");
const usersCounter = Array.from(statsCounters).find( expect(container.textContent).toContain("06.02.2026 19:00 (CET)");
(counter) => counter.getAttribute("data-target") === "1247"
);
expect(usersCounter).toBeInTheDocument();
expect(usersCounter).toHaveAttribute("data-label", "Registrierte Nutzer");
}); });
it("renders StatsCounter for Events with target 89", () => { it("renders the 'Top Länder' section with countries", () => {
const { container } = render(<DashboardOverviewScene />); const { container } = render(<DashboardOverviewScene />);
const statsCounters = container.querySelectorAll('[data-testid="stats-counter"]'); const sectionHeaders = container.querySelectorAll("h3");
const eventsCounter = Array.from(statsCounters).find( const countryHeader = Array.from(sectionHeaders).find(
(counter) => counter.getAttribute("data-target") === "89" (h3) => h3.textContent === "Top Länder"
); );
expect(eventsCounter).toBeInTheDocument(); expect(countryHeader).toBeInTheDocument();
expect(eventsCounter).toHaveAttribute("data-label", "Diese Woche");
// Check for country names
expect(container.textContent).toContain("Germany");
expect(container.textContent).toContain("Austria");
expect(container.textContent).toContain("Switzerland");
}); });
it("renders three SparklineChart components for trends", () => { it("renders the 'Top Meetups' section", () => {
const { container } = render(<DashboardOverviewScene />);
const sectionHeaders = container.querySelectorAll("h3");
const meetupHeader = Array.from(sectionHeaders).find(
(h3) => h3.textContent === "Top Meetups"
);
expect(meetupHeader).toBeInTheDocument();
// Check for meetup names
expect(container.textContent).toContain("Einundzwanzig Saarland");
expect(container.textContent).toContain("Einundzwanzig Frankfurt am Main");
});
it("renders the 'Meine Meetups' section", () => {
const { container } = render(<DashboardOverviewScene />);
const sectionHeaders = container.querySelectorAll("h3");
const myMeetupsHeader = Array.from(sectionHeaders).find(
(h3) => h3.textContent === "Meine Meetups"
);
expect(myMeetupsHeader).toBeInTheDocument();
});
it("renders SparklineChart components for country and meetup trends", () => {
const { container } = render(<DashboardOverviewScene />); const { container } = render(<DashboardOverviewScene />);
const sparklines = container.querySelectorAll('[data-testid="sparkline-chart"]'); const sparklines = container.querySelectorAll('[data-testid="sparkline-chart"]');
expect(sparklines.length).toBe(3); // 5 countries + 5 meetups = 10 sparklines
expect(sparklines.length).toBe(10);
}); });
it("renders SparklineCharts with fill enabled", () => { it("renders SparklineCharts with green stroke color", () => {
const { container } = render(<DashboardOverviewScene />); const { container } = render(<DashboardOverviewScene />);
const sparklines = container.querySelectorAll('[data-testid="sparkline-chart"]'); const sparklines = container.querySelectorAll('[data-testid="sparkline-chart"]');
sparklines.forEach((sparkline) => { sparklines.forEach((sparkline) => {
expect(sparkline).toHaveAttribute("data-show-fill", "true"); expect(sparkline).toHaveAttribute("data-stroke-color", "#22c55e");
}); });
}); });
it("renders ActivityItem components for recent activities", () => { it("renders ActivityItem components for activities", () => {
const { container } = render(<DashboardOverviewScene />); const { container } = render(<DashboardOverviewScene />);
const activityItems = container.querySelectorAll('[data-testid="activity-item"]'); const activityItems = container.querySelectorAll('[data-testid="activity-item"]');
expect(activityItems.length).toBe(3); expect(activityItems.length).toBe(7);
}); });
it("renders ActivityItem for EINUNDZWANZIG Kempten", () => { it("renders the 'Aktivitäten' section", () => {
const { container } = render(<DashboardOverviewScene />); const { container } = render(<DashboardOverviewScene />);
const activityItems = container.querySelectorAll('[data-testid="activity-item"]'); const sectionHeaders = container.querySelectorAll("h3");
const kemptenActivity = Array.from(activityItems).find( const activityHeader = Array.from(sectionHeaders).find(
(item) => item.getAttribute("data-event-name") === "EINUNDZWANZIG Kempten" (h3) => h3.textContent === "Aktivitäten"
); );
expect(kemptenActivity).toBeInTheDocument(); expect(activityHeader).toBeInTheDocument();
expect(kemptenActivity).toHaveAttribute("data-timestamp", "vor 13 Stunden");
}); });
it("renders audio sequences for sound effects", () => { it("renders audio sequences for sound effects", () => {
@@ -269,38 +250,17 @@ describe("DashboardOverviewScene", () => {
expect(vignettes.length).toBeGreaterThan(0); expect(vignettes.length).toBeGreaterThan(0);
}); });
it("renders the Letzte Aktivitäten section header", () => { it("renders country flags", () => {
const { container } = render(<DashboardOverviewScene />); const { container } = render(<DashboardOverviewScene />);
const sectionHeaders = container.querySelectorAll("h3"); expect(container.textContent).toContain("🇩🇪");
const activityHeader = Array.from(sectionHeaders).find( expect(container.textContent).toContain("🇦🇹");
(h3) => h3.textContent === "Letzte Aktivitäten" expect(container.textContent).toContain("🇨🇭");
);
expect(activityHeader).toBeInTheDocument();
}); });
it("renders the Schnellübersicht section header", () => { it("renders action buttons for user meetups", () => {
const { container } = render(<DashboardOverviewScene />); const { container } = render(<DashboardOverviewScene />);
const sectionHeaders = container.querySelectorAll("h3"); expect(container.textContent).toContain("Neues Event erstellen");
const quickStatsHeader = Array.from(sectionHeaders).find( expect(container.textContent).toContain("Bearbeiten");
(h3) => h3.textContent === "Schnellübersicht"
);
expect(quickStatsHeader).toBeInTheDocument();
});
it("renders quick stats labels", () => {
const { container } = render(<DashboardOverviewScene />);
expect(container.textContent).toContain("Länder");
expect(container.textContent).toContain("Neue diese Woche");
expect(container.textContent).toContain("Aktive Nutzer");
});
it("renders card section headers for stats", () => {
const { container } = render(<DashboardOverviewScene />);
const sectionHeaders = container.querySelectorAll("h3");
const headerTexts = Array.from(sectionHeaders).map((h3) => h3.textContent);
expect(headerTexts).toContain("Meetups");
expect(headerTexts).toContain("Benutzer");
expect(headerTexts).toContain("Events");
}); });
it("applies 3D perspective transform styles", () => { it("applies 3D perspective transform styles", () => {
@@ -309,4 +269,11 @@ describe("DashboardOverviewScene", () => {
const elements = container.querySelectorAll('[style*="perspective"]'); const elements = container.querySelectorAll('[style*="perspective"]');
expect(elements.length).toBeGreaterThan(0); expect(elements.length).toBeGreaterThan(0);
}); });
it("renders film grain overlay", () => {
const { container } = render(<DashboardOverviewScene />);
// Film grain uses mixBlendMode: overlay
const grainElement = container.querySelector('[style*="overlay"]');
expect(grainElement).toBeInTheDocument();
});
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -135,15 +135,6 @@ describe("PortalIntroScene", () => {
expect(sequences.length).toBeGreaterThanOrEqual(2); expect(sequences.length).toBeGreaterThanOrEqual(2);
}); });
it("includes logo-whoosh audio", () => {
const { container } = render(<PortalIntroScene />);
const audioElements = container.querySelectorAll('[data-testid="audio"]');
const whooshAudio = Array.from(audioElements).find((audio) =>
audio.getAttribute("src")?.includes("logo-whoosh.mp3")
);
expect(whooshAudio).toBeInTheDocument();
});
it("includes logo-reveal audio", () => { it("includes logo-reveal audio", () => {
const { container } = render(<PortalIntroScene />); const { container } = render(<PortalIntroScene />);
const audioElements = container.querySelectorAll('[data-testid="audio"]'); const audioElements = container.querySelectorAll('[data-testid="audio"]');

View File

@@ -26,7 +26,6 @@ import {
* 2. AnimatedLogo scales from 0 to 100% with spring animation * 2. AnimatedLogo scales from 0 to 100% with spring animation
* 3. Bitcoin particles fall in the background * 3. Bitcoin particles fall in the background
* 4. Glow effect pulses around the logo * 4. Glow effect pulses around the logo
* 5. Audio: logo-whoosh at start, logo-reveal when logo appears
*/ */
export const PortalIntroScene: React.FC = () => { export const PortalIntroScene: React.FC = () => {
const frame = useCurrentFrame(); const frame = useCurrentFrame();
@@ -99,10 +98,6 @@ export const PortalIntroScene: React.FC = () => {
return ( return (
<AbsoluteFill className="bg-zinc-900 overflow-hidden"> <AbsoluteFill className="bg-zinc-900 overflow-hidden">
{/* Audio: logo-whoosh at start */}
<Sequence durationInFrames={Math.floor(2 * fps)}>
<Audio src={staticFile("sfx/logo-whoosh.mp3")} volume={0.7} />
</Sequence>
{/* Audio: logo-reveal when logo appears */} {/* Audio: logo-reveal when logo appears */}
<Sequence from={logoEntranceDelay} durationInFrames={Math.floor(2 * fps)}> <Sequence from={logoEntranceDelay} durationInFrames={Math.floor(2 * fps)}>

View File

@@ -5,7 +5,7 @@ import { PortalOutroScene } from "./PortalOutroScene";
/* eslint-disable @remotion/warn-native-media-tag */ /* eslint-disable @remotion/warn-native-media-tag */
// Mock Remotion hooks // Mock Remotion hooks
vi.mock("remotion", () => ({ vi.mock("remotion", () => ({
useCurrentFrame: vi.fn(() => 90), useCurrentFrame: vi.fn(() => 750), // Frame 25 seconds in (after logo appears at 24s)
useVideoConfig: vi.fn(() => ({ fps: 30, width: 1920, height: 1080 })), useVideoConfig: vi.fn(() => ({ fps: 30, width: 1920, height: 1080 })),
interpolate: vi.fn((value, inputRange, outputRange, options) => { interpolate: vi.fn((value, inputRange, outputRange, options) => {
const [inMin, inMax] = inputRange; const [inMin, inMax] = inputRange;
@@ -37,6 +37,7 @@ vi.mock("remotion", () => ({
Easing: { Easing: {
out: vi.fn((fn) => fn), out: vi.fn((fn) => fn),
cubic: vi.fn((t: number) => t), cubic: vi.fn((t: number) => t),
inOut: vi.fn((fn) => fn),
}, },
})); }));
@@ -55,6 +56,13 @@ vi.mock("../../components/BitcoinEffect", () => ({
)), )),
})); }));
// Mock LogoMatrix3D component
vi.mock("../../components/LogoMatrix3D", () => ({
LogoMatrix3D: vi.fn(() => (
<div data-testid="logo-matrix-3d">LogoMatrix3D</div>
)),
}));
describe("PortalOutroScene", () => { describe("PortalOutroScene", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -102,30 +110,41 @@ describe("PortalOutroScene", () => {
expect(bitcoinEffect).toBeInTheDocument(); expect(bitcoinEffect).toBeInTheDocument();
}); });
it("renders the LogoMatrix3D component", () => {
const { container } = render(<PortalOutroScene />);
const logoMatrix = container.querySelector('[data-testid="logo-matrix-3d"]');
expect(logoMatrix).toBeInTheDocument();
});
it("renders the EINUNDZWANZIG title text", () => { it("renders the EINUNDZWANZIG title text", () => {
const { container } = render(<PortalOutroScene />); const { container } = render(<PortalOutroScene />);
const title = container.querySelector("h1"); const title = container.querySelector("h1");
expect(title).toBeInTheDocument(); expect(title).toBeInTheDocument();
expect(title).toHaveTextContent("EINUNDZWANZIG"); expect(title).toHaveTextContent("EINUNDZWANZIG");
expect(title).toHaveClass("text-5xl"); expect(title).toHaveClass("text-6xl");
expect(title).toHaveClass("font-bold"); expect(title).toHaveClass("font-bold");
expect(title).toHaveClass("text-white"); expect(title).toHaveClass("text-white");
expect(title).toHaveClass("tracking-widest");
}); });
it("renders the subtitle with orange color", () => { it("renders the subtitle with orange color", () => {
const { container } = render(<PortalOutroScene />); const { container } = render(<PortalOutroScene />);
const subtitle = container.querySelector("p"); const subtitle = container.querySelector("p.text-orange-400");
expect(subtitle).toBeInTheDocument(); expect(subtitle).toBeInTheDocument();
expect(subtitle).toHaveTextContent("Die deutschsprachige Bitcoin-Community"); expect(subtitle).toHaveTextContent("Die Bitcoin-Community");
expect(subtitle).toHaveClass("text-orange-500");
}); });
it("renders audio sequence for final-chime sound effect", () => { it("renders community count badge", () => {
const { container } = render(<PortalOutroScene />);
const badge = container.querySelector("span.text-orange-300");
expect(badge).toBeInTheDocument();
expect(badge).toHaveTextContent("230+ Meetups weltweit");
});
it("renders audio sequences for whoosh and final-chime sound effects", () => {
const { container } = render(<PortalOutroScene />); const { container } = render(<PortalOutroScene />);
const sequences = container.querySelectorAll('[data-testid="sequence"]'); const sequences = container.querySelectorAll('[data-testid="sequence"]');
// final-chime = 1 sequence // logo-whoosh + final-chime = 2 sequences
expect(sequences.length).toBe(1); expect(sequences.length).toBe(2);
}); });
it("includes final-chime audio", () => { it("includes final-chime audio", () => {
@@ -137,15 +156,24 @@ describe("PortalOutroScene", () => {
expect(chimeAudio).toBeInTheDocument(); expect(chimeAudio).toBeInTheDocument();
}); });
it("includes logo-whoosh audio", () => {
const { container } = render(<PortalOutroScene />);
const audioElements = container.querySelectorAll('[data-testid="audio"]');
const whooshAudio = Array.from(audioElements).find((audio) =>
audio.getAttribute("src")?.includes("logo-whoosh.mp3")
);
expect(whooshAudio).toBeInTheDocument();
});
it("renders vignette overlay with pointer-events-none", () => { it("renders vignette overlay with pointer-events-none", () => {
const { container } = render(<PortalOutroScene />); const { container } = render(<PortalOutroScene />);
const vignettes = container.querySelectorAll(".pointer-events-none"); const vignettes = container.querySelectorAll(".pointer-events-none");
expect(vignettes.length).toBeGreaterThan(0); expect(vignettes.length).toBeGreaterThan(0);
}); });
it("renders glow effect element with blur filter", () => { it("renders glow effect elements with blur filter", () => {
const { container } = render(<PortalOutroScene />); const { container } = render(<PortalOutroScene />);
const elements = container.querySelectorAll('[style*="blur(60px)"]'); const elements = container.querySelectorAll('[style*="blur"]');
expect(elements.length).toBeGreaterThan(0); expect(elements.length).toBeGreaterThan(0);
}); });
@@ -163,7 +191,7 @@ describe("PortalOutroScene", () => {
it("renders ambient glow at bottom", () => { it("renders ambient glow at bottom", () => {
const { container } = render(<PortalOutroScene />); const { container } = render(<PortalOutroScene />);
const ambientGlow = container.querySelector(".h-64.pointer-events-none"); const ambientGlow = container.querySelector(".h-80.pointer-events-none");
expect(ambientGlow).toBeInTheDocument(); expect(ambientGlow).toBeInTheDocument();
}); });
}); });
@@ -185,7 +213,7 @@ describe("PortalOutroScene logo display", () => {
img.getAttribute("src")?.includes("einundzwanzig-horizontal-inverted.svg") img.getAttribute("src")?.includes("einundzwanzig-horizontal-inverted.svg")
); );
expect(logo).toBeInTheDocument(); expect(logo).toBeInTheDocument();
expect(logo).toHaveStyle({ width: "600px" }); expect(logo).toHaveStyle({ width: "700px" });
}); });
it("logo is centered in the container", () => { it("logo is centered in the container", () => {
@@ -213,16 +241,16 @@ describe("PortalOutroScene text styling", () => {
expect(style).toContain("text-shadow"); expect(style).toContain("text-shadow");
}); });
it("subtitle has tracking-wide class", () => { it("subtitle has tracking-widest class", () => {
const { container } = render(<PortalOutroScene />); const { container } = render(<PortalOutroScene />);
const subtitle = container.querySelector("p"); const subtitle = container.querySelector("p.text-orange-400");
expect(subtitle).toBeInTheDocument(); expect(subtitle).toBeInTheDocument();
expect(subtitle).toHaveClass("tracking-wide"); expect(subtitle).toHaveClass("tracking-widest");
}); });
it("subtitle has font-medium class", () => { it("subtitle has font-medium class", () => {
const { container } = render(<PortalOutroScene />); const { container } = render(<PortalOutroScene />);
const subtitle = container.querySelector("p"); const subtitle = container.querySelector("p.text-orange-400");
expect(subtitle).toBeInTheDocument(); expect(subtitle).toBeInTheDocument();
expect(subtitle).toHaveClass("font-medium"); expect(subtitle).toHaveClass("font-medium");
}); });
@@ -238,7 +266,18 @@ describe("PortalOutroScene audio configuration", () => {
vi.resetAllMocks(); vi.resetAllMocks();
}); });
it("final-chime sequence starts at logo delay (1 second / 30 frames)", () => { it("logo-whoosh sequence starts at 4 seconds (120 frames)", () => {
const { container } = render(<PortalOutroScene />);
const sequences = container.querySelectorAll('[data-testid="sequence"]');
const whooshSequence = Array.from(sequences).find((seq) => {
const audio = seq.querySelector('[data-testid="audio"]');
return audio?.getAttribute("src")?.includes("logo-whoosh.mp3");
});
expect(whooshSequence).toBeInTheDocument();
expect(whooshSequence).toHaveAttribute("data-from", "120");
});
it("final-chime sequence starts at logo entrance (24 seconds / 720 frames)", () => {
const { container } = render(<PortalOutroScene />); const { container } = render(<PortalOutroScene />);
const sequences = container.querySelectorAll('[data-testid="sequence"]'); const sequences = container.querySelectorAll('[data-testid="sequence"]');
const chimeSequence = Array.from(sequences).find((seq) => { const chimeSequence = Array.from(sequences).find((seq) => {
@@ -246,7 +285,7 @@ describe("PortalOutroScene audio configuration", () => {
return audio?.getAttribute("src")?.includes("final-chime.mp3"); return audio?.getAttribute("src")?.includes("final-chime.mp3");
}); });
expect(chimeSequence).toBeInTheDocument(); expect(chimeSequence).toBeInTheDocument();
expect(chimeSequence).toHaveAttribute("data-from", "30"); expect(chimeSequence).toHaveAttribute("data-from", "720");
}); });
it("final-chime has correct duration (3 seconds / 90 frames)", () => { it("final-chime has correct duration (3 seconds / 90 frames)", () => {

View File

@@ -7,80 +7,79 @@ import {
Img, Img,
staticFile, staticFile,
Sequence, Sequence,
Easing,
} from "remotion"; } from "remotion";
import { Audio } from "@remotion/media"; import { Audio } from "@remotion/media";
import { BitcoinEffect } from "../../components/BitcoinEffect"; import { BitcoinEffect } from "../../components/BitcoinEffect";
import { LogoMatrix3D } from "../../components/LogoMatrix3D";
import { import {
SPRING_CONFIGS, SPRING_CONFIGS,
TIMING,
GLOW_CONFIG, GLOW_CONFIG,
secondsToFrames,
} from "../../config/timing"; } from "../../config/timing";
/** /**
* PortalOutroScene - Scene 9: Outro (12 seconds / 360 frames @ 30fps) * PortalOutroScene - Scene 9: Cinematic Logo Matrix Outro (30 seconds / 900 frames @ 30fps)
* *
* Animation sequence: * Animation sequence:
* 1. Fade from previous scene to wallpaper background * 1. 3D Logo Matrix materializes with all 230+ meetup logos
* 2. BitcoinEffect particles throughout * 2. Camera flies through the matrix cinematically
* 3. Horizontal Logo fades in at center * 3. Random meetups spotlight with name reveals
* 4. "EINUNDZWANZIG" text appears below logo * 4. All logos converge to center
* 5. Glow effect pulses around the logo * 5. Einundzwanzig main logo emerges triumphantly
* 6. Background music fades out in last 3 seconds * 6. Final fade to black
* 7. Audio: final-chime at logo appearance
*/ */
export const PortalOutroScene: React.FC = () => { export const PortalOutroScene: React.FC = () => {
const frame = useCurrentFrame(); const frame = useCurrentFrame();
const { fps } = useVideoConfig(); const { fps } = useVideoConfig();
const durationInFrames = 12 * fps; // 360 frames const durationInFrames = 30 * fps; // 900 frames
// Background fade-in from black using centralized config // Phase timing (in seconds)
const backgroundSpring = spring({ const PHASE = {
frame, MATRIX: { start: 0, end: 26 },
fps, FINAL_LOGO: { start: 24, end: 30 },
config: SPRING_CONFIGS.SMOOTH, FADE_OUT: { start: 28, end: 30 },
}); };
// Fine-tuned: Slightly increased max opacity for better visibility
const backgroundOpacity = interpolate(backgroundSpring, [0, 1], [0, 0.35]);
// Logo entrance animation using centralized timing // Background ambient glow
const logoDelay = secondsToFrames(TIMING.OUTRO_LOGO_DELAY, fps); const backgroundGlow = interpolate(
Math.sin(frame * 0.04),
[-1, 1],
[0.1, 0.25]
);
// Final logo entrance (emerges from convergence)
const logoEntranceFrame = PHASE.FINAL_LOGO.start * fps;
const logoSpring = spring({ const logoSpring = spring({
frame: frame - logoDelay, frame: frame - logoEntranceFrame,
fps, fps,
config: SPRING_CONFIGS.SNAPPY, config: { damping: 12, stiffness: 60 },
}); });
const logoOpacity = interpolate(logoSpring, [0, 1], [0, 1]); const logoOpacity = interpolate(
// Fine-tuned: Increased initial scale for smoother entrance frame,
const logoScale = interpolate(logoSpring, [0, 1], [0.85, 1]); [logoEntranceFrame, logoEntranceFrame + fps],
// Fine-tuned: Reduced Y translation [0, 1],
const logoY = interpolate(logoSpring, [0, 1], [25, 0]); { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const logoScale = interpolate(logoSpring, [0, 1], [0.3, 1]);
// Logo glow pulse effect using centralized config // Logo glow pulse
const glowIntensity = interpolate( const glowIntensity = interpolate(
Math.sin((frame - logoDelay) * GLOW_CONFIG.FREQUENCY.NORMAL), Math.sin((frame - logoEntranceFrame) * GLOW_CONFIG.FREQUENCY.NORMAL),
[-1, 1], [-1, 1],
GLOW_CONFIG.INTENSITY.NORMAL GLOW_CONFIG.INTENSITY.STRONG
);
const glowScale = interpolate(
Math.sin((frame - logoDelay) * GLOW_CONFIG.FREQUENCY.SLOW),
[-1, 1],
GLOW_CONFIG.SCALE.STRONG
); );
// Text entrance using centralized timing // Text entrances
const textDelay = secondsToFrames(TIMING.OUTRO_TEXT_DELAY, fps); const textDelay = logoEntranceFrame + fps;
const textSpring = spring({ const textSpring = spring({
frame: frame - textDelay, frame: frame - textDelay,
fps, fps,
config: SPRING_CONFIGS.SMOOTH, config: SPRING_CONFIGS.SMOOTH,
}); });
const textOpacity = interpolate(textSpring, [0, 1], [0, 1]); const textOpacity = interpolate(textSpring, [0, 1], [0, 1]);
// Fine-tuned: Reduced Y translation const textY = interpolate(textSpring, [0, 1], [30, 0]);
const textY = interpolate(textSpring, [0, 1], [18, 0]);
// Subtitle entrance using centralized timing const subtitleDelay = textDelay + Math.floor(0.5 * fps);
const subtitleDelay = secondsToFrames(TIMING.OUTRO_SUBTITLE_DELAY, fps);
const subtitleSpring = spring({ const subtitleSpring = spring({
frame: frame - subtitleDelay, frame: frame - subtitleDelay,
fps, fps,
@@ -88,135 +87,189 @@ export const PortalOutroScene: React.FC = () => {
}); });
const subtitleOpacity = interpolate(subtitleSpring, [0, 1], [0, 1]); const subtitleOpacity = interpolate(subtitleSpring, [0, 1], [0, 1]);
// Final fade out using centralized timing // Final fade out
const fadeOutDuration = secondsToFrames(TIMING.OUTRO_FADE_DURATION, fps); const fadeOutStart = PHASE.FADE_OUT.start * fps;
const fadeOutStart = durationInFrames - fadeOutDuration;
const finalFadeOpacity = interpolate( const finalFadeOpacity = interpolate(
frame, frame,
[fadeOutStart, durationInFrames], [fadeOutStart, durationInFrames],
[1, 0], [1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.cubic) }
);
// Matrix visibility (fade out during final logo)
const matrixOpacity = interpolate(
frame,
[PHASE.MATRIX.end * fps - fps * 2, PHASE.MATRIX.end * fps],
[1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" } { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
); );
return ( return (
<AbsoluteFill className="bg-zinc-900 overflow-hidden"> <AbsoluteFill className="bg-zinc-900 overflow-hidden">
{/* Audio: final-chime when logo appears */} {/* Audio: epic-whoosh at matrix flight, final-chime at logo reveal */}
<Sequence from={logoDelay} durationInFrames={Math.floor(3 * fps)}> <Sequence from={Math.floor(4 * fps)} durationInFrames={Math.floor(2 * fps)}>
<Audio src={staticFile("sfx/final-chime.mp3")} volume={0.6} /> <Audio src={staticFile("sfx/logo-whoosh.mp3")} volume={0.3} />
</Sequence>
<Sequence from={logoEntranceFrame} durationInFrames={Math.floor(3 * fps)}>
<Audio src={staticFile("sfx/final-chime.mp3")} volume={0.7} />
</Sequence> </Sequence>
{/* Content wrapper with final fade */} {/* Content wrapper with final fade */}
<div style={{ opacity: finalFadeOpacity }}> <div style={{ opacity: finalFadeOpacity }}>
{/* Wallpaper Background */} {/* Deep space background */}
<div <div
className="absolute inset-0" className="absolute inset-0"
style={{ style={{
transform: "scale(1.05)", background: `
transformOrigin: "center center", radial-gradient(ellipse at 30% 20%, rgba(247, 147, 26, ${backgroundGlow * 0.5}) 0%, transparent 40%),
}} radial-gradient(ellipse at 70% 80%, rgba(247, 147, 26, ${backgroundGlow * 0.3}) 0%, transparent 35%),
> radial-gradient(ellipse at 50% 50%, rgba(24, 24, 27, 1) 0%, rgba(0, 0, 0, 1) 100%)
<Img `,
src={staticFile("einundzwanzig-wallpaper.png")}
className="absolute inset-0 w-full h-full object-cover"
style={{ opacity: backgroundOpacity }}
/>
</div>
{/* Dark gradient overlay */}
<div
className="absolute inset-0"
style={{
background:
"radial-gradient(circle at center, rgba(24, 24, 27, 0.6) 0%, rgba(24, 24, 27, 0.9) 70%, rgba(24, 24, 27, 0.98) 100%)",
}} }}
/> />
{/* Bitcoin particle effect */} {/* Wallpaper subtle background */}
<Img
src={staticFile("einundzwanzig-wallpaper.png")}
className="absolute inset-0 w-full h-full object-cover"
style={{ opacity: 0.08 }}
/>
{/* 3D Logo Matrix */}
<div style={{ opacity: matrixOpacity }}>
<LogoMatrix3D />
</div>
{/* Bitcoin particle effect (always visible) */}
<BitcoinEffect /> <BitcoinEffect />
{/* Content container */} {/* Final Logo Reveal */}
<div className="absolute inset-0 flex flex-col items-center justify-center"> {frame >= logoEntranceFrame && (
{/* Outer glow effect behind logo */}
<div <div
className="absolute" className="absolute inset-0 flex flex-col items-center justify-center"
style={{ style={{ opacity: logoOpacity }}
width: 800,
height: 400,
background:
"radial-gradient(ellipse, rgba(247, 147, 26, 0.35) 0%, transparent 60%)",
opacity: glowIntensity * logoSpring,
transform: `scale(${glowScale * logoScale})`,
filter: "blur(60px)",
}}
/>
{/* Horizontal Logo */}
<div
style={{
opacity: logoOpacity,
transform: `scale(${logoScale}) translateY(${logoY}px)`,
}}
> >
{/* Massive outer glow */}
<div
className="absolute"
style={{
width: 1000,
height: 500,
background: `radial-gradient(ellipse, rgba(247, 147, 26, ${0.4 * glowIntensity}) 0%, transparent 60%)`,
filter: "blur(80px)",
transform: `scale(${logoScale})`,
}}
/>
{/* Secondary glow ring */}
<div
className="absolute"
style={{
width: 700,
height: 350,
background: `radial-gradient(ellipse, rgba(247, 147, 26, ${0.6 * glowIntensity}) 0%, transparent 50%)`,
filter: "blur(40px)",
transform: `scale(${logoScale})`,
}}
/>
{/* Main Logo */}
<div <div
style={{ style={{
filter: `drop-shadow(0 0 ${40 * glowIntensity}px rgba(247, 147, 26, 0.5))`, transform: `scale(${logoScale})`,
}} }}
> >
<Img <div
src={staticFile("einundzwanzig-horizontal-inverted.svg")}
style={{ style={{
width: 600, filter: `
height: "auto", drop-shadow(0 0 ${60 * glowIntensity}px rgba(247, 147, 26, 0.6))
drop-shadow(0 0 ${120 * glowIntensity}px rgba(247, 147, 26, 0.3))
`,
}} }}
/> >
<Img
src={staticFile("einundzwanzig-horizontal-inverted.svg")}
style={{
width: 700,
height: "auto",
}}
/>
</div>
</div>
{/* EINUNDZWANZIG text */}
<div
className="mt-12 text-center"
style={{
opacity: textOpacity,
transform: `translateY(${textY}px)`,
}}
>
<h1
className="text-6xl font-bold text-white tracking-[0.3em]"
style={{
textShadow: `
0 0 ${40 * glowIntensity}px rgba(247, 147, 26, 0.5),
0 0 ${80 * glowIntensity}px rgba(247, 147, 26, 0.3),
0 4px 30px rgba(0, 0, 0, 0.7)
`,
}}
>
EINUNDZWANZIG
</h1>
</div>
{/* Subtitle */}
<div
className="mt-8 text-center"
style={{ opacity: subtitleOpacity }}
>
<p
className="text-3xl text-orange-400 font-medium tracking-widest"
style={{
textShadow: "0 0 20px rgba(247, 147, 26, 0.5)",
}}
>
Die Bitcoin-Community
</p>
</div>
{/* Community count badge */}
<div
className="mt-6"
style={{
opacity: subtitleOpacity,
transform: `scale(${subtitleSpring})`,
}}
>
<div
className="px-6 py-2 rounded-full"
style={{
background: "rgba(247, 147, 26, 0.15)",
border: "1px solid rgba(247, 147, 26, 0.4)",
}}
>
<span className="text-xl text-orange-300 font-medium">
230+ Meetups weltweit
</span>
</div>
</div> </div>
</div> </div>
)}
{/* EINUNDZWANZIG text */} {/* Ambient bottom glow */}
<div
className="mt-12 text-center"
style={{
opacity: textOpacity,
transform: `translateY(${textY}px)`,
}}
>
<h1
className="text-5xl font-bold text-white tracking-widest"
style={{
textShadow: `0 0 ${30 * glowIntensity}px rgba(247, 147, 26, 0.4), 0 2px 20px rgba(0, 0, 0, 0.5)`,
}}
>
EINUNDZWANZIG
</h1>
</div>
{/* Subtitle */}
<div
className="mt-6 text-center"
style={{
opacity: subtitleOpacity,
}}
>
<p className="text-2xl text-orange-500 font-medium tracking-wide">
Die deutschsprachige Bitcoin-Community
</p>
</div>
</div>
{/* Ambient glow at bottom */}
<div <div
className="absolute inset-x-0 bottom-0 h-64 pointer-events-none" className="absolute inset-x-0 bottom-0 h-80 pointer-events-none"
style={{ style={{
background: background: `linear-gradient(to top, rgba(247, 147, 26, ${0.1 * glowIntensity}) 0%, transparent 100%)`,
"linear-gradient(to top, rgba(247, 147, 26, 0.08) 0%, transparent 100%)",
}} }}
/> />
{/* Vignette overlay */} {/* Heavy vignette for cinematic feel */}
<div <div
className="absolute inset-0 pointer-events-none" className="absolute inset-0 pointer-events-none"
style={{ style={{
boxShadow: "inset 0 0 250px 100px rgba(0, 0, 0, 0.8)", boxShadow: "inset 0 0 300px 120px rgba(0, 0, 0, 0.85)",
}} }}
/> />
</div> </div>

View File

@@ -114,7 +114,7 @@ describe("PortalTitleScene", () => {
const subtitle = container.querySelector("p"); const subtitle = container.querySelector("p");
expect(subtitle).toBeInTheDocument(); expect(subtitle).toBeInTheDocument();
expect(subtitle).toHaveTextContent( expect(subtitle).toHaveTextContent(
"Das Herzstück der deutschsprachigen Bitcoin-Community" "Das Herzstück der Bitcoin-Community"
); );
expect(subtitle).toHaveClass("text-zinc-300"); expect(subtitle).toHaveClass("text-zinc-300");
}); });

View File

@@ -31,7 +31,7 @@ export const PortalTitleScene: React.FC = () => {
// Main title text // Main title text
const titleText = "EINUNDZWANZIG PORTAL"; const titleText = "EINUNDZWANZIG PORTAL";
const subtitleText = "Das Herzstück der deutschsprachigen Bitcoin-Community"; const subtitleText = "Das Herzstück der Bitcoin-Community";
// Calculate typed characters for title using centralized timing // Calculate typed characters for title using centralized timing
const typedTitleChars = Math.min( const typedTitleChars = Math.min(

View File

@@ -22,9 +22,9 @@ import {
const TOP_MEETUPS_DATA = [ const TOP_MEETUPS_DATA = [
{ {
name: "EINUNDZWANZIG Saarland", name: "EINUNDZWANZIG Saarland",
logoFile: "EinundzwanzigSaarbrucken.png", logoFile: "EinundzwanzigSaarland.jpg",
userCount: 26, userCount: 26,
location: "Saarbrücken", location: "Saarland",
sparklineData: [5, 8, 10, 12, 14, 16, 18, 20, 22, 24, 25, 26], sparklineData: [5, 8, 10, 12, 14, 16, 18, 20, 22, 24, 25, 26],
rank: 1, rank: 1,
}, },

View File

@@ -106,15 +106,6 @@ describe("CallToActionSceneMobile", () => {
expect(overlay).toBeInTheDocument(); expect(overlay).toBeInTheDocument();
}); });
it("renders success-fanfare audio", () => {
const { container } = render(<CallToActionSceneMobile />);
const audioElements = container.querySelectorAll('[data-testid="audio"]');
const fanfareAudio = Array.from(audioElements).find((audio) =>
audio.getAttribute("src")?.includes("success-fanfare.mp3")
);
expect(fanfareAudio).toBeInTheDocument();
});
it("renders typing audio for URL", () => { it("renders typing audio for URL", () => {
const { container } = render(<CallToActionSceneMobile />); const { container } = render(<CallToActionSceneMobile />);
const audioElements = container.querySelectorAll('[data-testid="audio"]'); const audioElements = container.querySelectorAll('[data-testid="audio"]');
@@ -128,7 +119,7 @@ describe("CallToActionSceneMobile", () => {
const { container } = render(<CallToActionSceneMobile />); const { container } = render(<CallToActionSceneMobile />);
const subtitle = container.querySelector("p"); const subtitle = container.querySelector("p");
expect(subtitle).toBeInTheDocument(); expect(subtitle).toBeInTheDocument();
expect(subtitle).toHaveTextContent("Die deutschsprachige Bitcoin-Community wartet auf dich"); expect(subtitle).toHaveTextContent("Die Bitcoin-Community wartet auf dich");
// Mobile uses text-base vs desktop text-xl // Mobile uses text-base vs desktop text-xl
expect(subtitle).toHaveClass("text-base"); expect(subtitle).toHaveClass("text-base");
}); });

View File

@@ -116,10 +116,6 @@ export const CallToActionSceneMobile: React.FC = () => {
return ( return (
<AbsoluteFill className="bg-zinc-900 overflow-hidden"> <AbsoluteFill className="bg-zinc-900 overflow-hidden">
{/* Audio: success-fanfare */}
<Sequence from={titleDelay} durationInFrames={Math.floor(4 * fps)}>
<Audio src={staticFile("sfx/success-fanfare.mp3")} volume={0.6} />
</Sequence>
{/* Audio: typing for URL */} {/* Audio: typing for URL */}
<Sequence from={urlDelay} durationInFrames={Math.floor(1.5 * fps)}> <Sequence from={urlDelay} durationInFrames={Math.floor(1.5 * fps)}>
@@ -281,7 +277,7 @@ export const CallToActionSceneMobile: React.FC = () => {
transform: `translateY(${subtitleY}px)`, transform: `translateY(${subtitleY}px)`,
}} }}
> >
Die deutschsprachige Bitcoin-Community wartet auf dich Die Bitcoin-Community wartet auf dich
</p> </p>
</div> </div>
</div> </div>

View File

@@ -192,7 +192,7 @@ export const CountryStatsSceneMobile: React.FC = () => {
transform: `translateY(${subtitleY}px)`, transform: `translateY(${subtitleY}px)`,
}} }}
> >
Die deutschsprachige Bitcoin-Community wächst Die Bitcoin-Community wächst
</p> </p>
</div> </div>

View File

@@ -137,15 +137,6 @@ describe("PortalIntroSceneMobile", () => {
expect(sequences.length).toBeGreaterThanOrEqual(2); expect(sequences.length).toBeGreaterThanOrEqual(2);
}); });
it("includes logo-whoosh audio", () => {
const { container } = render(<PortalIntroSceneMobile />);
const audioElements = container.querySelectorAll('[data-testid="audio"]');
const whooshAudio = Array.from(audioElements).find((audio) =>
audio.getAttribute("src")?.includes("logo-whoosh.mp3")
);
expect(whooshAudio).toBeInTheDocument();
});
it("includes logo-reveal audio", () => { it("includes logo-reveal audio", () => {
const { container } = render(<PortalIntroSceneMobile />); const { container } = render(<PortalIntroSceneMobile />);
const audioElements = container.querySelectorAll('[data-testid="audio"]'); const audioElements = container.querySelectorAll('[data-testid="audio"]');

View File

@@ -83,10 +83,6 @@ export const PortalIntroSceneMobile: React.FC = () => {
return ( return (
<AbsoluteFill className="bg-zinc-900 overflow-hidden"> <AbsoluteFill className="bg-zinc-900 overflow-hidden">
{/* Audio: logo-whoosh at start */}
<Sequence durationInFrames={Math.floor(2 * fps)}>
<Audio src={staticFile("sfx/logo-whoosh.mp3")} volume={0.7} />
</Sequence>
{/* Audio: logo-reveal when logo appears */} {/* Audio: logo-reveal when logo appears */}
<Sequence from={logoEntranceDelay} durationInFrames={Math.floor(2 * fps)}> <Sequence from={logoEntranceDelay} durationInFrames={Math.floor(2 * fps)}>

View File

@@ -5,7 +5,7 @@ import { PortalOutroSceneMobile } from "./PortalOutroSceneMobile";
/* eslint-disable @remotion/warn-native-media-tag */ /* eslint-disable @remotion/warn-native-media-tag */
// Mock Remotion hooks // Mock Remotion hooks
vi.mock("remotion", () => ({ vi.mock("remotion", () => ({
useCurrentFrame: vi.fn(() => 60), useCurrentFrame: vi.fn(() => 750), // Frame 25 seconds in (after logo appears at 24s)
useVideoConfig: vi.fn(() => ({ fps: 30, width: 1080, height: 1920 })), useVideoConfig: vi.fn(() => ({ fps: 30, width: 1080, height: 1920 })),
interpolate: vi.fn((value, inputRange, outputRange, options) => { interpolate: vi.fn((value, inputRange, outputRange, options) => {
const [inMin, inMax] = inputRange; const [inMin, inMax] = inputRange;
@@ -34,6 +34,11 @@ vi.mock("remotion", () => ({
{children} {children}
</div> </div>
)), )),
Easing: {
out: vi.fn((fn) => fn),
cubic: vi.fn((t: number) => t),
inOut: vi.fn((fn) => fn),
},
})); }));
// Mock @remotion/media // Mock @remotion/media
@@ -51,6 +56,13 @@ vi.mock("../../../components/BitcoinEffect", () => ({
)), )),
})); }));
// Mock LogoMatrix3DMobile component
vi.mock("../../../components/LogoMatrix3DMobile", () => ({
LogoMatrix3DMobile: vi.fn(() => (
<div data-testid="logo-matrix-3d-mobile">LogoMatrix3DMobile</div>
)),
}));
describe("PortalOutroSceneMobile", () => { describe("PortalOutroSceneMobile", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -80,8 +92,8 @@ describe("PortalOutroSceneMobile", () => {
img.getAttribute("src")?.includes("einundzwanzig-horizontal-inverted.svg") img.getAttribute("src")?.includes("einundzwanzig-horizontal-inverted.svg")
); );
expect(logo).toBeInTheDocument(); expect(logo).toBeInTheDocument();
// Mobile uses 450px width vs desktop 600px // Mobile uses 500px width vs desktop 700px
expect(logo).toHaveStyle({ width: "450px" }); expect(logo).toHaveStyle({ width: "500px" });
}); });
it("renders EINUNDZWANZIG text with mobile-optimized size", () => { it("renders EINUNDZWANZIG text with mobile-optimized size", () => {
@@ -89,17 +101,24 @@ describe("PortalOutroSceneMobile", () => {
const title = container.querySelector("h1"); const title = container.querySelector("h1");
expect(title).toBeInTheDocument(); expect(title).toBeInTheDocument();
expect(title).toHaveTextContent("EINUNDZWANZIG"); expect(title).toHaveTextContent("EINUNDZWANZIG");
// Mobile uses text-4xl vs desktop text-5xl // Mobile uses text-5xl vs desktop text-6xl
expect(title).toHaveClass("text-4xl"); expect(title).toHaveClass("text-5xl");
}); });
it("renders subtitle with mobile-optimized size", () => { it("renders subtitle with mobile-optimized size", () => {
const { container } = render(<PortalOutroSceneMobile />); const { container } = render(<PortalOutroSceneMobile />);
const subtitle = container.querySelector("p"); const subtitle = container.querySelector("p.text-orange-400");
expect(subtitle).toBeInTheDocument(); expect(subtitle).toBeInTheDocument();
expect(subtitle).toHaveTextContent("Die deutschsprachige Bitcoin-Community"); expect(subtitle).toHaveTextContent("Die Bitcoin-Community");
// Mobile uses text-xl vs desktop text-2xl // Mobile uses text-2xl vs desktop text-3xl
expect(subtitle).toHaveClass("text-xl"); expect(subtitle).toHaveClass("text-2xl");
});
it("renders community count badge", () => {
const { container } = render(<PortalOutroSceneMobile />);
const badge = container.querySelector("span.text-orange-300");
expect(badge).toBeInTheDocument();
expect(badge).toHaveTextContent("230+ Meetups weltweit");
}); });
it("renders BitcoinEffect component", () => { it("renders BitcoinEffect component", () => {
@@ -108,6 +127,12 @@ describe("PortalOutroSceneMobile", () => {
expect(bitcoinEffect).toBeInTheDocument(); expect(bitcoinEffect).toBeInTheDocument();
}); });
it("renders LogoMatrix3DMobile component", () => {
const { container } = render(<PortalOutroSceneMobile />);
const logoMatrix = container.querySelector('[data-testid="logo-matrix-3d-mobile"]');
expect(logoMatrix).toBeInTheDocument();
});
it("renders final-chime audio", () => { it("renders final-chime audio", () => {
const { container } = render(<PortalOutroSceneMobile />); const { container } = render(<PortalOutroSceneMobile />);
const audioElements = container.querySelectorAll('[data-testid="audio"]'); const audioElements = container.querySelectorAll('[data-testid="audio"]');
@@ -117,6 +142,15 @@ describe("PortalOutroSceneMobile", () => {
expect(finalChimeAudio).toBeInTheDocument(); expect(finalChimeAudio).toBeInTheDocument();
}); });
it("renders logo-whoosh audio", () => {
const { container } = render(<PortalOutroSceneMobile />);
const audioElements = container.querySelectorAll('[data-testid="audio"]');
const whooshAudio = Array.from(audioElements).find((audio) =>
audio.getAttribute("src")?.includes("logo-whoosh.mp3")
);
expect(whooshAudio).toBeInTheDocument();
});
it("renders wallpaper background", () => { it("renders wallpaper background", () => {
const { container } = render(<PortalOutroSceneMobile />); const { container } = render(<PortalOutroSceneMobile />);
const images = container.querySelectorAll('[data-testid="remotion-img"]'); const images = container.querySelectorAll('[data-testid="remotion-img"]');
@@ -132,10 +166,49 @@ describe("PortalOutroSceneMobile", () => {
expect(vignette).toBeInTheDocument(); expect(vignette).toBeInTheDocument();
}); });
it("renders glow effect element with mobile-optimized dimensions", () => { it("renders glow effect element with blur filter", () => {
const { container } = render(<PortalOutroSceneMobile />); const { container } = render(<PortalOutroSceneMobile />);
// Look for the glow element with blur filter // Look for the glow elements with blur filter
const elements = container.querySelectorAll('[style*="blur(50px)"]'); const elements = container.querySelectorAll('[style*="blur"]');
expect(elements.length).toBeGreaterThan(0); expect(elements.length).toBeGreaterThan(0);
}); });
}); });
describe("PortalOutroSceneMobile audio configuration", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
vi.resetAllMocks();
});
it("renders two audio sequences (whoosh + chime)", () => {
const { container } = render(<PortalOutroSceneMobile />);
const sequences = container.querySelectorAll('[data-testid="sequence"]');
expect(sequences.length).toBe(2);
});
it("logo-whoosh sequence starts at 4 seconds (120 frames)", () => {
const { container } = render(<PortalOutroSceneMobile />);
const sequences = container.querySelectorAll('[data-testid="sequence"]');
const whooshSequence = Array.from(sequences).find((seq) => {
const audio = seq.querySelector('[data-testid="audio"]');
return audio?.getAttribute("src")?.includes("logo-whoosh.mp3");
});
expect(whooshSequence).toBeInTheDocument();
expect(whooshSequence).toHaveAttribute("data-from", "120");
});
it("final-chime sequence starts at logo entrance (24 seconds / 720 frames)", () => {
const { container } = render(<PortalOutroSceneMobile />);
const sequences = container.querySelectorAll('[data-testid="sequence"]');
const chimeSequence = Array.from(sequences).find((seq) => {
const audio = seq.querySelector('[data-testid="audio"]');
return audio?.getAttribute("src")?.includes("final-chime.mp3");
});
expect(chimeSequence).toBeInTheDocument();
expect(chimeSequence).toHaveAttribute("data-from", "720");
});
});

View File

@@ -7,70 +7,76 @@ import {
Img, Img,
staticFile, staticFile,
Sequence, Sequence,
Easing,
} from "remotion"; } from "remotion";
import { Audio } from "@remotion/media"; import { Audio } from "@remotion/media";
import { BitcoinEffect } from "../../../components/BitcoinEffect"; import { BitcoinEffect } from "../../../components/BitcoinEffect";
import { LogoMatrix3DMobile } from "../../../components/LogoMatrix3DMobile";
// Spring configurations // Spring configurations
const SMOOTH = { damping: 200 }; const SMOOTH = { damping: 200 };
const SNAPPY = { damping: 15, stiffness: 80 };
/** /**
* PortalOutroSceneMobile - Scene 9: Outro for Mobile (12 seconds / 360 frames @ 30fps) * PortalOutroSceneMobile - Scene 9: Cinematic Logo Matrix Outro for Mobile
* (30 seconds / 900 frames @ 30fps)
* *
* Mobile layout adaptations: * Mobile layout adaptations:
* - Smaller horizontal logo width (450px vs 600px) * - Portrait-optimized 3D logo matrix
* - Adjusted text sizes for portrait orientation * - Smaller logo cards and text
* - Smaller glow effects * - Adjusted camera movements for vertical format
*/ */
export const PortalOutroSceneMobile: React.FC = () => { export const PortalOutroSceneMobile: React.FC = () => {
const frame = useCurrentFrame(); const frame = useCurrentFrame();
const { fps } = useVideoConfig(); const { fps } = useVideoConfig();
const durationInFrames = 12 * fps; // 360 frames const durationInFrames = 30 * fps; // 900 frames
// Background fade-in from black (0-30 frames) // Phase timing (in seconds)
const backgroundSpring = spring({ const PHASE = {
frame, MATRIX: { start: 0, end: 26 },
fps, FINAL_LOGO: { start: 24, end: 30 },
config: SMOOTH, FADE_OUT: { start: 28, end: 30 },
}); };
const backgroundOpacity = interpolate(backgroundSpring, [0, 1], [0, 0.3]);
// Logo entrance animation (delayed 1 second) // Background ambient glow
const logoDelay = Math.floor(1 * fps); const backgroundGlow = interpolate(
Math.sin(frame * 0.04),
[-1, 1],
[0.1, 0.25]
);
// Final logo entrance
const logoEntranceFrame = PHASE.FINAL_LOGO.start * fps;
const logoSpring = spring({ const logoSpring = spring({
frame: frame - logoDelay, frame: frame - logoEntranceFrame,
fps, fps,
config: SNAPPY, config: { damping: 12, stiffness: 60 },
}); });
const logoOpacity = interpolate(logoSpring, [0, 1], [0, 1]); const logoOpacity = interpolate(
const logoScale = interpolate(logoSpring, [0, 1], [0.8, 1]); frame,
const logoY = interpolate(logoSpring, [0, 1], [30, 0]); [logoEntranceFrame, logoEntranceFrame + fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const logoScale = interpolate(logoSpring, [0, 1], [0.3, 1]);
// Logo glow pulse effect // Logo glow pulse
const glowIntensity = interpolate( const glowIntensity = interpolate(
Math.sin((frame - logoDelay) * 0.06), Math.sin((frame - logoEntranceFrame) * 0.06),
[-1, 1], [-1, 1],
[0.4, 0.9] [0.5, 1.0]
);
const glowScale = interpolate(
Math.sin((frame - logoDelay) * 0.04),
[-1, 1],
[1.0, 1.2]
); );
// Text entrance (delayed 2 seconds) // Text entrances
const textDelay = Math.floor(2 * fps); const textDelay = logoEntranceFrame + fps;
const textSpring = spring({ const textSpring = spring({
frame: frame - textDelay, frame: frame - textDelay,
fps, fps,
config: SMOOTH, config: SMOOTH,
}); });
const textOpacity = interpolate(textSpring, [0, 1], [0, 1]); const textOpacity = interpolate(textSpring, [0, 1], [0, 1]);
const textY = interpolate(textSpring, [0, 1], [20, 0]); const textY = interpolate(textSpring, [0, 1], [25, 0]);
// Subtitle entrance (delayed 2.5 seconds) const subtitleDelay = textDelay + Math.floor(0.5 * fps);
const subtitleDelay = Math.floor(2.5 * fps);
const subtitleSpring = spring({ const subtitleSpring = spring({
frame: frame - subtitleDelay, frame: frame - subtitleDelay,
fps, fps,
@@ -78,134 +84,189 @@ export const PortalOutroSceneMobile: React.FC = () => {
}); });
const subtitleOpacity = interpolate(subtitleSpring, [0, 1], [0, 1]); const subtitleOpacity = interpolate(subtitleSpring, [0, 1], [0, 1]);
// Final fade out in last 2 seconds (frames 300-360) // Final fade out
const fadeOutStart = durationInFrames - 2 * fps; const fadeOutStart = PHASE.FADE_OUT.start * fps;
const finalFadeOpacity = interpolate( const finalFadeOpacity = interpolate(
frame, frame,
[fadeOutStart, durationInFrames], [fadeOutStart, durationInFrames],
[1, 0], [1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.cubic) }
);
// Matrix visibility
const matrixOpacity = interpolate(
frame,
[PHASE.MATRIX.end * fps - fps * 2, PHASE.MATRIX.end * fps],
[1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" } { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
); );
return ( return (
<AbsoluteFill className="bg-zinc-900 overflow-hidden"> <AbsoluteFill className="bg-zinc-900 overflow-hidden">
{/* Audio: final-chime when logo appears */} {/* Audio */}
<Sequence from={logoDelay} durationInFrames={Math.floor(3 * fps)}> <Sequence from={Math.floor(4 * fps)} durationInFrames={Math.floor(2 * fps)}>
<Audio src={staticFile("sfx/final-chime.mp3")} volume={0.6} /> <Audio src={staticFile("sfx/logo-whoosh.mp3")} volume={0.3} />
</Sequence>
<Sequence from={logoEntranceFrame} durationInFrames={Math.floor(3 * fps)}>
<Audio src={staticFile("sfx/final-chime.mp3")} volume={0.7} />
</Sequence> </Sequence>
{/* Content wrapper with final fade */} {/* Content wrapper with final fade */}
<div style={{ opacity: finalFadeOpacity }}> <div style={{ opacity: finalFadeOpacity }}>
{/* Wallpaper Background */} {/* Deep space background */}
<div <div
className="absolute inset-0" className="absolute inset-0"
style={{ style={{
transform: "scale(1.05)", background: `
transformOrigin: "center center", radial-gradient(ellipse at 50% 20%, rgba(247, 147, 26, ${backgroundGlow * 0.4}) 0%, transparent 40%),
}} radial-gradient(ellipse at 50% 80%, rgba(247, 147, 26, ${backgroundGlow * 0.3}) 0%, transparent 35%),
> radial-gradient(ellipse at 50% 50%, rgba(24, 24, 27, 1) 0%, rgba(0, 0, 0, 1) 100%)
<Img `,
src={staticFile("einundzwanzig-wallpaper.png")}
className="absolute inset-0 w-full h-full object-cover"
style={{ opacity: backgroundOpacity }}
/>
</div>
{/* Dark gradient overlay */}
<div
className="absolute inset-0"
style={{
background:
"radial-gradient(circle at center, rgba(24, 24, 27, 0.6) 0%, rgba(24, 24, 27, 0.9) 70%, rgba(24, 24, 27, 0.98) 100%)",
}} }}
/> />
{/* Wallpaper subtle background */}
<Img
src={staticFile("einundzwanzig-wallpaper.png")}
className="absolute inset-0 w-full h-full object-cover"
style={{ opacity: 0.06 }}
/>
{/* 3D Logo Matrix */}
<div style={{ opacity: matrixOpacity }}>
<LogoMatrix3DMobile />
</div>
{/* Bitcoin particle effect */} {/* Bitcoin particle effect */}
<BitcoinEffect /> <BitcoinEffect />
{/* Content container */} {/* Final Logo Reveal */}
<div className="absolute inset-0 flex flex-col items-center justify-center px-6"> {frame >= logoEntranceFrame && (
{/* Outer glow effect behind logo - smaller for mobile */}
<div <div
className="absolute" className="absolute inset-0 flex flex-col items-center justify-center px-6"
style={{ style={{ opacity: logoOpacity }}
width: 600,
height: 350,
background:
"radial-gradient(ellipse, rgba(247, 147, 26, 0.35) 0%, transparent 60%)",
opacity: glowIntensity * logoSpring,
transform: `scale(${glowScale * logoScale})`,
filter: "blur(50px)",
}}
/>
{/* Horizontal Logo - smaller for mobile */}
<div
style={{
opacity: logoOpacity,
transform: `scale(${logoScale}) translateY(${logoY}px)`,
}}
> >
{/* Massive outer glow */}
<div
className="absolute"
style={{
width: 700,
height: 400,
background: `radial-gradient(ellipse, rgba(247, 147, 26, ${0.35 * glowIntensity}) 0%, transparent 60%)`,
filter: "blur(60px)",
transform: `scale(${logoScale})`,
}}
/>
{/* Secondary glow ring */}
<div
className="absolute"
style={{
width: 500,
height: 280,
background: `radial-gradient(ellipse, rgba(247, 147, 26, ${0.5 * glowIntensity}) 0%, transparent 50%)`,
filter: "blur(35px)",
transform: `scale(${logoScale})`,
}}
/>
{/* Main Logo - sized for mobile */}
<div <div
style={{ style={{
filter: `drop-shadow(0 0 ${35 * glowIntensity}px rgba(247, 147, 26, 0.5))`, transform: `scale(${logoScale})`,
}} }}
> >
<Img <div
src={staticFile("einundzwanzig-horizontal-inverted.svg")}
style={{ style={{
width: 450, filter: `
height: "auto", drop-shadow(0 0 ${50 * glowIntensity}px rgba(247, 147, 26, 0.6))
drop-shadow(0 0 ${100 * glowIntensity}px rgba(247, 147, 26, 0.3))
`,
}} }}
/> >
<Img
src={staticFile("einundzwanzig-horizontal-inverted.svg")}
style={{
width: 500,
height: "auto",
}}
/>
</div>
</div>
{/* EINUNDZWANZIG text */}
<div
className="mt-10 text-center"
style={{
opacity: textOpacity,
transform: `translateY(${textY}px)`,
}}
>
<h1
className="text-5xl font-bold text-white tracking-[0.2em]"
style={{
textShadow: `
0 0 ${35 * glowIntensity}px rgba(247, 147, 26, 0.5),
0 0 ${70 * glowIntensity}px rgba(247, 147, 26, 0.3),
0 3px 25px rgba(0, 0, 0, 0.7)
`,
}}
>
EINUNDZWANZIG
</h1>
</div>
{/* Subtitle */}
<div
className="mt-6 text-center"
style={{ opacity: subtitleOpacity }}
>
<p
className="text-2xl text-orange-400 font-medium tracking-widest"
style={{
textShadow: "0 0 18px rgba(247, 147, 26, 0.5)",
}}
>
Die Bitcoin-Community
</p>
</div>
{/* Community count badge */}
<div
className="mt-5"
style={{
opacity: subtitleOpacity,
transform: `scale(${subtitleSpring})`,
}}
>
<div
className="px-5 py-2 rounded-full"
style={{
background: "rgba(247, 147, 26, 0.15)",
border: "1px solid rgba(247, 147, 26, 0.4)",
}}
>
<span className="text-lg text-orange-300 font-medium">
230+ Meetups weltweit
</span>
</div>
</div> </div>
</div> </div>
)}
{/* EINUNDZWANZIG text - smaller for mobile */} {/* Ambient bottom glow */}
<div
className="mt-10 text-center"
style={{
opacity: textOpacity,
transform: `translateY(${textY}px)`,
}}
>
<h1
className="text-4xl font-bold text-white tracking-widest"
style={{
textShadow: `0 0 ${25 * glowIntensity}px rgba(247, 147, 26, 0.4), 0 2px 20px rgba(0, 0, 0, 0.5)`,
}}
>
EINUNDZWANZIG
</h1>
</div>
{/* Subtitle - smaller for mobile */}
<div
className="mt-5 text-center"
style={{
opacity: subtitleOpacity,
}}
>
<p className="text-xl text-orange-500 font-medium tracking-wide">
Die deutschsprachige Bitcoin-Community
</p>
</div>
</div>
{/* Ambient glow at bottom */}
<div <div
className="absolute inset-x-0 bottom-0 h-48 pointer-events-none" className="absolute inset-x-0 bottom-0 h-64 pointer-events-none"
style={{ style={{
background: background: `linear-gradient(to top, rgba(247, 147, 26, ${0.08 * glowIntensity}) 0%, transparent 100%)`,
"linear-gradient(to top, rgba(247, 147, 26, 0.08) 0%, transparent 100%)",
}} }}
/> />
{/* Vignette overlay */} {/* Heavy vignette */}
<div <div
className="absolute inset-0 pointer-events-none" className="absolute inset-0 pointer-events-none"
style={{ style={{
boxShadow: "inset 0 0 250px 100px rgba(0, 0, 0, 0.8)", boxShadow: "inset 0 0 280px 100px rgba(0, 0, 0, 0.85)",
}} }}
/> />
</div> </div>

View File

@@ -87,7 +87,7 @@ describe("PortalTitleSceneMobile", () => {
const { container } = render(<PortalTitleSceneMobile />); const { container } = render(<PortalTitleSceneMobile />);
const subtitle = container.querySelector("p"); const subtitle = container.querySelector("p");
expect(subtitle).toBeInTheDocument(); expect(subtitle).toBeInTheDocument();
expect(subtitle).toHaveTextContent("Das Herzstück der deutschsprachigen Bitcoin-Community"); expect(subtitle).toHaveTextContent("Das Herzstück der Bitcoin-Community");
expect(subtitle).toHaveClass("text-xl"); expect(subtitle).toHaveClass("text-xl");
expect(subtitle).toHaveClass("text-zinc-300"); expect(subtitle).toHaveClass("text-zinc-300");
}); });

View File

@@ -33,7 +33,7 @@ export const PortalTitleSceneMobile: React.FC = () => {
const titleLine1 = "EINUNDZWANZIG"; const titleLine1 = "EINUNDZWANZIG";
const titleLine2 = "PORTAL"; const titleLine2 = "PORTAL";
const fullTitle = titleLine1 + " " + titleLine2; const fullTitle = titleLine1 + " " + titleLine2;
const subtitleText = "Das Herzstück der deutschsprachigen Bitcoin-Community"; const subtitleText = "Das Herzstück der Bitcoin-Community";
// Calculate typed characters for title // Calculate typed characters for title
const typedTitleChars = Math.min( const typedTitleChars = Math.min(