diff --git a/apps/videos/src/Root.tsx b/apps/videos/src/Root.tsx index f4854712..e42c0888 100644 --- a/apps/videos/src/Root.tsx +++ b/apps/videos/src/Root.tsx @@ -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 = () => ( + +
+ + Hello SnapOtter + +
+
+ +
+
+ +
+ + + +
+); + +export const RemotionRoot: React.FC = () => ( + <> + + +); diff --git a/apps/videos/src/components/ClipReveal.tsx b/apps/videos/src/components/ClipReveal.tsx new file mode 100644 index 00000000..0e9400e1 --- /dev/null +++ b/apps/videos/src/components/ClipReveal.tsx @@ -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 ( +
+
+ {children} +
+
+ ); +}; diff --git a/apps/videos/src/components/Counter.tsx b/apps/videos/src/components/Counter.tsx new file mode 100644 index 00000000..432817a4 --- /dev/null +++ b/apps/videos/src/components/Counter.tsx @@ -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 {format ? format(value) : String(value)}; +}; diff --git a/apps/videos/src/components/GradientBlob.tsx b/apps/videos/src/components/GradientBlob.tsx new file mode 100644 index 00000000..d7ae3da5 --- /dev/null +++ b/apps/videos/src/components/GradientBlob.tsx @@ -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 ( +
+ ); +}; diff --git a/apps/videos/src/components/GrainOverlay.tsx b/apps/videos/src/components/GrainOverlay.tsx new file mode 100644 index 00000000..0b4f5289 --- /dev/null +++ b/apps/videos/src/components/GrainOverlay.tsx @@ -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 ( + + + + + + + + + + ); +}; diff --git a/apps/videos/src/components/PhotoPlaceholder.tsx b/apps/videos/src/components/PhotoPlaceholder.tsx new file mode 100644 index 00000000..8d5f2592 --- /dev/null +++ b/apps/videos/src/components/PhotoPlaceholder.tsx @@ -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 }) => ( +
+); diff --git a/apps/videos/src/components/ToolPill.tsx b/apps/videos/src/components/ToolPill.tsx new file mode 100644 index 00000000..b8815e88 --- /dev/null +++ b/apps/videos/src/components/ToolPill.tsx @@ -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 ( +
+ {name} +
+ ); +}; diff --git a/apps/videos/src/components/TypeWriter.tsx b/apps/videos/src/components/TypeWriter.tsx new file mode 100644 index 00000000..6f48582c --- /dev/null +++ b/apps/videos/src/components/TypeWriter.tsx @@ -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( + + {seg.text.slice(0, visible)} + , + ); + } + rendered += seg.text.length; + if (rendered >= visibleChars) break; + } + + const showCursor = visibleChars < fullText.length && Math.floor(frame / 8) % 2 === 0; + + return ( + + {nodes} + {showCursor && ( + + )} + + ); +}; diff --git a/apps/videos/src/components/WipeTransition.tsx b/apps/videos/src/components/WipeTransition.tsx new file mode 100644 index 00000000..0fd0ff7b --- /dev/null +++ b/apps/videos/src/components/WipeTransition.tsx @@ -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 {children}; +};