{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "reel-collage",
  "title": "Reel Collage",
  "description": "Photo reel that shuts into a collage. A picture cuts through a quick reel between two captions and slowly zooms, then shuts like an eyelid while the captions fold into one stacked line; the copy swaps, the lines split, and a scattered collage blooms in around them on a peach flush. Every curve is fitted from a reference recording, frame by frame.",
  "dependencies": [
    "remotion"
  ],
  "registryDependencies": [
    "https://snapcn.dev/r/snap-cn-ui.json"
  ],
  "files": [
    {
      "path": "registry/snap-cn/reel-collage/index.tsx",
      "content": "// Reel Collage · snapcn — https://snapcn.dev/docs/scenes/reel-collage\n\"use client\";\n\nimport type { CSSProperties } from \"react\";\nimport {\n  Easing,\n  getRemotionEnvironment,\n  Img,\n  interpolate,\n  staticFile,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\nimport {\n  resolveFont,\n  type SnapCnTheme,\n  useSnapCnTheme,\n} from \"@/lib/snap-cn-ui\";\n\n/**\n * A \"reel collage\": a photo cuts through a quick reel between two captions and\n * slowly zooms, then shuts like an eyelid — the frame closes to a hairline while\n * the captions fold into one stacked line. The copy swaps, the lines split\n * apart, and a scattered collage of the same kind of pictures blooms in around\n * them on a peach flush.\n *\n * Replicated from a 2.6s reference recording. Every curve below was fitted on\n * the recording's own timestamps, not eyeballed: the geometry is in reference\n * pixels (`REF_W` wide) and scales with the frame, the timing is in frames at\n * 30fps.\n *\n * The ground is a painted world rather than a surface — a warm page, a mint\n * glow and a peach flush — so like `announce-title` every one of its colours is\n * a prop. The type is the theme's `foreground`.\n */\n\nexport interface ReelCollageProps {\n  /** Caption above the reel. */\n  topLine?: string;\n  /** Caption below the reel. */\n  bottomLine?: string;\n  /** The first line after the swap — drifts up and left into the collage. */\n  swapTop?: string;\n  /** The second line after the swap — drifts down and right. */\n  swapBottom?: string;\n  /** The reel, in cut order. Eight shots; the last is the one that shuts. */\n  slides?: string[];\n  /**\n   * The collage, six pictures, in `COLLAGE` slot order: left, centre, top\n   * right, bottom left, bottom right, and the centre-right one on top of it.\n   */\n  collage?: string[];\n  /** The page. */\n  background?: string;\n  /** The soft glow the whole scene sits on. */\n  glowColor?: string;\n  /** The flush that rises around the edges as the collage blooms in. */\n  flushColor?: string;\n  /** Caption ink. Defaults to the theme's `foreground`. */\n  textColor?: string;\n  theme?: Partial<SnapCnTheme>;\n  mode?: \"light\" | \"dark\";\n  /**\n   * The face this scene paints its words in — a label from `fonts.ts`\n   * (\"Inter\", \"Space Grotesk\", \"Instrument Serif\") or a CSS family you have\n   * loaded yourself. Unset, the scene keeps the face it was designed around.\n   *\n   * Overrides `theme.fontFamily`, which is how a brand kit re-skins a whole\n   * timeline from one value.\n   */\n  fontFamily?: string;\n  speed?: number;\n  className?: string;\n}\n\nconst FONT_FAMILY =\n  'Inter, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif';\n\n/** snapcn's own scenes, as stills. Absolute, because a root-relative default\n *  becomes `staticFile()` in a render and 404s in the project this file was\n *  just copied into. Pass your own — root-relative or remote, both resolve. */\nconst POSTER = (slug: string) =>\n  `https://media.snapcn.dev/demos/posters/${slug}.webp`;\nconst DEFAULT_SLIDES = [\n  \"orbit-gallery\",\n  \"card-rail\",\n  \"logo-flicker\",\n  \"laptop-frame\",\n  \"count-grid\",\n  \"status-cycle\",\n  \"moodboard-reveal\",\n  \"announce-title\",\n].map(POSTER);\nconst DEFAULT_COLLAGE = [\n  \"phone-frame\",\n  \"terminal-simulator\",\n  \"roster-grant\",\n  \"channel-thread\",\n  \"hero-launch\",\n  \"follower-rush\",\n].map(POSTER);\n\n// ─── Geometry, in reference pixels ────────────────────────────────────────────\n/** The reference frame's width. Every length below is a fraction of it. */\nexport const REF_W = 712;\n\n/** Caption type: its size at zoom 1, and the tracking the reference sets it at. */\nexport const FONT_SIZE = 19.22;\nexport const LETTER_SPACING = \"-0.068em\";\n\n/** The reel's frame at zoom 1. */\nexport const BOX_W = 282.08;\n\n/**\n * Caption baselines, relative to the page centre, at zoom 1: open around the\n * reel, and folded into one stacked pair once the reel has shut.\n */\nexport const CAPTION_OPEN = { top: -110.07, bottom: 125.53 } as const;\nexport const CAPTION_SHUT = { top: -5.44, bottom: 13.75 } as const;\n\n/** The swapped pair starts this far left of centre, then drifts apart. */\nconst SWAP_DX = -1.9;\n/** Where the swapped lines come to rest: ink centre x, baseline y. */\nexport const SWAP_REST = {\n  top: { x: -71.0, y: -94.6 },\n  bottom: { x: 41.0, y: 104.6 },\n} as const;\n\n/**\n * The collage: each picture's resting centre, size, and the scale it blooms\n * from. The centre picture starts smaller — it opens out of the gap the lines\n * leave behind. Listed bottom to top: the right-hand pair overlap, 5 over 4.\n */\nexport const COLLAGE = [\n  { x: -195.5, y: -38.5, w: 105, h: 117, from: 0.616 },\n  { x: -66.5, y: -1, w: 141, h: 96, from: 0.425 },\n  { x: 106.5, y: -91.5, w: 161, h: 107, from: 0.554 },\n  { x: -153, y: 108, w: 150, h: 102, from: 0.644 },\n  { x: 200, y: 97.5, w: 142, h: 97, from: 0.61 },\n  { x: 81.5, y: 27.5, w: 139, h: 97, from: 0.601 },\n] as const;\n\n/** The glow and the flush: blurred rounded rectangles behind everything. */\nconst GLOW = { dx: 10.9, dy: 6.4, hw: 261.6, hh: 147, r: 128.4, blur: 36.2 };\nconst FLUSH = { dx: 4.4, dy: -4.2, hw: 301.8, hh: 151.5, r: 2, blur: 58.6 };\n\n// ─── Timeline (frames @ 30fps) ────────────────────────────────────────────────\n/** Frame each reel shot cuts in on. */\nexport const CUTS = [0, 3, 6, 10, 14, 19, 25, 29] as const;\n/**\n * Each shot sits in the frame a little differently — its own aspect, a hair of\n * extra width (`k`), and its centre's offset from the page centre. Measured per\n * cut; ignoring it puts every cut up to 2px off where the reference lands it.\n */\nconst SHOTS = [\n  { aspect: 1.505, k: 1.0004, dx: 0.47, dy: 0.1 },\n  { aspect: 1.4788, k: 0.9982, dx: 0.78, dy: 0.09 },\n  { aspect: 1.4832, k: 1.0038, dx: 1.67, dy: 1.16 },\n  { aspect: 1.4561, k: 1.0009, dx: 1.59, dy: 0.93 },\n  { aspect: 1.4573, k: 0.9999, dx: 1.8, dy: 0.84 },\n  { aspect: 1.4505, k: 0.9988, dx: 1.92, dy: 1.3 },\n  { aspect: 1.4465, k: 0.9984, dx: 1.8, dy: 1.14 },\n  { aspect: 1.4465, k: 1, dx: 1.3, dy: -0.1 },\n] as const;\n/** The copy swaps on this frame, mid-way through the split. */\nexport const SWAP_AT = 46;\n\nconst CLAMP = { extrapolateLeft: \"clamp\", extrapolateRight: \"clamp\" } as const;\n\n/**\n * A fitted segment: `from → to` across `[start, end]` on a cubic-bezier. The\n * curves are sharp in-outs — slow to leave, one or two frames of real travel,\n * then a settle — which is what makes the shut read as a snap rather than a\n * slide. None of them freezes: the tails still move > 0.5px a frame at 1280.\n */\nfunction seg(\n  frame: number,\n  [start, end]: readonly [number, number],\n  [from, to]: readonly [number, number],\n  bezier: readonly [number, number, number, number],\n): number {\n  return interpolate(frame, [start, end], [from, to], {\n    ...CLAMP,\n    easing: Easing.bezier(...bezier),\n  });\n}\n\n/** Caption zoom — the type grows 17% across the reel. */\nexport const textZoom = (f: number) =>\n  seg(f, [7.59, 37], [1, 1.1741], [0.9062, 0.1924, 0.4104, 1.0142]);\n/** Reel zoom — the frame runs a little ahead of the type. */\nexport const boxZoom = (f: number) =>\n  seg(f, [0.42, 54.55], [1, 1.1852], [0.8548, 0.3903, 0.0817, 1.1691]);\n/** How open the reel is, 1 → 0. The eyelid. */\nexport const reelOpen = (f: number) =>\n  seg(f, [18.71, 37.47], [1, 0], [0.8703, 0, 0.078, 1]);\n/** How far apart the captions still are, 1 → 0. Lags the eyelid at the tail. */\nexport const captionsOpen = (f: number) =>\n  seg(f, [18.4, 38], [1, 0], [0.8766, 0, 0.0413, 1]);\n/** The swapped lines' split, 0 → 1. The lower line leaves first. */\nexport const splitTop = (f: number) =>\n  seg(f, [43.69, 69.24], [0, 1], [0.9243, 0.1575, 0.1018, 0.781]);\nexport const splitBottom = (f: number) =>\n  seg(f, [40.33, 69.32], [0, 1], [0.9897, 0, 0.1683, 0.7764]);\n/** The collage drifts left as it blooms: x offset, reference px. */\nexport const collagePan = (f: number) =>\n  seg(f, [46.66, 75.15], [57, 0], [0.8112, 0.3372, 0.0414, 0.6655]);\n/** The collage's bloom, 0 → 1 — each picture from its own `from` scale to 1. */\nexport const collageBloom = (f: number) =>\n  seg(f, [48.92, 73.18], [0, 1], [0.5174, -0.3347, 0.0854, 1]);\n/** Collage fade-in: linear. The bottom-right picture trails the rest. */\nexport const collageOpacity = (f: number, slot: number) =>\n  slot === 4\n    ? interpolate(f, [50.74, 65.08], [0, 1], CLAMP)\n    : interpolate(f, [50.14, 62.3], [0, 1], CLAMP);\n/** The flush: up with the bloom, gone once it has landed. */\nexport const flushOpacity = (f: number) =>\n  interpolate(f, [48, 56, 57.5, 73.5], [0, 1, 1, 0], CLAMP);\n\n/** The reel shot on screen at `frame`. */\nexport const slideAt = (frame: number) =>\n  CUTS.reduce<number>((k, cut, i) => (frame >= cut ? i : k), 0);\n\n/** Rewrite root-relative assets through staticFile everywhere but the Player. */\nfunction resolveSrc(src: string): string {\n  const isLocal = src.startsWith(\"/\") && !src.startsWith(\"//\");\n  if (!isLocal || getRemotionEnvironment().isPlayer) return src;\n  // A value that already came out of staticFile() carries the static base\n  // (`/static-<hash>/…`); running it through again would prefix it twice.\n  const base = staticFile(\"_\").slice(0, -2);\n  if (base && src.startsWith(`${base}/`)) return src;\n  try {\n    return staticFile(src.replace(/^\\/+/, \"\"));\n  } catch {\n    return src;\n  }\n}\n\nconst cover: CSSProperties = {\n  width: \"100%\",\n  height: \"100%\",\n  // Preflight's `img { max-width: 100% }` collapses a box sized by its parent.\n  maxWidth: \"none\",\n  objectFit: \"cover\",\n  display: \"block\",\n};\n\nexport function ReelCollage({\n  topLine = \"The videos\",\n  bottomLine = \"That you ship\",\n  swapTop = \"Especially on\",\n  swapBottom = \"Launch day\",\n  slides = DEFAULT_SLIDES,\n  collage = DEFAULT_COLLAGE,\n  background = \"#f5f4f2\",\n  glowColor = \"#dfe7e3\",\n  flushColor = \"#fdc7bc\",\n  textColor,\n  theme,\n  mode,\n  fontFamily,\n  speed = 1,\n  className,\n}: ReelCollageProps) {\n  const frame = useCurrentFrame() * speed;\n  const { width, height } = useVideoConfig();\n  const t = useSnapCnTheme(theme, mode);\n  const face = resolveFont(fontFamily ?? t.fontFamily) ?? FONT_FAMILY;\n  const ink = textColor ?? t.foreground;\n  const u = width / REF_W;\n  const cx = width / 2;\n  const cy = height / 2;\n\n  // ── Reel ──────────────────────────────────────────────────────────────────\n  const slide = slideAt(frame);\n  const shot = SHOTS[slide] ?? SHOTS[0];\n  const open = reelOpen(frame);\n  const boxW = BOX_W * shot.k * boxZoom(frame) * u;\n  const fullH = boxW / shot.aspect;\n  const boxH = fullH * open;\n  const reelSrc = slides[slide % slides.length];\n\n  // ── Captions ──────────────────────────────────────────────────────────────\n  // Both lines scale by the same zoom, pivoted on their own baseline so the\n  // baseline never climbs the pixel grid (motion-quality: \"stuck\" text).\n  const zoom = textZoom(frame);\n  const apart = captionsOpen(frame);\n  const baseline = (openY: number, shutY: number) =>\n    zoom * (openY * apart + shutY * (1 - apart));\n  const swapped = frame >= SWAP_AT;\n  const pTop = splitTop(frame);\n  const pBottom = splitBottom(frame);\n  const lines = [\n    {\n      text: swapped ? swapTop : topLine,\n      x: (swapped ? SWAP_DX * (1 - pTop) : 0) + SWAP_REST.top.x * pTop,\n      y:\n        baseline(CAPTION_OPEN.top, CAPTION_SHUT.top) +\n        (SWAP_REST.top.y - zoom * CAPTION_SHUT.top) * pTop,\n    },\n    {\n      text: swapped ? swapBottom : bottomLine,\n      x: (swapped ? SWAP_DX * (1 - pBottom) : 0) + SWAP_REST.bottom.x * pBottom,\n      y:\n        baseline(CAPTION_OPEN.bottom, CAPTION_SHUT.bottom) +\n        (SWAP_REST.bottom.y - zoom * CAPTION_SHUT.bottom) * pBottom,\n    },\n  ];\n\n  // ── Collage ───────────────────────────────────────────────────────────────\n  const pan = collagePan(frame);\n  const bloom = collageBloom(frame);\n  const flush = flushOpacity(frame);\n  const isRendering = getRemotionEnvironment().isRendering;\n\n  const blob = (\n    g: typeof GLOW,\n    color: string,\n    opacity: number,\n  ): CSSProperties => ({\n    position: \"absolute\",\n    left: cx + (g.dx - g.hw) * u,\n    top: cy + (g.dy - g.hh) * u,\n    width: 2 * g.hw * u,\n    height: 2 * g.hh * u,\n    borderRadius: g.r * u,\n    background: color,\n    filter: `blur(${g.blur * u}px)`,\n    opacity,\n  });\n\n  return (\n    <div\n      className={className}\n      style={{\n        position: \"absolute\",\n        inset: 0,\n        overflow: \"hidden\",\n        backgroundColor: background,\n        fontFamily: face,\n      }}\n    >\n      {flush > 0 && <div style={blob(FLUSH, flushColor, flush)} />}\n      <div style={blob(GLOW, glowColor, 1)} />\n\n      {/* ── Reel: a centred window that shuts on a full-size picture ── */}\n      {boxH > 0.01 && reelSrc && (\n        <div\n          style={{\n            position: \"absolute\",\n            left: cx + shot.dx * u - boxW / 2,\n            top: cy + shot.dy * u - boxH / 2,\n            width: boxW,\n            height: boxH,\n            overflow: \"hidden\",\n          }}\n        >\n          {/* The picture keeps its open size; only the window closes on it. */}\n          <Img\n            src={resolveSrc(reelSrc)}\n            style={{\n              ...cover,\n              position: \"absolute\",\n              left: 0,\n              top: (boxH - fullH) / 2,\n              height: fullH,\n            }}\n          />\n        </div>\n      )}\n\n      {/* ── Collage ── */}\n      {COLLAGE.map((p, slot) => {\n        const opacity = collageOpacity(frame, slot);\n        const src = collage[slot % collage.length];\n        if (opacity <= 0 || !src) return null;\n        const scale = p.from + (1 - p.from) * bloom;\n        return (\n          <div\n            key={`${p.x}-${p.y}`}\n            style={{\n              position: \"absolute\",\n              left: cx + (p.x + pan - p.w / 2) * u,\n              top: cy + (p.y - p.h / 2) * u,\n              width: p.w * u,\n              height: p.h * u,\n              opacity,\n              transform: `scale(${scale})`,\n              ...(isRendering ? {} : { willChange: \"transform\" as const }),\n            }}\n          >\n            <Img src={resolveSrc(src)} style={cover} />\n          </div>\n        );\n      })}\n\n      {/* ── Captions: SVG text, so `y` IS the baseline ── */}\n      <svg\n        width={width}\n        height={height}\n        style={{ position: \"absolute\", inset: 0, overflow: \"visible\" }}\n        aria-hidden\n      >\n        {lines.map((l, i) => (\n          <g\n            // biome-ignore lint/suspicious/noArrayIndexKey: two fixed lines\n            key={i}\n            transform={`translate(${cx + l.x * u} ${cy + l.y * u}) scale(${zoom})`}\n          >\n            <text\n              x={0}\n              y={0}\n              textAnchor=\"middle\"\n              fill={ink}\n              style={{\n                fontSize: FONT_SIZE * u,\n                fontWeight: 500,\n                letterSpacing: LETTER_SPACING,\n                // Hinting re-snaps the stems at every size; the outline doesn't.\n                textRendering: \"geometricPrecision\",\n              }}\n            >\n              {l.text}\n            </text>\n          </g>\n        ))}\n      </svg>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/snap-cn/reel-collage.tsx"
    }
  ],
  "docs": "Docs and props: https://snapcn.dev/docs/scenes/reel-collage?ref=cli — the page, glow and flush are props; the captions take the theme's foreground. — 50 Pro components too — charts, launch scenes, device shots: https://snapcn.dev/docs/pricing?ref=cli-docs",
  "type": "registry:component"
}
