mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-nostr.git
synced 2026-01-28 07:43:18 +00:00
🎨 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:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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", () => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
619
videos/src/components/LogoMatrix3D.tsx
Normal file
619
videos/src/components/LogoMatrix3D.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
623
videos/src/components/LogoMatrix3DMobile.tsx
Normal file
623
videos/src/components/LogoMatrix3DMobile.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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"]');
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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"]');
|
||||||
|
|||||||
@@ -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)}>
|
||||||
|
|||||||
@@ -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)", () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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"]');
|
||||||
|
|||||||
@@ -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)}>
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user