{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "brush-stroke-simulator", "title": "Brush Stroke Simulator", "description": "A simulated finger brushes across an image, revealing a pixelated layer along its bezier path.", "dependencies": [ "remotion" ], "files": [ { "path": "registry/remocn/brush-stroke-simulator/index.tsx", "content": "\"use client\";\n\nimport { interpolate, spring, useCurrentFrame, useVideoConfig } from \"remotion\";\n\nexport interface BrushStrokeSimulatorProps {\n /** Brush stroke width in pixels. */\n brushSize?: number;\n /** Cursor fill color (semi-transparent). */\n cursorColor?: string;\n /** Outer page background. */\n background?: string;\n /** Primary tint of the simulated portrait base. */\n baseColorA?: string;\n /** Secondary tint of the simulated portrait base. */\n baseColorB?: string;\n /** Tint of the obscured / pixelated overlay. */\n overlayColor?: string;\n /** Frame at which the brushing motion begins. */\n startFrame?: number;\n /** How many frames the brush takes to complete its sweep. */\n sweepDuration?: number;\n /** Playback speed multiplier. */\n speed?: number;\n className?: string;\n}\n\nconst FONT_FAMILY =\n \"var(--font-geist-sans), -apple-system, BlinkMacSystemFont, sans-serif\";\n\nconst STAGE_W = 1280;\nconst STAGE_H = 720;\n\n/**\n * Two faces' worth of waypoints — sweeps left face, lifts, sweeps right face.\n * Y values cluster around the upper-middle of the frame so it reads like\n * brushing across the eyes/cheeks of a portrait.\n */\nconst WAYPOINTS: { x: number; y: number; press: boolean }[] = [\n { x: 280, y: 280, press: true },\n { x: 460, y: 260, press: true },\n { x: 460, y: 360, press: true },\n { x: 280, y: 380, press: true },\n // Lift between faces\n { x: 640, y: 220, press: false },\n // Right face\n { x: 820, y: 280, press: true },\n { x: 1000, y: 260, press: true },\n { x: 1000, y: 360, press: true },\n { x: 820, y: 380, press: true },\n];\n\nfunction cubicBezier(\n t: number,\n p0: { x: number; y: number },\n p1: { x: number; y: number },\n p2: { x: number; y: number },\n p3: { x: number; y: number },\n) {\n const u = 1 - t;\n const tt = t * t;\n const uu = u * u;\n const uuu = uu * u;\n const ttt = tt * t;\n return {\n x: uuu * p0.x + 3 * uu * t * p1.x + 3 * u * tt * p2.x + ttt * p3.x,\n y: uuu * p0.y + 3 * uu * t * p1.y + 3 * u * tt * p2.y + ttt * p3.y,\n };\n}\n\nfunction easeInOut(t: number) {\n return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;\n}\n\nfunction controlsFor(\n a: { x: number; y: number },\n b: { x: number; y: number },\n index: number,\n) {\n const dx = b.x - a.x;\n const dy = b.y - a.y;\n const len = Math.hypot(dx, dy) || 1;\n const sign = index % 2 === 0 ? 1 : -1;\n const px = (-dy / len) * (len * 0.18) * sign;\n const py = (dx / len) * (len * 0.18) * sign;\n return [\n { x: a.x + dx / 3 + px, y: a.y + dy / 3 + py },\n { x: a.x + (2 * dx) / 3 + px, y: a.y + (2 * dy) / 3 + py },\n ];\n}\n\n/**\n * Walks the bezier path up to the given progress (0..1) and returns:\n * - the active position\n * - whether the brush is currently \"pressed\" (between two press waypoints)\n * - a sampled trail of every position visited so far (for the reveal mask)\n */\nfunction sampleBrushPath(progress: number) {\n const segments = WAYPOINTS.length - 1;\n const samplesPerSegment = 14;\n const totalSamples = segments * samplesPerSegment;\n const reachedSamples = Math.max(\n 1,\n Math.min(totalSamples, Math.floor(progress * totalSamples)),\n );\n\n const trail: { x: number; y: number; press: boolean }[] = [];\n let pos = { x: WAYPOINTS[0].x, y: WAYPOINTS[0].y };\n let pressed = WAYPOINTS[0].press;\n\n for (let i = 0; i < reachedSamples; i++) {\n const segIdx = Math.min(segments - 1, Math.floor(i / samplesPerSegment));\n const localT = (i % samplesPerSegment) / samplesPerSegment;\n const a = WAYPOINTS[segIdx];\n const b = WAYPOINTS[segIdx + 1];\n const [c1, c2] = controlsFor(a, b, segIdx);\n const t = easeInOut(localT);\n pos = cubicBezier(t, a, c1, c2, b);\n pressed = a.press && b.press;\n trail.push({ x: pos.x, y: pos.y, press: pressed });\n }\n\n return { pos, pressed, trail };\n}\n\nexport function BrushStrokeSimulator({\n brushSize = 70,\n cursorColor = \"rgba(255,255,255,0.45)\",\n background = \"#0a0a0a\",\n baseColorA = \"#f4a261\",\n baseColorB = \"#e76f51\",\n overlayColor = \"#1f1f23\",\n startFrame = 12,\n sweepDuration = 150,\n speed = 1,\n className,\n}: BrushStrokeSimulatorProps) {\n const frame = useCurrentFrame() * speed;\n const { fps } = useVideoConfig();\n\n const localFrame = Math.max(0, frame - startFrame);\n const progress = Math.min(1, localFrame / sweepDuration);\n\n const { pos, pressed, trail } = sampleBrushPath(progress);\n\n // Press scale: cursor shrinks slightly while pressing onto the surface.\n const pressTarget = pressed ? 0.86 : 1;\n const pressSpring = spring({\n frame: localFrame,\n fps,\n config: { damping: 18, stiffness: 220, mass: 0.6 },\n });\n const cursorScale = interpolate(pressSpring, [0, 1], [1, pressTarget]);\n\n // Build SVG path d-attribute from the accumulated trail.\n const maskPathD =\n trail.length === 0\n ? \"\"\n : trail\n .map((p, i) => `${i === 0 ? \"M\" : \"L\"} ${p.x.toFixed(2)} ${p.y.toFixed(2)}`)\n .join(\" \");\n\n // Tiny intro fade so the scene \"lands\" before the brush starts.\n const introOpacity = interpolate(frame, [0, 8], [0, 1], {\n extrapolateLeft: \"clamp\",\n extrapolateRight: \"clamp\",\n });\n\n return (\n \n {/* Sharp base layer — fake portrait built from radial gradients */}\n \n\n {/* Eye / detail dots so the unbrushed area reads as \"faces\" */}\n \n\n {/* Pixelated overlay — the layer the brush \"reveals\" through the mask */}\n \n \n \n \n {maskPathD && (\n \n )}\n \n \n \n \n \n \n \n \n \n\n {/* Cursor / fingertip */}\n \n \n );\n}\n\nfunction FaceDots() {\n return (\n \n {/* Left face */}\n \n \n \n {/* Right face */}\n \n \n \n \n );\n}\n", "type": "registry:component", "target": "components/remocn/brush-stroke-simulator.tsx" } ], "type": "registry:component" }