🎨 Enhance Outro Scene with cinematic improvements and extend duration

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -10,7 +10,7 @@ vi.mock("remotion", () => ({
fps: 30,
width: 1920,
height: 1080,
durationInFrames: 2700,
durationInFrames: 3240,
})),
interpolate: vi.fn((value, inputRange, outputRange, options) => {
const [inMin, inMax] = inputRange;
@@ -246,16 +246,16 @@ describe("PortalPresentation", () => {
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 sequences = container.querySelectorAll('[data-testid="sequence"]');
const durations = Array.from(sequences).map((seq) =>
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);
expect(totalDuration).toBe(2700);
expect(totalDuration).toBe(3240);
});
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");
});
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 sequences = container.querySelectorAll('[data-testid="sequence"]');
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", () => {

View File

@@ -14,7 +14,7 @@ import { PortalAudioManager } from "./components/PortalAudioManager";
/**
* 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
* 2. Portal Title (4s) - Frames 180-300
* 3. Dashboard Overview (12s) - Frames 300-660
@@ -23,7 +23,7 @@ import { PortalAudioManager } from "./components/PortalAudioManager";
* 6. Top Meetups (10s) - Frames 1380-1680
* 7. Activity Feed (10s) - Frames 1680-1980
* 8. Call to Action (12s) - Frames 1980-2340
* 9. Outro (12s) - Frames 2340-2700
* 9. Outro - Cinematic Logo Matrix (30s) - Frames 2340-3240
*/
export const PortalPresentation: React.FC = () => {
const { fps } = useVideoConfig();
@@ -38,7 +38,7 @@ export const PortalPresentation: React.FC = () => {
topMeetups: 10,
activityFeed: 10,
callToAction: 12,
outro: 12,
outro: 30, // Extended for cinematic logo matrix animation
};
// Calculate frame positions for each scene

View File

@@ -10,7 +10,7 @@ vi.mock("remotion", () => ({
fps: 30,
width: 1080,
height: 1920,
durationInFrames: 2700,
durationInFrames: 3240,
})),
interpolate: vi.fn((value, inputRange, outputRange, options) => {
const [inMin, inMax] = inputRange;
@@ -246,16 +246,16 @@ describe("PortalPresentationMobile", () => {
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 sequences = container.querySelectorAll('[data-testid="sequence"]');
const durations = Array.from(sequences).map((seq) =>
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);
expect(totalDuration).toBe(2700);
expect(totalDuration).toBe(3240);
});
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");
});
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 sequences = container.querySelectorAll('[data-testid="sequence"]');
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", () => {

View File

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

View File

@@ -63,7 +63,7 @@ describe("RemotionRoot", () => {
expect(composition?.getAttribute("data-width")).toBe("1920");
expect(composition?.getAttribute("data-height")).toBe("1080");
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", () => {
@@ -76,7 +76,7 @@ describe("RemotionRoot", () => {
expect(composition?.getAttribute("data-width")).toBe("1080");
expect(composition?.getAttribute("data-height")).toBe("1920");
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", () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -33,11 +33,11 @@ describe("Audio Sync Configuration Constants", () => {
});
it("defines correct total duration in frames", () => {
expect(TOTAL_DURATION_FRAMES).toBe(2700);
expect(TOTAL_DURATION_FRAMES).toBe(3240);
});
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", () => {
@@ -408,7 +408,7 @@ describe("calculateBackgroundMusicVolume", () => {
describe("fade-out phase", () => {
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 volumeAtStart = calculateBackgroundMusicVolume(fadeOutStart);
const volumeAfterStart = calculateBackgroundMusicVolume(fadeOutStart + 1);
@@ -419,14 +419,14 @@ describe("calculateBackgroundMusicVolume", () => {
});
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 volume = calculateBackgroundMusicVolume(midPoint);
expect(volume).toBeCloseTo(0.125, 4);
});
it("returns 0 at final frame", () => {
const volume = calculateBackgroundMusicVolume(2700);
const volume = calculateBackgroundMusicVolume(3240);
expect(volume).toBe(0);
});
});

View File

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

View File

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

View File

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

View File

@@ -103,23 +103,7 @@ describe("CallToActionScene", () => {
it("renders the subtitle text", () => {
const { container } = render(<CallToActionScene />);
expect(container.textContent).toContain("Die deutschsprachige 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();
expect(container.textContent).toContain("Die Bitcoin-Community wartet auf dich");
});
it("includes typing audio for URL animation", () => {

View File

@@ -29,7 +29,6 @@ const PORTAL_URL = "portal.einundzwanzig.space";
* 4. URL types out: `portal.einundzwanzig.space`
* 5. URL pulses with orange glow
* 6. EINUNDZWANZIG Logo appears center with glow
* 7. Audio: success-fanfare
*/
export const CallToActionScene: React.FC = () => {
const frame = useCurrentFrame();
@@ -132,10 +131,6 @@ export const CallToActionScene: React.FC = () => {
return (
<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 */}
<Sequence from={urlDelay} durationInFrames={Math.floor(1.5 * fps)}>
@@ -297,7 +292,7 @@ export const CallToActionScene: React.FC = () => {
transform: `translateY(${subtitleY}px)`,
}}
>
Die deutschsprachige Bitcoin-Community wartet auf dich
Die Bitcoin-Community wartet auf dich
</p>
</div>
</div>

View File

@@ -129,7 +129,7 @@ describe("CountryStatsScene", () => {
it("renders the subtitle text", () => {
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", () => {

View File

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

View File

@@ -20,6 +20,7 @@ vi.mock("remotion", () => ({
return outMin + progress * (outMax - outMin);
}),
spring: vi.fn(() => 1),
random: vi.fn((seed: string) => 0.5),
AbsoluteFill: vi.fn(({ children, className, style }) => (
<div data-testid="absolute-fill" className={className} style={style}>
{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
vi.mock("../../components/SparklineChart", () => ({
SparklineChart: vi.fn(({ data, width, height, delay, showFill }) => (
SparklineChart: vi.fn(({ data, width, height, delay, showFill, strokeColor }) => (
<div
data-testid="sparkline-chart"
data-points={data.length}
@@ -90,6 +75,7 @@ vi.mock("../../components/SparklineChart", () => ({
data-height={height}
data-delay={delay}
data-show-fill={showFill}
data-stroke-color={strokeColor}
>
SparklineChart
</div>
@@ -131,7 +117,6 @@ describe("DashboardOverviewScene", () => {
const { container } = render(<DashboardOverviewScene />);
const absoluteFill = container.querySelector('[data-testid="absolute-fill"]');
expect(absoluteFill).toBeInTheDocument();
expect(absoluteFill).toHaveClass("bg-zinc-900");
expect(absoluteFill).toHaveClass("overflow-hidden");
});
@@ -148,95 +133,91 @@ describe("DashboardOverviewScene", () => {
const { container } = render(<DashboardOverviewScene />);
const sidebar = container.querySelector('[data-testid="dashboard-sidebar"]');
expect(sidebar).toBeInTheDocument();
expect(sidebar).toHaveAttribute("data-width", "280");
expect(sidebar).toHaveAttribute("data-height", "1080");
expect(sidebar).toHaveAttribute("data-width", "220");
expect(sidebar).toHaveAttribute("data-delay", "0");
// Should have navigation items
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 header = container.querySelector("h1");
expect(header).toBeInTheDocument();
expect(header).toHaveTextContent("Dashboard");
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"
const sectionHeaders = container.querySelectorAll("h3");
const terminHeader = Array.from(sectionHeaders).find(
(h3) => h3.textContent === "Meine nächsten Meetup Termine"
);
expect(meetupsCounter).toBeInTheDocument();
expect(meetupsCounter).toHaveAttribute("data-label", "Aktive Gruppen");
expect(terminHeader).toBeInTheDocument();
});
it("renders StatsCounter for Users with target 1247", () => {
it("renders the upcoming Kempten meetup", () => {
const { container } = render(<DashboardOverviewScene />);
const statsCounters = container.querySelectorAll('[data-testid="stats-counter"]');
const usersCounter = Array.from(statsCounters).find(
(counter) => counter.getAttribute("data-target") === "1247"
);
expect(usersCounter).toBeInTheDocument();
expect(usersCounter).toHaveAttribute("data-label", "Registrierte Nutzer");
expect(container.textContent).toContain("Einundzwanzig Kempten");
expect(container.textContent).toContain("06.02.2026 19:00 (CET)");
});
it("renders StatsCounter for Events with target 89", () => {
it("renders the 'Top Länder' section with countries", () => {
const { container } = render(<DashboardOverviewScene />);
const statsCounters = container.querySelectorAll('[data-testid="stats-counter"]');
const eventsCounter = Array.from(statsCounters).find(
(counter) => counter.getAttribute("data-target") === "89"
const sectionHeaders = container.querySelectorAll("h3");
const countryHeader = Array.from(sectionHeaders).find(
(h3) => h3.textContent === "Top Länder"
);
expect(eventsCounter).toBeInTheDocument();
expect(eventsCounter).toHaveAttribute("data-label", "Diese Woche");
expect(countryHeader).toBeInTheDocument();
// 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 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 sparklines = container.querySelectorAll('[data-testid="sparkline-chart"]');
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 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 activityItems = container.querySelectorAll('[data-testid="activity-item"]');
const kemptenActivity = Array.from(activityItems).find(
(item) => item.getAttribute("data-event-name") === "EINUNDZWANZIG Kempten"
const sectionHeaders = container.querySelectorAll("h3");
const activityHeader = Array.from(sectionHeaders).find(
(h3) => h3.textContent === "Aktivitäten"
);
expect(kemptenActivity).toBeInTheDocument();
expect(kemptenActivity).toHaveAttribute("data-timestamp", "vor 13 Stunden");
expect(activityHeader).toBeInTheDocument();
});
it("renders audio sequences for sound effects", () => {
@@ -269,38 +250,17 @@ describe("DashboardOverviewScene", () => {
expect(vignettes.length).toBeGreaterThan(0);
});
it("renders the Letzte Aktivitäten section header", () => {
it("renders country flags", () => {
const { container } = render(<DashboardOverviewScene />);
const sectionHeaders = container.querySelectorAll("h3");
const activityHeader = Array.from(sectionHeaders).find(
(h3) => h3.textContent === "Letzte Aktivitäten"
);
expect(activityHeader).toBeInTheDocument();
expect(container.textContent).toContain("🇩🇪");
expect(container.textContent).toContain("🇦🇹");
expect(container.textContent).toContain("🇨🇭");
});
it("renders the Schnellübersicht section header", () => {
it("renders action buttons for user meetups", () => {
const { container } = render(<DashboardOverviewScene />);
const sectionHeaders = container.querySelectorAll("h3");
const quickStatsHeader = Array.from(sectionHeaders).find(
(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");
expect(container.textContent).toContain("Neues Event erstellen");
expect(container.textContent).toContain("Bearbeiten");
});
it("applies 3D perspective transform styles", () => {
@@ -309,4 +269,11 @@ describe("DashboardOverviewScene", () => {
const elements = container.querySelectorAll('[style*="perspective"]');
expect(elements.length).toBeGreaterThan(0);
});
it("renders film grain overlay", () => {
const { container } = render(<DashboardOverviewScene />);
// Film grain uses mixBlendMode: overlay
const grainElement = container.querySelector('[style*="overlay"]');
expect(grainElement).toBeInTheDocument();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -135,15 +135,6 @@ describe("PortalIntroScene", () => {
expect(sequences.length).toBeGreaterThanOrEqual(2);
});
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", () => {
const { container } = render(<PortalIntroScene />);
const audioElements = container.querySelectorAll('[data-testid="audio"]');

View File

@@ -26,7 +26,6 @@ import {
* 2. AnimatedLogo scales from 0 to 100% with spring animation
* 3. Bitcoin particles fall in the background
* 4. Glow effect pulses around the logo
* 5. Audio: logo-whoosh at start, logo-reveal when logo appears
*/
export const PortalIntroScene: React.FC = () => {
const frame = useCurrentFrame();
@@ -99,10 +98,6 @@ export const PortalIntroScene: React.FC = () => {
return (
<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 */}
<Sequence from={logoEntranceDelay} durationInFrames={Math.floor(2 * fps)}>

View File

@@ -5,7 +5,7 @@ import { PortalOutroScene } from "./PortalOutroScene";
/* eslint-disable @remotion/warn-native-media-tag */
// Mock Remotion hooks
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 })),
interpolate: vi.fn((value, inputRange, outputRange, options) => {
const [inMin, inMax] = inputRange;
@@ -37,6 +37,7 @@ vi.mock("remotion", () => ({
Easing: {
out: vi.fn((fn) => fn),
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", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -102,30 +110,41 @@ describe("PortalOutroScene", () => {
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", () => {
const { container } = render(<PortalOutroScene />);
const title = container.querySelector("h1");
expect(title).toBeInTheDocument();
expect(title).toHaveTextContent("EINUNDZWANZIG");
expect(title).toHaveClass("text-5xl");
expect(title).toHaveClass("text-6xl");
expect(title).toHaveClass("font-bold");
expect(title).toHaveClass("text-white");
expect(title).toHaveClass("tracking-widest");
});
it("renders the subtitle with orange color", () => {
const { container } = render(<PortalOutroScene />);
const subtitle = container.querySelector("p");
const subtitle = container.querySelector("p.text-orange-400");
expect(subtitle).toBeInTheDocument();
expect(subtitle).toHaveTextContent("Die deutschsprachige Bitcoin-Community");
expect(subtitle).toHaveClass("text-orange-500");
expect(subtitle).toHaveTextContent("Die Bitcoin-Community");
});
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 sequences = container.querySelectorAll('[data-testid="sequence"]');
// final-chime = 1 sequence
expect(sequences.length).toBe(1);
// logo-whoosh + final-chime = 2 sequences
expect(sequences.length).toBe(2);
});
it("includes final-chime audio", () => {
@@ -137,15 +156,24 @@ describe("PortalOutroScene", () => {
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", () => {
const { container } = render(<PortalOutroScene />);
const vignettes = container.querySelectorAll(".pointer-events-none");
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 elements = container.querySelectorAll('[style*="blur(60px)"]');
const elements = container.querySelectorAll('[style*="blur"]');
expect(elements.length).toBeGreaterThan(0);
});
@@ -163,7 +191,7 @@ describe("PortalOutroScene", () => {
it("renders ambient glow at bottom", () => {
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();
});
});
@@ -185,7 +213,7 @@ describe("PortalOutroScene logo display", () => {
img.getAttribute("src")?.includes("einundzwanzig-horizontal-inverted.svg")
);
expect(logo).toBeInTheDocument();
expect(logo).toHaveStyle({ width: "600px" });
expect(logo).toHaveStyle({ width: "700px" });
});
it("logo is centered in the container", () => {
@@ -213,16 +241,16 @@ describe("PortalOutroScene text styling", () => {
expect(style).toContain("text-shadow");
});
it("subtitle has tracking-wide class", () => {
it("subtitle has tracking-widest class", () => {
const { container } = render(<PortalOutroScene />);
const subtitle = container.querySelector("p");
const subtitle = container.querySelector("p.text-orange-400");
expect(subtitle).toBeInTheDocument();
expect(subtitle).toHaveClass("tracking-wide");
expect(subtitle).toHaveClass("tracking-widest");
});
it("subtitle has font-medium class", () => {
const { container } = render(<PortalOutroScene />);
const subtitle = container.querySelector("p");
const subtitle = container.querySelector("p.text-orange-400");
expect(subtitle).toBeInTheDocument();
expect(subtitle).toHaveClass("font-medium");
});
@@ -238,7 +266,18 @@ describe("PortalOutroScene audio configuration", () => {
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 sequences = container.querySelectorAll('[data-testid="sequence"]');
const chimeSequence = Array.from(sequences).find((seq) => {
@@ -246,7 +285,7 @@ describe("PortalOutroScene audio configuration", () => {
return audio?.getAttribute("src")?.includes("final-chime.mp3");
});
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)", () => {

View File

@@ -7,80 +7,79 @@ import {
Img,
staticFile,
Sequence,
Easing,
} from "remotion";
import { Audio } from "@remotion/media";
import { BitcoinEffect } from "../../components/BitcoinEffect";
import { LogoMatrix3D } from "../../components/LogoMatrix3D";
import {
SPRING_CONFIGS,
TIMING,
GLOW_CONFIG,
secondsToFrames,
} 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:
* 1. Fade from previous scene to wallpaper background
* 2. BitcoinEffect particles throughout
* 3. Horizontal Logo fades in at center
* 4. "EINUNDZWANZIG" text appears below logo
* 5. Glow effect pulses around the logo
* 6. Background music fades out in last 3 seconds
* 7. Audio: final-chime at logo appearance
* 1. 3D Logo Matrix materializes with all 230+ meetup logos
* 2. Camera flies through the matrix cinematically
* 3. Random meetups spotlight with name reveals
* 4. All logos converge to center
* 5. Einundzwanzig main logo emerges triumphantly
* 6. Final fade to black
*/
export const PortalOutroScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const durationInFrames = 12 * fps; // 360 frames
const durationInFrames = 30 * fps; // 900 frames
// Background fade-in from black using centralized config
const backgroundSpring = spring({
frame,
fps,
config: SPRING_CONFIGS.SMOOTH,
});
// Fine-tuned: Slightly increased max opacity for better visibility
const backgroundOpacity = interpolate(backgroundSpring, [0, 1], [0, 0.35]);
// Phase timing (in seconds)
const PHASE = {
MATRIX: { start: 0, end: 26 },
FINAL_LOGO: { start: 24, end: 30 },
FADE_OUT: { start: 28, end: 30 },
};
// Logo entrance animation using centralized timing
const logoDelay = secondsToFrames(TIMING.OUTRO_LOGO_DELAY, fps);
// Background ambient glow
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({
frame: frame - logoDelay,
frame: frame - logoEntranceFrame,
fps,
config: SPRING_CONFIGS.SNAPPY,
config: { damping: 12, stiffness: 60 },
});
const logoOpacity = interpolate(logoSpring, [0, 1], [0, 1]);
// Fine-tuned: Increased initial scale for smoother entrance
const logoScale = interpolate(logoSpring, [0, 1], [0.85, 1]);
// Fine-tuned: Reduced Y translation
const logoY = interpolate(logoSpring, [0, 1], [25, 0]);
const logoOpacity = interpolate(
frame,
[logoEntranceFrame, logoEntranceFrame + fps],
[0, 1],
{ 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(
Math.sin((frame - logoDelay) * GLOW_CONFIG.FREQUENCY.NORMAL),
Math.sin((frame - logoEntranceFrame) * GLOW_CONFIG.FREQUENCY.NORMAL),
[-1, 1],
GLOW_CONFIG.INTENSITY.NORMAL
);
const glowScale = interpolate(
Math.sin((frame - logoDelay) * GLOW_CONFIG.FREQUENCY.SLOW),
[-1, 1],
GLOW_CONFIG.SCALE.STRONG
GLOW_CONFIG.INTENSITY.STRONG
);
// Text entrance using centralized timing
const textDelay = secondsToFrames(TIMING.OUTRO_TEXT_DELAY, fps);
// Text entrances
const textDelay = logoEntranceFrame + fps;
const textSpring = spring({
frame: frame - textDelay,
fps,
config: SPRING_CONFIGS.SMOOTH,
});
const textOpacity = interpolate(textSpring, [0, 1], [0, 1]);
// Fine-tuned: Reduced Y translation
const textY = interpolate(textSpring, [0, 1], [18, 0]);
const textY = interpolate(textSpring, [0, 1], [30, 0]);
// Subtitle entrance using centralized timing
const subtitleDelay = secondsToFrames(TIMING.OUTRO_SUBTITLE_DELAY, fps);
const subtitleDelay = textDelay + Math.floor(0.5 * fps);
const subtitleSpring = spring({
frame: frame - subtitleDelay,
fps,
@@ -88,135 +87,189 @@ export const PortalOutroScene: React.FC = () => {
});
const subtitleOpacity = interpolate(subtitleSpring, [0, 1], [0, 1]);
// Final fade out using centralized timing
const fadeOutDuration = secondsToFrames(TIMING.OUTRO_FADE_DURATION, fps);
const fadeOutStart = durationInFrames - fadeOutDuration;
// Final fade out
const fadeOutStart = PHASE.FADE_OUT.start * fps;
const finalFadeOpacity = interpolate(
frame,
[fadeOutStart, durationInFrames],
[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" }
);
return (
<AbsoluteFill className="bg-zinc-900 overflow-hidden">
{/* Audio: final-chime when logo appears */}
<Sequence from={logoDelay} durationInFrames={Math.floor(3 * fps)}>
<Audio src={staticFile("sfx/final-chime.mp3")} volume={0.6} />
{/* Audio: epic-whoosh at matrix flight, final-chime at logo reveal */}
<Sequence from={Math.floor(4 * fps)} durationInFrames={Math.floor(2 * fps)}>
<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>
{/* Content wrapper with final fade */}
<div style={{ opacity: finalFadeOpacity }}>
{/* Wallpaper Background */}
{/* Deep space background */}
<div
className="absolute inset-0"
style={{
transform: "scale(1.05)",
transformOrigin: "center center",
}}
>
<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%)",
background: `
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%)
`,
}}
/>
{/* 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 />
{/* Content container */}
<div className="absolute inset-0 flex flex-col items-center justify-center">
{/* Outer glow effect behind logo */}
{/* Final Logo Reveal */}
{frame >= logoEntranceFrame && (
<div
className="absolute"
style={{
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)`,
}}
className="absolute inset-0 flex flex-col items-center justify-center"
style={{ opacity: logoOpacity }}
>
{/* 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
style={{
filter: `drop-shadow(0 0 ${40 * glowIntensity}px rgba(247, 147, 26, 0.5))`,
transform: `scale(${logoScale})`,
}}
>
<Img
src={staticFile("einundzwanzig-horizontal-inverted.svg")}
<div
style={{
width: 600,
height: "auto",
filter: `
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>
)}
{/* EINUNDZWANZIG text */}
<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 */}
{/* Ambient bottom glow */}
<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={{
background:
"linear-gradient(to top, rgba(247, 147, 26, 0.08) 0%, transparent 100%)",
background: `linear-gradient(to top, rgba(247, 147, 26, ${0.1 * glowIntensity}) 0%, transparent 100%)`,
}}
/>
{/* Vignette overlay */}
{/* Heavy vignette for cinematic feel */}
<div
className="absolute inset-0 pointer-events-none"
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>

View File

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

View File

@@ -31,7 +31,7 @@ export const PortalTitleScene: React.FC = () => {
// Main title text
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
const typedTitleChars = Math.min(

View File

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

View File

@@ -106,15 +106,6 @@ describe("CallToActionSceneMobile", () => {
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", () => {
const { container } = render(<CallToActionSceneMobile />);
const audioElements = container.querySelectorAll('[data-testid="audio"]');
@@ -128,7 +119,7 @@ describe("CallToActionSceneMobile", () => {
const { container } = render(<CallToActionSceneMobile />);
const subtitle = container.querySelector("p");
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
expect(subtitle).toHaveClass("text-base");
});

View File

@@ -116,10 +116,6 @@ export const CallToActionSceneMobile: React.FC = () => {
return (
<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 */}
<Sequence from={urlDelay} durationInFrames={Math.floor(1.5 * fps)}>
@@ -281,7 +277,7 @@ export const CallToActionSceneMobile: React.FC = () => {
transform: `translateY(${subtitleY}px)`,
}}
>
Die deutschsprachige Bitcoin-Community wartet auf dich
Die Bitcoin-Community wartet auf dich
</p>
</div>
</div>

View File

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

View File

@@ -137,15 +137,6 @@ describe("PortalIntroSceneMobile", () => {
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", () => {
const { container } = render(<PortalIntroSceneMobile />);
const audioElements = container.querySelectorAll('[data-testid="audio"]');

View File

@@ -83,10 +83,6 @@ export const PortalIntroSceneMobile: React.FC = () => {
return (
<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 */}
<Sequence from={logoEntranceDelay} durationInFrames={Math.floor(2 * fps)}>

View File

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

View File

@@ -7,70 +7,76 @@ import {
Img,
staticFile,
Sequence,
Easing,
} from "remotion";
import { Audio } from "@remotion/media";
import { BitcoinEffect } from "../../../components/BitcoinEffect";
import { LogoMatrix3DMobile } from "../../../components/LogoMatrix3DMobile";
// Spring configurations
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:
* - Smaller horizontal logo width (450px vs 600px)
* - Adjusted text sizes for portrait orientation
* - Smaller glow effects
* - Portrait-optimized 3D logo matrix
* - Smaller logo cards and text
* - Adjusted camera movements for vertical format
*/
export const PortalOutroSceneMobile: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const durationInFrames = 12 * fps; // 360 frames
const durationInFrames = 30 * fps; // 900 frames
// Background fade-in from black (0-30 frames)
const backgroundSpring = spring({
frame,
fps,
config: SMOOTH,
});
const backgroundOpacity = interpolate(backgroundSpring, [0, 1], [0, 0.3]);
// Phase timing (in seconds)
const PHASE = {
MATRIX: { start: 0, end: 26 },
FINAL_LOGO: { start: 24, end: 30 },
FADE_OUT: { start: 28, end: 30 },
};
// Logo entrance animation (delayed 1 second)
const logoDelay = Math.floor(1 * fps);
// Background ambient glow
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({
frame: frame - logoDelay,
frame: frame - logoEntranceFrame,
fps,
config: SNAPPY,
config: { damping: 12, stiffness: 60 },
});
const logoOpacity = interpolate(logoSpring, [0, 1], [0, 1]);
const logoScale = interpolate(logoSpring, [0, 1], [0.8, 1]);
const logoY = interpolate(logoSpring, [0, 1], [30, 0]);
const logoOpacity = interpolate(
frame,
[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(
Math.sin((frame - logoDelay) * 0.06),
Math.sin((frame - logoEntranceFrame) * 0.06),
[-1, 1],
[0.4, 0.9]
);
const glowScale = interpolate(
Math.sin((frame - logoDelay) * 0.04),
[-1, 1],
[1.0, 1.2]
[0.5, 1.0]
);
// Text entrance (delayed 2 seconds)
const textDelay = Math.floor(2 * fps);
// Text entrances
const textDelay = logoEntranceFrame + fps;
const textSpring = spring({
frame: frame - textDelay,
fps,
config: SMOOTH,
});
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 = Math.floor(2.5 * fps);
const subtitleDelay = textDelay + Math.floor(0.5 * fps);
const subtitleSpring = spring({
frame: frame - subtitleDelay,
fps,
@@ -78,134 +84,189 @@ export const PortalOutroSceneMobile: React.FC = () => {
});
const subtitleOpacity = interpolate(subtitleSpring, [0, 1], [0, 1]);
// Final fade out in last 2 seconds (frames 300-360)
const fadeOutStart = durationInFrames - 2 * fps;
// Final fade out
const fadeOutStart = PHASE.FADE_OUT.start * fps;
const finalFadeOpacity = interpolate(
frame,
[fadeOutStart, durationInFrames],
[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" }
);
return (
<AbsoluteFill className="bg-zinc-900 overflow-hidden">
{/* Audio: final-chime when logo appears */}
<Sequence from={logoDelay} durationInFrames={Math.floor(3 * fps)}>
<Audio src={staticFile("sfx/final-chime.mp3")} volume={0.6} />
{/* Audio */}
<Sequence from={Math.floor(4 * fps)} durationInFrames={Math.floor(2 * fps)}>
<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>
{/* Content wrapper with final fade */}
<div style={{ opacity: finalFadeOpacity }}>
{/* Wallpaper Background */}
{/* Deep space background */}
<div
className="absolute inset-0"
style={{
transform: "scale(1.05)",
transformOrigin: "center center",
}}
>
<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%)",
background: `
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%)
`,
}}
/>
{/* 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 */}
<BitcoinEffect />
{/* Content container */}
<div className="absolute inset-0 flex flex-col items-center justify-center px-6">
{/* Outer glow effect behind logo - smaller for mobile */}
{/* Final Logo Reveal */}
{frame >= logoEntranceFrame && (
<div
className="absolute"
style={{
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)`,
}}
className="absolute inset-0 flex flex-col items-center justify-center px-6"
style={{ opacity: logoOpacity }}
>
{/* 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
style={{
filter: `drop-shadow(0 0 ${35 * glowIntensity}px rgba(247, 147, 26, 0.5))`,
transform: `scale(${logoScale})`,
}}
>
<Img
src={staticFile("einundzwanzig-horizontal-inverted.svg")}
<div
style={{
width: 450,
height: "auto",
filter: `
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>
)}
{/* EINUNDZWANZIG text - smaller for mobile */}
<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 */}
{/* Ambient bottom glow */}
<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={{
background:
"linear-gradient(to top, rgba(247, 147, 26, 0.08) 0%, transparent 100%)",
background: `linear-gradient(to top, rgba(247, 147, 26, ${0.08 * glowIntensity}) 0%, transparent 100%)`,
}}
/>
{/* Vignette overlay */}
{/* Heavy vignette */}
<div
className="absolute inset-0 pointer-events-none"
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>

View File

@@ -87,7 +87,7 @@ describe("PortalTitleSceneMobile", () => {
const { container } = render(<PortalTitleSceneMobile />);
const subtitle = container.querySelector("p");
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-zinc-300");
});

View File

@@ -33,7 +33,7 @@ export const PortalTitleSceneMobile: React.FC = () => {
const titleLine1 = "EINUNDZWANZIG";
const titleLine2 = "PORTAL";
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
const typedTitleChars = Math.min(