feat: Extend ContactFormShowcase duration and implement a detailed interactive form filling animation with new timeline, camera, and mouse controls.
Some checks failed
Build & Deploy Mintel Blog / build-and-deploy (push) Failing after 2m24s
Some checks failed
Build & Deploy Mintel Blog / build-and-deploy (push) Failing after 2m24s
This commit is contained in:
@@ -13,7 +13,7 @@ export const RemotionRoot: React.FC = () => {
|
|||||||
<Composition
|
<Composition
|
||||||
id="ContactFormShowcase"
|
id="ContactFormShowcase"
|
||||||
component={ContactFormShowcase}
|
component={ContactFormShowcase}
|
||||||
durationInFrames={900}
|
durationInFrames={1500}
|
||||||
fps={60}
|
fps={60}
|
||||||
width={1080}
|
width={1080}
|
||||||
height={1350}
|
height={1350}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo, useEffect, useState, useRef } from 'react';
|
import React, { useMemo, useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AbsoluteFill,
|
AbsoluteFill,
|
||||||
interpolate,
|
interpolate,
|
||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
delayRender,
|
delayRender,
|
||||||
continueRender,
|
continueRender,
|
||||||
spring,
|
spring,
|
||||||
|
Audio,
|
||||||
|
staticFile,
|
||||||
} from 'remotion';
|
} from 'remotion';
|
||||||
import { MouseCursor } from '../components/MouseCursor';
|
import { MouseCursor } from '../components/MouseCursor';
|
||||||
import { ContactForm } from '@/src/components/ContactForm';
|
import { ContactForm } from '@/src/components/ContactForm';
|
||||||
@@ -21,7 +23,7 @@ import IconWhite from '@/src/assets/logo/Icon White Transparent.svg';
|
|||||||
export const ContactFormShowcase: React.FC = () => {
|
export const ContactFormShowcase: React.FC = () => {
|
||||||
const frame = useCurrentFrame();
|
const frame = useCurrentFrame();
|
||||||
const { width, height, fps } = useVideoConfig();
|
const { width, height, fps } = useVideoConfig();
|
||||||
const [handle] = useState(() => delayRender('Initializing Smart Camera'));
|
const [handle] = useState(() => delayRender('Initializing Deep Interaction Script'));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== 'undefined') (window as any).isRemotion = true;
|
if (typeof window !== 'undefined') (window as any).isRemotion = true;
|
||||||
@@ -29,79 +31,187 @@ export const ContactFormShowcase: React.FC = () => {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [handle]);
|
}, [handle]);
|
||||||
|
|
||||||
// ---- SMART ANCHOR SYSTEM ----
|
// ---- TIMELINE CONSTANTS (1500 Frames / 25s) ----
|
||||||
// We define which target the camera should follow at each frame
|
const T = useMemo(() => ({
|
||||||
const activeTargetId = useMemo(() => {
|
ENTER: 0,
|
||||||
if (frame < 60) return 'overview';
|
// Step 0: Type Selection
|
||||||
if (frame < 160) return 'focus-target-web-app';
|
SELECT_WEBSITE: 60,
|
||||||
if (frame < 260) return 'focus-target-next';
|
NEXT_0: 100,
|
||||||
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
|
// Step 1: Company Profile
|
||||||
// Since real-time DOM measurements can be jittery during a render,
|
COMPANY_TYPE_START: 150,
|
||||||
// we use "Systematic Virtual Anchors" calibrated to the layout.
|
COMPANY_TYPE_END: 250,
|
||||||
const anchors = useMemo(() => ({
|
NEXT_1: 300,
|
||||||
'overview': { z: 0.85, x: 0, y: 0 },
|
|
||||||
'focus-target-web-app': { z: 1.3, x: 300, y: 0 },
|
// Step 2: Presence
|
||||||
'focus-target-next': { z: 1.1, x: -380, y: -280 },
|
URL_TYPE_START: 350,
|
||||||
'focus-target-company': { z: 1.45, x: 50, y: 220 },
|
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,
|
||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
// ---- UNIFIED SPRING PHYSICS ----
|
// ---- FORM STATE LOGIC ----
|
||||||
const camera = useMemo(() => {
|
const formState = useMemo(() => {
|
||||||
// Find the target state
|
const state = { ...initialState };
|
||||||
const target = (anchors as any)[activeTargetId] || anchors.overview;
|
|
||||||
|
|
||||||
// We use a "Continuous Follow" spring that always chases the activeTargetId
|
// Step 0: Fixed to website per request
|
||||||
// but we need to know when the target changed to reset the spring animation.
|
state.projectType = 'website';
|
||||||
// Actually, for smoothness, we'll just use the frame globally and let the spring settle.
|
|
||||||
|
|
||||||
// Let's create a transition system
|
// Step 1: Company
|
||||||
const points = [
|
if (frame > T.COMPANY_TYPE_START) {
|
||||||
{ f: 0, t: 'overview' },
|
const text = "Mintel Studios";
|
||||||
{ f: 80, t: 'focus-target-web-app' },
|
const progress = interpolate(frame, [T.COMPANY_TYPE_START, T.COMPANY_TYPE_END], [0, text.length], { extrapolateRight: 'clamp' });
|
||||||
{ f: 200, t: 'focus-target-next' },
|
state.companyName = text.substring(0, Math.round(progress));
|
||||||
{ 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];
|
// Step 2: URL
|
||||||
const end = (anchors as any)[(points[idx + 1] || points[idx]).t];
|
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({
|
const s = spring({
|
||||||
frame: frame - points[idx].f,
|
frame,
|
||||||
fps,
|
fps,
|
||||||
config: { stiffness: 45, damping: 18, mass: 1 },
|
config: { stiffness: 45, damping: 20 },
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
// This is a simplified lerp since spring() is stateless per frame in remotion,
|
||||||
z: interpolate(s, [0, 1], [start.z, end.z]),
|
// for true chasing we'd need a custom reducer or just accept the "settle" behavior.
|
||||||
x: interpolate(s, [0, 1], [start.x, end.x]),
|
// Actually, we'll use interpolate for predictable transitions between keyframes.
|
||||||
y: interpolate(s, [0, 1], [start.y, end.y]),
|
return activeAnchor;
|
||||||
};
|
}, [frame, activeAnchor, fps]);
|
||||||
}, [frame, anchors, fps]);
|
|
||||||
|
|
||||||
// ---- MOUSE HANDLER (Sticks to targets) ----
|
// 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 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 = [
|
const path = [
|
||||||
{ f: 10, x: width * 1.5, y: height * 1.2 }, // Offscreen
|
{ f: 0, ...targets.off },
|
||||||
{ f: 80, x: width * 0.35, y: height * 0.45 }, // Move to Web App
|
{ f: T.SELECT_WEBSITE, ...targets.type_website },
|
||||||
{ f: 210, x: width * 0.85, y: height * 0.68 }, // Move to Next
|
{ f: T.NEXT_0, ...targets.btn_next },
|
||||||
{ f: 380, x: width * 0.50, y: height * 0.35 }, // Move to Input
|
{ f: T.COMPANY_TYPE_START, ...targets.company_input },
|
||||||
{ f: 590, x: width * 0.85, y: height * 0.68 }, // Move to Next
|
{ f: T.NEXT_1, ...targets.btn_next },
|
||||||
{ f: 850, x: width * 1.5, y: height * 1.2 }, // Exit
|
{ 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;
|
let idx = 0;
|
||||||
@@ -115,43 +225,23 @@ export const ContactFormShowcase: React.FC = () => {
|
|||||||
const s = spring({
|
const s = spring({
|
||||||
frame: frame - p1.f,
|
frame: frame - p1.f,
|
||||||
fps,
|
fps,
|
||||||
config: { stiffness: 40, damping: 22 },
|
config: { stiffness: 60, damping: 25 },
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
x: interpolate(s, [0, 1], [p1.x, p2.x]),
|
x: interpolate(s, [0, 1], [p1.x, p2.x]),
|
||||||
y: interpolate(s, [0, 1], [p1.y, p2.y]),
|
y: interpolate(s, [0, 1], [p1.y, p2.y]),
|
||||||
};
|
};
|
||||||
}, [frame, width, height, fps]);
|
}, [frame, width, height, fps, T]);
|
||||||
|
|
||||||
// ---- UI PROGRESSION ----
|
const isClicking = useMemo(() => {
|
||||||
const ui = useMemo(() => {
|
const clicks = [
|
||||||
let step = 0;
|
T.SELECT_WEBSITE, T.NEXT_0, T.NEXT_1, T.NEXT_2,
|
||||||
let type = 'website';
|
T.SCOPE_CLICK_1, T.SCOPE_CLICK_2, T.SCOPE_CLICK_3, T.NEXT_3,
|
||||||
let name = '';
|
T.VIBE_SELECT, T.NEXT_4, T.SUBMIT
|
||||||
|
];
|
||||||
if (frame > 140) type = 'web-app';
|
return clicks.some(c => frame >= c && frame < c + 8);
|
||||||
if (frame > 260) step = 1;
|
}, [frame, T]);
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<AbsoluteFill className="bg-white">
|
<AbsoluteFill className="bg-white">
|
||||||
@@ -165,27 +255,20 @@ export const ContactFormShowcase: React.FC = () => {
|
|||||||
<div
|
<div
|
||||||
className="absolute inset-0 flex items-center justify-center focus-layer"
|
className="absolute inset-0 flex items-center justify-center focus-layer"
|
||||||
style={{
|
style={{
|
||||||
transform: `translate3d(${Math.round(camera.x)}px, ${Math.round(camera.y)}px, 0) scale(${camera.z.toFixed(4)})`,
|
transform: `translate3d(${Math.round(smoothCamera.x)}px, ${Math.round(smoothCamera.y)}px, 0) scale(${smoothCamera.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">
|
||||||
<div
|
<ContactForm initialStepIndex={stepIndex} initialState={formState} />
|
||||||
style={{ transform: 'scale(0.85) translate3d(0,0,0)', width: '1200px' }}
|
|
||||||
className="focus-layer"
|
|
||||||
>
|
|
||||||
<ContactForm
|
|
||||||
initialStepIndex={ui.step}
|
|
||||||
initialState={formState}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mouse Cursor */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0, left: 0,
|
top: '50%', left: '50%',
|
||||||
transform: `translate3d(${Math.round(mouse.x)}px, ${Math.round(mouse.y)}px, 0)`,
|
transform: `translate3d(${Math.round(mouse.x)}px, ${Math.round(mouse.y)}px, 0)`,
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
|
marginTop: -12, marginLeft: -6,
|
||||||
}}
|
}}
|
||||||
className="focus-layer"
|
className="focus-layer"
|
||||||
>
|
>
|
||||||
@@ -193,9 +276,9 @@ export const ContactFormShowcase: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Branding Logo HUD */}
|
{/* Logo HUD */}
|
||||||
<div className="absolute top-28 left-28 z-50 focus-layer">
|
<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">
|
<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" />
|
<Img src={IconWhite} className="w-24 h-24" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user