feat(film): crane swing curve (TDD), crane SVG, suspended CTA card
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user