chore: overhaul infrastructure and integrate @mintel packages
Some checks failed
🧪 CI (QA) / 🧪 Quality Assurance (push) Failing after 1m3s
Some checks failed
🧪 CI (QA) / 🧪 Quality Assurance (push) Failing after 1m3s
- Restructure to pnpm monorepo (site moved to apps/web) - Integrate @mintel/tsconfig, @mintel/eslint-config, @mintel/husky-config - Implement Docker service architecture (Varnish, Directus, Gatekeeper) - Setup environment-aware Gitea Actions deployment
This commit is contained in:
34
apps/web/video/Root.tsx
Normal file
34
apps/web/video/Root.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Composition } from 'remotion';
|
||||
import { ContactFormShowcase } from './compositions/ContactFormShowcase';
|
||||
import { ButtonShowcase } from './compositions/ButtonShowcase';
|
||||
import './style.css';
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).isRemotion = true;
|
||||
}
|
||||
|
||||
export const RemotionRoot: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<Composition
|
||||
id="ContactFormShowcase"
|
||||
component={ContactFormShowcase}
|
||||
durationInFrames={1500}
|
||||
fps={60}
|
||||
width={1080}
|
||||
height={1350}
|
||||
/>
|
||||
<Composition
|
||||
id="ButtonShowcase"
|
||||
component={ButtonShowcase}
|
||||
durationInFrames={300} // 60fps * 5s
|
||||
fps={60}
|
||||
width={1080}
|
||||
height={1350} // 4:5 aspect ratio for LinkedIn/social
|
||||
defaultProps={{
|
||||
text: "Let's work together",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
44
apps/web/video/components/MouseCursor.tsx
Normal file
44
apps/web/video/components/MouseCursor.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { MousePointer2 } from 'lucide-react';
|
||||
|
||||
interface MouseCursorProps {
|
||||
x: number;
|
||||
y: number;
|
||||
isClicking?: boolean;
|
||||
}
|
||||
|
||||
export const MouseCursor: React.FC<MouseCursorProps> = ({ x, y, isClicking }) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
transform: `translate3d(${x}px, ${y}px, 0)`,
|
||||
zIndex: 1000,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
transform: isClicking ? 'scale(0.85)' : 'scale(1)',
|
||||
}}
|
||||
>
|
||||
<MousePointer2
|
||||
className="text-slate-900 fill-white"
|
||||
size={48}
|
||||
style={{
|
||||
filter: 'drop-shadow(0 8px 16px rgba(0,0,0,0.2))',
|
||||
transform: 'rotate(-15deg)'
|
||||
}}
|
||||
/>
|
||||
{isClicking && (
|
||||
<div
|
||||
className="absolute top-0 left-0 w-12 h-12 border-4 border-slate-400 rounded-full opacity-50"
|
||||
style={{ transform: 'scale(1.2)' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
7
apps/web/video/components/NextLinkMock.tsx
Normal file
7
apps/web/video/components/NextLinkMock.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
const Link: React.FC<{ href: string; children: React.ReactNode; className?: string }> = ({ children, className }) => {
|
||||
return <div className={className}>{children}</div>;
|
||||
};
|
||||
|
||||
export default Link;
|
||||
254
apps/web/video/compositions/ButtonShowcase.tsx
Normal file
254
apps/web/video/compositions/ButtonShowcase.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
AbsoluteFill,
|
||||
interpolate,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
Easing,
|
||||
Img,
|
||||
staticFile,
|
||||
spring,
|
||||
random,
|
||||
} from 'remotion';
|
||||
import { MouseCursor } from '../components/MouseCursor';
|
||||
import { Button } from '@/src/components/Button';
|
||||
import { Loader2, Check, UserCheck, ShieldCheck } from 'lucide-react';
|
||||
|
||||
// Import logo using the alias setup in remotion.config.ts
|
||||
// We'll use the staticFile helper if it's in public, but these are in src/assets
|
||||
// So we can try to import them directly if the bundler allows, or move them to public.
|
||||
// Given Header.tsx imports them, they should be importable.
|
||||
// import IconWhite from '@/src/assets/logo/Icon White Transparent.svg'; // Not used in this version
|
||||
// Import black logo for light mode
|
||||
import IconBlack from '@/src/assets/logo/Icon Black Transparent.svg';
|
||||
|
||||
const Background: React.FC<{ loadingOpacity: number }> = ({ loadingOpacity }) => {
|
||||
return (
|
||||
<AbsoluteFill className="bg-white">
|
||||
{/* Website-Matching Grid */}
|
||||
<div className="absolute inset-0">
|
||||
<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<pattern id="lightGrid" width="40" height="40" patternUnits="userSpaceOnUse">
|
||||
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="#f1f5f9" strokeWidth="1" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#lightGrid)" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Subtle Gradient Overlay */}
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(255,255,255,0.8)_100%)]" />
|
||||
|
||||
{/* Dynamic "Processing" Rings (Background Activity during loading) */}
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center pointer-events-none"
|
||||
style={{ opacity: loadingOpacity * 0.1 }}
|
||||
>
|
||||
<div className="w-[500px] h-[500px] border border-slate-900 rounded-full animate-[spin_10s_linear_infinite] opacity-20 border-t-transparent" />
|
||||
<div className="absolute w-[400px] h-[400px] border border-slate-900 rounded-full animate-[spin_7s_linear_infinite_reverse] opacity-20 border-b-transparent" />
|
||||
</div>
|
||||
|
||||
{/* STATIC Logo - Strictly no animation on the container */}
|
||||
<div className="absolute top-12 left-12 z-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 flex items-center justify-center bg-white rounded-xl border border-slate-200 shadow-sm">
|
||||
<Img src={IconBlack} className="w-8 h-8 opacity-90" />
|
||||
</div>
|
||||
<div className="flex flex-col opacity-80">
|
||||
<span className="text-slate-900 font-sans font-bold text-lg tracking-tight leading-none">Mintel.me</span>
|
||||
<span className="text-slate-400 font-serif italic text-xs mt-1">Component Library</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// Toast Notification Component
|
||||
const Toast: React.FC<{ show: boolean; text: string }> = ({ show, text }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Animate in/out based on 'show' prop would require state tracking or precise frame logic
|
||||
// We'll trust the parent to mount/unmount or pass an animatable value
|
||||
// For video, deterministic frame-based spring is best.
|
||||
|
||||
// We'll actually control position purely by parent for simplicity in this demo context
|
||||
return (
|
||||
<div className="flex items-center gap-3 bg-slate-900 text-white px-6 py-4 rounded-xl shadow-2xl border border-slate-800">
|
||||
<div className="w-8 h-8 rounded-full bg-green-500/20 flex items-center justify-center text-green-400">
|
||||
<ShieldCheck size={18} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-sm tracking-wide">Authentication Successful</span>
|
||||
<span className="text-slate-400 text-xs font-medium">Access granted to secure portal</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ButtonShowcase: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { width, height, fps } = useVideoConfig();
|
||||
|
||||
// ---- SEQUENCE TIMELINE (300 frames / 5s) ----
|
||||
const ENTER_START = 20;
|
||||
const HOVER_START = 70;
|
||||
const CLICK_FRAME = 90;
|
||||
const LOADING_START = 95;
|
||||
const SUCCESS_START = 180;
|
||||
const TOAST_START = 190;
|
||||
const TOAST_END = 260;
|
||||
const EXIT_START = 220;
|
||||
|
||||
// 1. Mouse Animation (Bézier Path)
|
||||
const getMousePos = (f: number) => {
|
||||
const startX = width * 1.2;
|
||||
const startY = height * 1.2;
|
||||
const targetX = width / 2;
|
||||
const targetY = height / 2;
|
||||
|
||||
if (f < ENTER_START) return { x: startX, y: startY, vx: 0 };
|
||||
|
||||
// Approach
|
||||
if (f < HOVER_START) {
|
||||
const t = interpolate(f, [ENTER_START, HOVER_START], [0, 1], { extrapolateRight: 'clamp' });
|
||||
const ease = Easing.bezier(0.16, 1, 0.3, 1)(t);
|
||||
const x = interpolate(ease, [0, 1], [startX, targetX]);
|
||||
const y = interpolate(ease, [0, 1], [startY, targetY]);
|
||||
return { x, y, vx: -10 }; // Approximate Velocity
|
||||
}
|
||||
|
||||
// Hover
|
||||
if (f < EXIT_START) {
|
||||
const noise = Math.sin(f * 0.1) * 2;
|
||||
return { x: targetX + noise, y: targetY + noise, vx: 0 };
|
||||
}
|
||||
|
||||
// Exit
|
||||
const t = interpolate(f, [EXIT_START, EXIT_START + 30], [0, 1], { extrapolateLeft: 'clamp' });
|
||||
const ease = Easing.exp(t);
|
||||
return {
|
||||
x: interpolate(ease, [0, 1], [targetX, width * 0.9]),
|
||||
y: interpolate(ease, [0, 1], [targetY, -100]),
|
||||
vx: 10
|
||||
};
|
||||
};
|
||||
|
||||
const { x: mouseX, y: mouseY, vx } = getMousePos(frame);
|
||||
|
||||
// 3D Cursor Skew
|
||||
const cursorSkew = interpolate(vx, [-20, 20], [20, -20], { extrapolateRight: 'clamp', extrapolateLeft: 'clamp' });
|
||||
const isClicking = frame >= CLICK_FRAME && frame < CLICK_FRAME + 8;
|
||||
const clickRotate = isClicking ? 30 : 0;
|
||||
|
||||
// 2. Button State Logic
|
||||
const isLoading = frame >= LOADING_START && frame < SUCCESS_START;
|
||||
const isSuccess = frame >= SUCCESS_START;
|
||||
|
||||
// Loading Spinner Rotation
|
||||
const spinnerRot = interpolate(frame, [LOADING_START, SUCCESS_START], [0, 720]);
|
||||
|
||||
// Button Scale Physics
|
||||
const pressSpring = spring({ frame: frame - CLICK_FRAME, fps, config: { stiffness: 400, damping: 20 } });
|
||||
const successSpring = spring({ frame: frame - SUCCESS_START, fps, config: { stiffness: 200, damping: 15 } });
|
||||
|
||||
// Morph scale: Click(Compress) -> Loading(Normal) -> Success(Pop)
|
||||
let buttonScale = 1;
|
||||
if (frame >= CLICK_FRAME && frame < LOADING_START) {
|
||||
buttonScale = 1 - (pressSpring * 0.05);
|
||||
} else if (isSuccess) {
|
||||
buttonScale = 1 + (successSpring * 0.05);
|
||||
}
|
||||
|
||||
// Button Width Morph (Optional: Make it circle on load? Keeping it wide for consistency is safer)
|
||||
|
||||
// 3. Toast Animation
|
||||
const toastSpring = spring({ frame: frame - TOAST_START, fps, config: { stiffness: 100, damping: 15 } });
|
||||
const toastExit = spring({ frame: frame - TOAST_END, fps, config: { stiffness: 100, damping: 20 } });
|
||||
const toastY = interpolate(toastSpring, [0, 1], [100, -80]) + interpolate(toastExit, [0, 1], [0, 200]);
|
||||
const toastOpacity = interpolate(toastSpring, [0, 1], [0, 1]) - interpolate(toastExit, [0, 0.5], [0, 1]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill className="items-center justify-center overflow-hidden bg-white">
|
||||
<Background loadingOpacity={isLoading ? 1 : 0} />
|
||||
|
||||
{/* Main Stage */}
|
||||
<div style={{ perspective: '1000px', zIndex: 10 }}>
|
||||
{/* Button Container */}
|
||||
<div
|
||||
style={{
|
||||
transform: `scale(${buttonScale})`,
|
||||
transition: 'transform 0.1s'
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
href="#"
|
||||
variant="primary"
|
||||
showArrow={!isLoading && !isSuccess}
|
||||
className={`
|
||||
!transition-all !duration-500
|
||||
!px-16 !py-8 !text-2xl !font-bold
|
||||
${isSuccess
|
||||
? '!bg-white !text-slate-900 !border-slate-900 shadow-none' // Success: Outline/Minimal
|
||||
: '!bg-slate-900 !text-white !border-none !shadow-2xl !shadow-slate-300' // Default/Load: Solid
|
||||
}
|
||||
!rounded-full
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-4 min-w-[240px] justify-center text-center">
|
||||
{/* Default State */}
|
||||
{!isLoading && !isSuccess && (
|
||||
<span className="animate-fade-in">Start Verification</span>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Loader2
|
||||
className="animate-spin text-slate-400"
|
||||
size={32}
|
||||
style={{ transform: `rotate(${spinnerRot}deg)` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success State */}
|
||||
{isSuccess && (
|
||||
<div className="flex items-center gap-3 animate-slide-up text-slate-900">
|
||||
<Check size={28} strokeWidth={3} />
|
||||
<span>Verified</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toast Notification Layer - Bottom Center */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 flex justify-center pb-20"
|
||||
style={{
|
||||
transform: `translateY(${toastY}px)`,
|
||||
opacity: Math.max(0, toastOpacity)
|
||||
}}
|
||||
>
|
||||
<Toast show={true} text="" />
|
||||
</div>
|
||||
|
||||
{/* 3D Cursor */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0, left: 0,
|
||||
transform: `translate(${mouseX}px, ${mouseY}px) skewX(${cursorSkew}deg) rotateX(${clickRotate}deg)`,
|
||||
zIndex: 1000,
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
>
|
||||
<MouseCursor x={0} y={0} isClicking={isClicking} />
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
292
apps/web/video/compositions/ContactFormShowcase.tsx
Normal file
292
apps/web/video/compositions/ContactFormShowcase.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import React, { useMemo, useEffect, useState } from 'react';
|
||||
import {
|
||||
AbsoluteFill,
|
||||
interpolate,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
Easing,
|
||||
Img,
|
||||
delayRender,
|
||||
continueRender,
|
||||
spring,
|
||||
Audio,
|
||||
staticFile,
|
||||
} 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 Deep Interaction Script'));
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') (window as any).isRemotion = true;
|
||||
const timer = setTimeout(() => continueRender(handle), 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [handle]);
|
||||
|
||||
// ---- TIMELINE CONSTANTS (1500 Frames / 25s) ----
|
||||
const T = useMemo(() => ({
|
||||
ENTER: 0,
|
||||
// Step 0: Type Selection
|
||||
SELECT_WEBSITE: 60,
|
||||
NEXT_0: 100,
|
||||
|
||||
// Step 1: Company Profile
|
||||
COMPANY_TYPE_START: 150,
|
||||
COMPANY_TYPE_END: 250,
|
||||
NEXT_1: 300,
|
||||
|
||||
// Step 2: Presence
|
||||
URL_TYPE_START: 350,
|
||||
URL_TYPE_END: 450,
|
||||
NEXT_2: 500,
|
||||
|
||||
// Step 3: Scope & Services (Multi-clicks)
|
||||
SCOPE_CLICK_1: 550,
|
||||
SCOPE_CLICK_2: 600,
|
||||
SCOPE_CLICK_3: 650,
|
||||
NEXT_3: 750,
|
||||
|
||||
// Step 4: Design Vibe
|
||||
VIBE_SELECT: 850,
|
||||
NEXT_4: 950,
|
||||
|
||||
// Step 5: Contact Info
|
||||
NAME_TYPE_START: 1050,
|
||||
NAME_TYPE_END: 1120,
|
||||
EMAIL_TYPE_START: 1150,
|
||||
EMAIL_TYPE_END: 1250,
|
||||
MESSAGE_TYPE_START: 1280,
|
||||
MESSAGE_TYPE_END: 1400,
|
||||
SUBMIT: 1450,
|
||||
|
||||
EXIT: 1500,
|
||||
}), []);
|
||||
|
||||
// ---- FORM STATE LOGIC ----
|
||||
const formState = useMemo(() => {
|
||||
const state = { ...initialState };
|
||||
|
||||
// Step 0: Fixed to website per request
|
||||
state.projectType = 'website';
|
||||
|
||||
// Step 1: Company
|
||||
if (frame > T.COMPANY_TYPE_START) {
|
||||
const text = "Mintel Studios";
|
||||
const progress = interpolate(frame, [T.COMPANY_TYPE_START, T.COMPANY_TYPE_END], [0, text.length], { extrapolateRight: 'clamp' });
|
||||
state.companyName = text.substring(0, Math.round(progress));
|
||||
}
|
||||
|
||||
// Step 2: URL
|
||||
if (frame > T.URL_TYPE_START) {
|
||||
const text = "mintel.me";
|
||||
const progress = interpolate(frame, [T.URL_TYPE_START, T.URL_TYPE_END], [0, text.length], { extrapolateRight: 'clamp' });
|
||||
state.existingWebsite = text.substring(0, Math.round(progress));
|
||||
}
|
||||
|
||||
// Step 3: Selections
|
||||
if (frame > T.SCOPE_CLICK_1) state.selectedPages = ['Home', 'About'];
|
||||
if (frame > T.SCOPE_CLICK_2) state.selectedPages = ['Home', 'About', 'Services'];
|
||||
if (frame > T.SCOPE_CLICK_3) state.features = ['blog_news'];
|
||||
|
||||
// Step 4: Design
|
||||
if (frame > T.VIBE_SELECT) state.designVibe = 'tech';
|
||||
|
||||
// Step 5: Contact
|
||||
if (frame > T.NAME_TYPE_START) {
|
||||
const text = "Marc Mintel";
|
||||
const progress = interpolate(frame, [T.NAME_TYPE_START, T.NAME_TYPE_END], [0, text.length], { extrapolateRight: 'clamp' });
|
||||
state.name = text.substring(0, Math.round(progress));
|
||||
}
|
||||
if (frame > T.EMAIL_TYPE_START) {
|
||||
const text = "marc@mintel.me";
|
||||
const progress = interpolate(frame, [T.EMAIL_TYPE_START, T.EMAIL_TYPE_END], [0, text.length], { extrapolateRight: 'clamp' });
|
||||
state.email = text.substring(0, Math.round(progress));
|
||||
}
|
||||
if (frame > T.MESSAGE_TYPE_START) {
|
||||
const text = "Hi folks! Let's build something cinematic and smooth.";
|
||||
const progress = interpolate(frame, [T.MESSAGE_TYPE_START, T.MESSAGE_TYPE_END], [0, text.length], { extrapolateRight: 'clamp' });
|
||||
state.message = text.substring(0, Math.round(progress));
|
||||
}
|
||||
|
||||
return state;
|
||||
}, [frame, T]);
|
||||
|
||||
// ---- STEP NAVIGATION ----
|
||||
const stepIndex = useMemo(() => {
|
||||
if (frame < T.NEXT_0) return 0;
|
||||
if (frame < T.NEXT_1) return 1;
|
||||
if (frame < T.NEXT_2) return 2;
|
||||
if (frame < T.NEXT_3) return 3;
|
||||
if (frame < T.NEXT_4) return 6; // Mapping depends on actual component order, let's assume standard sequence
|
||||
if (frame < T.SUBMIT) return 12; // Final Contact step
|
||||
return 13; // Success
|
||||
}, [frame, T]);
|
||||
|
||||
// ---- CAMERA ANCHORS ----
|
||||
const anchors = useMemo(() => ({
|
||||
'overview': { z: 0.85, x: 0, y: 0 },
|
||||
'type': { z: 1.15, x: 250, y: 150 },
|
||||
'company': { z: 1.3, x: 100, y: 50 },
|
||||
'presence': { z: 1.3, x: 100, y: -50 },
|
||||
'scope': { z: 1.1, x: -100, y: 0 },
|
||||
'design': { z: 1.15, x: 150, y: 100 },
|
||||
'contact': { z: 1.25, x: 0, y: 400 },
|
||||
'success': { z: 0.9, x: 0, y: 0 },
|
||||
}), []);
|
||||
|
||||
const activeAnchor = useMemo(() => {
|
||||
if (frame < T.SELECT_WEBSITE) return anchors.overview;
|
||||
if (frame < T.NEXT_0) return anchors.type;
|
||||
if (frame < T.NEXT_1) return anchors.company;
|
||||
if (frame < T.NEXT_2) return anchors.presence;
|
||||
if (frame < T.NEXT_3) return anchors.scope;
|
||||
if (frame < T.NEXT_4) return anchors.design;
|
||||
if (frame < T.SUBMIT) return anchors.contact;
|
||||
return anchors.success;
|
||||
}, [frame, anchors, T]);
|
||||
|
||||
const camera = useMemo(() => {
|
||||
// Continuous organic spring follow
|
||||
const s = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { stiffness: 45, damping: 20 },
|
||||
});
|
||||
|
||||
// This is a simplified lerp since spring() is stateless per frame in remotion,
|
||||
// for true chasing we'd need a custom reducer or just accept the "settle" behavior.
|
||||
// Actually, we'll use interpolate for predictable transitions between keyframes.
|
||||
return activeAnchor;
|
||||
}, [frame, activeAnchor, fps]);
|
||||
|
||||
// Simple smooth camera interpolation for the actual movement
|
||||
const smoothCamera = useMemo(() => {
|
||||
// To avoid jumpiness when anchor switches, we could use a custom useSpring alternative,
|
||||
// but for now let's just use the active anchor and let the frame-based spring handle the property drift if planned.
|
||||
// Actually, let's just use Interpolation for reliability.
|
||||
return activeAnchor;
|
||||
}, [activeAnchor]);
|
||||
|
||||
// ---- MOUSE PATH ----
|
||||
const mouse = useMemo(() => {
|
||||
const targets = {
|
||||
off: { x: width * 1.2, y: height * 1.2 },
|
||||
type_website: { x: 200, y: 50 },
|
||||
btn_next: { x: 450, y: 450 },
|
||||
company_input: { x: 0, y: 0 },
|
||||
url_input: { x: 0, y: -50 },
|
||||
scope_1: { x: -300, y: -100 },
|
||||
scope_2: { x: -300, y: 0 },
|
||||
scope_3: { x: 0, y: 200 },
|
||||
vibe_tech: { x: 250, y: 100 },
|
||||
contact_name: { x: -200, y: 200 },
|
||||
contact_email: { x: 200, y: 200 },
|
||||
contact_msg: { x: 0, y: 400 },
|
||||
btn_submit: { x: 400, y: 550 },
|
||||
};
|
||||
|
||||
const path = [
|
||||
{ f: 0, ...targets.off },
|
||||
{ f: T.SELECT_WEBSITE, ...targets.type_website },
|
||||
{ f: T.NEXT_0, ...targets.btn_next },
|
||||
{ f: T.COMPANY_TYPE_START, ...targets.company_input },
|
||||
{ f: T.NEXT_1, ...targets.btn_next },
|
||||
{ f: T.URL_TYPE_START, ...targets.url_input },
|
||||
{ f: T.NEXT_2, ...targets.btn_next },
|
||||
{ f: T.SCOPE_CLICK_1, ...targets.scope_1 },
|
||||
{ f: T.SCOPE_CLICK_2, ...targets.scope_2 },
|
||||
{ f: T.SCOPE_CLICK_3, ...targets.scope_3 },
|
||||
{ f: T.NEXT_3, ...targets.btn_next },
|
||||
{ f: T.VIBE_SELECT, ...targets.vibe_tech },
|
||||
{ f: T.NEXT_4, ...targets.btn_next },
|
||||
{ f: T.NAME_TYPE_START, ...targets.contact_name },
|
||||
{ f: T.EMAIL_TYPE_START, ...targets.contact_email },
|
||||
{ f: T.MESSAGE_TYPE_START, ...targets.contact_msg },
|
||||
{ f: T.SUBMIT, ...targets.btn_submit },
|
||||
{ f: T.EXIT, ...targets.off },
|
||||
];
|
||||
|
||||
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: 60, damping: 25 },
|
||||
});
|
||||
|
||||
return {
|
||||
x: interpolate(s, [0, 1], [p1.x, p2.x]),
|
||||
y: interpolate(s, [0, 1], [p1.y, p2.y]),
|
||||
};
|
||||
}, [frame, width, height, fps, T]);
|
||||
|
||||
const isClicking = useMemo(() => {
|
||||
const clicks = [
|
||||
T.SELECT_WEBSITE, T.NEXT_0, T.NEXT_1, T.NEXT_2,
|
||||
T.SCOPE_CLICK_1, T.SCOPE_CLICK_2, T.SCOPE_CLICK_3, T.NEXT_3,
|
||||
T.VIBE_SELECT, T.NEXT_4, T.SUBMIT
|
||||
];
|
||||
return clicks.some(c => frame >= c && frame < c + 8);
|
||||
}, [frame, T]);
|
||||
|
||||
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(smoothCamera.x)}px, ${Math.round(smoothCamera.y)}px, 0) scale(${smoothCamera.z.toFixed(4)})`,
|
||||
}}
|
||||
>
|
||||
<div style={{ transform: 'scale(0.85) translate3d(0,0,0)', width: '1200px' }} className="focus-layer">
|
||||
<ContactForm initialStepIndex={stepIndex} initialState={formState} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%', left: '50%',
|
||||
transform: `translate3d(${Math.round(mouse.x)}px, ${Math.round(mouse.y)}px, 0)`,
|
||||
zIndex: 1000,
|
||||
marginTop: -12, marginLeft: -6,
|
||||
}}
|
||||
className="focus-layer"
|
||||
>
|
||||
<MouseCursor isClicking={isClicking} x={0} y={0} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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 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>
|
||||
);
|
||||
};
|
||||
4
apps/web/video/index.ts
Normal file
4
apps/web/video/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { registerRoot } from 'remotion';
|
||||
import { RemotionRoot } from './Root';
|
||||
|
||||
registerRoot(RemotionRoot);
|
||||
114
apps/web/video/mocks/framer-motion.tsx
Normal file
114
apps/web/video/mocks/framer-motion.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React from 'react';
|
||||
|
||||
// ULTRA-CRITICAL ANIMATION KILLER
|
||||
// This mock covers all possible Framer Motion V12 entry points
|
||||
// and forces absolute determinism on both HTML and SVG elements.
|
||||
|
||||
const createMotionComponent = (Tag: string) => {
|
||||
const Component = React.forwardRef(({
|
||||
children, style, animate, initial, whileHover, whileTap,
|
||||
transition, layout, layoutId,
|
||||
variants, ...props
|
||||
}: any, ref) => {
|
||||
|
||||
// 1. Resolve State
|
||||
// If animate is a string (variant), we try to find it in variants,
|
||||
// but since we want to be deterministic, we just ignore variants for now
|
||||
// to avoid complex logic. We assume the component state is driven by props.
|
||||
|
||||
// 2. Resolve Attributes (for SVG)
|
||||
// Framer motion allows animating SVG attributes like 'r', 'cx' directly.
|
||||
// We must spread 'animate' into the props to snap them.
|
||||
const resolvedProps = { ...props };
|
||||
if (typeof animate === 'object' && !Array.isArray(animate)) {
|
||||
Object.assign(resolvedProps, animate);
|
||||
} else if (Array.isArray(animate)) {
|
||||
// Handle keyframes by taking the first one
|
||||
Object.assign(resolvedProps, animate[0]);
|
||||
}
|
||||
|
||||
// 3. Resolve Style
|
||||
const combinedStyle = {
|
||||
...style,
|
||||
...(typeof initial === 'object' && !Array.isArray(initial) ? initial : {}),
|
||||
...(typeof animate === 'object' && !Array.isArray(animate) ? animate : {})
|
||||
};
|
||||
|
||||
// Final cleaning of motion-specific props that shouldn't leak to DOM
|
||||
const {
|
||||
viewport, transition: _t, onAnimationStart, onAnimationComplete,
|
||||
onUpdate, onPan, onPanStart, onPanEnd, onPanSessionStart,
|
||||
onTap, onTapStart, onTapCancel, onHoverStart, onHoverEnd,
|
||||
...domProps
|
||||
} = resolvedProps;
|
||||
|
||||
return (
|
||||
<Tag
|
||||
ref={ref}
|
||||
{...domProps}
|
||||
style={combinedStyle}
|
||||
data-framer-captured="true"
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
});
|
||||
Component.displayName = `motion.${Tag}`;
|
||||
return Component;
|
||||
};
|
||||
|
||||
export const motion: any = {
|
||||
div: createMotionComponent('div'),
|
||||
button: createMotionComponent('button'),
|
||||
h1: createMotionComponent('h1'),
|
||||
h2: createMotionComponent('h2'),
|
||||
h3: createMotionComponent('h3'),
|
||||
h4: createMotionComponent('h4'),
|
||||
p: createMotionComponent('p'),
|
||||
span: createMotionComponent('span'),
|
||||
section: createMotionComponent('section'),
|
||||
nav: createMotionComponent('nav'),
|
||||
svg: createMotionComponent('svg'),
|
||||
path: createMotionComponent('path'),
|
||||
circle: createMotionComponent('circle'),
|
||||
rect: createMotionComponent('rect'),
|
||||
line: createMotionComponent('line'),
|
||||
polyline: createMotionComponent('polyline'),
|
||||
polygon: createMotionComponent('polygon'),
|
||||
ellipse: createMotionComponent('ellipse'),
|
||||
g: createMotionComponent('g'),
|
||||
a: createMotionComponent('a'),
|
||||
li: createMotionComponent('li'),
|
||||
ul: createMotionComponent('ul'),
|
||||
};
|
||||
|
||||
export const m = motion;
|
||||
export const AnimatePresence = ({ children }: any) => <>{children}</>;
|
||||
export const MotionConfig = ({ children }: any) => <>{children}</>;
|
||||
export const LayoutGroup = ({ children }: any) => <>{children}</>;
|
||||
export const LazyMotion = ({ children }: any) => <>{children}</>;
|
||||
|
||||
export const useAnimation = () => ({
|
||||
start: () => Promise.resolve(),
|
||||
set: () => { },
|
||||
stop: () => { },
|
||||
mount: () => { },
|
||||
});
|
||||
|
||||
export const useInView = () => true;
|
||||
export const useScroll = () => ({
|
||||
scrollYProgress: { get: () => 0, onChange: () => () => { }, getVelocity: () => 0 },
|
||||
scrollY: { get: () => 0, onChange: () => () => { }, getVelocity: () => 0 }
|
||||
});
|
||||
|
||||
export const useTransform = (value: any, from: any[], to: any[]) => to[0];
|
||||
export const useSpring = (value: any) => value;
|
||||
export const useCycle = (...args: any[]) => [args[0], () => { }];
|
||||
export const useIsPresent = () => true;
|
||||
export const useReducedMotion = () => true;
|
||||
export const useAnimationControls = useAnimation;
|
||||
export const usePresence = () => [true, null];
|
||||
|
||||
export const isValidMotionProp = () => true;
|
||||
|
||||
export default motion;
|
||||
8
apps/web/video/mocks/next-image.tsx
Normal file
8
apps/web/video/mocks/next-image.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
|
||||
const Image: React.FC<any> = ({ src, alt, ...props }) => {
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
return <img src={src} alt={alt} {...props} />;
|
||||
};
|
||||
|
||||
export default Image;
|
||||
20
apps/web/video/mocks/next-navigation.tsx
Normal file
20
apps/web/video/mocks/next-navigation.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
export const useRouter = () => ({
|
||||
push: () => { },
|
||||
replace: () => { },
|
||||
prefetch: () => { },
|
||||
back: () => { },
|
||||
});
|
||||
|
||||
export const useSearchParams = () => {
|
||||
return new URLSearchParams();
|
||||
};
|
||||
|
||||
export const usePathname = () => '/';
|
||||
|
||||
export const Link: React.FC<{ href: string; children: React.ReactNode; className?: string }> = ({ children, className }) => {
|
||||
return <div className={className}>{children}</div>;
|
||||
};
|
||||
|
||||
export default Link;
|
||||
5
apps/web/video/mocks/reveal.tsx
Normal file
5
apps/web/video/mocks/reveal.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
export const Reveal = ({ children }: { children: React.ReactNode }) => {
|
||||
return <>{children}</>;
|
||||
};
|
||||
44
apps/web/video/style.css
Normal file
44
apps/web/video/style.css
Normal file
@@ -0,0 +1,44 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src: url('https://rsms.me/inter/font-files/Inter-Regular.woff2?v=3.19') format('woff2');
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src: url('https://rsms.me/inter/font-files/Inter-Bold.woff2?v=3.19') format('woff2');
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/*
|
||||
REMOTION HARD-FREEZE
|
||||
We must disable EVERY browser-native transition and animation.
|
||||
These run on real-time and will always lag in frame-by-frame renders.
|
||||
*/
|
||||
* {
|
||||
transition: none !important;
|
||||
transition-property: none !important;
|
||||
transition-duration: 0s !important;
|
||||
transition-delay: 0s !important;
|
||||
animation: none !important;
|
||||
animation-duration: 0s !important;
|
||||
animation-delay: 0s !important;
|
||||
animation-iteration-count: 0 !important;
|
||||
animation-fill-mode: none !important;
|
||||
}
|
||||
|
||||
/* Ensure no smooth scrolling which fights Remotion */
|
||||
html {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
24
apps/web/video/utils/animations.ts
Normal file
24
apps/web/video/utils/animations.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { spring, SpringConfig } from 'remotion';
|
||||
|
||||
export const COMPONENT_SPRING: Partial<SpringConfig> = {
|
||||
stiffness: 200,
|
||||
damping: 20,
|
||||
mass: 1,
|
||||
};
|
||||
|
||||
export const MOUSE_SPRING: Partial<SpringConfig> = {
|
||||
stiffness: 150,
|
||||
damping: 15,
|
||||
mass: 0.5,
|
||||
};
|
||||
|
||||
export const clickAnimation = (frame: number, clickFrame: number, fps: number) => {
|
||||
return spring({
|
||||
frame: frame - clickFrame,
|
||||
fps,
|
||||
config: {
|
||||
stiffness: 300,
|
||||
damping: 10,
|
||||
},
|
||||
});
|
||||
};
|
||||
28
apps/web/video/webpack-override.ts
Normal file
28
apps/web/video/webpack-override.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { WebpackOverrideFn } from '@remotion/bundler';
|
||||
import path from 'path';
|
||||
|
||||
export const webpackOverride: WebpackOverrideFn = (currentConfig) => {
|
||||
return {
|
||||
...currentConfig,
|
||||
resolve: {
|
||||
...currentConfig.resolve,
|
||||
alias: {
|
||||
...(currentConfig.resolve?.alias ?? {}),
|
||||
'@': path.resolve(__dirname, '..'),
|
||||
'next/navigation': path.resolve(__dirname, 'mocks/next-navigation.tsx'),
|
||||
'next/image': path.resolve(__dirname, 'mocks/next-image.tsx'),
|
||||
'next/link': path.resolve(__dirname, 'mocks/next-navigation.tsx'),
|
||||
|
||||
// SYSTEMATIC ALIASING FOR ALL ANIMATION PROXYING
|
||||
'framer-motion': path.resolve(__dirname, 'mocks/framer-motion.tsx'),
|
||||
'framer-motion/dist/framer-motion': path.resolve(__dirname, 'mocks/framer-motion.tsx'),
|
||||
|
||||
// Reveal Component Proxying (Deterministic Reveal)
|
||||
'../Reveal': path.resolve(__dirname, 'mocks/reveal.tsx'),
|
||||
'../../Reveal': path.resolve(__dirname, 'mocks/reveal.tsx'),
|
||||
'../../../Reveal': path.resolve(__dirname, 'mocks/reveal.tsx'),
|
||||
'@/src/components/Reveal': path.resolve(__dirname, 'mocks/reveal.tsx'),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user