feat: Integrate Remotion for video generation, add video compositions, and adapt ContactForm for Remotion compatibility.
Some checks failed
Build & Deploy Mintel Blog / build-and-deploy (push) Failing after 2m19s
Some checks failed
Build & Deploy Mintel Blog / build-and-deploy (push) Failing after 2m19s
This commit is contained in:
40
.agent/workflows/video-toolkit.md
Normal file
40
.agent/workflows/video-toolkit.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
description: Build and render UI showcase videos using Remotion
|
||||||
|
---
|
||||||
|
|
||||||
|
# UI Showcase Toolkit Workflow
|
||||||
|
|
||||||
|
This workflow guides you through creating and rendering deterministic UI showcase videos for LinkedIn/social media.
|
||||||
|
|
||||||
|
## 1. Create a new Composition
|
||||||
|
Add a new file in `video/compositions/` (e.g., `MyNewComponent.tsx`).
|
||||||
|
Use the existing `ButtonShowcase.tsx` as a template. The default format is **1080x1350 (4:5)** which is optimized for LinkedIn portrait view.
|
||||||
|
|
||||||
|
## 2. Register the Composition
|
||||||
|
Open `video/Root.tsx` and add your new composition using the `<Composition />` component.
|
||||||
|
|
||||||
|
## 3. Preview locally
|
||||||
|
// turbo
|
||||||
|
Run the following command to open the Remotion Studio:
|
||||||
|
```bash
|
||||||
|
npm run video:preview
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Render the video
|
||||||
|
// turbo
|
||||||
|
To render a deterministic, pixel-perfect MP4 file:
|
||||||
|
```bash
|
||||||
|
npm run video:render
|
||||||
|
```
|
||||||
|
The output will be saved in `out/button-showcase.mp4`.
|
||||||
|
|
||||||
|
## 5. Scripted Rendering (CLI)
|
||||||
|
You can customize the props via the CLI:
|
||||||
|
```bash
|
||||||
|
npx remotion render video/index.ts ButtonShowcase out/button-showcase.mp4 --props '{"text": "Register Now"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tech Stack Note
|
||||||
|
- **Remotion**: Video rendering engine
|
||||||
|
- **Tailwind**: Styling (reusing project's `tailwind.config.js`)
|
||||||
|
- **Framer Motion**: Supported for complex animations within compositions
|
||||||
19
README.md
19
README.md
@@ -70,4 +70,21 @@ The blog uses modular React components for article elements:
|
|||||||
- **Readability First**: Clear typography, generous spacing
|
- **Readability First**: Clear typography, generous spacing
|
||||||
- **No Distractions**: Clean layout, focus on content
|
- **No Distractions**: Clean layout, focus on content
|
||||||
- **Practical**: Tools and techniques that work
|
- **Practical**: Tools and techniques that work
|
||||||
- **Calm**: No hype, just facts
|
- **Calm**: No hype, just facts
|
||||||
|
|
||||||
|
## 🎥 UI Showcase Toolkit
|
||||||
|
A Remotion-based toolkit for creating high-quality, deterministic showcase videos of our React components.
|
||||||
|
|
||||||
|
- **Source of Truth**: Renders real project components.
|
||||||
|
- **Automated**: CLI-driven rendering for LinkedIn-ready videos.
|
||||||
|
- **Pixel-Perfect**: 60fps, crisp text, brand-aligned style.
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
```bash
|
||||||
|
# Preview compositions
|
||||||
|
npm run video:preview
|
||||||
|
|
||||||
|
# Render the showcase video
|
||||||
|
npm run video:render
|
||||||
|
```
|
||||||
|
See `.agent/workflows/video-toolkit.md` for more details.
|
||||||
5111
package-lock.json
generated
5111
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@@ -12,10 +12,20 @@
|
|||||||
"test:smoke": "tsx ./scripts/smoke-test.ts",
|
"test:smoke": "tsx ./scripts/smoke-test.ts",
|
||||||
"test:links": "tsx ./scripts/test-links.ts",
|
"test:links": "tsx ./scripts/test-links.ts",
|
||||||
"test:file-examples": "tsx ./scripts/test-file-examples-comprehensive.ts",
|
"test:file-examples": "tsx ./scripts/test-file-examples-comprehensive.ts",
|
||||||
"clone-website": "tsx ./scripts/clone-recursive.ts"
|
"clone-website": "tsx ./scripts/clone-recursive.ts",
|
||||||
|
"video:preview": "remotion preview video/index.ts",
|
||||||
|
"video:render": "remotion render video/index.ts ButtonShowcase out/button-showcase.mp4",
|
||||||
|
"video:render:contact": "remotion render video/index.ts ContactFormShowcase out/contact-showcase.mp4 --concurrency=1 --codec=h264 --crf=16 --pixel-format=yuv420p --overwrite",
|
||||||
|
"video:render:button": "remotion render video/index.ts ButtonShowcase out/button-showcase.mp4 --concurrency=1 --codec=h264 --crf=16 --pixel-format=yuv420p --overwrite",
|
||||||
|
"video:render:all": "npm run video:render:contact && npm run video:render:button"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
|
"@remotion/bundler": "^4.0.414",
|
||||||
|
"@remotion/cli": "^4.0.414",
|
||||||
|
"@remotion/lottie": "^4.0.414",
|
||||||
|
"@remotion/renderer": "^4.0.414",
|
||||||
|
"@remotion/tailwind": "^4.0.414",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/ioredis": "^4.28.10",
|
"@types/ioredis": "^4.28.10",
|
||||||
"@types/react": "^19.2.8",
|
"@types/react": "^19.2.8",
|
||||||
@@ -35,11 +45,13 @@
|
|||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
|
"remotion": "^4.0.414",
|
||||||
"shiki": "^1.24.2",
|
"shiki": "^1.24.2",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"website-scraper": "^6.0.0",
|
"website-scraper": "^6.0.0",
|
||||||
"website-scraper-puppeteer": "^2.0.0"
|
"website-scraper-puppeteer": "^2.0.0",
|
||||||
|
"zod": "3.22.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
@@ -51,4 +63,4 @@
|
|||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "5.9.3"
|
"typescript": "5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
25
remotion.config.ts
Normal file
25
remotion.config.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Config } from '@remotion/cli/config';
|
||||||
|
import { enableTailwind } from '@remotion/tailwind';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
Config.overrideWebpackConfig((currentConfig) => {
|
||||||
|
const withTailwind = enableTailwind(currentConfig);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...withTailwind,
|
||||||
|
resolve: {
|
||||||
|
...withTailwind.resolve,
|
||||||
|
alias: {
|
||||||
|
...(withTailwind.resolve?.alias ?? {}),
|
||||||
|
'@/src': path.resolve(process.cwd(), 'src'),
|
||||||
|
'@/components': path.resolve(process.cwd(), 'src/components'),
|
||||||
|
'@': path.resolve(process.cwd()),
|
||||||
|
// Mock next/link
|
||||||
|
'next/link': path.resolve(process.cwd(), 'video/components/NextLinkMock.tsx'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
Config.setVideoImageFormat('jpeg');
|
||||||
|
Config.setCodec('h264');
|
||||||
@@ -29,51 +29,87 @@ import { TimelineStep } from './ContactForm/steps/TimelineStep';
|
|||||||
import { ContactStep } from './ContactForm/steps/ContactStep';
|
import { ContactStep } from './ContactForm/steps/ContactStep';
|
||||||
import { WebAppStep } from './ContactForm/steps/WebAppStep';
|
import { WebAppStep } from './ContactForm/steps/WebAppStep';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ConceptTarget,
|
ConceptTarget,
|
||||||
ConceptWebsite,
|
ConceptWebsite,
|
||||||
ConceptPrototyping,
|
ConceptPrototyping,
|
||||||
ConceptCommunication,
|
ConceptCommunication,
|
||||||
ConceptSystem,
|
ConceptSystem,
|
||||||
ConceptCode,
|
ConceptCode,
|
||||||
ConceptAutomation,
|
ConceptAutomation,
|
||||||
ConceptPrice,
|
ConceptPrice,
|
||||||
HeroArchitecture
|
HeroArchitecture
|
||||||
} from './Landing/ConceptIllustrations';
|
} from './Landing/ConceptIllustrations';
|
||||||
|
|
||||||
export function ContactForm() {
|
export interface ContactFormProps {
|
||||||
const router = useRouter();
|
initialStepIndex?: number;
|
||||||
const searchParams = useSearchParams();
|
initialState?: FormState;
|
||||||
const [stepIndex, setStepIndex] = useState(0);
|
onStepChange?: (index: number) => void;
|
||||||
const [state, setState] = useState<FormState>(initialState);
|
onStateChange?: (state: FormState) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactForm({ initialStepIndex, initialState: propState, onStepChange, onStateChange }: ContactFormProps = {}) {
|
||||||
|
// Use a safe version of useRouter/useSearchParams that doesn't crash if not in a router context
|
||||||
|
let router: any = null;
|
||||||
|
let searchParams: any = null;
|
||||||
|
try { router = useRouter(); } catch (e) { /* ignore */ }
|
||||||
|
try { searchParams = useSearchParams(); } catch (e) { /* ignore */ }
|
||||||
|
|
||||||
|
const [internalStepIndex, setInternalStepIndex] = useState(0);
|
||||||
|
const [internalState, setInternalState] = useState<FormState>(initialState);
|
||||||
|
|
||||||
|
// Sync with props if provided
|
||||||
|
const stepIndex = initialStepIndex !== undefined ? initialStepIndex : internalStepIndex;
|
||||||
|
const state = propState !== undefined ? propState : internalState;
|
||||||
|
|
||||||
|
const setStepIndex = (val: number) => {
|
||||||
|
setInternalStepIndex(val);
|
||||||
|
onStepChange?.(val);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setState = (val: any) => {
|
||||||
|
if (typeof val === 'function') {
|
||||||
|
setInternalState(prev => {
|
||||||
|
const next = val(prev);
|
||||||
|
onStateChange?.(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setInternalState(val);
|
||||||
|
onStateChange?.(val);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||||
const [isClient, setIsClient] = useState(false);
|
|
||||||
const [qrCodeData, setQrCodeData] = useState<string>('');
|
const [qrCodeData, setQrCodeData] = useState<string>('');
|
||||||
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
|
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
|
||||||
const [hoveredStep, setHoveredStep] = useState<number | null>(null);
|
const [hoveredStep, setHoveredStep] = useState<number | null>(null);
|
||||||
const [isSticky, setIsSticky] = useState(false);
|
const [isSticky, setIsSticky] = useState(false);
|
||||||
const formContainerRef = useRef<HTMLDivElement>(null);
|
const formContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const isRemotion = typeof window !== 'undefined' && (window as any).isRemotion;
|
||||||
|
const [isClient, setIsClient] = useState(isRemotion);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isRemotion) return;
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
if (formContainerRef.current) {
|
if (formContainerRef.current) {
|
||||||
const rect = formContainerRef.current.getBoundingClientRect();
|
const rect = formContainerRef.current.getBoundingClientRect();
|
||||||
// Stick when the container top reaches the sticky position
|
|
||||||
setIsSticky(rect.top <= 80);
|
setIsSticky(rect.top <= 80);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('scroll', handleScroll);
|
window.addEventListener('scroll', handleScroll);
|
||||||
// Initial check
|
|
||||||
handleScroll();
|
handleScroll();
|
||||||
return () => window.removeEventListener('scroll', handleScroll);
|
return () => window.removeEventListener('scroll', handleScroll);
|
||||||
}, []);
|
}, [isRemotion]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsClient(true);
|
if (!isRemotion) setIsClient(true);
|
||||||
}, []);
|
}, [isRemotion]);
|
||||||
|
|
||||||
// URL Binding
|
// URL Binding
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!searchParams) return;
|
||||||
const step = searchParams.get('step');
|
const step = searchParams.get('step');
|
||||||
if (step) setStepIndex(parseInt(step));
|
if (step) setStepIndex(parseInt(step));
|
||||||
|
|
||||||
@@ -81,18 +117,18 @@ export function ContactForm() {
|
|||||||
if (config) {
|
if (config) {
|
||||||
try {
|
try {
|
||||||
const decoded = JSON.parse(decodeURIComponent(escape(atob(config))));
|
const decoded = JSON.parse(decodeURIComponent(escape(atob(config))));
|
||||||
setState(s => ({ ...s, ...decoded }));
|
setInternalState((s: FormState) => ({ ...s, ...decoded }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to decode config", e);
|
console.error("Failed to decode config", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, [searchParams]);
|
||||||
|
|
||||||
const currentUrl = useMemo(() => {
|
const currentUrl = useMemo(() => {
|
||||||
if (!isClient) return '';
|
if (!isClient) return '';
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('step', stepIndex.toString());
|
params.set('step', stepIndex.toString());
|
||||||
|
|
||||||
const configData = {
|
const configData = {
|
||||||
projectType: state.projectType,
|
projectType: state.projectType,
|
||||||
companyName: state.companyName,
|
companyName: state.companyName,
|
||||||
@@ -131,19 +167,20 @@ export function ContactForm() {
|
|||||||
visualStaging: state.visualStaging,
|
visualStaging: state.visualStaging,
|
||||||
complexInteractions: state.complexInteractions
|
complexInteractions: state.complexInteractions
|
||||||
};
|
};
|
||||||
|
|
||||||
const stateString = btoa(unescape(encodeURIComponent(JSON.stringify(configData))));
|
const stateString = btoa(unescape(encodeURIComponent(JSON.stringify(configData))));
|
||||||
params.set('config', stateString);
|
params.set('config', stateString);
|
||||||
|
|
||||||
return `${window.location.origin}${window.location.pathname}?${params.toString()}`;
|
return `${window.location.origin}${window.location.pathname}?${params.toString()}`;
|
||||||
}, [state, stepIndex, isClient]);
|
}, [state, stepIndex, isClient]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentUrl) {
|
if (isRemotion) return;
|
||||||
|
if (currentUrl && router) {
|
||||||
router.replace(currentUrl, { scroll: false });
|
router.replace(currentUrl, { scroll: false });
|
||||||
QRCode.toDataURL(currentUrl, { margin: 1, width: 200 }).then(setQrCodeData);
|
QRCode.toDataURL(currentUrl, { margin: 1, width: 200 }).then(setQrCodeData);
|
||||||
}
|
}
|
||||||
}, [currentUrl]);
|
}, [currentUrl, router, isRemotion]);
|
||||||
|
|
||||||
const totalPagesCount = useMemo(() => {
|
const totalPagesCount = useMemo(() => {
|
||||||
return state.selectedPages.length + state.otherPages.length + (state.otherPagesCount || 0);
|
return state.selectedPages.length + state.otherPages.length + (state.otherPagesCount || 0);
|
||||||
@@ -179,7 +216,7 @@ export function ContactForm() {
|
|||||||
}, [state]);
|
}, [state]);
|
||||||
|
|
||||||
const updateState = (updates: Partial<FormState>) => {
|
const updateState = (updates: Partial<FormState>) => {
|
||||||
setState((s) => ({ ...s, ...updates }));
|
setState((s: FormState) => ({ ...s, ...updates }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleItem = (list: string[], id: string) => {
|
const toggleItem = (list: string[], id: string) => {
|
||||||
@@ -187,6 +224,7 @@ export function ContactForm() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const scrollToTop = () => {
|
const scrollToTop = () => {
|
||||||
|
if (isRemotion) return;
|
||||||
if (formContainerRef.current) {
|
if (formContainerRef.current) {
|
||||||
const offset = 120;
|
const offset = 120;
|
||||||
const bodyRect = document.body.getBoundingClientRect().top;
|
const bodyRect = document.body.getBoundingClientRect().top;
|
||||||
@@ -204,14 +242,14 @@ export function ContactForm() {
|
|||||||
const nextStep = () => {
|
const nextStep = () => {
|
||||||
if (stepIndex < activeSteps.length - 1) {
|
if (stepIndex < activeSteps.length - 1) {
|
||||||
setStepIndex(stepIndex + 1);
|
setStepIndex(stepIndex + 1);
|
||||||
setTimeout(scrollToTop, 50);
|
if (!isRemotion) setTimeout(scrollToTop, 50);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const prevStep = () => {
|
const prevStep = () => {
|
||||||
if (stepIndex > 0) {
|
if (stepIndex > 0) {
|
||||||
setStepIndex(stepIndex - 1);
|
setStepIndex(stepIndex - 1);
|
||||||
setTimeout(scrollToTop, 50);
|
if (!isRemotion) setTimeout(scrollToTop, 50);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -311,7 +349,7 @@ export function ContactForm() {
|
|||||||
${state.message}
|
${state.message}
|
||||||
`;
|
`;
|
||||||
window.location.href = `mailto:marc@mintel.me?subject=Projektanfrage: ${state.name}&body=${encodeURIComponent(mailBody)}`;
|
window.location.href = `mailto:marc@mintel.me?subject=Projektanfrage: ${state.name}&body=${encodeURIComponent(mailBody)}`;
|
||||||
|
|
||||||
// Celebration!
|
// Celebration!
|
||||||
const duration = 5 * 1000;
|
const duration = 5 * 1000;
|
||||||
const animationEnd = Date.now() + duration;
|
const animationEnd = Date.now() + duration;
|
||||||
@@ -319,7 +357,7 @@ export function ContactForm() {
|
|||||||
|
|
||||||
const randomInRange = (min: number, max: number) => Math.random() * (max - min) + min;
|
const randomInRange = (min: number, max: number) => Math.random() * (max - min) + min;
|
||||||
|
|
||||||
const interval: any = setInterval(function() {
|
const interval: any = !isRemotion ? setInterval(function () {
|
||||||
const timeLeft = animationEnd - Date.now();
|
const timeLeft = animationEnd - Date.now();
|
||||||
|
|
||||||
if (timeLeft <= 0) {
|
if (timeLeft <= 0) {
|
||||||
@@ -327,9 +365,9 @@ export function ContactForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const particleCount = 50 * (timeLeft / duration);
|
const particleCount = 50 * (timeLeft / duration);
|
||||||
confetti({ ...defaults, particleCount, origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 } });
|
(confetti as any)({ ...defaults, particleCount, origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 } });
|
||||||
confetti({ ...defaults, particleCount, origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 } });
|
(confetti as any)({ ...defaults, particleCount, origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 } });
|
||||||
}, 250);
|
}, 250) : null;
|
||||||
|
|
||||||
setIsSubmitted(true);
|
setIsSubmitted(true);
|
||||||
} else {
|
} else {
|
||||||
@@ -430,9 +468,10 @@ export function ContactForm() {
|
|||||||
<ChevronLeft size={isSticky ? 16 : 20} /> Zurück
|
<ChevronLeft size={isSticky ? 16 : 20} /> Zurück
|
||||||
</motion.button>
|
</motion.button>
|
||||||
) : <div className={isSticky ? 'w-0' : 'w-32'} />}
|
) : <div className={isSticky ? 'w-0' : 'w-32'} />}
|
||||||
|
|
||||||
{stepIndex < activeSteps.length - 1 ? (
|
{stepIndex < activeSteps.length - 1 ? (
|
||||||
<motion.button
|
<motion.button
|
||||||
|
id="focus-target-next"
|
||||||
whileHover={{ x: 3, scale: 1.02 }}
|
whileHover={{ x: 3, scale: 1.02 }}
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -455,7 +494,7 @@ export function ContactForm() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className={`flex gap-1.5 transition-all duration-500 ${isSticky ? 'h-1' : 'h-2.5'}`}>
|
<div className={`flex gap-1.5 transition-all duration-500 ${isSticky ? 'h-1' : 'h-2.5'}`}>
|
||||||
{activeSteps.map((step, i) => (
|
{activeSteps.map((step, i) => (
|
||||||
@@ -471,10 +510,9 @@ export function ContactForm() {
|
|||||||
setStepIndex(i);
|
setStepIndex(i);
|
||||||
setTimeout(scrollToTop, 50);
|
setTimeout(scrollToTop, 50);
|
||||||
}}
|
}}
|
||||||
className={`w-full h-full rounded-full transition-all duration-700 ${
|
className={`w-full h-full rounded-full transition-all duration-700 ${i === stepIndex ? 'bg-slate-900 scale-y-150 shadow-lg shadow-slate-200' :
|
||||||
i === stepIndex ? 'bg-slate-900 scale-y-150 shadow-lg shadow-slate-200' :
|
|
||||||
i < stepIndex ? 'bg-slate-400' : 'bg-slate-100'
|
i < stepIndex ? 'bg-slate-400' : 'bg-slate-100'
|
||||||
} cursor-pointer focus:outline-none p-0 border-none relative group`}
|
} cursor-pointer focus:outline-none p-0 border-none relative group`}
|
||||||
>
|
>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{hoveredStep === i && (
|
{hoveredStep === i && (
|
||||||
@@ -492,23 +530,22 @@ export function ContactForm() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isSticky && (
|
{!isSticky && (
|
||||||
<div className="flex justify-between mt-4 px-1">
|
<div className="flex justify-between mt-4 px-1">
|
||||||
{chapters.map((chapter, idx) => {
|
{chapters.map((chapter, idx) => {
|
||||||
const chapterSteps = activeSteps.filter(s => s.chapter === chapter.id);
|
const chapterSteps = activeSteps.filter(s => s.chapter === chapter.id);
|
||||||
if (chapterSteps.length === 0) return null;
|
if (chapterSteps.length === 0) return null;
|
||||||
|
|
||||||
const firstStepIdx = activeSteps.indexOf(chapterSteps[0]);
|
const firstStepIdx = activeSteps.indexOf(chapterSteps[0]);
|
||||||
const lastStepIdx = activeSteps.indexOf(chapterSteps[chapterSteps.length - 1]);
|
const lastStepIdx = activeSteps.indexOf(chapterSteps[chapterSteps.length - 1]);
|
||||||
const isActive = stepIndex >= firstStepIdx && stepIndex <= lastStepIdx;
|
const isActive = stepIndex >= firstStepIdx && stepIndex <= lastStepIdx;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={chapter.id}
|
key={chapter.id}
|
||||||
className={`text-[10px] font-bold uppercase tracking-[0.2em] transition-colors duration-500 ${
|
className={`text-[10px] font-bold uppercase tracking-[0.2em] transition-colors duration-500 ${isActive ? 'text-slate-900' : 'text-slate-300'
|
||||||
isActive ? 'text-slate-900' : 'text-slate-300'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{chapter.title}
|
{chapter.title}
|
||||||
</div>
|
</div>
|
||||||
@@ -533,7 +570,7 @@ export function ContactForm() {
|
|||||||
{renderStepContent()}
|
{renderStepContent()}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Contextual Help / Why this matters */}
|
{/* Contextual Help / Why this matters */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
@@ -559,21 +596,21 @@ export function ContactForm() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<PriceCalculation
|
<PriceCalculation
|
||||||
state={state}
|
state={state}
|
||||||
totalPrice={totalPrice}
|
totalPrice={totalPrice}
|
||||||
monthlyPrice={monthlyPrice}
|
monthlyPrice={monthlyPrice}
|
||||||
totalPagesCount={totalPagesCount}
|
totalPagesCount={totalPagesCount}
|
||||||
isClient={isClient}
|
isClient={isClient}
|
||||||
qrCodeData={qrCodeData}
|
qrCodeData={qrCodeData}
|
||||||
onShare={handleShare}
|
onShare={handleShare}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ShareModal
|
<ShareModal
|
||||||
isOpen={isShareModalOpen}
|
isOpen={isShareModalOpen}
|
||||||
onClose={() => setIsShareModalOpen(false)}
|
onClose={() => setIsShareModalOpen(false)}
|
||||||
url={currentUrl}
|
url={currentUrl}
|
||||||
qrCodeData={qrCodeData}
|
qrCodeData={qrCodeData}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function CompanyStep({ state, updateState }: CompanyStepProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-16">
|
<div className="space-y-16">
|
||||||
<Reveal width="100%" delay={0.1}>
|
<Reveal width="100%" delay={0.1}>
|
||||||
<div className="space-y-8">
|
<div className="space-y-8" id="focus-target-company">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-black">
|
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-black">
|
||||||
<Building2 size={24} />
|
<Building2 size={24} />
|
||||||
@@ -27,9 +27,9 @@ export function CompanyStep({ state, updateState }: CompanyStepProps) {
|
|||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
label="Name des Unternehmens"
|
label="Name des Unternehmens"
|
||||||
placeholder="z.B. Muster GmbH"
|
placeholder="z.B. Muster GmbH"
|
||||||
value={state.companyName}
|
value={state.companyName}
|
||||||
onChange={(e) => updateState({ companyName: e.target.value })}
|
onChange={(e) => updateState({ companyName: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
@@ -50,9 +50,8 @@ export function CompanyStep({ state, updateState }: CompanyStepProps) {
|
|||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => updateState({ employeeCount: option.id })}
|
onClick={() => updateState({ employeeCount: option.id })}
|
||||||
className={`p-6 rounded-[2rem] border-2 transition-all duration-300 font-bold text-lg ${
|
className={`p-6 rounded-[2rem] border-2 transition-all duration-300 font-bold text-lg ${state.employeeCount === option.id ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-100 bg-white hover:border-slate-300 text-slate-600'
|
||||||
state.employeeCount === option.id ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-100 bg-white hover:border-slate-300 text-slate-600'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
|||||||
@@ -20,13 +20,13 @@ export function TypeStep({ state, updateState }: TypeStepProps) {
|
|||||||
].map((type, index) => (
|
].map((type, index) => (
|
||||||
<Reveal key={type.id} width="100%" delay={index * 0.1}>
|
<Reveal key={type.id} width="100%" delay={index * 0.1}>
|
||||||
<motion.button
|
<motion.button
|
||||||
|
id={`focus-target-${type.id}`}
|
||||||
whileHover={{ y: -8 }}
|
whileHover={{ y: -8 }}
|
||||||
whileTap={{ scale: 0.98 }}
|
whileTap={{ scale: 0.98 }}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => updateState({ projectType: type.id as ProjectType })}
|
onClick={() => updateState({ projectType: type.id as ProjectType })}
|
||||||
className={`w-full p-12 rounded-[4rem] border-2 text-left transition-all duration-700 focus:outline-none overflow-hidden relative group ${
|
className={`w-full p-12 rounded-[4rem] border-2 text-left transition-all duration-700 focus:outline-none overflow-hidden relative group ${state.projectType === type.id ? 'border-slate-900 bg-slate-900 text-white shadow-2xl shadow-slate-400' : 'border-slate-100 bg-white hover:border-slate-300 hover:shadow-xl'
|
||||||
state.projectType === type.id ? 'border-slate-900 bg-slate-900 text-white shadow-2xl shadow-slate-400' : 'border-slate-100 bg-white hover:border-slate-300 hover:shadow-xl'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div className={`transition-all duration-700 group-hover:scale-110 group-hover:rotate-3 ${state.projectType === type.id ? 'text-white' : 'text-slate-900'}`}>{type.illustration}</div>
|
<div className={`transition-all duration-700 group-hover:scale-110 group-hover:rotate-3 ${state.projectType === type.id ? 'text-white' : 'text-slate-900'}`}>{type.illustration}</div>
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="flex items-center gap-4 mb-6">
|
||||||
@@ -34,7 +34,7 @@ export function TypeStep({ state, updateState }: TypeStepProps) {
|
|||||||
<span className={`px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest ${state.projectType === type.id ? 'bg-white/20 text-white' : 'bg-slate-100 text-slate-500'}`}>Grundlage</span>
|
<span className={`px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest ${state.projectType === type.id ? 'bg-white/20 text-white' : 'bg-slate-100 text-slate-500'}`}>Grundlage</span>
|
||||||
</div>
|
</div>
|
||||||
<p className={`text-2xl leading-relaxed ${state.projectType === type.id ? 'text-slate-200' : 'text-slate-500'}`}>{type.desc}</p>
|
<p className={`text-2xl leading-relaxed ${state.projectType === type.id ? 'text-slate-200' : 'text-slate-500'}`}>{type.desc}</p>
|
||||||
|
|
||||||
{state.projectType === type.id && (
|
{state.projectType === type.id && (
|
||||||
<motion.div
|
<motion.div
|
||||||
layoutId="activeType"
|
layoutId="activeType"
|
||||||
|
|||||||
34
video/Root.tsx
Normal file
34
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={900}
|
||||||
|
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
video/components/MouseCursor.tsx
Normal file
44
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
video/components/NextLinkMock.tsx
Normal file
7
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
video/compositions/ButtonShowcase.tsx
Normal file
254
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
209
video/compositions/ContactFormShowcase.tsx
Normal file
209
video/compositions/ContactFormShowcase.tsx
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
4
video/index.ts
Normal file
4
video/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { registerRoot } from 'remotion';
|
||||||
|
import { RemotionRoot } from './Root';
|
||||||
|
|
||||||
|
registerRoot(RemotionRoot);
|
||||||
114
video/mocks/framer-motion.tsx
Normal file
114
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
video/mocks/next-image.tsx
Normal file
8
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
video/mocks/next-navigation.tsx
Normal file
20
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
video/mocks/reveal.tsx
Normal file
5
video/mocks/reveal.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export const Reveal = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
44
video/style.css
Normal file
44
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
video/utils/animations.ts
Normal file
24
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
video/webpack-override.ts
Normal file
28
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