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 { 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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