{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "caption-split-flap",
  "title": "Caption Split Flap",
  "description": "Every letter sits on its own flap tile and clatters through a few characters before landing the moment the word is spoken, like a station departure board.",
  "dependencies": ["remotion", "@remotion/captions"],
  "registryDependencies": ["@remocn/caption-core"],
  "files": [
    {
      "path": "registry/remocn/caption-split-flap/index.tsx",
      "content": "\"use client\";\n\nimport { type CSSProperties, Fragment } from \"react\";\nimport { Easing, interpolate, random } from \"remotion\";\nimport {\n  type CaptionBaseProps,\n  captionWordText,\n  DEFAULT_HOLD_MS,\n  getExitWindowMs,\n  groupCaptionWords,\n  useCaptions,\n} from \"@/lib/remocn/caption-core\";\n\nexport interface CaptionSplitFlapProps extends CaptionBaseProps {\n  tileColor?: string;\n}\n\nconst ENTER_MS = 160;\nconst EXIT_MS = 220;\nconst RISE_EM = 0.14;\nconst FLIP_MS = 60;\nconst MIN_SETTLE_MS = 350;\nconst TILE_W = 0.78;\nconst TILE_H = 1.2;\nconst TILE_GAP = 0.06;\nconst GLYPH_SCALE = 0.8;\nconst RADIUS = 0.08;\nconst GLYPHS = \"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\";\n\nconst enterEasing = Easing.bezier(0.2, 0.8, 0.2, 1);\n\nconst clamp = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\ninterface FlapFrame {\n  current: string;\n  next: string;\n  progress: number | null;\n}\n\nfunction flapFrame(\n  target: string,\n  seed: string,\n  index: number,\n  count: number,\n  fromMs: number,\n  windowMs: number,\n  timeMs: number,\n): FlapFrame {\n  const flips = 3 + Math.floor(random(`${seed}-flips`) * 4);\n  const share = count > 1 ? index / (count - 1) : 1;\n  const endMs = fromMs + windowMs * (0.55 + 0.45 * share);\n  const flipMs = Math.min(FLIP_MS, (endMs - fromMs) / flips);\n  const startMs = endMs - flips * flipMs;\n  if (timeMs < startMs) return { current: \"\", next: \"\", progress: null };\n  if (timeMs >= endMs) return { current: target, next: target, progress: null };\n  const glyphAt = (k: number): string => {\n    if (k <= 0) return \"\";\n    if (k >= flips) return target;\n    return GLYPHS[Math.floor(random(`${seed}-${k}`) * GLYPHS.length)];\n  };\n  const elapsed = (timeMs - startMs) / flipMs;\n  const k = Math.floor(elapsed);\n  return { current: glyphAt(k), next: glyphAt(k + 1), progress: elapsed - k };\n}\n\nconst TOP_HALF = \"inset(0 0 50% 0)\";\nconst BOTTOM_HALF = \"inset(50% 0 0 0)\";\n\nfunction Leaf({\n  char,\n  tileColor,\n  clipPath,\n  transform,\n  brightness = 1,\n}: {\n  char: string;\n  tileColor: string;\n  clipPath?: string;\n  transform?: string;\n  brightness?: number;\n}) {\n  const style: CSSProperties = {\n    position: \"absolute\",\n    inset: 0,\n    display: \"flex\",\n    alignItems: \"center\",\n    justifyContent: \"center\",\n    background: tileColor,\n    borderRadius: `${RADIUS}em`,\n    clipPath,\n    transform,\n    transformOrigin: \"50% 50%\",\n    filter: brightness < 1 ? `brightness(${brightness})` : undefined,\n  };\n  return (\n    <span style={style}>\n      <span style={{ fontSize: `${GLYPH_SCALE}em`, lineHeight: 1 }}>\n        {char}\n      </span>\n    </span>\n  );\n}\n\nfunction Tile({ frame, tileColor }: { frame: FlapFrame; tileColor: string }) {\n  const perspective = `perspective(${TILE_H * 4}em)`;\n  const { current, next, progress } = frame;\n  return (\n    <span\n      style={{\n        position: \"relative\",\n        display: \"inline-block\",\n        width: `${TILE_W}em`,\n        height: `${TILE_H}em`,\n        flexShrink: 0,\n      }}\n    >\n      {progress === null ? (\n        <Leaf char={current} tileColor={tileColor} />\n      ) : (\n        <>\n          <Leaf char={next} tileColor={tileColor} clipPath={TOP_HALF} />\n          <Leaf char={current} tileColor={tileColor} clipPath={BOTTOM_HALF} />\n          {progress < 0.5 ? (\n            <Leaf\n              char={current}\n              tileColor={tileColor}\n              clipPath={TOP_HALF}\n              transform={`${perspective} rotateX(${-180 * progress}deg)`}\n              brightness={1 - 0.7 * progress}\n            />\n          ) : (\n            <Leaf\n              char={next}\n              tileColor={tileColor}\n              clipPath={BOTTOM_HALF}\n              transform={`${perspective} rotateX(${180 * (1 - progress)}deg)`}\n              brightness={0.65 + 0.7 * (progress - 0.5)}\n            />\n          )}\n        </>\n      )}\n      <span\n        aria-hidden\n        style={{\n          position: \"absolute\",\n          left: 0,\n          right: 0,\n          top: \"50%\",\n          height: \"max(1px, 0.025em)\",\n          transform: \"translateY(-50%)\",\n          background: \"rgba(0, 0, 0, 0.6)\",\n        }}\n      />\n    </span>\n  );\n}\n\nexport function CaptionSplitFlap({\n  captions,\n  combineTokensWithinMilliseconds = 1000,\n  holdMs = DEFAULT_HOLD_MS,\n  fontSize = 56,\n  fontWeight = 800,\n  color = \"#ffffff\",\n  tileColor = \"#111111\",\n  className,\n}: CaptionSplitFlapProps) {\n  const state = useCaptions({\n    captions,\n    combineTokensWithinMilliseconds,\n    holdMs,\n  });\n  if (!state) return null;\n\n  const enter = interpolate(\n    state.previous ? ENTER_MS : state.sinceStartMs,\n    [0, ENTER_MS],\n    [0, 1],\n    { ...clamp, easing: enterEasing },\n  );\n  const exit = state.endsInSilence\n    ? interpolate(\n        state.untilEndMs,\n        [0, getExitWindowMs(state, EXIT_MS)],\n        [0, 1],\n        clamp,\n      )\n    : 1;\n\n  return (\n    <div\n      className={className}\n      style={{\n        fontSize,\n        fontWeight,\n        color,\n        lineHeight: TILE_H + 0.24,\n        textAlign: \"center\",\n        opacity: Math.min(enter, exit),\n        transform: `translateY(${(1 - enter) * RISE_EM}em)`,\n      }}\n    >\n      {groupCaptionWords(state.tokens).map((word, w) => {\n        const first = word[0];\n        const fromMs = first.token.fromMs;\n        const toMs = word[word.length - 1].token.toMs;\n        const windowMs = Math.max(toMs - fromMs, MIN_SETTLE_MS);\n        const chars = Array.from(captionWordText(word));\n        return (\n          <Fragment key={`${state.pageIndex}-${first.index}`}>\n            {w > 0 && \" \"}\n            <span\n              style={{\n                display: \"inline-flex\",\n                gap: `${TILE_GAP}em`,\n                verticalAlign: \"top\",\n                paddingInline: `${TILE_GAP}em`,\n              }}\n            >\n              {chars.map((char, c) => (\n                <Tile\n                  key={c}\n                  tileColor={tileColor}\n                  frame={flapFrame(\n                    char,\n                    `${state.pageIndex}-${first.index}-${c}`,\n                    c,\n                    chars.length,\n                    fromMs,\n                    windowMs,\n                    state.timeMs,\n                  )}\n                />\n              ))}\n            </span>\n          </Fragment>\n        );\n      })}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/caption-split-flap.tsx"
    }
  ],
  "type": "registry:component"
}
