577 lines
16 KiB
TypeScript
577 lines
16 KiB
TypeScript
import React from "react";
|
|
import {
|
|
AbsoluteFill,
|
|
Img,
|
|
interpolate,
|
|
spring,
|
|
useCurrentFrame,
|
|
useVideoConfig,
|
|
Sequence,
|
|
staticFile,
|
|
Easing,
|
|
} from "remotion";
|
|
|
|
// ============================================
|
|
// CANVAS VIEWPORT - Zoom and pan WITHIN an image
|
|
// ============================================
|
|
const CanvasViewport: React.FC<{
|
|
src: string;
|
|
startX: number; // Starting viewport position (0-1)
|
|
startY: number;
|
|
startZoom: number; // Starting zoom level
|
|
endX: number; // Ending viewport position
|
|
endY: number;
|
|
endZoom: number;
|
|
progress: number; // Animation progress (0-1)
|
|
}> = ({ src, startX, startY, startZoom, endX, endY, endZoom, progress }) => {
|
|
const { width, height } = useVideoConfig();
|
|
|
|
// Interpolate current position
|
|
const x = interpolate(progress, [0, 1], [startX, endX]);
|
|
const y = interpolate(progress, [0, 1], [startY, endY]);
|
|
const zoom = interpolate(progress, [0, 1], [startZoom, endZoom]);
|
|
|
|
// Canvas size (image is rendered larger than viewport)
|
|
const canvasWidth = width * zoom;
|
|
const canvasHeight = height * zoom;
|
|
|
|
// Calculate offset to pan to the target position
|
|
const offsetX = (canvasWidth - width) * x;
|
|
const offsetY = (canvasHeight - height) * y;
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
width,
|
|
height,
|
|
overflow: "hidden",
|
|
position: "relative",
|
|
}}
|
|
>
|
|
<Img
|
|
src={src}
|
|
style={{
|
|
position: "absolute",
|
|
width: canvasWidth,
|
|
height: canvasHeight,
|
|
left: -offsetX,
|
|
top: -offsetY,
|
|
objectFit: "cover",
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ============================================
|
|
// STEP BADGE
|
|
// ============================================
|
|
const StepBadge: React.FC<{
|
|
step: number;
|
|
text: string;
|
|
}> = ({ step, text }) => {
|
|
const frame = useCurrentFrame();
|
|
const { fps } = useVideoConfig();
|
|
|
|
const progress = spring({ frame: frame - 10, fps, config: { damping: 20 } });
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
bottom: 50,
|
|
left: 50,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 14,
|
|
opacity: progress,
|
|
transform: `translateX(${interpolate(progress, [0, 1], [-40, 0])}px)`,
|
|
zIndex: 100,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: 52,
|
|
height: 52,
|
|
borderRadius: "50%",
|
|
background: "linear-gradient(135deg, #6366f1, #8b5cf6)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
fontSize: 26,
|
|
fontWeight: 700,
|
|
color: "white",
|
|
boxShadow: "0 8px 30px rgba(99, 102, 241, 0.5)",
|
|
}}
|
|
>
|
|
{step}
|
|
</div>
|
|
<div
|
|
style={{
|
|
background: "rgba(0, 0, 0, 0.85)",
|
|
backdropFilter: "blur(10px)",
|
|
padding: "14px 28px",
|
|
borderRadius: 10,
|
|
fontSize: 20,
|
|
fontWeight: 600,
|
|
color: "white",
|
|
}}
|
|
>
|
|
{text}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ============================================
|
|
// TITLE CARD
|
|
// ============================================
|
|
const TitleCard: React.FC<{
|
|
title: string;
|
|
subtitle?: string;
|
|
}> = ({ title, subtitle }) => {
|
|
const frame = useCurrentFrame();
|
|
const { fps } = useVideoConfig();
|
|
|
|
const progress = spring({ frame: frame - 15, fps, config: { damping: 25 } });
|
|
const subtitleProgress = spring({ frame: frame - 40, fps, config: { damping: 25 } });
|
|
|
|
return (
|
|
<AbsoluteFill
|
|
style={{
|
|
background: "linear-gradient(135deg, #0f172a 0%, #1e1b4b 50%, #0f172a 100%)",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
fontSize: 72,
|
|
fontWeight: 800,
|
|
color: "white",
|
|
textAlign: "center",
|
|
opacity: progress,
|
|
transform: `translateY(${interpolate(progress, [0, 1], [40, 0])}px)`,
|
|
}}
|
|
>
|
|
{title}
|
|
</div>
|
|
{subtitle && (
|
|
<div
|
|
style={{
|
|
fontSize: 28,
|
|
color: "#94a3b8",
|
|
marginTop: 20,
|
|
opacity: subtitleProgress,
|
|
transform: `translateY(${interpolate(subtitleProgress, [0, 1], [20, 0])}px)`,
|
|
}}
|
|
>
|
|
{subtitle}
|
|
</div>
|
|
)}
|
|
</AbsoluteFill>
|
|
);
|
|
};
|
|
|
|
// ============================================
|
|
// SCENE WRAPPER with crossfade
|
|
// ============================================
|
|
const Scene: React.FC<{
|
|
children: React.ReactNode;
|
|
fadeIn?: number;
|
|
fadeOut?: number;
|
|
}> = ({ children, fadeIn = 20, fadeOut = 20 }) => {
|
|
const frame = useCurrentFrame();
|
|
const { durationInFrames } = useVideoConfig();
|
|
|
|
const opacity = Math.min(
|
|
interpolate(frame, [0, fadeIn], [0, 1], { extrapolateRight: "clamp" }),
|
|
interpolate(frame, [durationInFrames - fadeOut, durationInFrames], [1, 0], { extrapolateLeft: "clamp" })
|
|
);
|
|
|
|
return <AbsoluteFill style={{ opacity }}>{children}</AbsoluteFill>;
|
|
};
|
|
|
|
// ============================================
|
|
// INDIVIDUAL SCENES with canvas viewport movement
|
|
// ============================================
|
|
|
|
const SceneLogin: React.FC = () => {
|
|
const frame = useCurrentFrame();
|
|
const { durationInFrames } = useVideoConfig();
|
|
|
|
// Eased progress through the scene
|
|
const progress = interpolate(frame, [0, durationInFrames], [0, 1], {
|
|
easing: Easing.inOut(Easing.cubic),
|
|
});
|
|
|
|
return (
|
|
<Scene>
|
|
<AbsoluteFill style={{ background: "#0f172a" }}>
|
|
<CanvasViewport
|
|
src={staticFile("assets/02-login-filled.png")}
|
|
startX={0.5}
|
|
startY={0.2}
|
|
startZoom={2.0} // Start zoomed into the logo/header
|
|
endX={0.5}
|
|
endY={0.7}
|
|
endZoom={2.5} // End zoomed into login button
|
|
progress={progress}
|
|
/>
|
|
{/* Vignette */}
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
inset: 0,
|
|
background: "radial-gradient(ellipse at center, transparent 30%, rgba(0,0,0,0.5) 100%)",
|
|
pointerEvents: "none",
|
|
}}
|
|
/>
|
|
<StepBadge step={1} text="Login to Reonomy" />
|
|
</AbsoluteFill>
|
|
</Scene>
|
|
);
|
|
};
|
|
|
|
const SceneSearch: React.FC = () => {
|
|
const frame = useCurrentFrame();
|
|
const { durationInFrames } = useVideoConfig();
|
|
|
|
const progress = interpolate(frame, [0, durationInFrames], [0, 1], {
|
|
easing: Easing.inOut(Easing.cubic),
|
|
});
|
|
|
|
return (
|
|
<Scene>
|
|
<AbsoluteFill style={{ background: "#0f172a" }}>
|
|
<CanvasViewport
|
|
src={staticFile("assets/04-search-results.png")}
|
|
startX={0.0}
|
|
startY={0.0}
|
|
startZoom={1.8} // Start on search bar area
|
|
endX={0.3}
|
|
endY={0.5}
|
|
endZoom={1.6} // Pan down to results
|
|
progress={progress}
|
|
/>
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
inset: 0,
|
|
background: "radial-gradient(ellipse at center, transparent 30%, rgba(0,0,0,0.5) 100%)",
|
|
pointerEvents: "none",
|
|
}}
|
|
/>
|
|
<StepBadge step={2} text="Search & Filter" />
|
|
</AbsoluteFill>
|
|
</Scene>
|
|
);
|
|
};
|
|
|
|
const SceneProperty: React.FC = () => {
|
|
const frame = useCurrentFrame();
|
|
const { durationInFrames } = useVideoConfig();
|
|
|
|
const progress = interpolate(frame, [0, durationInFrames], [0, 1], {
|
|
easing: Easing.inOut(Easing.cubic),
|
|
});
|
|
|
|
return (
|
|
<Scene>
|
|
<AbsoluteFill style={{ background: "#0f172a" }}>
|
|
<CanvasViewport
|
|
src={staticFile("assets/05-property-detail.png")}
|
|
startX={0.3}
|
|
startY={0.0}
|
|
startZoom={1.6} // Start on property header/image
|
|
endX={0.5}
|
|
endY={0.4}
|
|
endZoom={1.4} // Pan to property details
|
|
progress={progress}
|
|
/>
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
inset: 0,
|
|
background: "radial-gradient(ellipse at center, transparent 30%, rgba(0,0,0,0.5) 100%)",
|
|
pointerEvents: "none",
|
|
}}
|
|
/>
|
|
<StepBadge step={3} text="Select Property" />
|
|
</AbsoluteFill>
|
|
</Scene>
|
|
);
|
|
};
|
|
|
|
const SceneOwner: React.FC = () => {
|
|
const frame = useCurrentFrame();
|
|
const { durationInFrames } = useVideoConfig();
|
|
|
|
const progress = interpolate(frame, [0, durationInFrames], [0, 1], {
|
|
easing: Easing.inOut(Easing.cubic),
|
|
});
|
|
|
|
return (
|
|
<Scene>
|
|
<AbsoluteFill style={{ background: "#0f172a" }}>
|
|
<CanvasViewport
|
|
src={staticFile("assets/06-owner-tab.png")}
|
|
startX={0.5}
|
|
startY={0.2}
|
|
startZoom={1.5} // Start on tabs
|
|
endX={0.6}
|
|
endY={0.5}
|
|
endZoom={1.8} // Zoom into owner info
|
|
progress={progress}
|
|
/>
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
inset: 0,
|
|
background: "radial-gradient(ellipse at center, transparent 30%, rgba(0,0,0,0.5) 100%)",
|
|
pointerEvents: "none",
|
|
}}
|
|
/>
|
|
<StepBadge step={4} text="Owner Tab" />
|
|
</AbsoluteFill>
|
|
</Scene>
|
|
);
|
|
};
|
|
|
|
const SceneContacts: React.FC = () => {
|
|
const frame = useCurrentFrame();
|
|
const { durationInFrames } = useVideoConfig();
|
|
|
|
const progress = interpolate(frame, [0, durationInFrames], [0, 1], {
|
|
easing: Easing.inOut(Easing.cubic),
|
|
});
|
|
|
|
return (
|
|
<Scene>
|
|
<AbsoluteFill style={{ background: "#0f172a" }}>
|
|
<CanvasViewport
|
|
src={staticFile("assets/09-contact-modal.png")}
|
|
startX={0.6}
|
|
startY={0.1}
|
|
startZoom={1.8} // Start on contact name
|
|
endX={0.6}
|
|
endY={0.6}
|
|
endZoom={2.2} // Pan down through all contacts
|
|
progress={progress}
|
|
/>
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
inset: 0,
|
|
background: "radial-gradient(ellipse at center, transparent 30%, rgba(0,0,0,0.5) 100%)",
|
|
pointerEvents: "none",
|
|
}}
|
|
/>
|
|
<StepBadge step={5} text="Extract Contacts" />
|
|
</AbsoluteFill>
|
|
</Scene>
|
|
);
|
|
};
|
|
|
|
const SceneResults: React.FC = () => {
|
|
const frame = useCurrentFrame();
|
|
const { fps } = useVideoConfig();
|
|
|
|
const phones = [
|
|
"919-469-9553",
|
|
"727-341-0186",
|
|
"903-566-9506",
|
|
"407-671-2400",
|
|
"407-382-2683",
|
|
];
|
|
|
|
const emails = [
|
|
"berrizoro@gmail.com",
|
|
"aberriz@hotmail.com",
|
|
"jasonhitch1@gmail.com",
|
|
"albert@annarborusa.org",
|
|
"albertb@sterlinghousing.com",
|
|
];
|
|
|
|
return (
|
|
<Scene>
|
|
<AbsoluteFill
|
|
style={{
|
|
background: "linear-gradient(135deg, #0f172a 0%, #1e1b4b 50%, #0f172a 100%)",
|
|
padding: 80,
|
|
}}
|
|
>
|
|
{/* Title */}
|
|
<div style={{ textAlign: "center", marginBottom: 50 }}>
|
|
<div
|
|
style={{
|
|
fontSize: 52,
|
|
fontWeight: 800,
|
|
color: "white",
|
|
opacity: spring({ frame: frame - 10, fps, config: { damping: 25 } }),
|
|
}}
|
|
>
|
|
🎉 Contacts Extracted
|
|
</div>
|
|
</div>
|
|
|
|
{/* Two columns */}
|
|
<div style={{ display: "flex", gap: 60, justifyContent: "center" }}>
|
|
{/* Phones */}
|
|
<div>
|
|
<div
|
|
style={{
|
|
fontSize: 22,
|
|
color: "#10b981",
|
|
marginBottom: 20,
|
|
fontWeight: 700,
|
|
opacity: spring({ frame: frame - 30, fps }),
|
|
}}
|
|
>
|
|
📞 Phone Numbers
|
|
</div>
|
|
{phones.map((phone, i) => {
|
|
const p = spring({ frame: frame - 40 - i * 8, fps, config: { damping: 18 } });
|
|
return (
|
|
<div
|
|
key={phone}
|
|
style={{
|
|
background: "rgba(255,255,255,0.95)",
|
|
padding: "14px 24px",
|
|
borderRadius: 10,
|
|
marginBottom: 10,
|
|
fontSize: 18,
|
|
fontWeight: 600,
|
|
color: "#1e293b",
|
|
opacity: p,
|
|
transform: `translateX(${interpolate(p, [0, 1], [-30, 0])}px)`,
|
|
}}
|
|
>
|
|
{phone}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Emails */}
|
|
<div>
|
|
<div
|
|
style={{
|
|
fontSize: 22,
|
|
color: "#6366f1",
|
|
marginBottom: 20,
|
|
fontWeight: 700,
|
|
opacity: spring({ frame: frame - 35, fps }),
|
|
}}
|
|
>
|
|
📧 Email Addresses
|
|
</div>
|
|
{emails.map((email, i) => {
|
|
const p = spring({ frame: frame - 45 - i * 8, fps, config: { damping: 18 } });
|
|
return (
|
|
<div
|
|
key={email}
|
|
style={{
|
|
background: "rgba(255,255,255,0.95)",
|
|
padding: "14px 24px",
|
|
borderRadius: 10,
|
|
marginBottom: 10,
|
|
fontSize: 18,
|
|
fontWeight: 600,
|
|
color: "#1e293b",
|
|
opacity: p,
|
|
transform: `translateX(${interpolate(p, [0, 1], [30, 0])}px)`,
|
|
}}
|
|
>
|
|
{email}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "center",
|
|
gap: 60,
|
|
marginTop: 50,
|
|
}}
|
|
>
|
|
{[
|
|
{ value: "10", label: "Contacts", color: "#6366f1" },
|
|
{ value: "5", label: "Phones", color: "#10b981" },
|
|
{ value: "5", label: "Emails", color: "#f59e0b" },
|
|
{ value: "60s", label: "Time", color: "#ec4899" },
|
|
].map((stat, i) => {
|
|
const p = spring({ frame: frame - 100 - i * 10, fps, config: { damping: 20 } });
|
|
return (
|
|
<div
|
|
key={stat.label}
|
|
style={{
|
|
textAlign: "center",
|
|
opacity: p,
|
|
transform: `scale(${interpolate(p, [0, 1], [0.5, 1])})`,
|
|
}}
|
|
>
|
|
<div style={{ fontSize: 48, fontWeight: 800, color: stat.color }}>{stat.value}</div>
|
|
<div style={{ fontSize: 16, color: "#94a3b8" }}>{stat.label}</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</AbsoluteFill>
|
|
</Scene>
|
|
);
|
|
};
|
|
|
|
// ============================================
|
|
// MAIN COMPOSITION
|
|
// ============================================
|
|
export const ReonomyCanvasDemo: React.FC = () => {
|
|
return (
|
|
<AbsoluteFill style={{ backgroundColor: "#0f172a" }}>
|
|
{/* Intro */}
|
|
<Sequence from={0} durationInFrames={120}>
|
|
<TitleCard
|
|
title="🏢 Reonomy Contact Extractor"
|
|
subtitle="CRE Leads → Your CRM … On Demand"
|
|
/>
|
|
</Sequence>
|
|
|
|
{/* Login - camera moves down to login button */}
|
|
<Sequence from={110} durationInFrames={150}>
|
|
<SceneLogin />
|
|
</Sequence>
|
|
|
|
{/* Search - camera pans across search results */}
|
|
<Sequence from={250} durationInFrames={150}>
|
|
<SceneSearch />
|
|
</Sequence>
|
|
|
|
{/* Property - camera explores property details */}
|
|
<Sequence from={390} durationInFrames={140}>
|
|
<SceneProperty />
|
|
</Sequence>
|
|
|
|
{/* Owner - camera moves to owner section */}
|
|
<Sequence from={520} durationInFrames={140}>
|
|
<SceneOwner />
|
|
</Sequence>
|
|
|
|
{/* Contacts - camera pans down contact list */}
|
|
<Sequence from={650} durationInFrames={180}>
|
|
<SceneContacts />
|
|
</Sequence>
|
|
|
|
{/* Results */}
|
|
<Sequence from={820} durationInFrames={280}>
|
|
<SceneResults />
|
|
</Sequence>
|
|
</AbsoluteFill>
|
|
);
|
|
};
|