{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "punch-lines",
  "title": "Punch Lines",
  "description": "Kinetic typography title cards: bold statements that cut hard between beats. A run of full-frame title cards that cut hard between beats. A statement card is flicked in from the left line by line and settles on a critically damped curve, then rushes past the camera; a hero card punches its words up from a third of their size in a wave and pushes slowly forward until it cuts. Every curve is fitted from frame measurements — the slide to 0.42px rms over 37 frames.",
  "dependencies": [
    "remotion",
    "@remotion/google-fonts"
  ],
  "registryDependencies": [
    "https://snapcn.dev/r/snap-cn-ui.json"
  ],
  "files": [
    {
      "path": "registry/snap-cn/punch-lines/index.tsx",
      "content": "// Punch Lines · snapcn — https://snapcn.dev/docs/text/punch-lines\n\"use client\";\n\nimport { loadFont as loadSans } from \"@remotion/google-fonts/Inter\";\nimport { Fragment, useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n  AbsoluteFill,\n  continueRender,\n  delayRender,\n  Easing,\n  getRemotionEnvironment,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\nimport {\n  Item,\n  resolveFont,\n  type SnapCnTheme,\n  useSnapCnTheme,\n} from \"@/lib/snap-cn-ui\";\n\n/**\n * A run of full-frame title cards that cut hard between beats.\n *\n * Two looks, and the sequence alternates them:\n *\n * - **slide** — the statement card. Its lines are already at full size when the\n *   card opens, sitting left of centre; each one is flicked right and settles\n *   home on a critically damped curve, one line behind the last. It leaves by\n *   rushing past the camera.\n * - **punch** — the hero line. Every word starts a third of its size and swells\n *   into place in a wave, while the whole line pushes slowly forward for as long\n *   as it is on screen. It leaves on a cut.\n *\n * It paints a whole page rather than a control, so it takes the design system's\n * two page colours — `background` and `foreground` — and inverts with `mode`.\n * Only the accent is a bare prop, because an accent that is supposed to shout is\n * the design and not a token.\n *\n * ## Where the numbers come from\n *\n * Every curve here was fitted to a reference recording, frame by frame, not\n * eyeballed. The recording is 24fps variable-rate, so each fit is against the\n * frames' real timestamps and the results are stored in **seconds** — a frame\n * index is not a clock.\n *\n * | beat | signal | fit |\n * | --- | --- | --- |\n * | slide | each line's ink centroid, 37 frames, both lines at once | `(A + Bt)·e^(-λt)`, **rms 0.42px** |\n * | punch | per-word ink width, 27 frames | scale `0.32 → 1` on an in-out cubic |\n * | push | per-word ink width once the wave has landed | linear, `+20.5%/s` |\n * | rush | ink second moment (`rmsX`/`rmsY` agreed to 0.02%, so it is a pure scale) | `1/(1 − travel)`, travel on a hard ease-in |\n *\n * Two of those are worth keeping because they are the sort of thing you cannot\n * see and would never guess:\n *\n * - **The slide is not a spring.** A damped sinusoid fits the overshoot and then\n *   demands an undershoot that is not there; a spring released from rest cannot\n *   overshoot 36px without starting 2,000px out. What actually fits, to under\n *   half a pixel over 37 frames, is a **critically damped** system given an\n *   initial kick: `(A + Bt)·e^(-λt)`. It starts 0.74em left of centre, sails\n *   0.71em past it, and comes home without ever crossing back.\n * - **The rush is a scale about the frame centre, not the type's own centre.**\n *   Solving the ink centroid for the pivot lands on the middle of the frame in\n *   both axes, which is why the lower line dives *down* out of shot while the\n *   upper one climbs out of the top.\n *\n * ## The cadence, and why a card is already moving when you first see it\n *\n * `holds` is time **on screen**, one entry per card, so the rhythm is exactly\n * what you wrote and no card can ever bleed over the one in front of it. The\n * cards are a straight run of hard cuts.\n *\n * The one thing that happens off screen is `lead`: a card's clock starts a few\n * frames before its cut, so it is caught mid-entrance rather than starting from\n * nothing. That is the reference's own trick — its third card is at 87% of its\n * size on the first frame you can see it, with not one coloured pixel on the\n * frame before — and it is most of what makes a cut read as expensive rather\n * than abrupt. `lead: 0` plays every entrance in full.\n */\nconst { fontFamily: SANS } = loadSans(\"normal\", {\n  weights: [\"400\", \"500\", \"600\", \"700\"],\n  subsets: [\"latin\"],\n});\n\n/** How a card arrives, and how it leaves. */\nexport type PunchLineStyle = \"slide\" | \"punch\";\n\nexport interface PunchLinesProps {\n  /**\n   * The whole script in one string. `|` starts a new card, `/` starts a new\n   * line inside a card:\n   *\n   * ```\n   * \"Ready-made scenes / for Remotion. | One command. Own it. | snapcn.\"\n   * ```\n   */\n  script?: string;\n  /**\n   * Per-card look — `slide` or `punch` — comma separated. The last value repeats,\n   * so `\"slide,punch\"` gives the first card the statement look and every card\n   * after it the hero look.\n   */\n  styles?: string;\n  /**\n   * Per-card type size, as a multiple of `fontSize`, comma separated; the last\n   * value repeats. A one-word payoff wants to be much bigger than the sentence\n   * that set it up — that is the whole point of the beat.\n   */\n  sizes?: string;\n  /**\n   * Per-card length in frames, comma separated; the last value repeats. Measured\n   * from the frame the card's own clock starts, so it includes the entrance and\n   * the exit.\n   */\n  holds?: string;\n  /** Which card is painted in `accentColor`, counting from 1. `0` = none. */\n  accentBeat?: number;\n\n  /**\n   * Frames of a card's entrance that run before it is cut to — it arrives\n   * already moving instead of from nothing. `0` plays every entrance in full.\n   */\n  lead?: number;\n\n  /** Type size of a `size = 1` card, in px. */\n  fontSize?: number;\n  fontWeight?: number | string;\n  /**\n   * Baseline-to-baseline distance inside a card, in em. Tight on purpose — a\n   * statement card is a lockup, not a paragraph — but a face's descender depth\n   * and ascender height both eat into the gap, so a number carried over from\n   * another face will collide rather than kiss.\n   */\n  lineHeight?: number;\n  /** Resting letter-spacing. */\n  letterSpacing?: string;\n  /**\n   * Resting word-spacing. The reference crushes it almost to nothing — measured,\n   * its word gaps are 0.17em where the face's own space is 0.26em — which is what\n   * makes a line read as one block of type rather than four words.\n   */\n  wordSpacing?: string;\n\n  /** The ground the cards are cut on. Defaults to the design system's page. */\n  ground?: string;\n  /** The type. Defaults to the design system's foreground. */\n  ink?: string;\n  /**\n   * The one card that is not the type colour.\n   *\n   * A prop and not `theme.primary` on purpose: an accent that is *supposed* to\n   * shout is the design, not a token — see the design system's note on burned-in\n   * video type. Swap it for your brand's and the rest of the card still follows\n   * whatever palette it was dropped into.\n   */\n  accentColor?: string;\n\n  /** Design-system token overrides. */\n  theme?: Partial<SnapCnTheme>;\n  mode?: \"light\" | \"dark\";\n  /**\n   * The face the cards are set in — a label from `fonts.ts` (\"Geist\", \"Outfit\",\n   * \"Montserrat\") or a CSS family you have loaded yourself. Unset, the cards are\n   * set in Inter, the same face the rest of the text tier uses.\n   */\n  fontFamily?: string;\n\n  /** How far left of centre a slide card's line starts, in em. */\n  slideFrom?: number;\n  /** The kick that throws it right, in em per second. */\n  slideKick?: number;\n  /** How fast the slide settles. Higher is tighter. */\n  slideSettle?: number;\n  /** Frames between one line of a slide card leaving and the next. */\n  lineStagger?: number;\n  /**\n   * How far below the optical centre a slide card hangs, in em. The reference's\n   * two-line statement sits low so the eye lands on the first line rather than\n   * in the gap between them.\n   */\n  slideDrop?: number;\n  /**\n   * Shutter angle for the slide's motion blur, as a fraction of the frame — `1`\n   * is a fully open shutter, `0` turns it off.\n   *\n   * A browser animating `translate` draws every frame perfectly sharp. Film, and\n   * anything rendered with a shutter, does not: a line travelling 30px in a\n   * frame is smeared across those 30px, and its absence is most of what makes a\n   * fast move read as \"computer graphics\". The reference has it — measured, its\n   * first frame's ink is spread by exactly the frame's own travel — so this\n   * smears the line horizontally by the distance it covered, and by nothing at\n   * all once it has stopped.\n   */\n  motionBlur?: number;\n\n  /** The size a punch card's word starts at, relative to its final size. */\n  punchFrom?: number;\n  /** Frames a word takes to swell into place. */\n  punchFrames?: number;\n  /** Frames between one word starting to swell and the next. */\n  wordStagger?: number;\n  /** The size a punch card's line opens at, relative to its final size. */\n  pushFrom?: number;\n  /** How fast the line keeps growing while it holds, per second. */\n  pushRate?: number;\n\n  /** Frames before a rushing card ends that it starts to rush. */\n  rushLead?: number;\n  /** Frames the rush would take to run its whole travel. */\n  rushFrames?: number;\n  /**\n   * How far the card travels toward the camera, as a fraction of its distance.\n   * `0.85` ends at 6.7× — long gone off every edge.\n   */\n  rushTravel?: number;\n\n  /** Fraction of the frame width a line may occupy before it is scaled down. */\n  fit?: number;\n  speed?: number;\n  className?: string;\n}\n\n/**\n * A word swelling into place. Symmetric in-out cubic: it leaves and arrives at a\n * standstill, and spends its frames in the middle where you can see them.\n *\n * Fitted twice — on the last word of the second beat and on the single word of\n * the third — and both landed on 11 frames at 30fps, which is the check that it\n * is the real curve and not one that happened to fit one sample.\n */\nconst PUNCH_EASE = Easing.bezier(0.65, 0, 0.35, 1);\n\n/**\n * The card's travel toward the camera on the way out.\n *\n * Brutally eased in: 28% of the travel in the first 60% of the time, and the\n * rest in the last 40%. That is what makes the exit read as a *rush* rather than\n * a zoom — nothing appears to happen, and then the type is past you.\n */\nconst RUSH_EASE = Easing.bezier(1, 0, 0.48, 1);\n\n/**\n * The furthest a slide card's line ever gets from centre, in em — its start, or\n * the far side of its overshoot, whichever is bigger. Needed to fit a card: the\n * line has to be inside the frame at both ends of the swing, not just at rest.\n */\nexport function slideSwing(from: number, kick: number, settle: number): number {\n  // (A + Bt)·e^(-λt) turns over where B = λ(A + Bt). No kick, no turn — and the\n  // division would be an infinity that comes back as NaN and quietly disables\n  // the fit for the one card that most needs it.\n  const peakAt = kick > 0 ? Math.max(0, 1 / settle - from / kick) : 0;\n  const peak = (from + kick * peakAt) * Math.exp(-settle * peakAt);\n  return Math.max(Math.abs(from), Math.abs(peak));\n}\n\n/** Split a comma list, trimmed, empties dropped. */\nconst list = (s: string | undefined): string[] =>\n  (s ?? \"\")\n    .split(\",\")\n    .map((v) => v.trim())\n    .filter(Boolean);\n\n/** Read index `i` out of a list, repeating the last value forever. */\nconst at = <T,>(values: T[], i: number, fallback: T): T =>\n  values.length === 0\n    ? fallback\n    : (values[Math.min(i, values.length - 1)] as T);\n\nexport interface PunchBeat {\n  lines: string[][];\n  style: PunchLineStyle;\n  size: number;\n  accent: boolean;\n  /** Frame the card's own clock starts — `lead` frames before it is revealed. */\n  start: number;\n  /** Frame the card leaves the screen. */\n  end: number;\n  /** Frame the card is cut to, which is the frame the card in front of it ends. */\n  revealAt: number;\n}\n\n/**\n * Parse the script and lay the cards out on the timeline.\n *\n * `holds[i]` is time **on screen**, so the cards are a straight run of hard cuts\n * and the cadence is exactly what you wrote — no card can bleed over the one in\n * front of it, whichever way that one leaves.\n *\n * `lead` is the only thing that happens off screen: a card's clock starts that\n * many frames before its cut, so it arrives already moving rather than from\n * nothing. Raise it and the cut lands harder; set it to 0 and every card plays\n * its entrance in full.\n */\nexport function planPunchLines(\n  script: string,\n  styles: string,\n  sizes: string,\n  holds: string,\n  accentBeat: number,\n  lead: number,\n): PunchBeat[] {\n  const texts = script\n    .split(\"|\")\n    .map((s) => s.trim())\n    .filter(Boolean);\n  const styleList = list(styles) as PunchLineStyle[];\n  const sizeList = list(sizes).map(Number).filter(Number.isFinite);\n  const holdList = list(holds).map(Number).filter(Number.isFinite);\n\n  const beats: PunchBeat[] = [];\n  let revealAt = 0;\n  for (let i = 0; i < texts.length; i++) {\n    const style = at(styleList, i, \"punch\");\n    const hold = Math.max(1, at(holdList, i, 48));\n    beats.push({\n      lines: (texts[i] as string)\n        .split(\"/\")\n        .map((l) => l.trim())\n        .filter(Boolean)\n        .map((l) => l.split(/\\s+/)),\n      style: style === \"slide\" ? \"slide\" : \"punch\",\n      size: at(sizeList, i, 1),\n      accent: i + 1 === accentBeat,\n      // Nothing is behind the first card, so there is nothing for it to arrive\n      // out of — it starts where it is revealed.\n      start: i === 0 ? 0 : revealAt - lead,\n      revealAt,\n      end: revealAt + hold,\n    });\n    revealAt += hold;\n  }\n  return beats;\n}\n\n/** Total frames a script needs, so the config and the component cannot drift. */\nexport function punchLinesDuration(script: string, holds: string): number {\n  const n = script\n    .split(\"|\")\n    .map((s) => s.trim())\n    .filter(Boolean).length;\n  const holdList = list(holds).map(Number).filter(Number.isFinite);\n  let total = 0;\n  for (let i = 0; i < n; i++) total += Math.max(1, at(holdList, i, 48));\n  return total;\n}\n\ninterface FaceMetrics {\n  /** Top of the line box down to the baseline, at `fontSize` 100. */\n  baseline: number;\n  /**\n   * Per card, at `fontSize` 100: how far its first line's *ink* rises above the\n   * baseline, and how far its last line's ink falls below one.\n   *\n   * Both measured from the copy actually passed, because a card is centred on\n   * the ink you can see and nothing else knows where that is. A face's cap\n   * height is the wrong number twice over: some faces draw their ascenders\n   * taller than their caps, so a line with an ascender sits high; and a word\n   * with no ascender at all but a descender in it — a lowercase wordmark, say —\n   * is not a cap band, and centring it as one drops it half a descender low.\n   */\n  inkAscent: number[];\n  inkDescent: number[];\n}\n\nexport function PunchLines({\n  script = \"Ready-made scenes / for Remotion. | One command. Own it. | snapcn.\",\n  styles = \"slide,punch\",\n  sizes = \"1,0.945,2.7\",\n  holds = \"48\",\n  accentBeat = 3,\n  lead = 4,\n\n  fontSize = 93,\n  fontWeight = 600,\n  lineHeight = 0.89,\n  letterSpacing = \"-0.022em\",\n  wordSpacing = \"-0.02em\",\n\n  ground,\n  ink,\n  accentColor = \"#ff1c8e\",\n\n  theme,\n  mode,\n  fontFamily,\n\n  slideFrom = -0.737,\n  slideKick = 7.34,\n  slideSettle = 2.846,\n  lineStagger = 3.5,\n  slideDrop = 0.313,\n  motionBlur = 1,\n\n  punchFrom = 0.32,\n  punchFrames = 15,\n  wordStagger = 2.8,\n  pushFrom = 0.778,\n  pushRate = 0.205,\n\n  rushLead = 12.2,\n  rushFrames = 18,\n  rushTravel = 0.85,\n\n  fit = 0.92,\n  speed = 1,\n  className,\n}: PunchLinesProps) {\n  const frame = useCurrentFrame() * speed;\n  const { width, fps } = useVideoConfig();\n  const t = useSnapCnTheme(theme, mode);\n  const face = resolveFont(fontFamily ?? t.fontFamily) ?? SANS;\n  // A card is a full-bleed page, so it takes the page's own two colours rather\n  // than inventing a pair. Pass `mode=\"dark\"` and the whole run inverts.\n  const paper = ground ?? t.background;\n  const type = ink ?? t.foreground;\n\n  const beats = useMemo(\n    () => planPunchLines(script, styles, sizes, holds, accentBeat, lead),\n    [script, styles, sizes, holds, accentBeat, lead],\n  );\n\n  // Two things have to be measured rather than assumed, both constant across\n  // every frame: where the baseline sits inside a line box, and how far each\n  // card's own first line rises above it. Together they place the block — a card\n  // is centred on its *ink*, not on its line boxes, which is what keeps a\n  // one-line hero optically centred while a two-line statement hangs from the\n  // same rule.\n  const probeRef = useRef<HTMLSpanElement>(null);\n  const baselineRef = useRef<HTMLSpanElement>(null);\n  const lineRefs = useRef<(HTMLSpanElement | null)[]>([]);\n  const [handle] = useState(() => delayRender(\"punch-lines: measure the face\"));\n  const [metrics, setMetrics] = useState<FaceMetrics | null>(null);\n  const [lineWidths, setLineWidths] = useState<number[] | null>(null);\n\n  useEffect(() => {\n    const probe = probeRef.current;\n    if (!probe) {\n      continueRender(handle);\n      return;\n    }\n    // An empty, zero-sized inline-block sits with its bottom edge on the text\n    // baseline, so its offsetTop *is* the baseline — read off the real font\n    // metrics instead of guessed from a line-height ratio.\n    const baseline = baselineRef.current?.offsetTop ?? 88;\n    const firstLines = beats.map((b) => (b.lines[0] ?? []).join(\" \"));\n    const lastLines = beats.map((b) =>\n      (b.lines[b.lines.length - 1] ?? []).join(\" \"),\n    );\n    let inkAscent = firstLines.map(() => 70);\n    let inkDescent = lastLines.map(() => 0);\n    try {\n      const ctx = document.createElement(\"canvas\").getContext(\"2d\");\n      if (ctx) {\n        ctx.font = `${fontWeight} 100px ${face}`;\n        inkAscent = firstLines.map((text) => {\n          const a = ctx.measureText(text).actualBoundingBoxAscent;\n          return a > 0 ? a : 70;\n        });\n        inkDescent = lastLines.map((text) =>\n          Math.max(0, ctx.measureText(text).actualBoundingBoxDescent),\n        );\n      }\n    } catch {\n      // A context can be refused (a headless tab with no GPU, a hardened\n      // browser). 0.70em is a sane cap height for any grotesque.\n    }\n    const lineCount = beats.reduce((n, b) => n + b.lines.length, 0);\n    setLineWidths(\n      lineRefs.current\n        .slice(0, lineCount)\n        .map((el) => (el ? el.offsetWidth : 0)),\n    );\n    setMetrics({ baseline, inkAscent, inkDescent });\n  }, [handle, face, fontWeight, beats]);\n\n  // Release the render only once the measurement has re-rendered, so the very\n  // first captured frame already carries the correct geometry.\n  useEffect(() => {\n    if (metrics) continueRender(handle);\n  }, [metrics, handle]);\n\n  const M: FaceMetrics = metrics ?? {\n    baseline: 88,\n    inkAscent: beats.map(() => 70),\n    inkDescent: beats.map(() => 0),\n  };\n\n  // Index every line of every card into one flat list, so one hidden probe row\n  // measures them all and each card can look its own lines up by offset.\n  const lineIndex: number[] = [];\n  {\n    let n = 0;\n    for (const beat of beats) {\n      lineIndex.push(n);\n      n += beat.lines.length;\n    }\n  }\n  // `concat(...map)`, not `flatMap` (ES2019): stock Remotion types ES2015.\n  const allLines = ([] as { words: string[]; size: number }[]).concat(\n    ...beats.map((beat) =>\n      beat.lines.map((words) => ({ words, size: beat.size })),\n    ),\n  );\n\n  return (\n    <AbsoluteFill\n      className={className}\n      style={{\n        backgroundColor: paper,\n        fontFamily: face,\n        fontWeight,\n        // Hinting re-snaps every stem as a size slides, and the letterforms\n        // literally change shape frame to frame. This turns it off and renders\n        // the outline as it is — 15× steadier under a scale, measured. The\n        // slight softness is the absence of a lie, not blur.\n        textRendering: \"geometricPrecision\",\n        WebkitFontSmoothing: \"antialiased\",\n      }}\n    >\n      {/*\n        Two measuring rows, laid out and never painted, and separate on purpose:\n        the baseline probe reads a line box, and a line box grows to fit the\n        tallest thing on it. Share a row with a 250px card title and the baseline\n        it reports is that title's, not the type's.\n      */}\n      <div\n        aria-hidden\n        style={{\n          position: \"absolute\",\n          top: 0,\n          left: 0,\n          visibility: \"hidden\",\n          pointerEvents: \"none\",\n          whiteSpace: \"nowrap\",\n        }}\n      >\n        <span\n          ref={probeRef}\n          style={{ fontSize: 100, lineHeight: `${lineHeight * 100}px` }}\n        >\n          Hxg\n          <span\n            ref={baselineRef}\n            style={{ display: \"inline-block\", width: 0, height: 0 }}\n          />\n        </span>\n      </div>\n      <div\n        aria-hidden\n        style={{\n          position: \"absolute\",\n          top: 0,\n          left: 0,\n          visibility: \"hidden\",\n          pointerEvents: \"none\",\n          whiteSpace: \"nowrap\",\n        }}\n      >\n        {allLines.map((line, i) => (\n          <span\n            // biome-ignore lint/suspicious/noArrayIndexKey: the probe row is positional\n            key={i}\n            ref={(el) => {\n              lineRefs.current[i] = el;\n            }}\n            style={{\n              // inline-block, NOT block. A block-level child of a shrink-to-fit\n              // absolutely positioned box fills that box, so every probe reported\n              // the *widest line in the whole script* as its own width — and one\n              // long card quietly took a size off every other card.\n              display: \"inline-block\",\n              fontSize: fontSize * line.size,\n              lineHeight: `${lineHeight}em`,\n              letterSpacing,\n              wordSpacing,\n            }}\n          >\n            {line.words.join(\" \")}\n          </span>\n        ))}\n      </div>\n\n      {beats.map((beat, i) => (\n        <Card\n          // biome-ignore lint/suspicious/noArrayIndexKey: cards are positional\n          key={i}\n          beat={beat}\n          frame={frame}\n          fps={fps}\n          width={width}\n          fontSize={fontSize}\n          lineHeight={lineHeight}\n          letterSpacing={letterSpacing}\n          wordSpacing={wordSpacing}\n          metrics={M}\n          beatIndex={i}\n          widths={\n            lineWidths\n              ? lineWidths.slice(\n                  lineIndex[i] as number,\n                  (lineIndex[i] as number) + beat.lines.length,\n                )\n              : null\n          }\n          color={beat.accent ? accentColor : type}\n          fit={fit}\n          slideFrom={slideFrom}\n          slideKick={slideKick}\n          slideSettle={slideSettle}\n          lineStagger={lineStagger}\n          slideDrop={slideDrop}\n          motionBlur={motionBlur}\n          punchFrom={punchFrom}\n          punchFrames={punchFrames}\n          wordStagger={wordStagger}\n          pushFrom={pushFrom}\n          pushRate={pushRate}\n          rushLead={rushLead}\n          rushFrames={rushFrames}\n          rushTravel={rushTravel}\n        />\n      ))}\n    </AbsoluteFill>\n  );\n}\n\n/**\n * One card.\n *\n * Nothing here reflows: the type is laid out once at its resting size and every\n * beat of the animation is a `scale` or a `translate` on top of it.\n */\nfunction Card({\n  beat,\n  frame,\n  fps,\n  width,\n  fontSize,\n  lineHeight,\n  letterSpacing,\n  wordSpacing,\n  metrics,\n  beatIndex,\n  widths,\n  color,\n  fit,\n  slideFrom,\n  slideKick,\n  slideSettle,\n  lineStagger,\n  slideDrop,\n  motionBlur,\n  punchFrom,\n  punchFrames,\n  wordStagger,\n  pushFrom,\n  pushRate,\n  rushLead,\n  rushFrames,\n  rushTravel,\n}: {\n  beat: PunchBeat;\n  frame: number;\n  fps: number;\n  width: number;\n  fontSize: number;\n  lineHeight: number;\n  letterSpacing: string;\n  wordSpacing: string;\n  metrics: FaceMetrics;\n  beatIndex: number;\n  widths: number[] | null;\n  color: string;\n  fit: number;\n  slideFrom: number;\n  slideKick: number;\n  slideSettle: number;\n  lineStagger: number;\n  slideDrop: number;\n  motionBlur: number;\n  punchFrom: number;\n  punchFrames: number;\n  wordStagger: number;\n  pushFrom: number;\n  pushRate: number;\n  rushLead: number;\n  rushFrames: number;\n  rushTravel: number;\n}) {\n  if (frame < beat.revealAt || frame >= beat.end) return null;\n\n  // The card's own clock, in seconds. Everything below is fitted in seconds\n  // because the reference is variable-rate and a frame index is not a clock.\n  const t = (frame - beat.start) / fps;\n\n  // Longest line decides whether the whole card has to come down a size, so the\n  // block never changes shape when one line happens to be the long one — and it\n  // is fitted against the widest the card will ever be, not the width it is laid\n  // out at. A punch card keeps growing for its whole life and a slide card swings\n  // most of an em either side of centre; a fit that ignores both passes at frame\n  // one and runs off the stage later, which is the worst way to find out. The\n  // rush is left out on purpose: leaving the frame is what it is for.\n  const natural = widths?.length ? Math.max(...widths) : 0;\n  const maxPush =\n    beat.style === \"punch\"\n      ? pushFrom + pushRate * ((beat.end - beat.start) / fps)\n      : 1;\n  const swing =\n    beat.style === \"slide\"\n      ? slideSwing(slideFrom, slideKick, slideSettle) * fontSize * beat.size\n      : 0;\n  const need = natural * maxPush + 2 * swing;\n  const shrink = need > width * fit && need > 0 ? (width * fit) / need : 1;\n  const em = fontSize * beat.size * shrink;\n  const L = lineHeight * em;\n\n  // A card is centred on its *ink*: the top of its first line's tallest letter\n  // down to the bottom of its last line's lowest one. Flexbox centres the *line\n  // boxes* instead, and `dy` is the difference — one number, the same for every\n  // line of a card.\n  const baseline = (metrics.baseline / 100) * em;\n  const rise = ((metrics.inkAscent[beatIndex] ?? 70) / 100) * em;\n  const fall = ((metrics.inkDescent[beatIndex] ?? 0) / 100) * em;\n  const dy =\n    L / 2 -\n    baseline +\n    (rise - fall) / 2 +\n    (beat.style === \"slide\" ? slideDrop * em : 0);\n\n  // The push, and the rush, are both scales about the middle of the frame. The\n  // rush's pivot is not a guess: solving the ink centroid across the exit for\n  // the fixed point of the scale puts it within a couple of pixels of the frame\n  // centre in both axes.\n  const push =\n    beat.style === \"punch\" ? pushFrom + pushRate * Math.max(0, t) : 1;\n  const rushT = (frame - (beat.end - rushLead)) / fps;\n  const rush =\n    beat.style === \"slide\" && rushT > 0\n      ? 1 /\n        (1 - rushTravel * RUSH_EASE(Math.min(1, rushT / (rushFrames / fps))))\n      : 1;\n\n  let wordIndex = 0;\n\n  return (\n    <AbsoluteFill\n      style={{\n        alignItems: \"center\",\n        justifyContent: \"center\",\n        color,\n        transform: `scale(${push * rush})`,\n        transformOrigin: \"50% 50%\",\n        // The compositor resampling a bitmap is the right trade in a live\n        // Player, where a line of type reshaped at a brand-new size every frame\n        // will not fit an 8ms budget — and the wrong one in a render, where\n        // parallel tabs inherit each other's stale rasters and a still frame\n        // shimmers. So it is on in one and off in the other.\n        ...(getRemotionEnvironment().isRendering\n          ? null\n          : { willChange: \"transform\" as const }),\n      }}\n    >\n      {/* The card Studio selects is its block of lines, not the full-frame\n          fill around it; it sits inside the push, so the outline scales too. */}\n      <Item index={beatIndex}>\n        <div\n          style={{\n            display: \"flex\",\n            flexDirection: \"column\",\n            alignItems: \"center\",\n            transform: `translateY(${dy}px)`,\n          }}\n        >\n          {beat.lines.map((words, li) => {\n            // Each line of a slide card leaves after the one above it, and the\n            // whole travel is one critically damped move: out at speed, past the\n            // mark by 0.71em, home without ever crossing back.\n            const lt = t - (li * lineStagger) / fps;\n            const slide = (u: number) =>\n              beat.style !== \"slide\"\n                ? 0\n                : u <= 0\n                  ? slideFrom * em\n                  : (slideFrom * em + slideKick * em * u) *\n                    Math.exp(-slideSettle * u);\n            const x = slide(lt);\n            // The smear is the travel: a box blur one frame wide has a Gaussian\n            // equivalent of width/√12, and the reference measures at exactly that.\n            const sigma =\n              (motionBlur * Math.abs(x - slide(lt - 1 / fps))) / Math.sqrt(12);\n            const blurId = `punch-lines-${beatIndex}-${li}`;\n\n            return (\n              <div\n                // biome-ignore lint/suspicious/noArrayIndexKey: lines are positional\n                key={li}\n                style={{\n                  whiteSpace: \"nowrap\",\n                  fontSize: em,\n                  lineHeight: `${L}px`,\n                  letterSpacing,\n                  wordSpacing,\n                  transform: `translateX(${x}px)`,\n                  // Under a fifth of a pixel there is nothing to smear, and a\n                  // filter that is on for a still frame only costs it sharpness.\n                  filter: sigma > 0.2 ? `url(#${blurId})` : undefined,\n                }}\n              >\n                {sigma > 0.2 ? (\n                  <svg\n                    aria-hidden\n                    width={0}\n                    height={0}\n                    style={{ position: \"absolute\" }}\n                  >\n                    <title>motion blur</title>\n                    <filter\n                      id={blurId}\n                      x=\"-25%\"\n                      y=\"-25%\"\n                      width=\"150%\"\n                      height=\"150%\"\n                      colorInterpolationFilters=\"sRGB\"\n                    >\n                      <feGaussianBlur stdDeviation={`${sigma} 0`} />\n                    </filter>\n                  </svg>\n                ) : null}\n                {words.map((word, wi) => {\n                  const w = wordIndex++;\n                  const wt = t - (w * wordStagger) / fps;\n                  const p =\n                    beat.style === \"punch\"\n                      ? PUNCH_EASE(\n                          Math.max(0, Math.min(1, wt / (punchFrames / fps))),\n                        )\n                      : 1;\n                  const s =\n                    beat.style === \"punch\"\n                      ? punchFrom + (1 - punchFrom) * p\n                      : 1;\n                  return (\n                    // A trailing space inside an inline-block is stripped — it\n                    // sits at the end of that box's line and CSS removes it — so\n                    // the separator goes *between* the spans, never inside one.\n                    <Fragment key={w}>\n                      {wi > 0 ? \" \" : null}\n                      <span\n                        style={{\n                          display: \"inline-block\",\n                          transform: `scale(${s})`,\n                          // Not the baseline. The reference's words plainly grow\n                          // about their own middles — at a third of their size\n                          // they sit on a shared centre line, with their baselines\n                          // nowhere near each other — and half the line box is\n                          // that middle for a face whose ascent and descent\n                          // straddle it evenly.\n                          transformOrigin: `50% ${L / 2}px`,\n                        }}\n                      >\n                        {word}\n                      </span>\n                    </Fragment>\n                  );\n                })}\n              </div>\n            );\n          })}\n        </div>\n      </Item>\n    </AbsoluteFill>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/snap-cn/punch-lines.tsx"
    }
  ],
  "docs": "Docs and props: https://snapcn.dev/docs/text/punch-lines?ref=cli — pass mode=\"dark\" if you are rendering onto a dark frame — these default to shadcn's light palette and a bare Remotion render is black. — 82 Pro components too — charts, launch scenes, device shots: https://snapcn.dev/docs/pricing?ref=cli-docs",
  "type": "registry:component"
}
