clawdbot-workspace/reonomy-demo-video/src/ReonomyCanvasDemo.tsx

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>
);
};