{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "moodboard-reveal",
  "title": "Moodboard Reveal",
  "description": "A kinetic headline with a swapping inline image, then a scattered gallery of photos flies in, the camera pushes through it as the background lifts from near-black to off-white, and it lands on a single hero image.",
  "dependencies": [
    "remotion"
  ],
  "registryDependencies": [
    "https://snapcn.dev/r/snap-cn-ui.json"
  ],
  "files": [
    {
      "path": "registry/snap-cn/moodboard-reveal/index.tsx",
      "content": "\"use client\";\n\nimport type { CSSProperties } from \"react\";\nimport {\n  Easing,\n  getRemotionEnvironment,\n  Img,\n  interpolate,\n  interpolateColors,\n  staticFile,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\nimport { type SnapCnTheme, useSnapCnTheme, withAlpha } from \"@/lib/snap-cn-ui\";\n\n/**\n * A \"moodboard reveal\": a kinetic headline with a swapping inline image, then a\n * scattered gallery of image cards flies in, the camera pushes through it while\n * the background lifts from near-black to off-white, and it lands on a single\n * hero image. A camera-drives-through montage (see MOTION.md), replicated from a\n * reference reel and populated with the showcase assets.\n */\n\nexport interface MoodboardRevealProps {\n  /** Text before the inline image. */\n  leadIn?: string;\n  /** The emphasised (bold) word right after the inline image. */\n  emphasis?: string;\n  /** Text after the emphasised word. */\n  tailIn?: string;\n  /** Image URLs for the gallery + the intro swap. First-served covers the slots. */\n  images?: string[];\n  /** The image the montage lands on. Defaults to `images[3]`. */\n  heroImage?: string;\n  /** Near-black start background. Defaults to the dark theme's `background`. */\n  darkColor?: string;\n  /** Off-white end background. Defaults to the light theme's `background`. */\n  lightColor?: string;\n  /**\n   * Design-system token overrides. Applied to both ends of the crossfade —\n   * this scene spans light and dark, so it takes no `mode`.\n   */\n  theme?: Partial<SnapCnTheme>;\n  speed?: number;\n  className?: string;\n}\n\nconst FONT_FAMILY =\n  'Inter, var(--font-geist-sans), -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif';\n\n/** Sample images. Absolute, because a root-relative default becomes\n *  `staticFile()` in a render and 404s in the project this file was just\n *  copied into. Pass your own — root-relative or remote, both resolve. */\nconst DEFAULT_IMAGES = [\n  \"https://snapcn.dev/showcase-assets/438b9e6b50654a44d404fbf358c26e9f.webp\",\n  \"https://snapcn.dev/showcase-assets/5e5305b05bd405a0d89570725434099e.webp\",\n  \"https://snapcn.dev/showcase-assets/767d99bb371a54d0d36751e8cecae43c.jpg\",\n  \"https://snapcn.dev/showcase-assets/821d815affa6496c39cbdeeec7a84603.jpg\",\n  \"https://snapcn.dev/showcase-assets/937438c560ada1c83317f2c11b3454b0.jpg\",\n  \"https://snapcn.dev/showcase-assets/98f89cb9994f5c382ab964062c4039db.jpg\",\n  \"https://snapcn.dev/showcase-assets/b25b82db2892efff9be3204e860d30ee.jpg\",\n  \"https://snapcn.dev/showcase-assets/c9ebc6337aa2268ac4b357f9cb1ac547.jpg\",\n];\n\n// ─── Timeline (frames @ 30fps; ~5s to mirror the ~4.7s reference) ──────────────\n// The sentence holds long enough that the last intro image lands and rests\n// before it fades; the fade overlaps the first gallery positions appearing.\nexport const SENTENCE_OUT = 50;\nexport const COLLAGE_START = 46;\nexport const APPEAR_FRAMES = 12;\n/** Each position swaps its image every `FLICKER_HOLD` frames — the shuffle. */\nexport const FLICKER_HOLD = 2;\n/** The cluster orbits its centre from COLLAGE_START to here, sweeping ORBIT_DEG°. */\nexport const ORBIT_END = 88;\nexport const ORBIT_DEG = 130;\n/** Then every position converges to the centre — the \"merge into one\". */\nexport const MERGE_START = 88;\n// Fast collapse — the positions snap together, they don't drift in.\nexport const MERGE_END = 100;\nexport const BG_START = 88;\nexport const BG_END = 104;\n/**\n * Every position collapses to this common width at the centre and un-rotates, so\n * the cards stack exactly on top of each other. They never fade — the topmost\n * card (the hero) simply covers the rest, so the merge *becomes* the one image.\n */\nexport const MERGE_W = 300;\n\n/** Moderate decelerate — not quint/expo-out, which freeze on a frame clock. */\nexport const EASE = Easing.bezier(0.2, 0.6, 0.35, 1);\nconst CLAMP = { extrapolateLeft: \"clamp\", extrapolateRight: \"clamp\" } as const;\n\nconst CARD_RATIO = 1.34; // portrait cards (~3:4)\n\n/**\n * Gallery *positions* (offset from centre, width, tilt) — slots, not fixed\n * pictures. Each one flickers through the whole image set (see `flickerImage`).\n * Laid out as the spread moodboard the montage orbits and then collapses.\n */\nexport const SLOTS: ReadonlyArray<{\n  x: number;\n  y: number;\n  w: number;\n  rot: number;\n}> = [\n  { x: -401, y: -51, w: 188, rot: -5 },\n  { x: -186, y: -191, w: 164, rot: 4 },\n  { x: 44, y: -168, w: 214, rot: -2 },\n  { x: 283, y: -79, w: 173, rot: 6 },\n  { x: 377, y: 151, w: 169, rot: -4 },\n  { x: -340, y: 179, w: 183, rot: 5 },\n  { x: -101, y: 193, w: 214, rot: -3 },\n  { x: 138, y: 193, w: 188, rot: 3 },\n];\n\n/** The cluster's orbit angle (degrees) at `frame`. */\nexport function orbitAngle(frame: number): number {\n  return interpolate(frame, [COLLAGE_START, ORBIT_END], [0, ORBIT_DEG], {\n    ...CLAMP,\n    easing: Easing.inOut(Easing.cubic),\n  });\n}\n\n/** How far the merge-to-centre has progressed (0 → 1). */\nexport function mergeProgress(frame: number): number {\n  return interpolate(frame, [MERGE_START, MERGE_END], [0, 1], {\n    ...CLAMP,\n    easing: EASE,\n  });\n}\n\n/**\n * Which image a position shows. Positions don't hold one picture — they flicker\n * through the whole set every `FLICKER_HOLD` frames, staggered per slot, so it\n * reads as all the photos shuffling in place. The image freezes at `MERGE_START`\n * so the convergence isn't a strobe.\n */\nexport function flickerImage(\n  frame: number,\n  slot: number,\n  count: number,\n): number {\n  const f = Math.min(frame, MERGE_START);\n  const step = Math.max(0, Math.floor((f - COLLAGE_START) / FLICKER_HOLD));\n  return (step * 3 + slot * 5) % count;\n}\n\nexport interface SlotPose {\n  x: number;\n  y: number;\n  scale: number;\n  opacity: number;\n}\n\n/**\n * A position's live pose: it fades/scales in, orbits the centre, then converges\n * to the centre and to the common `MERGE_W` footprint during the merge. Opacity\n * is only the entrance fade — it NEVER fades on merge, so the positions stack on\n * top of each other and the topmost one (the hero) covers the rest.\n */\nexport function slotPose(\n  frame: number,\n  slot: { x: number; y: number; w: number },\n  index: number,\n): SlotPose {\n  const appear = COLLAGE_START + index * 2;\n  const opacity = interpolate(\n    frame,\n    [appear, appear + APPEAR_FRAMES],\n    [0, 1],\n    CLAMP,\n  );\n  const scIn = interpolate(frame, [appear, appear + APPEAR_FRAMES], [0.6, 1], {\n    ...CLAMP,\n    easing: EASE,\n  });\n  const a = (orbitAngle(frame) * Math.PI) / 180;\n  const ox = slot.x * Math.cos(a) - slot.y * Math.sin(a);\n  const oy = slot.x * Math.sin(a) + slot.y * Math.cos(a);\n  const m = mergeProgress(frame);\n  const target = MERGE_W / slot.w; // every card lands at the same width\n  return {\n    x: ox * (1 - m),\n    y: oy * (1 - m),\n    scale: scIn * (1 + (target - 1) * m),\n    opacity,\n  };\n}\n\n/** Motion blur (px) as the positions converge — peaks mid-merge, zero at ends. */\nexport function mergeBlur(frame: number): number {\n  return interpolate(\n    frame,\n    [MERGE_START, (MERGE_START + MERGE_END) / 2, MERGE_END],\n    [0, 6, 0],\n    CLAMP,\n  );\n}\n\n/** 0 = dark background, 1 = light background. */\nexport function bgProgress(frame: number): number {\n  return interpolate(frame, [BG_START, BG_END], [0, 1], CLAMP);\n}\n\n/** Intro image swap cadence, and the \"drop\" each new image lands with. */\nexport const INLINE_START = 8;\nexport const INLINE_HOLD = 12;\n/** How many images cycle in the intro (kept small so each drop is legible). */\nexport const INLINE_COUNT = 3;\nexport const DROP_FRAMES = 9;\nexport const DROP_SCALE = 1.16;\n\nexport interface InlineDrop {\n  index: number;\n  scale: number;\n  opacity: number;\n}\n\n/**\n * The inline image drops in — the *same* smooth landing the hero uses: each new\n * image fades in over a few frames while settling from a slight zoom\n * (`DROP_SCALE` → 1), so you actually see it drop into place rather than cut.\n * Past images stay stacked underneath so the swap never flashes empty; the\n * newest sits on top. `DROP_FRAMES` < `INLINE_HOLD`, so every image — the last\n * one included — fully lands before the next arrives.\n */\nexport function inlineImage(frame: number, count: number): InlineDrop {\n  const index = Math.min(\n    count - 1,\n    Math.max(0, Math.floor((frame - INLINE_START) / INLINE_HOLD)),\n  );\n  const age = frame - (INLINE_START + index * INLINE_HOLD);\n  const scale = interpolate(age, [0, DROP_FRAMES], [DROP_SCALE, 1], {\n    ...CLAMP,\n    easing: EASE,\n  });\n  const opacity = interpolate(age, [0, 5], [0, 1], { ...CLAMP, easing: EASE });\n  return { index, scale, opacity };\n}\n\nfunction Card({ src, style }: { src: string; style: CSSProperties }) {\n  return (\n    <Img\n      src={resolveSrc(src)}\n      style={{\n        width: \"100%\",\n        height: \"100%\",\n        maxWidth: \"none\",\n        objectFit: \"cover\",\n        display: \"block\",\n        ...style,\n      }}\n    />\n  );\n}\n\n/** Rewrite root-relative assets through staticFile only while rendering. */\nfunction resolveSrc(src: string): string {\n  const isLocal = src.startsWith(\"/\") && !src.startsWith(\"//\");\n  if (isLocal && getRemotionEnvironment().isRendering) {\n    return staticFile(src.replace(/^\\/+/, \"\"));\n  }\n  return src;\n}\n\n/** Thin wireframe square/diamond marker that drifts and slowly rotates. */\nfunction WireMarker({\n  frame,\n  cx,\n  cy,\n  size,\n  spin,\n  color,\n  from,\n  to,\n}: {\n  frame: number;\n  cx: number;\n  cy: number;\n  size: number;\n  spin: number;\n  color: string;\n  from: number;\n  to: number;\n}) {\n  const opacity = interpolate(\n    frame,\n    [from, from + 8, to - 8, to],\n    [0, 1, 1, 0],\n    CLAMP,\n  );\n  if (opacity <= 0) return null;\n  const drift = Math.sin((frame - from) / 22) * 8;\n  const rot = spin + (frame - from) * 0.6;\n  return (\n    <div\n      style={{\n        position: \"absolute\",\n        left: cx,\n        top: cy + drift,\n        width: size,\n        height: size,\n        border: `1px solid ${color}`,\n        transform: `rotate(${rot}deg)`,\n        opacity,\n      }}\n    />\n  );\n}\n\nexport function MoodboardReveal({\n  leadIn = \"that lets you\",\n  emphasis = \"filter\",\n  tailIn = \"out AI.\",\n  images = DEFAULT_IMAGES,\n  heroImage,\n  darkColor,\n  lightColor,\n  theme,\n  speed = 1,\n  className,\n}: MoodboardRevealProps) {\n  const frame = useCurrentFrame() * speed;\n  const { width, height } = useVideoConfig();\n  // The scene crossfades a dark page into a light one, so it needs both ends of\n  // the system at once rather than one resolved mode. Both still take a user's\n  // `theme` override.\n  const dark = useSnapCnTheme(theme, \"dark\");\n  const light = useSnapCnTheme(theme, \"light\");\n  const cx = width / 2;\n  const cy = height / 2;\n\n  const bg = bgProgress(frame);\n  const bgColor = interpolateColors(\n    bg,\n    [0, 1],\n    [darkColor ?? dark.background, lightColor ?? light.background],\n  );\n  // The marker is always the opposite page: light ink on the dark half, dark on\n  // the light one.\n  const markerColor =\n    bg < 0.5\n      ? withAlpha(light.background, 0.55)\n      : withAlpha(dark.background, 0.5);\n  const hero = heroImage ?? images[3] ?? images[0];\n\n  // ── Intro sentence ──────────────────────────────────────────────────────────\n  const sentenceOp = interpolate(\n    frame,\n    [0, 8, SENTENCE_OUT - 6, SENTENCE_OUT],\n    [0, 1, 1, 0],\n    CLAMP,\n  );\n  const inline = inlineImage(frame, Math.min(INLINE_COUNT, images.length));\n\n  // ── Gallery (flicker + orbit + merge) ────────────────────────────────────────\n  const mBlur = mergeBlur(frame);\n  const merge = mergeProgress(frame);\n  const isRendering = getRemotionEnvironment().isRendering;\n\n  return (\n    <div\n      className={className}\n      style={{\n        position: \"absolute\",\n        inset: 0,\n        overflow: \"hidden\",\n        backgroundColor: bgColor,\n        fontFamily: FONT_FAMILY,\n        // Any scaled text renders as the outline, not re-hinted per frame.\n        textRendering: \"geometricPrecision\",\n      }}\n    >\n      {/* Dotted grid, only on the dark background */}\n      <div\n        style={{\n          position: \"absolute\",\n          inset: 0,\n          opacity: 1 - bg,\n          backgroundImage: `radial-gradient(circle, ${withAlpha(\n            light.background,\n            0.07,\n          )} 1px, transparent 1.5px)`,\n          backgroundSize: \"20px 20px\",\n        }}\n      />\n\n      {/* ── Intro sentence: \"<leadIn> [img] <emphasis> <tailIn>\" ── */}\n      {sentenceOp > 0 && (\n        <div\n          style={{\n            position: \"absolute\",\n            inset: 0,\n            display: \"flex\",\n            alignItems: \"center\",\n            justifyContent: \"center\",\n            gap: 18,\n            opacity: sentenceOp,\n            color: dark.foreground,\n            fontSize: 28,\n            fontWeight: 400,\n            letterSpacing: \"-0.01em\",\n          }}\n        >\n          <span>{leadIn}</span>\n          <div\n            style={{\n              position: \"relative\",\n              width: 108,\n              height: 108 * CARD_RATIO,\n              overflow: \"hidden\",\n              flexShrink: 0,\n            }}\n          >\n            {images.slice(0, INLINE_COUNT).map((src, i) => {\n              if (i > inline.index) return null; // future image, not yet dropped\n              const isActive = i === inline.index;\n              return (\n                <Card\n                  key={src}\n                  src={src}\n                  style={{\n                    position: \"absolute\",\n                    inset: 0,\n                    zIndex: i,\n                    opacity: isActive ? inline.opacity : 1,\n                    transform: isActive ? `scale(${inline.scale})` : undefined,\n                    transformOrigin: \"center center\",\n                  }}\n                />\n              );\n            })}\n          </div>\n          <span>\n            <strong style={{ fontWeight: 700 }}>{emphasis}</strong> {tailIn}\n          </span>\n        </div>\n      )}\n\n      {/* ── Gallery: positions that flicker, orbit, then merge to centre ── */}\n      <div\n        style={{\n          position: \"absolute\",\n          inset: 0,\n          filter: mBlur > 0.1 ? `blur(${mBlur}px)` : undefined,\n          ...(isRendering ? {} : { willChange: \"filter\" as const }),\n        }}\n      >\n        {SLOTS.map((s, i) => {\n          const pose = slotPose(frame, s, i);\n          if (pose.opacity <= 0) return null;\n          // The last-rendered position sits on top of the merged stack, so it is\n          // the one that remains — freeze it to the hero as the merge begins.\n          const isTop = i === SLOTS.length - 1;\n          const src =\n            isTop && frame >= MERGE_START\n              ? hero\n              : images[flickerImage(frame, i, images.length)];\n          const h = s.w * CARD_RATIO;\n          // Un-rotate as they merge so the stack aligns into one clean image.\n          const rot = s.rot * (1 - merge);\n          return (\n            <div\n              key={`slot-${s.x}-${s.y}`}\n              style={{\n                position: \"absolute\",\n                left: cx + pose.x - s.w / 2,\n                top: cy + pose.y - h / 2,\n                width: s.w,\n                height: h,\n                opacity: pose.opacity,\n                transform: `rotate(${rot}deg) scale(${pose.scale})`,\n                // Shadow gives depth in the spread, but 8 stacked shadows pile\n                // into a dark halo — so fade it out as they merge to one.\n                boxShadow:\n                  merge > 0.98\n                    ? \"none\"\n                    : `0 18px 40px ${withAlpha(dark.background, 0.28 * (1 - merge))}`,\n              }}\n            >\n              <Card src={src} style={{}} />\n            </div>\n          );\n        })}\n      </div>\n\n      {/* ── Wireframe markers ── */}\n      <WireMarker\n        frame={frame}\n        cx={cx + 150}\n        cy={cy - 130}\n        size={44}\n        spin={0}\n        color={markerColor}\n        from={4}\n        to={SENTENCE_OUT}\n      />\n      <WireMarker\n        frame={frame}\n        cx={cx - 240}\n        cy={cy + 40}\n        size={54}\n        spin={45}\n        color={markerColor}\n        from={MERGE_END - 6}\n        to={150}\n      />\n      <WireMarker\n        frame={frame}\n        cx={cx + 190}\n        cy={cy + 70}\n        size={40}\n        spin={12}\n        color={markerColor}\n        from={MERGE_END + 8}\n        to={150}\n      />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/snap-cn/moodboard-reveal.tsx"
    }
  ],
  "type": "registry:component"
}