🎨 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

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(