Files
mintel.me/video/compositions/ContactFormShowcase.tsx
2026-02-01 12:55:01 +01:00

210 lines
7.4 KiB
TypeScript

import React, { useMemo, useEffect, useState, useRef } from 'react';
import {
AbsoluteFill,
interpolate,
useCurrentFrame,
useVideoConfig,
Easing,
Img,
delayRender,
continueRender,
spring,
} from 'remotion';
import { MouseCursor } from '../components/MouseCursor';
import { ContactForm } from '@/src/components/ContactForm';
import { BackgroundGrid } from '@/src/components/Layout';
import { initialState } from '@/src/components/ContactForm/constants';
// Brand Assets
import IconWhite from '@/src/assets/logo/Icon White Transparent.svg';
export const ContactFormShowcase: React.FC = () => {
const frame = useCurrentFrame();
const { width, height, fps } = useVideoConfig();
const [handle] = useState(() => delayRender('Initializing Smart Camera'));
useEffect(() => {
if (typeof window !== 'undefined') (window as any).isRemotion = true;
const timer = setTimeout(() => continueRender(handle), 500);
return () => clearTimeout(timer);
}, [handle]);
// ---- SMART ANCHOR SYSTEM ----
// We define which target the camera should follow at each frame
const activeTargetId = useMemo(() => {
if (frame < 60) return 'overview';
if (frame < 160) return 'focus-target-web-app';
if (frame < 260) return 'focus-target-next';
if (frame < 300) return 'overview';
if (frame < 550) return 'focus-target-company';
if (frame < 650) return 'focus-target-next';
if (frame < 800) return 'overview';
return 'overview';
}, [frame]);
// Pre-calculated coordinates for common targets in the 1200px scaled form
// Since real-time DOM measurements can be jittery during a render,
// we use "Systematic Virtual Anchors" calibrated to the layout.
const anchors = useMemo(() => ({
'overview': { z: 0.85, x: 0, y: 0 },
'focus-target-web-app': { z: 1.3, x: 300, y: 0 },
'focus-target-next': { z: 1.1, x: -380, y: -280 },
'focus-target-company': { z: 1.45, x: 50, y: 220 },
}), []);
// ---- UNIFIED SPRING PHYSICS ----
const camera = useMemo(() => {
// Find the target state
const target = (anchors as any)[activeTargetId] || anchors.overview;
// We use a "Continuous Follow" spring that always chases the activeTargetId
// but we need to know when the target changed to reset the spring animation.
// Actually, for smoothness, we'll just use the frame globally and let the spring settle.
// Let's create a transition system
const points = [
{ f: 0, t: 'overview' },
{ f: 80, t: 'focus-target-web-app' },
{ f: 200, t: 'focus-target-next' },
{ f: 280, t: 'overview' },
{ f: 380, t: 'focus-target-company' },
{ f: 580, t: 'focus-target-next' },
{ f: 700, t: 'overview' },
];
let idx = 0;
for (let i = 0; i < points.length; i++) {
if (frame >= points[i].f) idx = i;
}
const start = (anchors as any)[points[idx].t];
const end = (anchors as any)[(points[idx + 1] || points[idx]).t];
const s = spring({
frame: frame - points[idx].f,
fps,
config: { stiffness: 45, damping: 18, mass: 1 },
});
return {
z: interpolate(s, [0, 1], [start.z, end.z]),
x: interpolate(s, [0, 1], [start.x, end.x]),
y: interpolate(s, [0, 1], [start.y, end.y]),
};
}, [frame, anchors, fps]);
// ---- MOUSE HANDLER (Sticks to targets) ----
const mouse = useMemo(() => {
const path = [
{ f: 10, x: width * 1.5, y: height * 1.2 }, // Offscreen
{ f: 80, x: width * 0.35, y: height * 0.45 }, // Move to Web App
{ f: 210, x: width * 0.85, y: height * 0.68 }, // Move to Next
{ f: 380, x: width * 0.50, y: height * 0.35 }, // Move to Input
{ f: 590, x: width * 0.85, y: height * 0.68 }, // Move to Next
{ f: 850, x: width * 1.5, y: height * 1.2 }, // Exit
];
let idx = 0;
for (let i = 0; i < path.length; i++) {
if (frame >= path[i].f) idx = i;
}
const p1 = path[idx];
const p2 = path[idx + 1] || p1;
const s = spring({
frame: frame - p1.f,
fps,
config: { stiffness: 40, damping: 22 },
});
return {
x: interpolate(s, [0, 1], [p1.x, p2.x]),
y: interpolate(s, [0, 1], [p1.y, p2.y]),
};
}, [frame, width, height, fps]);
// ---- UI PROGRESSION ----
const ui = useMemo(() => {
let step = 0;
let type = 'website';
let name = '';
if (frame > 140) type = 'web-app';
if (frame > 260) step = 1;
if (frame > 400) {
const txt = "Mintel Studios";
const chars = Math.min(Math.floor((frame - 400) / 4), txt.length);
name = txt.substring(0, chars);
}
if (frame > 620) step = 2;
return { step, type, name };
}, [frame]);
const formState = useMemo(() => ({
...initialState,
projectType: ui.type as any,
companyName: ui.name,
}), [ui]);
// Click triggers
const isClicking = (frame > 135 && frame < 150) || (frame > 255 && frame < 270) || (frame > 615 && frame < 630);
return (
<AbsoluteFill className="bg-white">
<BackgroundGrid />
<style>{`
* { transition: none !important; animation: none !important; -webkit-font-smoothing: antialiased; }
.focus-layer { transform-style: preserve-3d; backface-visibility: hidden; will-change: transform; }
`}</style>
<div
className="absolute inset-0 flex items-center justify-center focus-layer"
style={{
transform: `translate3d(${Math.round(camera.x)}px, ${Math.round(camera.y)}px, 0) scale(${camera.z.toFixed(4)})`,
}}
>
{/* Scale factor 0.85 for the base container */}
<div
style={{ transform: 'scale(0.85) translate3d(0,0,0)', width: '1200px' }}
className="focus-layer"
>
<ContactForm
initialStepIndex={ui.step}
initialState={formState}
/>
</div>
{/* Mouse Cursor */}
<div
style={{
position: 'absolute',
top: 0, left: 0,
transform: `translate3d(${Math.round(mouse.x)}px, ${Math.round(mouse.y)}px, 0)`,
zIndex: 1000,
}}
className="focus-layer"
>
<MouseCursor isClicking={isClicking} x={0} y={0} />
</div>
</div>
{/* Branding Logo HUD */}
<div className="absolute top-28 left-28 z-50 focus-layer">
<div className="w-40 h-40 bg-black rounded-[3.5rem] flex items-center justify-center border-4 border-white/5 shadow-2xl">
<Img src={IconWhite} className="w-24 h-24" />
</div>
</div>
<AbsoluteFill
className="bg-white pointer-events-none"
style={{ opacity: interpolate(frame, [0, 15], [1, 0], { extrapolateRight: 'clamp' }) }}
/>
</AbsoluteFill>
);
};