feat(videos): add PrivacyPromise hero loop with shield draw animation

This commit is contained in:
SnapOtter
2026-05-08 16:34:30 +08:00
parent 07003b7ba3
commit 89eec108fe
2 changed files with 282 additions and 0 deletions
+9
View File
@@ -12,6 +12,7 @@ import { FloatingTools } from "./compositions/ambient/FloatingTools";
import { AiMagicReel } from "./compositions/features/AiMagicReel";
import { FormatUniverse } from "./compositions/features/FormatUniverse";
import { PipelineFlow } from "./compositions/features/PipelineFlow";
import { PrivacyPromise } from "./compositions/hero/PrivacyPromise";
import { TEXT } from "./lib/fonts";
const ComponentTest: React.FC = () => (
@@ -101,5 +102,13 @@ export const RemotionRoot: React.FC = () => (
width={800}
height={600}
/>
<Composition
id="PrivacyPromise"
component={PrivacyPromise}
durationInFrames={300}
fps={30}
width={1920}
height={1080}
/>
</>
);
@@ -0,0 +1,273 @@
import type React from "react";
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
import { ClipReveal } from "@/components/ClipReveal";
import { GrainOverlay } from "@/components/GrainOverlay";
import { PhotoPlaceholder } from "@/components/PhotoPlaceholder";
import { ShieldIcon } from "@/components/ShieldIcon";
import { TEXT } from "@/lib/fonts";
import { EASE, SPRING } from "@/lib/motion";
/* ------------------------------------------------------------------ */
/* Photo config */
/* ------------------------------------------------------------------ */
const PHOTOS = [
{ hue: 210, w: 180, h: 135, fromX: -300, fromY: -250, delay: 0 },
{ hue: 150, w: 160, h: 120, fromX: 320, fromY: 280, delay: 4 },
{ hue: 280, w: 150, h: 115, fromX: 280, fromY: -260, delay: 8 },
] as const;
/* ------------------------------------------------------------------ */
/* Padlock SVG (simple circle + rect) */
/* ------------------------------------------------------------------ */
const PadlockIcon: React.FC<{ opacity: number }> = ({ opacity }) => (
<svg
width={24}
height={24}
viewBox="0 0 24 24"
style={{ opacity }}
role="img"
aria-label="Padlock"
>
<rect x={5} y={11} width={14} height={11} rx={2} fill="rgba(255,255,255,0.6)" />
<path
d="M8 11V7a4 4 0 0 1 8 0v4"
stroke="rgba(255,255,255,0.6)"
strokeWidth={2}
strokeLinecap="round"
fill="none"
/>
</svg>
);
/* ------------------------------------------------------------------ */
/* Main composition */
/* ------------------------------------------------------------------ */
export const PrivacyPromise: React.FC = () => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
const cx = width / 2;
const cy = height / 2;
/* ---- Act 3: shield + photos scale & move up ---- */
const act3Scale = interpolate(frame, [160, 180], [1, 0.6], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.snap,
});
const act3OffsetY = interpolate(frame, [160, 180], [0, -120], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.snap,
});
/* ---- Act 4: fade out ---- */
const act4ShieldOpacity = interpolate(frame, [240, 270], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.exit,
});
/* ---- Shield fill (Act 2) ---- */
const shieldFillOpacity = interpolate(frame, [110, 130], [0, 0.08], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
/* ---- Shield pulse glow (Act 2, frame 130-160) ---- */
const pulseScale = frame >= 130 && frame <= 240 ? 1 + Math.sin((frame - 130) * 0.314) * 0.02 : 1;
/* ---- Padlock fade in (Act 2) ---- */
const padlockOpacity = interpolate(frame, [130, 145], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
/* ---- Act 4 scale-down for shield ---- */
const act4ShieldScale = interpolate(frame, [240, 270], [1, 0.8], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.exit,
});
/* ---- Text fade out (Act 4) ---- */
const act4TextOpacity = interpolate(frame, [240, 265], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.exit,
});
return (
<AbsoluteFill
style={{
background: `radial-gradient(ellipse 60% 50% at 50% 50%, rgba(245,158,11,0.15) 0%, #0c0a09 70%)`,
overflow: "hidden",
}}
>
{/* ---- Shield + Photos group (scales together in Act 3) ---- */}
<div
style={{
position: "absolute",
left: cx,
top: cy,
transform: `translate(-50%, -50%) translateY(${act3OffsetY}px) scale(${act3Scale * act4ShieldScale * pulseScale})`,
opacity: act4ShieldOpacity,
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
{/* Shield icon */}
<ShieldIcon size={200} startFrame={75} duration={35} fillOpacity={shieldFillOpacity} />
{/* Padlock at shield base */}
<div style={{ marginTop: -30 }}>
<PadlockIcon opacity={padlockOpacity * act4ShieldOpacity} />
</div>
{/* Photos floating around the shield */}
{PHOTOS.map((photo, i) => {
const entrySpring = spring({
frame: frame - 15 - photo.delay,
fps,
config: SPRING.natural,
});
/* Act 1: drift from edges toward center */
const driftProgress = interpolate(frame, [25, 75], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.smooth,
});
/* Act 2: settle inside shield */
const settleSpring = spring({
frame: frame - 110,
fps,
config: SPRING.settle,
});
/* Settled positions: clustered near center */
const settledX = (i - 1) * 40;
const settledY = (i - 1) * 25;
/* Current position blends drift -> settled */
const preSettleX = photo.fromX * (1 - driftProgress);
const preSettleY = photo.fromY * (1 - driftProgress);
const photoX =
frame < 110 ? preSettleX : preSettleX + (settledX - preSettleX) * settleSpring;
const photoY =
frame < 110 ? preSettleY : preSettleY + (settledY - preSettleY) * settleSpring;
/* Rotation: organic drift then settle */
const driftRotation = (i % 2 === 0 ? 3 : -3) * (1 - driftProgress);
const settledRotation = (i - 1) * 2;
const rotation =
frame < 110
? driftRotation
: driftRotation + (settledRotation - driftRotation) * settleSpring;
/* Act 4: photos drift back out (reverse of arrival) */
const exitProgress = interpolate(frame, [270, 295], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.exit,
});
const exitX = photoX + photo.fromX * exitProgress;
const exitY = photoY + photo.fromY * exitProgress;
const exitOpacity = interpolate(frame, [270, 295], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.exit,
});
/* Entry opacity (photos appear from frame 15) */
const entryOpacity = interpolate(frame, [15 + photo.delay, 20 + photo.delay], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<PhotoPlaceholder
key={photo.hue}
width={photo.w}
height={photo.h}
hue={photo.hue}
style={{
position: "absolute",
left: `calc(50% + ${exitX}px - ${photo.w / 2}px)`,
top: `calc(50% + ${exitY}px - ${photo.h / 2}px)`,
transform: `scale(${entrySpring}) rotate(${rotation}deg)`,
opacity: entryOpacity * exitOpacity,
zIndex: i,
}}
/>
);
})}
</div>
{/* ---- "Your images." text (Act 2) ---- */}
<div
style={{
position: "absolute",
left: cx,
top: cy + 130 * act3Scale + act3OffsetY,
transform: "translateX(-50%)",
opacity: act4TextOpacity,
textAlign: "center",
}}
>
<ClipReveal startFrame={95}>
<span style={{ ...TEXT.heroHeadline }}>Your images.</span>
</ClipReveal>
</div>
{/* ---- "Stay yours." text (Act 2) ---- */}
<div
style={{
position: "absolute",
left: cx,
top: cy + 200 * act3Scale + act3OffsetY,
transform: "translateX(-50%)",
opacity: act4TextOpacity,
textAlign: "center",
}}
>
<ClipReveal startFrame={130}>
<span style={{ ...TEXT.heroHeadline }}>Stay yours.</span>
</ClipReveal>
</div>
{/* ---- Subtext lines (Act 3) ---- */}
{frame >= 160 && (
<div
style={{
position: "absolute",
left: cx,
top: cy + 160,
transform: "translateX(-50%)",
opacity: act4TextOpacity,
textAlign: "center",
display: "flex",
flexDirection: "column",
gap: 12,
}}
>
<ClipReveal startFrame={180}>
<span style={{ ...TEXT.heroSub, opacity: 0.8 }}>No uploads to the cloud. Ever.</span>
</ClipReveal>
<ClipReveal startFrame={184}>
<span style={{ ...TEXT.heroSub, opacity: 0.8 }}>100% local processing.</span>
</ClipReveal>
<ClipReveal startFrame={188}>
<span style={{ ...TEXT.heroSub, opacity: 0.8 }}>Free. Forever.</span>
</ClipReveal>
</div>
)}
<GrainOverlay />
</AbsoluteFill>
);
};