feat(film): crane swing curve (TDD), crane SVG, suspended CTA card

This commit is contained in:
Satyam Rastogi
2026-05-30 02:44:34 +05:30
parent b35033ced3
commit 350cc4a601
4 changed files with 128 additions and 0 deletions
@@ -0,0 +1,52 @@
// Blueprint tower-crane line art, ported from src/pages/Landing.jsx (dark-theme colors inlined).
const STRONG = "rgba(255,255,255,0.16)";
const MEDIUM = "rgba(255,255,255,0.10)";
const SUBTLE = "rgba(255,255,255,0.06)";
const SURFACE = "rgba(255,255,255,0.03)";
export const Crane: React.FC<{ width?: number }> = ({ width = 1100 }) => (
<svg viewBox="0 0 800 460" fill="none" style={{ width, display: "block" }} aria-hidden>
{/* tower rails */}
<line x1="145" y1="55" x2="145" y2="460" stroke={STRONG} strokeWidth="2" />
<line x1="195" y1="55" x2="195" y2="460" stroke={STRONG} strokeWidth="2" />
{[93,131,169,207,245,283,321,359,397,435].map((y) => (
<line key={y} x1="145" y1={y} x2="195" y2={y} stroke={MEDIUM} strokeWidth="1" />
))}
{/* X-bracing */}
{[[93,131],[169,207],[245,283],[321,359],[359,397],[397,435],[435,460]].map(([a,b],i)=>(
<g key={i}>
<line x1="145" y1={a} x2="195" y2={b} stroke={SUBTLE} strokeWidth="0.75" />
<line x1="195" y1={a} x2="145" y2={b} stroke={SUBTLE} strokeWidth="0.75" />
</g>
))}
{/* slewing platform + cab */}
<rect x="135" y="55" width="70" height="18" rx="2" fill={SURFACE} stroke={MEDIUM} strokeWidth="1.5" />
<rect x="135" y="40" width="34" height="17" rx="1" fill="rgba(59,130,246,0.06)" stroke="rgba(59,130,246,0.18)" strokeWidth="1" />
<rect x="140" y="44" width="12" height="8" rx="0.5" fill="rgba(59,130,246,0.1)" stroke="rgba(59,130,246,0.22)" strokeWidth="0.5" />
{/* A-frame apex */}
<line x1="170" y1="8" x2="145" y2="55" stroke={STRONG} strokeWidth="1.5" />
<line x1="170" y1="8" x2="195" y2="55" stroke={STRONG} strokeWidth="1.5" />
<line x1="155" y1="36" x2="185" y2="36" stroke={MEDIUM} strokeWidth="1" />
{/* support cables */}
<line x1="170" y1="8" x2="30" y2="58" stroke={MEDIUM} strokeWidth="1" />
<line x1="170" y1="8" x2="720" y2="58" stroke={MEDIUM} strokeWidth="1" />
{/* counter-jib */}
<line x1="30" y1="58" x2="145" y2="58" stroke={MEDIUM} strokeWidth="1.5" />
<line x1="48" y1="70" x2="145" y2="70" stroke={MEDIUM} strokeWidth="1" />
<line x1="30" y1="58" x2="48" y2="70" stroke={MEDIUM} strokeWidth="1" />
<rect x="28" y="58" width="26" height="18" rx="1" fill="rgba(59,130,246,0.08)" stroke="rgba(59,130,246,0.2)" strokeWidth="1" />
<rect x="58" y="60" width="18" height="14" rx="1" fill="rgba(59,130,246,0.05)" stroke="rgba(59,130,246,0.14)" strokeWidth="0.75" />
{/* main jib */}
<line x1="195" y1="58" x2="720" y2="58" stroke={STRONG} strokeWidth="2" />
<line x1="195" y1="70" x2="720" y2="70" stroke={MEDIUM} strokeWidth="1.5" />
<line x1="720" y1="58" x2="720" y2="70" stroke={MEDIUM} strokeWidth="1" />
{[[215,260],[280,325],[345,390],[410,455],[475,520],[540,585],[605,650],[670,715]].map(([a,b],i)=>(
<line key={i} x1={a} y1="70" x2={b} y2="58" stroke={SUBTLE} strokeWidth="0.75" />
))}
{/* trolley + hoist cable + hook */}
<rect x="390" y="70" width="20" height="10" rx="1" fill="rgba(59,130,246,0.1)" stroke="rgba(59,130,246,0.28)" strokeWidth="1" />
<line x1="400" y1="80" x2="400" y2="430" stroke="rgba(59,130,246,0.28)" strokeWidth="1.5" strokeDasharray="6 4" />
<rect x="394" y="430" width="12" height="8" rx="2" fill="rgba(59,130,246,0.1)" stroke="rgba(59,130,246,0.3)" strokeWidth="1" />
<path d="M400 438 C400 453 392 453 392 448" stroke="rgba(59,130,246,0.4)" strokeWidth="1.5" fill="none" strokeLinecap="round" />
</svg>
);
@@ -0,0 +1,47 @@
import { useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
import { theme } from "../theme";
import { swingRotation } from "./craneSwing";
export const CraneCard: React.FC<{ dropStartFrame?: number }> = ({ dropStartFrame = 0 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const local = frame - dropStartFrame;
// descend into place over the first ~1.2s
const drop = spring({ frame: local, fps, durationInFrames: 36, config: { damping: 14, mass: 0.8 } });
const dropY = interpolate(drop, [0, 1], [-220, 0]);
const rotate = swingRotation(Math.max(0, local), fps);
return (
<div
style={{
position: "relative",
transformOrigin: "top center",
transform: `translateY(${dropY}px) rotate(${rotate}deg)`,
width: 760,
background: "rgba(255,255,255,0.03)",
backdropFilter: "blur(8px)",
borderRadius: 24,
border: `1px solid ${theme.border}`,
boxShadow: "0 30px 90px rgba(0,0,0,0.55)",
padding: "56px 64px",
textAlign: "center",
fontFamily: theme.font,
}}
>
{/* attachment point connecting to the cable */}
<div style={{ position: "absolute", top: -14, left: "50%", transform: "translateX(-50%)", width: 26, height: 26, borderRadius: 999, background: theme.bg, border: "1px solid rgba(59,130,246,0.25)", display: "flex", alignItems: "center", justifyContent: "center" }}>
<div style={{ width: 9, height: 9, borderRadius: 999, background: "rgba(59,130,246,0.5)" }} />
</div>
<div style={{ display: "inline-block", padding: "6px 14px", borderRadius: 999, border: "1px solid rgba(16,185,129,0.3)", color: theme.emerald, fontSize: 18, fontWeight: 700, marginBottom: 24 }}> Project Ready</div>
<div style={{ fontSize: 64, fontWeight: 900, color: theme.text, letterSpacing: "-0.02em", marginBottom: 14 }}>Ready to Build?</div>
<div style={{ fontSize: 26, color: theme.textDim, maxWidth: 460, margin: "0 auto 36px", lineHeight: 1.4 }}>
Whether you protect one home or manage a thousand roofs, the future of roofing starts here.
</div>
<div style={{ display: "flex", gap: 18, justifyContent: "center" }}>
<div style={{ padding: "18px 34px", borderRadius: 999, background: theme.blue, color: "white", fontWeight: 700, fontSize: 22 }}>Schedule Demo </div>
<div style={{ padding: "18px 34px", borderRadius: 999, background: "rgba(255,255,255,0.06)", border: `1px solid ${theme.border}`, color: theme.text, fontWeight: 700, fontSize: 22 }}>Start Free Trial </div>
</div>
</div>
);
};
@@ -0,0 +1,19 @@
import { describe, it, expect } from "vitest";
import { swingRotation } from "./craneSwing";
describe("swingRotation", () => {
it("starts at the +3deg keyframe, amplified by the entry boost", () => {
// t=0: base = +3, boost = 3 -> 9deg
expect(swingRotation(0, 30)).toBeCloseTo(9, 5);
});
it("settles to within +/-3deg after the settle window", () => {
for (let f = 30 * 3; f < 30 * 12; f++) {
expect(Math.abs(swingRotation(f, 30))).toBeLessThanOrEqual(3.0001);
}
});
it("is periodic at 4s once settled (same phase 4s apart)", () => {
const a = swingRotation(30 * 4, 30); // t=4s
const b = swingRotation(30 * 8, 30); // t=8s
expect(a).toBeCloseTo(b, 5);
});
});
@@ -0,0 +1,10 @@
// Reproduces the landing page .crane-swing animation: rotate(+3deg) <-> rotate(-3deg),
// 4s ease-in-out loop, transform-origin top center. A cosine approximates the eased pendulum.
// An entry "boost" damps a larger initial swing down to +/-3deg over settleSeconds.
export function swingRotation(frame: number, fps: number, settleSeconds = 2): number {
const t = frame / fps;
const period = 4; // seconds
const base = 3 * Math.cos((2 * Math.PI) / period * t); // +3 at t=0
const boost = 1 + 2 * Math.max(0, 1 - t / settleSeconds); // 3x -> 1x across settle window
return base * boost;
}