{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "aspect-slit",
  "title": "Aspect Slit",
  "description": "A film-title card opens through a slit, with a serif hero line and a small caption.",
  "dependencies": ["remotion", "@remotion/google-fonts"],
  "files": [
    {
      "path": "registry/remocn/aspect-slit/index.tsx",
      "content": "\"use client\";\n\nimport { loadFont as loadSans } from \"@remotion/google-fonts/InstrumentSans\";\nimport { loadFont as loadSerif } from \"@remotion/google-fonts/InstrumentSerif\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport {\n  AbsoluteFill,\n  continueRender,\n  delayRender,\n  Easing,\n  interpolate,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\n\n/** Film-title serif for the hero, its sans sibling for the caption. */\nconst serif = loadSerif(\"normal\", { weights: [\"400\"], subsets: [\"latin\"] });\nconst sans = loadSans(\"normal\", { weights: [\"400\"], subsets: [\"latin\"] });\n\nexport interface AspectSlitProps {\n  /** Short title. It first appears inside the slit, then grows to hero size. */\n  title?: string;\n  /** Small line under the hero title. Pass \"\" to hide. */\n  caption?: string;\n  /** Height of the narrowest slit, as a share of the frame height. */\n  slit?: number;\n  accent?: string;\n  /** Playback multiplier. */\n  speed?: number;\n  theme?: \"dark\" | \"light\";\n}\n\n// Beat sheet (30fps):\n//  0–24    letterbox bars close from 16:9 to 2.39:1 (already moving on frame 0)\n//  24–32   a beat on scope\n//  32–64   the bars keep closing to a thin slit\n//  50–74   the title rises into the slit from behind the lower bar; holds,\n//          tracking tightening, to 98\n//  98–138  bars open back to full frame; the title grows to hero size, never\n//          taller than the opening\n//  128–150 caption settles under the title; poster hold to 180\nconst SCOPE_AT = -6;\nconst SCOPE_END = 24;\nconst SLIT_AT = 32;\nconst SLIT_END = 64;\nconst RISE_AT = 50;\nconst RISE_END = 74;\nconst OPEN_AT = 98;\nconst OPEN_END = 138;\nconst GROW_AT = 102;\nconst GROW_END = 146;\nconst CAPTION_AT = 128;\nconst CAPTION_END = 150;\n\n/** Bars: a mechanical matte, eased at both ends. */\nconst BARS = Easing.bezier(0.62, 0, 0.3, 1);\nconst OPEN = Easing.bezier(0.7, 0, 0.22, 1);\nconst EASE_OUT = Easing.bezier(0.22, 1, 0.36, 1);\nconst CLAMP = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\n/** Hero tracking at full size, in em. The serif wants less than a grotesk. */\nconst HERO_TRACK = -0.022;\n\ninterface TitleMetrics {\n  /** Cap top and baseline inside a line-height: 1 box, in em. */\n  capTop: number;\n  baseline: number;\n  /** Descender depth below the baseline, in em. */\n  descent: number;\n  /** Title advance at 1em with hero tracking, in em. */\n  width: number;\n}\n\nfunction measureTitle(title: string): TitleMetrics {\n  const ctx = document.createElement(\"canvas\").getContext(\"2d\");\n  const n = Math.max(1, [...title].length);\n  if (!ctx) {\n    return { capTop: 0.2, baseline: 0.85, descent: 0.22, width: n * 0.42 };\n  }\n  ctx.font = `400 200px \"${serif.fontFamily}\"`;\n  const box = ctx.measureText(\"H\");\n  const run = ctx.measureText(title);\n  const asc = box.fontBoundingBoxAscent / 200;\n  const desc = box.fontBoundingBoxDescent / 200;\n  const baseline = 0.5 + (asc - desc) / 2;\n  return {\n    capTop: baseline - box.actualBoundingBoxAscent / 200,\n    baseline,\n    descent: Math.max(0.12, run.actualBoundingBoxDescent / 200),\n    width: run.width / 200 + HERO_TRACK * (n - 1),\n  };\n}\n\nfunction useTitleMetrics(title: string) {\n  const [metrics, setMetrics] = useState<TitleMetrics | null>(null);\n  const [handle] = useState(() => delayRender(\"aspect-slit: measuring\"));\n  const released = useRef(false);\n  useEffect(() => {\n    let live = true;\n    Promise.all([\n      serif.waitUntilDone(),\n      sans.waitUntilDone(),\n      document.fonts.load(`400 100px \"${serif.fontFamily}\"`),\n    ])\n      .catch(() => undefined)\n      .then(() => {\n        if (!live) return;\n        setMetrics(measureTitle(title));\n        if (!released.current) {\n          released.current = true;\n          continueRender(handle);\n        }\n      });\n    return () => {\n      live = false;\n    };\n  }, [title, handle]);\n  return metrics;\n}\n\n/**\n * The opening is lit by a gradients.sh palette: \"mesh\" on dark (built on the\n * accent), \"pixel-glass\" washed onto the off-white stage for light. Blob\n * positions drift a few percent over the shot.\n */\nconst BLOBS = {\n  dark: [\n    { color: \"#8796EE\", x: 16, y: 20, a: 0.9 },\n    { color: \"#ACCENT\", x: 84, y: 26, a: 0.95 },\n    { color: \"#0E1645\", x: 70, y: 96, a: 0.6 },\n    { color: \"#1D2A8F\", x: 22, y: 82, a: 0.85 },\n    { color: \"#ACCENT\", x: 52, y: 48, a: 0.8 },\n    { color: \"#ECDCF2\", x: 92, y: 62, a: 0.55 },\n  ],\n  light: [\n    { color: \"#7A3CFF\", x: 18, y: 24, a: 0.2 },\n    { color: \"#FF4FB8\", x: 82, y: 30, a: 0.16 },\n    { color: \"#3D6BFF\", x: 66, y: 84, a: 0.22 },\n    { color: \"#ACCENT\", x: 24, y: 82, a: 0.14 },\n  ],\n} as const;\n\nconst THEMES = {\n  dark: {\n    bars: \"#0A0A0B\",\n    ground: \"#2E45B7\",\n    ink: \"#FFFFFF\",\n    caption: \"rgba(255,255,255,0.78)\",\n    grain: 0.07,\n    accentPeriod: false,\n  },\n  light: {\n    bars: \"#0A0A0B\",\n    ground: \"#F4F3F1\",\n    ink: \"#111113\",\n    caption: \"#5E5C57\",\n    grain: 0.05,\n    accentPeriod: true,\n  },\n} as const;\n\nconst alpha = (color: string, a: number) =>\n  `color-mix(in oklab, ${color} ${Math.round(a * 100)}%, transparent)`;\n\nfunction Grain({ opacity }: { opacity: number }) {\n  const id = `g${useId().replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n  return (\n    <svg\n      width=\"100%\"\n      height=\"100%\"\n      style={{ position: \"absolute\", inset: 0, opacity, pointerEvents: \"none\" }}\n      aria-hidden\n    >\n      <filter id={id}>\n        <feTurbulence\n          type=\"fractalNoise\"\n          baseFrequency=\"0.85\"\n          numOctaves={2}\n          seed={7}\n          stitchTiles=\"stitch\"\n        />\n        <feColorMatrix values=\"0 0 0 0 0.5  0 0 0 0 0.5  0 0 0 0 0.5  0 0 0 0.9 0\" />\n      </filter>\n      <rect width=\"100%\" height=\"100%\" filter={`url(#${id})`} />\n    </svg>\n  );\n}\n\nexport function AspectSlit({\n  title = \"Now showing.\",\n  caption = \"A launch film, built in React.\",\n  slit = 0.125,\n  accent = \"#3B5BFD\",\n  speed = 1,\n  theme = \"dark\",\n}: AspectSlitProps) {\n  const frame = useCurrentFrame() * speed;\n  const { width: W, height: H } = useVideoConfig();\n  const c = THEMES[theme];\n  const metrics = useTitleMetrics(title);\n\n  // Bar height per side.\n  const scopeBar = Math.max(0, (H - W / 2.39) / 2);\n  const slitH = Math.round(H * slit);\n  const slitBar = (H - slitH) / 2;\n  const toScope = interpolate(frame, [SCOPE_AT, SCOPE_END], [0, 1], {\n    ...CLAMP,\n    easing: BARS,\n  });\n  const toSlit = interpolate(frame, [SLIT_AT, SLIT_END], [0, 1], {\n    ...CLAMP,\n    easing: BARS,\n  });\n  const open = interpolate(frame, [OPEN_AT, OPEN_END], [0, 1], {\n    ...CLAMP,\n    easing: OPEN,\n  });\n  const closed = toScope * scopeBar + toSlit * (slitBar - scopeBar);\n  const bar = closed * (1 - open);\n  const opening = H - 2 * bar;\n\n  const drift = (k: number) => ({\n    x: Math.sin(frame / 44 + k * 1.7) * 3,\n    y: Math.cos(frame / 57 + k * 2.3) * 2.5,\n  });\n  const ground = [\n    ...BLOBS[theme].map((b, k) => {\n      const o = drift(k);\n      const col = b.color === \"#ACCENT\" ? accent : b.color;\n      return `radial-gradient(85% 95% at ${(b.x + o.x).toFixed(2)}% ${(b.y + o.y).toFixed(2)}%, ${alpha(col, b.a)} 0%, transparent 68%)`;\n    }),\n    c.ground,\n  ].join(\", \");\n\n  const bars = (\n    <>\n      <div\n        style={{\n          position: \"absolute\",\n          left: 0,\n          top: 0,\n          width: W,\n          height: Math.ceil(bar),\n          backgroundColor: c.bars,\n        }}\n      />\n      <div\n        style={{\n          position: \"absolute\",\n          left: 0,\n          bottom: 0,\n          width: W,\n          height: Math.ceil(bar),\n          backgroundColor: c.bars,\n        }}\n      />\n    </>\n  );\n\n  if (!metrics) {\n    return (\n      <div\n        style={{\n          position: \"absolute\",\n          inset: 0,\n          background: ground,\n          overflow: \"hidden\",\n        }}\n      >\n        {bars}\n      </div>\n    );\n  }\n  const { capTop: CAP_TOP, baseline: BASELINE } = metrics;\n\n  // Title sizes: in the slit its caps take about half the gap; at hero size it\n  // spans most of the width.\n  const slitSize = (slitH * 0.5) / (BASELINE - CAP_TOP);\n  const heroSize = Math.min(H * 0.3, (W * 0.78) / metrics.width);\n  const grow = interpolate(frame, [GROW_AT, GROW_END], [0, 1], {\n    ...CLAMP,\n    easing: EASE_OUT,\n  });\n  // A slow push while it sits in the slit, so the hold is never frozen.\n  const push = interpolate(frame, [RISE_END - 6, OPEN_AT + 4], [0, 1], CLAMP);\n  const wanted = slitSize * (1 + 0.035 * push) + (heroSize - slitSize) * grow;\n  // Framed, never clipped: the ink stays inside the opening with room to spare.\n  const size = Math.min(\n    wanted,\n    (opening * 0.6) / (BASELINE + metrics.descent - CAP_TOP),\n  );\n  const scale = size / heroSize;\n\n  const rise = interpolate(frame, [RISE_AT, RISE_END], [0, 1], {\n    ...CLAMP,\n    easing: EASE_OUT,\n  });\n  const tracking = interpolate(\n    frame,\n    [RISE_AT, OPEN_AT, GROW_END],\n    [0.01, -0.012, HERO_TRACK],\n    CLAMP,\n  );\n\n  const captionIn = interpolate(frame, [CAPTION_AT, CAPTION_END], [0, 1], {\n    ...CLAMP,\n    easing: EASE_OUT,\n  });\n  const captionSize = H * 0.034;\n  const captionGap = H * 0.028;\n\n  // Cap-height centre of the title sits on the frame centre, then the group\n  // (title + caption) recentres as the caption arrives.\n  const capMid = ((CAP_TOP + BASELINE) / 2) * size;\n  const descender = metrics.descent * size;\n  const groupShift = -captionIn * (descender + captionGap + captionSize) * 0.5;\n  // Starts with its cap line just behind the lower bar's edge, wherever it is.\n  const riseFrom = opening / 2 + ((BASELINE - CAP_TOP) / 2) * size + 2;\n  const titleTop = H / 2 - capMid + groupShift + (1 - rise) * riseFrom;\n  const baseline = titleTop + BASELINE * size;\n\n  const body =\n    c.accentPeriod && title.endsWith(\".\") ? title.slice(0, -1) : title;\n  const period = body !== title;\n\n  return (\n    <AbsoluteFill style={{ background: ground, overflow: \"hidden\" }}>\n      <Grain opacity={c.grain} />\n      {rise > 0 ? (\n        <div\n          style={{\n            position: \"absolute\",\n            left: 0,\n            width: W,\n            top: titleTop,\n            height: heroSize,\n            display: \"flex\",\n            justifyContent: \"center\",\n          }}\n        >\n          <div\n            style={{\n              fontFamily: serif.fontFamily,\n              fontWeight: 400,\n              fontSize: heroSize,\n              lineHeight: 1,\n              letterSpacing: `${tracking}em`,\n              color: c.ink,\n              whiteSpace: \"nowrap\",\n              transform: `scale(${scale})`,\n              transformOrigin: \"50% 0%\",\n              // letter-spacing trails the last glyph; pad it back to centre the ink\n              paddingRight: `${-tracking}em`,\n            }}\n          >\n            {body}\n            {period ? <span style={{ color: accent }}>.</span> : null}\n          </div>\n        </div>\n      ) : null}\n\n      {caption && captionIn > 0 ? (\n        <div\n          style={{\n            position: \"absolute\",\n            left: 0,\n            right: 0,\n            top: baseline + descender + captionGap - captionSize * 0.2,\n            textAlign: \"center\",\n            fontFamily: sans.fontFamily,\n            fontWeight: 400,\n            fontSize: captionSize,\n            lineHeight: 1.2,\n            letterSpacing: \"-0.005em\",\n            color: c.caption,\n            opacity: captionIn,\n            transform: `translateY(${(1 - captionIn) * H * 0.012}px)`,\n          }}\n        >\n          {caption}\n        </div>\n      ) : null}\n\n      {bars}\n    </AbsoluteFill>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/aspect-slit.tsx"
    }
  ],
  "type": "registry:component"
}
