feat(videos): add shared animation components (grain, clip reveal, tool pill, counter, etc.)

This commit is contained in:
SnapOtter
2026-05-08 16:12:05 +08:00
parent dfeb358bb8
commit 447ef3a07e
9 changed files with 303 additions and 4 deletions
+58 -4
View File
@@ -1,6 +1,60 @@
import "./style.css";
import { Composition } from "remotion";
import type React from "react";
import { AbsoluteFill, Composition } from "remotion";
import { ClipReveal } from "./components/ClipReveal";
import { Counter } from "./components/Counter";
import { GradientBlob } from "./components/GradientBlob";
import { GrainOverlay } from "./components/GrainOverlay";
import { PhotoPlaceholder } from "./components/PhotoPlaceholder";
import { ToolPill } from "./components/ToolPill";
import { TEXT } from "./lib/fonts";
export const RemotionRoot: React.FC = () => {
return <>{/* Compositions will be added as they are built */}</>;
};
const ComponentTest: React.FC = () => (
<AbsoluteFill style={{ backgroundColor: "#0c0a09" }}>
<div style={{ position: "absolute", top: 40, left: 40 }}>
<ClipReveal startFrame={0}>
<span style={{ ...TEXT.heroHeadline }}>Hello SnapOtter</span>
</ClipReveal>
</div>
<div style={{ position: "absolute", top: 150, left: 40 }}>
<ToolPill name="Resize" category="essentials" />
</div>
<div style={{ position: "absolute", top: 200, left: 40 }}>
<Counter from={0} to={48} startFrame={0} duration={60} style={TEXT.counter} />
</div>
<PhotoPlaceholder
width={200}
height={150}
style={{ position: "absolute", top: 300, left: 40 }}
/>
<GradientBlob
config={{
color: "#f59e0b",
radius: 100,
cx: 600,
cy: 300,
a: 2,
b: 3,
phaseX: 0,
phaseY: 0,
amplitudeX: 50,
amplitudeY: 40,
}}
duration={90}
/>
<GrainOverlay />
</AbsoluteFill>
);
export const RemotionRoot: React.FC = () => (
<>
<Composition
id="ComponentTest"
component={ComponentTest}
durationInFrames={90}
fps={30}
width={1920}
height={1080}
/>
</>
);
+36
View File
@@ -0,0 +1,36 @@
import type React from "react";
import { interpolate, useCurrentFrame } from "remotion";
import { EASE } from "@/lib/motion";
export const ClipReveal: React.FC<{
children: React.ReactNode;
startFrame: number;
duration?: number;
direction?: "up" | "down";
}> = ({ children, startFrame, duration = 15, direction = "up" }) => {
const frame = useCurrentFrame();
const progress = interpolate(frame, [startFrame, startFrame + duration], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.enter,
});
const translateY =
direction === "up"
? interpolate(progress, [0, 1], [40, 0])
: interpolate(progress, [0, 1], [-40, 0]);
return (
<div style={{ overflow: "hidden", display: "inline-block" }}>
<div
style={{
transform: `translateY(${translateY}px)`,
opacity: interpolate(progress, [0, 0.3], [0, 1], {
extrapolateRight: "clamp",
}),
}}
>
{children}
</div>
</div>
);
};
+21
View File
@@ -0,0 +1,21 @@
import type React from "react";
import { interpolate, useCurrentFrame } from "remotion";
import { EASE } from "@/lib/motion";
export const Counter: React.FC<{
from: number;
to: number;
startFrame: number;
duration: number;
style?: React.CSSProperties;
format?: (n: number) => string;
}> = ({ from, to, startFrame, duration, style, format }) => {
const frame = useCurrentFrame();
const raw = interpolate(frame, [startFrame, startFrame + duration], [from, to], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.enter,
});
const value = Math.floor(raw);
return <span style={style}>{format ? format(value) : String(value)}</span>;
};
@@ -0,0 +1,41 @@
import type React from "react";
import { useCurrentFrame } from "remotion";
interface BlobConfig {
color: string;
radius: number;
cx: number;
cy: number;
a: number;
b: number;
phaseX: number;
phaseY: number;
amplitudeX: number;
amplitudeY: number;
}
export const GradientBlob: React.FC<{
config: BlobConfig;
duration: number;
}> = ({ config, duration }) => {
const frame = useCurrentFrame();
const t = (frame / duration) * Math.PI * 2;
const x = config.cx + config.amplitudeX * Math.sin(config.a * t + config.phaseX);
const y = config.cy + config.amplitudeY * Math.sin(config.b * t + config.phaseY);
return (
<div
style={{
position: "absolute",
width: config.radius * 2,
height: config.radius * 2,
left: x - config.radius,
top: y - config.radius,
background: `radial-gradient(circle, ${config.color} 0%, transparent 70%)`,
filter: "blur(60px)",
opacity: 0.5,
mixBlendMode: "screen",
}}
/>
);
};
@@ -0,0 +1,23 @@
import type React from "react";
import { AbsoluteFill, useCurrentFrame } from "remotion";
export const GrainOverlay: React.FC<{ opacity?: number }> = ({ opacity = 0.03 }) => {
const frame = useCurrentFrame();
return (
<AbsoluteFill style={{ opacity, mixBlendMode: "overlay", pointerEvents: "none" }}>
<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
<filter id={`grain-${frame}`}>
<feTurbulence
type="fractalNoise"
baseFrequency={0.65}
numOctaves={3}
seed={frame}
stitchTiles="stitch"
/>
<feColorMatrix type="saturate" values="0" />
</filter>
<rect width="100%" height="100%" filter={`url(#grain-${frame})`} />
</svg>
</AbsoluteFill>
);
};
@@ -0,0 +1,20 @@
import type React from "react";
export const PhotoPlaceholder: React.FC<{
width: number;
height: number;
hue?: number;
style?: React.CSSProperties;
}> = ({ width, height, hue = 30, style }) => (
<div
style={{
width,
height,
borderRadius: 8,
background: `linear-gradient(135deg, hsl(${hue}, 60%, 65%) 0%, hsl(${hue + 30}, 50%, 55%) 100%)`,
border: "1.5px solid rgba(255,255,255,0.15)",
boxShadow: "0 8px 24px rgba(0,0,0,0.3)",
...style,
}}
/>
);
+28
View File
@@ -0,0 +1,28 @@
import type React from "react";
import { COLOR } from "@/lib/colors";
import { TEXT } from "@/lib/fonts";
export const ToolPill: React.FC<{
name: string;
category: string;
style?: React.CSSProperties;
}> = ({ name, category, style }) => {
const color = COLOR.category[category] ?? COLOR.accent;
return (
<div
style={{
display: "inline-flex",
alignItems: "center",
padding: "4px 12px",
borderRadius: 6,
backgroundColor: color,
color: "white",
...TEXT.toolPill,
whiteSpace: "nowrap",
...style,
}}
>
{name}
</div>
);
};
+55
View File
@@ -0,0 +1,55 @@
import type React from "react";
import { useCurrentFrame } from "remotion";
import { FONT } from "@/lib/fonts";
interface Segment {
text: string;
color: string;
}
export const TypeWriter: React.FC<{
segments: Segment[];
startFrame: number;
speed?: number;
style?: React.CSSProperties;
}> = ({ segments, startFrame, speed = 2, style }) => {
const frame = useCurrentFrame();
const elapsed = Math.max(0, frame - startFrame);
const charIndex = Math.floor(elapsed / speed);
const fullText = segments.map((s) => s.text).join("");
const visibleChars = Math.min(charIndex, fullText.length);
let rendered = 0;
const nodes: React.ReactNode[] = [];
for (const seg of segments) {
const segStart = rendered;
const visible = Math.max(0, Math.min(visibleChars - segStart, seg.text.length));
if (visible > 0) {
nodes.push(
<span key={segStart} style={{ color: seg.color }}>
{seg.text.slice(0, visible)}
</span>,
);
}
rendered += seg.text.length;
if (rendered >= visibleChars) break;
}
const showCursor = visibleChars < fullText.length && Math.floor(frame / 8) % 2 === 0;
return (
<span style={{ fontFamily: FONT.mono, ...style }}>
{nodes}
{showCursor && (
<span
style={{
backgroundColor: "#7ee787",
width: 10,
height: 20,
display: "inline-block",
}}
/>
)}
</span>
);
};
@@ -0,0 +1,21 @@
import type React from "react";
import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion";
import { EASE } from "@/lib/motion";
export const WipeTransition: React.FC<{
children: React.ReactNode;
startFrame: number;
duration?: number;
direction?: "left" | "right";
}> = ({ children, startFrame, duration = 18, direction = "left" }) => {
const frame = useCurrentFrame();
const progress = interpolate(frame, [startFrame, startFrame + duration], [0, 100], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.enter,
});
const clipPath =
direction === "left" ? `inset(0 ${100 - progress}% 0 0)` : `inset(0 0 0 ${100 - progress}%)`;
return <AbsoluteFill style={{ clipPath }}>{children}</AbsoluteFill>;
};