mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(videos): add shared animation components (grain, clip reveal, tool pill, counter, etc.)
This commit is contained in:
@@ -1,6 +1,60 @@
|
|||||||
import "./style.css";
|
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 = () => {
|
const ComponentTest: React.FC = () => (
|
||||||
return <>{/* Compositions will be added as they are built */}</>;
|
<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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user